mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e9beac59f | |||
| a0f7c76405 |
@@ -1,28 +1,29 @@
|
|||||||
{
|
{
|
||||||
"presets": [
|
"presets": [
|
||||||
"@babel/preset-react",
|
"@babel/preset-react",
|
||||||
"@babel/preset-env",
|
"@babel/preset-typescript",
|
||||||
"@babel/preset-typescript"
|
[
|
||||||
|
"@babel/preset-env",
|
||||||
|
{
|
||||||
|
"corejs": {
|
||||||
|
"version": "3",
|
||||||
|
"proposals": true
|
||||||
|
},
|
||||||
|
"useBuiltIns": "usage"
|
||||||
|
}
|
||||||
|
]
|
||||||
],
|
],
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"babel-plugin-transform-typescript-metadata",
|
"styled-components",
|
||||||
[
|
[
|
||||||
"@babel/plugin-proposal-decorators",
|
"@babel/plugin-proposal-decorators",
|
||||||
{
|
{
|
||||||
"legacy": true
|
"legacy": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"@babel/plugin-transform-class-properties",
|
"@babel/plugin-transform-destructuring",
|
||||||
[
|
"@babel/plugin-transform-regenerator",
|
||||||
"transform-inline-environment-variables",
|
"transform-class-properties"
|
||||||
{
|
|
||||||
"include": [
|
|
||||||
"SOURCE_COMMIT",
|
|
||||||
"SOURCE_VERSION"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"tsconfig-paths-module-resolver"
|
|
||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"production": {
|
"production": {
|
||||||
@@ -35,29 +36,13 @@
|
|||||||
]
|
]
|
||||||
],
|
],
|
||||||
"ignore": [
|
"ignore": [
|
||||||
"**/__mocks__",
|
|
||||||
"**/*.test.ts"
|
"**/*.test.ts"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"development": {
|
"development": {
|
||||||
"ignore": [
|
"ignore": [
|
||||||
"**/__mocks__",
|
|
||||||
"**/*.test.ts"
|
"**/*.test.ts"
|
||||||
]
|
]
|
||||||
},
|
|
||||||
"test": {
|
|
||||||
"presets": [
|
|
||||||
[
|
|
||||||
"@babel/preset-env",
|
|
||||||
{
|
|
||||||
"corejs": {
|
|
||||||
"version": "3",
|
|
||||||
"proposals": true
|
|
||||||
},
|
|
||||||
"useBuiltIns": "usage"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
version: 2.1
|
||||||
|
|
||||||
|
defaults: &defaults
|
||||||
|
working_directory: ~/outline
|
||||||
|
docker:
|
||||||
|
- image: cimg/node:18.12
|
||||||
|
- 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
|
||||||
|
NODE_OPTIONS: --max-old-space-size=8000
|
||||||
|
|
||||||
|
executors:
|
||||||
|
docker-publisher:
|
||||||
|
environment:
|
||||||
|
IMAGE_NAME: outlinewiki/outline
|
||||||
|
BASE_IMAGE_NAME: outlinewiki/outline-base
|
||||||
|
docker:
|
||||||
|
- image: circleci/buildpack-deps:stretch
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
<<: *defaults
|
||||||
|
steps:
|
||||||
|
- checkout
|
||||||
|
- restore_cache:
|
||||||
|
key: dependency-cache-{{ checksum "package.json" }}
|
||||||
|
- run:
|
||||||
|
name: install-deps
|
||||||
|
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-shared:
|
||||||
|
<<: *defaults
|
||||||
|
steps:
|
||||||
|
- checkout
|
||||||
|
- restore_cache:
|
||||||
|
key: dependency-cache-{{ checksum "package.json" }}
|
||||||
|
- run:
|
||||||
|
name: test
|
||||||
|
command: yarn test:shared
|
||||||
|
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: test
|
||||||
|
command: yarn test:server --forceExit
|
||||||
|
bundle-size:
|
||||||
|
<<: *defaults
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
steps:
|
||||||
|
- checkout
|
||||||
|
- restore_cache:
|
||||||
|
key: dependency-cache-{{ checksum "package.json" }}
|
||||||
|
- run:
|
||||||
|
name: build-vite
|
||||||
|
command: yarn vite:build
|
||||||
|
- run:
|
||||||
|
name: Send bundle stats to RelativeCI
|
||||||
|
command: npx relative-ci-agent
|
||||||
|
build-image:
|
||||||
|
executor: docker-publisher
|
||||||
|
steps:
|
||||||
|
- checkout
|
||||||
|
- setup_remote_docker:
|
||||||
|
version: 20.10.6
|
||||||
|
- run:
|
||||||
|
name: Install Docker buildx
|
||||||
|
command: |
|
||||||
|
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: Enable Docker buildx
|
||||||
|
command: export DOCKER_CLI_EXPERIMENTAL=enabled
|
||||||
|
- run:
|
||||||
|
name: Initialize Docker buildx
|
||||||
|
command: |
|
||||||
|
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
|
||||||
|
all:
|
||||||
|
jobs:
|
||||||
|
- build
|
||||||
|
- lint:
|
||||||
|
requires:
|
||||||
|
- build
|
||||||
|
- test-server:
|
||||||
|
requires:
|
||||||
|
- build
|
||||||
|
- test-shared:
|
||||||
|
requires:
|
||||||
|
- build
|
||||||
|
- test-app:
|
||||||
|
requires:
|
||||||
|
- build
|
||||||
|
- types:
|
||||||
|
requires:
|
||||||
|
- build
|
||||||
|
- bundle-size:
|
||||||
|
requires:
|
||||||
|
- test-app
|
||||||
|
- test-shared
|
||||||
|
- test-server
|
||||||
|
|
||||||
|
build-docker:
|
||||||
|
jobs:
|
||||||
|
- build-image:
|
||||||
|
filters:
|
||||||
|
tags:
|
||||||
|
only: /^v.*/
|
||||||
|
branches:
|
||||||
|
ignore: /.*/
|
||||||
Executable
+7
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
curl --user ${CIRCLE_TOKEN}: \
|
||||||
|
--request POST \
|
||||||
|
--form revision=<ENTER COMMIT SHA HERE>\
|
||||||
|
--form config=@config.yml \
|
||||||
|
--form notify=false \
|
||||||
|
https://circleci.com/api/v1.1/project/github/outline/outline/tree/master
|
||||||
@@ -13,4 +13,5 @@ app.json
|
|||||||
crowdin.yml
|
crowdin.yml
|
||||||
build
|
build
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
|
fakes3
|
||||||
node_modules
|
node_modules
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
URL=https://local.outline.dev:3000
|
|
||||||
|
|
||||||
DATABASE_URL=postgres://user:pass@127.0.0.1:5432/outline
|
|
||||||
REDIS_URL=redis://127.0.0.1:6379
|
|
||||||
|
|
||||||
SMTP_FROM_EMAIL=hello@example.com
|
|
||||||
|
|
||||||
# Enable unsafe-inline in script-src CSP directive
|
|
||||||
# Setting it to true allows React dev tools add-on in Firefox to successfully detect the project
|
|
||||||
DEVELOPMENT_UNSAFE_INLINE_CSP=true
|
|
||||||
|
|
||||||
# Increase the log level to debug for development
|
|
||||||
LOG_LEVEL=debug
|
|
||||||
+25
-64
@@ -12,14 +12,15 @@ UTILS_SECRET=generate_a_new_key
|
|||||||
|
|
||||||
# For production point these at your databases, in development the default
|
# For production point these at your databases, in development the default
|
||||||
# should work out of the box.
|
# should work out of the box.
|
||||||
DATABASE_URL=postgres://user:pass@postgres:5432/outline
|
DATABASE_URL=postgres://user:pass@localhost:5432/outline
|
||||||
|
DATABASE_URL_TEST=postgres://user:pass@localhost:5432/outline-test
|
||||||
DATABASE_CONNECTION_POOL_MIN=
|
DATABASE_CONNECTION_POOL_MIN=
|
||||||
DATABASE_CONNECTION_POOL_MAX=
|
DATABASE_CONNECTION_POOL_MAX=
|
||||||
# Uncomment this to disable SSL for connecting to Postgres
|
# Uncomment this to disable SSL for connecting to Postgres
|
||||||
# PGSSLMODE=disable
|
# PGSSLMODE=disable
|
||||||
|
|
||||||
# For redis you can either specify an ioredis compatible url like this
|
# For redis you can either specify an ioredis compatible url like this
|
||||||
REDIS_URL=redis://redis:6379
|
REDIS_URL=redis://localhost:6379
|
||||||
# or alternatively, if you would like to provide additional connection options,
|
# or alternatively, if you would like to provide additional connection options,
|
||||||
# use a base64 encoded JSON connection option object. Refer to the ioredis documentation
|
# use a base64 encoded JSON connection option object. Refer to the ioredis documentation
|
||||||
# for a list of available options.
|
# for a list of available options.
|
||||||
@@ -29,44 +30,32 @@ REDIS_URL=redis://redis:6379
|
|||||||
|
|
||||||
# URL should point to the fully qualified, publicly accessible URL. If using a
|
# URL should point to the fully qualified, publicly accessible URL. If using a
|
||||||
# proxy the port in URL and PORT may be different.
|
# proxy the port in URL and PORT may be different.
|
||||||
URL=
|
URL=https://app.outline.dev:3000
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
# See [documentation](docs/SERVICES.md) on running a separate collaboration
|
# See [documentation](docs/SERVICES.md) on running a separate collaboration
|
||||||
# server, for normal operation this does not need to be set.
|
# server, for normal operation this does not need to be set.
|
||||||
COLLABORATION_URL=
|
COLLABORATION_URL=
|
||||||
|
|
||||||
# Specify what storage system to use. Possible value is one of "s3" or "local".
|
# To support uploading of images for avatars and document attachments an
|
||||||
# For "local", the avatar images and document attachments will be saved on local disk.
|
# s3-compatible storage must be provided. AWS S3 is recommended for redundancy
|
||||||
FILE_STORAGE=local
|
# however if you want to keep all file storage local an alternative such as
|
||||||
|
# minio (https://github.com/minio/minio) can be used.
|
||||||
|
|
||||||
# If "local" is configured for FILE_STORAGE above, then this sets the parent directory under
|
# A more detailed guide on setting up S3 is available here:
|
||||||
# which all attachments/images go. Make sure that the process has permissions to create
|
# => https://wiki.generaloutline.com/share/125de1cc-9ff6-424b-8415-0d58c809a40f
|
||||||
# this path and also to write files to it.
|
#
|
||||||
FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
|
|
||||||
|
|
||||||
# Maximum allowed size for the uploaded attachment.
|
|
||||||
FILE_STORAGE_UPLOAD_MAX_SIZE=262144000
|
|
||||||
|
|
||||||
# Override the maximum size of document imports, generally this should be lower
|
|
||||||
# than the document attachment maximum size.
|
|
||||||
FILE_STORAGE_IMPORT_MAX_SIZE=
|
|
||||||
|
|
||||||
# Override the maximum size of workspace imports, these can be especially large
|
|
||||||
# and the files are temporary being automatically deleted after a period of time.
|
|
||||||
FILE_STORAGE_WORKSPACE_IMPORT_MAX_SIZE=
|
|
||||||
|
|
||||||
# To support uploading of images for avatars and document attachments in a distributed
|
|
||||||
# architecture an s3-compatible storage can be configured if FILE_STORAGE=s3 above.
|
|
||||||
AWS_ACCESS_KEY_ID=get_a_key_from_aws
|
AWS_ACCESS_KEY_ID=get_a_key_from_aws
|
||||||
AWS_SECRET_ACCESS_KEY=get_the_secret_of_above_key
|
AWS_SECRET_ACCESS_KEY=get_the_secret_of_above_key
|
||||||
AWS_REGION=xx-xxxx-x
|
AWS_REGION=xx-xxxx-x
|
||||||
AWS_S3_ACCELERATE_URL=
|
AWS_S3_ACCELERATE_URL=
|
||||||
AWS_S3_UPLOAD_BUCKET_URL=http://s3:4569
|
AWS_S3_UPLOAD_BUCKET_URL=http://s3:4569
|
||||||
AWS_S3_UPLOAD_BUCKET_NAME=bucket_name_here
|
AWS_S3_UPLOAD_BUCKET_NAME=bucket_name_here
|
||||||
|
AWS_S3_UPLOAD_MAX_SIZE=26214400
|
||||||
AWS_S3_FORCE_PATH_STYLE=true
|
AWS_S3_FORCE_PATH_STYLE=true
|
||||||
AWS_S3_ACL=private
|
AWS_S3_ACL=private
|
||||||
|
|
||||||
|
|
||||||
# –––––––––––––– AUTHENTICATION ––––––––––––––
|
# –––––––––––––– AUTHENTICATION ––––––––––––––
|
||||||
|
|
||||||
# Third party signin credentials, at least ONE OF EITHER Google, Slack,
|
# Third party signin credentials, at least ONE OF EITHER Google, Slack,
|
||||||
@@ -104,7 +93,6 @@ OIDC_CLIENT_SECRET=
|
|||||||
OIDC_AUTH_URI=
|
OIDC_AUTH_URI=
|
||||||
OIDC_TOKEN_URI=
|
OIDC_TOKEN_URI=
|
||||||
OIDC_USERINFO_URI=
|
OIDC_USERINFO_URI=
|
||||||
OIDC_LOGOUT_URI=
|
|
||||||
|
|
||||||
# Specify which claims to derive user information from
|
# Specify which claims to derive user information from
|
||||||
# Supports any valid JSON path with the JWT payload
|
# Supports any valid JSON path with the JWT payload
|
||||||
@@ -116,40 +104,6 @@ OIDC_DISPLAY_NAME=OpenID Connect
|
|||||||
# Space separated auth scopes.
|
# Space separated auth scopes.
|
||||||
OIDC_SCOPES=openid profile email
|
OIDC_SCOPES=openid profile email
|
||||||
|
|
||||||
# To configure the GitHub integration, you'll need to create a GitHub App at
|
|
||||||
# => https://github.com/settings/apps
|
|
||||||
#
|
|
||||||
# When configuring the Client ID, add a redirect URL under "Permissions & events":
|
|
||||||
# https://<URL>/api/github.callback
|
|
||||||
GITHUB_CLIENT_ID=
|
|
||||||
GITHUB_CLIENT_SECRET=
|
|
||||||
GITHUB_APP_NAME=
|
|
||||||
GITHUB_APP_ID=
|
|
||||||
GITHUB_APP_PRIVATE_KEY=
|
|
||||||
|
|
||||||
# To configure Discord auth, you'll need to create a Discord Application at
|
|
||||||
# => https://discord.com/developers/applications/
|
|
||||||
#
|
|
||||||
# When configuring the Client ID, add a redirect URL under "OAuth2":
|
|
||||||
# https://<URL>/auth/discord.callback
|
|
||||||
DISCORD_CLIENT_ID=
|
|
||||||
DISCORD_CLIENT_SECRET=
|
|
||||||
|
|
||||||
# DISCORD_SERVER_ID should be the ID of the Discord server that Outline is
|
|
||||||
# integrated with.
|
|
||||||
# Used to verify that the user is a member of the server as well as server
|
|
||||||
# metadata such as nicknames, server icon and name.
|
|
||||||
DISCORD_SERVER_ID=
|
|
||||||
|
|
||||||
# DISCORD_SERVER_ROLES should be a comma separated list of role IDs that are
|
|
||||||
# allowed to access Outline. If this is not set, all members of the server
|
|
||||||
# will be allowed to access Outline.
|
|
||||||
# DISCORD_SERVER_ID and DISCORD_SERVER_ROLES must be set together.
|
|
||||||
DISCORD_SERVER_ROLES=
|
|
||||||
|
|
||||||
# –––––––––––––– IMPORTS ––––––––––––––
|
|
||||||
NOTION_CLIENT_ID=
|
|
||||||
NOTION_CLIENT_SECRET=
|
|
||||||
|
|
||||||
# –––––––––––––––– OPTIONAL ––––––––––––––––
|
# –––––––––––––––– OPTIONAL ––––––––––––––––
|
||||||
|
|
||||||
@@ -177,6 +131,10 @@ ENABLE_UPDATES=true
|
|||||||
# available memory by 512 for a rough estimate
|
# available memory by 512 for a rough estimate
|
||||||
WEB_CONCURRENCY=1
|
WEB_CONCURRENCY=1
|
||||||
|
|
||||||
|
# Override the maximum size of document imports, could be required if you have
|
||||||
|
# especially large Word documents with embedded imagery
|
||||||
|
MAXIMUM_IMPORT_SIZE=5120000
|
||||||
|
|
||||||
# You can remove this line if your reverse proxy already logs incoming http
|
# You can remove this line if your reverse proxy already logs incoming http
|
||||||
# requests and this ends up being duplicative
|
# requests and this ends up being duplicative
|
||||||
DEBUG=http
|
DEBUG=http
|
||||||
@@ -193,9 +151,8 @@ SLACK_VERIFICATION_TOKEN=your_token
|
|||||||
SLACK_APP_ID=A0XXXXXXX
|
SLACK_APP_ID=A0XXXXXXX
|
||||||
SLACK_MESSAGE_ACTIONS=true
|
SLACK_MESSAGE_ACTIONS=true
|
||||||
|
|
||||||
# For Dropbox integration, follow these instructions to get the key https://www.dropbox.com/developers/embedder#setup
|
# Optionally enable google analytics to track pageviews in the knowledge base
|
||||||
# and do not forget to whitelist your domain name in the app settings
|
GOOGLE_ANALYTICS_ID=
|
||||||
DROPBOX_APP_KEY=
|
|
||||||
|
|
||||||
# Optionally enable Sentry (sentry.io) to track errors and performance,
|
# Optionally enable Sentry (sentry.io) to track errors and performance,
|
||||||
# and optionally add a Sentry proxy tunnel for bypassing ad blockers in the UI:
|
# and optionally add a Sentry proxy tunnel for bypassing ad blockers in the UI:
|
||||||
@@ -205,10 +162,14 @@ SENTRY_TUNNEL=
|
|||||||
|
|
||||||
# To support sending outgoing transactional emails such as "document updated" or
|
# To support sending outgoing transactional emails such as "document updated" or
|
||||||
# "you've been invited" you'll need to provide authentication for an SMTP server
|
# "you've been invited" you'll need to provide authentication for an SMTP server
|
||||||
SMTP_SERVICE=
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=
|
||||||
SMTP_USERNAME=
|
SMTP_USERNAME=
|
||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=
|
||||||
SMTP_FROM_EMAIL=
|
SMTP_FROM_EMAIL=hello@example.com
|
||||||
|
SMTP_REPLY_EMAIL=hello@example.com
|
||||||
|
SMTP_TLS_CIPHERS=
|
||||||
|
SMTP_SECURE=true
|
||||||
|
|
||||||
# The default interface language. See translate.getoutline.com for a list of
|
# The default interface language. See translate.getoutline.com for a list of
|
||||||
# available language codes and their rough percentage translated.
|
# available language codes and their rough percentage translated.
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
NODE_ENV=test
|
|
||||||
DATABASE_URL=postgres://user:pass@127.0.0.1:5432/outline-test
|
|
||||||
SECRET_KEY=F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
|
|
||||||
|
|
||||||
SMTP_HOST=smtp.example.com
|
|
||||||
SMTP_USERNAME=test
|
|
||||||
SMTP_FROM_EMAIL=hello@example.com
|
|
||||||
SMTP_REPLY_EMAIL=hello@example.com
|
|
||||||
|
|
||||||
GOOGLE_CLIENT_ID=123
|
|
||||||
GOOGLE_CLIENT_SECRET=123
|
|
||||||
|
|
||||||
SLACK_CLIENT_ID=123
|
|
||||||
SLACK_CLIENT_SECRET=123
|
|
||||||
|
|
||||||
GITHUB_CLIENT_ID=123;
|
|
||||||
GITHUB_CLIENT_SECRET=123;
|
|
||||||
GITHUB_APP_NAME=outline-test;
|
|
||||||
|
|
||||||
OIDC_CLIENT_ID=client-id
|
|
||||||
OIDC_CLIENT_SECRET=client-secret
|
|
||||||
OIDC_AUTH_URI=http://localhost/authorize
|
|
||||||
OIDC_TOKEN_URI=http://localhost/token
|
|
||||||
OIDC_USERINFO_URI=http://localhost/userinfo
|
|
||||||
|
|
||||||
IFRAMELY_API_KEY=123
|
|
||||||
|
|
||||||
RATE_LIMITER_ENABLED=false
|
|
||||||
|
|
||||||
FILE_STORAGE=local
|
|
||||||
FILE_STORAGE_LOCAL_ROOT_DIR=/tmp
|
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
"eslint-plugin-import",
|
"eslint-plugin-import",
|
||||||
"eslint-plugin-node",
|
"eslint-plugin-node",
|
||||||
"eslint-plugin-react",
|
"eslint-plugin-react",
|
||||||
"eslint-plugin-lodash"
|
"import"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"eqeqeq": 2,
|
"eqeqeq": 2,
|
||||||
@@ -32,21 +32,11 @@
|
|||||||
"object-shorthand": "error",
|
"object-shorthand": "error",
|
||||||
"no-mixed-operators": "off",
|
"no-mixed-operators": "off",
|
||||||
"no-useless-escape": "off",
|
"no-useless-escape": "off",
|
||||||
"no-shadow": "off",
|
|
||||||
"es/no-regexp-lookbehind-assertions": "error",
|
"es/no-regexp-lookbehind-assertions": "error",
|
||||||
"react/self-closing-comp": ["error", {
|
"react/self-closing-comp": ["error", {
|
||||||
"component": true,
|
"component": true,
|
||||||
"html": true
|
"html": true
|
||||||
}],
|
}],
|
||||||
"@typescript-eslint/no-shadow": [
|
|
||||||
"warn",
|
|
||||||
{
|
|
||||||
"allow": ["transaction"],
|
|
||||||
"hoist": "all",
|
|
||||||
"ignoreTypeValueShadow": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"@typescript-eslint/no-explicit-any": "warn",
|
|
||||||
"@typescript-eslint/no-floating-promises": "error",
|
"@typescript-eslint/no-floating-promises": "error",
|
||||||
"@typescript-eslint/await-thenable": "error",
|
"@typescript-eslint/await-thenable": "error",
|
||||||
"@typescript-eslint/no-misused-promises": [
|
"@typescript-eslint/no-misused-promises": [
|
||||||
@@ -65,7 +55,6 @@
|
|||||||
],
|
],
|
||||||
"padding-line-between-statements": ["error", { "blankLine": "always", "prev": "*", "next": "export" }],
|
"padding-line-between-statements": ["error", { "blankLine": "always", "prev": "*", "next": "export" }],
|
||||||
"lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }],
|
"lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }],
|
||||||
"lodash/import-scope": ["warn", "method"],
|
|
||||||
"import/no-named-as-default": "off",
|
"import/no-named-as-default": "off",
|
||||||
"import/no-named-as-default-member": "off",
|
"import/no-named-as-default-member": "off",
|
||||||
"import/newline-after-import": 2,
|
"import/newline-after-import": 2,
|
||||||
@@ -140,4 +129,4 @@
|
|||||||
"typescript": {}
|
"typescript": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Create a report to help us improve
|
||||||
|
title: ''
|
||||||
|
labels: bug
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
|
**To Reproduce**
|
||||||
|
Steps to reproduce the behavior:
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '....'
|
||||||
|
3. Scroll down to '....'
|
||||||
|
4. See error
|
||||||
|
|
||||||
|
**Expected behavior**
|
||||||
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
|
**Screenshots**
|
||||||
|
If applicable, add screenshots or videos to help explain your problem.
|
||||||
|
|
||||||
|
**Outline (please complete the following information):**
|
||||||
|
- Install: [getoutline.com or self hosted]
|
||||||
|
- Version: [commit sha if self hosted]
|
||||||
|
|
||||||
|
**Desktop (please complete the following information):**
|
||||||
|
- OS: [e.g. iOS]
|
||||||
|
- Browser [e.g. chrome, safari]
|
||||||
|
- Version [e.g. 22]
|
||||||
|
|
||||||
|
**Mobile (please complete the following information):**
|
||||||
|
- Device: [e.g. iPhone6]
|
||||||
|
- OS: [e.g. iOS8.1]
|
||||||
|
- Browser [e.g. stock browser, safari]
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
name: Bug report
|
|
||||||
description: File a bug to help us improve
|
|
||||||
labels: ["bug"]
|
|
||||||
body:
|
|
||||||
- type: checkboxes
|
|
||||||
attributes:
|
|
||||||
label: Is there an existing issue for this?
|
|
||||||
description: Please search to see if an issue already exists for the bug you encountered.
|
|
||||||
options:
|
|
||||||
- label: I have searched the existing issues
|
|
||||||
required: true
|
|
||||||
- type: checkboxes
|
|
||||||
attributes:
|
|
||||||
label: This is not related to configuring Outline
|
|
||||||
description: I understand that questions related to configuring self-hosted Outline should be asked in the [community forum](https://github.com/outline/outline/discussions/categories/self-hosting).
|
|
||||||
options:
|
|
||||||
- label: The issue is not related to self-hosting config
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: Current Behavior
|
|
||||||
description: A concise description of what you're experiencing.
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: Expected Behavior
|
|
||||||
description: A concise description of what you expected to happen.
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: Steps To Reproduce
|
|
||||||
description: Steps to reproduce the behavior.
|
|
||||||
placeholder: |
|
|
||||||
1. In this environment...
|
|
||||||
1. With this config...
|
|
||||||
1. Run '...'
|
|
||||||
1. See error...
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: Environment
|
|
||||||
description: |
|
|
||||||
examples:
|
|
||||||
- **Outline**: Outline 0.80.0
|
|
||||||
- **Browser**: Safari
|
|
||||||
value: |
|
|
||||||
- Outline:
|
|
||||||
- Browser:
|
|
||||||
render: markdown
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: Anything else?
|
|
||||||
description: |
|
|
||||||
Links? References? Anything that will give us more context about the issue you are encountering!
|
|
||||||
|
|
||||||
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
@@ -15,8 +15,6 @@ requestInfoDefaultTitles:
|
|||||||
|
|
||||||
requestInfoLabelToAdd: more information needed
|
requestInfoLabelToAdd: more information needed
|
||||||
|
|
||||||
requestInfoUserstoExclude:
|
|
||||||
- tommoor
|
|
||||||
|
|
||||||
# Configuration for new-pr-welcome - https://github.com/behaviorbot/new-pr-welcome
|
# Configuration for new-pr-welcome - https://github.com/behaviorbot/new-pr-welcome
|
||||||
|
|
||||||
|
|||||||
@@ -13,16 +13,3 @@ updates:
|
|||||||
update-types: ["version-update:semver-major"]
|
update-types: ["version-update:semver-major"]
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
groups:
|
|
||||||
babel:
|
|
||||||
patterns:
|
|
||||||
- "@babel/*"
|
|
||||||
sentry:
|
|
||||||
patterns:
|
|
||||||
- "@sentry/*"
|
|
||||||
fortawesome:
|
|
||||||
patterns:
|
|
||||||
- "@fortawesome/*"
|
|
||||||
aws:
|
|
||||||
patterns:
|
|
||||||
- "@aws-sdk/*"
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Configuration for probot-no-response - https://github.com/probot/no-response
|
# Configuration for probot-no-response - https://github.com/probot/no-response
|
||||||
|
|
||||||
# Number of days of inactivity before an Issue is closed for lack of response
|
# Number of days of inactivity before an Issue is closed for lack of response
|
||||||
daysUntilClose: 7
|
daysUntilClose: 14
|
||||||
|
|
||||||
# Label requiring a response
|
# Label requiring a response
|
||||||
responseRequiredLabel: more information needed
|
responseRequiredLabel: more information needed
|
||||||
|
|||||||
@@ -1,163 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ main ]
|
|
||||||
pull_request:
|
|
||||||
branches: [ main ]
|
|
||||||
|
|
||||||
env:
|
|
||||||
NODE_ENV: test
|
|
||||||
DATABASE_URL: postgres://postgres:password@localhost:5432/outline_test
|
|
||||||
REDIS_URL: redis://127.0.0.1:6379
|
|
||||||
URL: http://localhost:3000
|
|
||||||
NODE_OPTIONS: --max-old-space-size=8000
|
|
||||||
SECRET_KEY: F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
|
|
||||||
UTILS_SECRET: 123456
|
|
||||||
SLACK_VERIFICATION_TOKEN: 123456
|
|
||||||
SMTP_USERNAME: localhost
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
node-version: [20.x]
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Use Node.js ${{ matrix.node-version }}
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: ${{ matrix.node-version }}
|
|
||||||
cache: 'yarn'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: yarn install --frozen-lockfile
|
|
||||||
|
|
||||||
lint:
|
|
||||||
needs: build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20.x
|
|
||||||
cache: 'yarn'
|
|
||||||
- run: yarn install --frozen-lockfile
|
|
||||||
- run: yarn lint
|
|
||||||
|
|
||||||
types:
|
|
||||||
needs: build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20.x
|
|
||||||
cache: 'yarn'
|
|
||||||
- run: yarn install --frozen-lockfile
|
|
||||||
- run: yarn tsc
|
|
||||||
|
|
||||||
changes:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
server: ${{ steps.filter.outputs.server }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: dorny/paths-filter@v2
|
|
||||||
id: filter
|
|
||||||
with:
|
|
||||||
filters: |
|
|
||||||
server:
|
|
||||||
- 'server/**'
|
|
||||||
- 'shared/**'
|
|
||||||
- 'package.json'
|
|
||||||
- 'yarn.lock'
|
|
||||||
app:
|
|
||||||
- 'app/**'
|
|
||||||
- 'shared/**'
|
|
||||||
- 'package.json'
|
|
||||||
- 'yarn.lock'
|
|
||||||
|
|
||||||
test:
|
|
||||||
needs: build
|
|
||||||
if: ${{ needs.changes.outputs.app == 'true' }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
test-group: [app, shared]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20.x
|
|
||||||
cache: 'yarn'
|
|
||||||
- run: yarn install --frozen-lockfile
|
|
||||||
- run: yarn test:${{ matrix.test-group }}
|
|
||||||
|
|
||||||
test-server:
|
|
||||||
needs: [build, changes]
|
|
||||||
if: ${{ needs.changes.outputs.server == 'true' }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:14.2
|
|
||||||
env:
|
|
||||||
POSTGRES_USER: postgres
|
|
||||||
POSTGRES_PASSWORD: password
|
|
||||||
POSTGRES_DB: outline_test
|
|
||||||
ports:
|
|
||||||
- 5432:5432
|
|
||||||
options: >-
|
|
||||||
--health-cmd pg_isready
|
|
||||||
--health-interval 10s
|
|
||||||
--health-timeout 5s
|
|
||||||
--health-retries 5
|
|
||||||
|
|
||||||
redis:
|
|
||||||
image: redis:5.0
|
|
||||||
ports:
|
|
||||||
- 6379:6379
|
|
||||||
options: >-
|
|
||||||
--health-cmd "redis-cli ping"
|
|
||||||
--health-interval 10s
|
|
||||||
--health-timeout 5s
|
|
||||||
--health-retries 5
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
shard: [1, 2, 3]
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20.x
|
|
||||||
cache: 'yarn'
|
|
||||||
- run: yarn install --frozen-lockfile
|
|
||||||
- run: yarn sequelize db:migrate
|
|
||||||
- name: Run server tests
|
|
||||||
run: |
|
|
||||||
TESTFILES=$(find . -name "*.test.ts" -path "*/server/*" | sort | split -n -d -l $(($(find . -name "*.test.ts" -path "*/server/*" | wc -l)/${{ matrix.shard }})) - | sed -n "${{ matrix.shard }}p")
|
|
||||||
yarn test --maxWorkers=2 $TESTFILES
|
|
||||||
|
|
||||||
bundle-size:
|
|
||||||
needs: [build, types]
|
|
||||||
if: ${{ needs.changes.outputs.app == 'true' }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20.x
|
|
||||||
cache: 'yarn'
|
|
||||||
- run: yarn install --frozen-lockfile
|
|
||||||
- name: Set environment to production
|
|
||||||
run: echo "NODE_ENV=production" >> $GITHUB_ENV
|
|
||||||
- run: yarn vite:build
|
|
||||||
- name: Send bundle stats to RelativeCI
|
|
||||||
uses: relative-ci/agent-action@v2
|
|
||||||
with:
|
|
||||||
key: ${{ secrets.RELATIVE_CI_KEY }}
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
name: Docker
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
|
|
||||||
env:
|
|
||||||
IMAGE_NAME: outlinewiki/outline
|
|
||||||
BASE_IMAGE_NAME: outlinewiki/outline-base
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-push:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build and push base image
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: Dockerfile.base
|
|
||||||
push: true
|
|
||||||
tags: ${{ env.BASE_IMAGE_NAME }}:latest
|
|
||||||
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x
|
|
||||||
|
|
||||||
- name: Extract version
|
|
||||||
id: version
|
|
||||||
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Build and push main image
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
push: true
|
|
||||||
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x
|
|
||||||
tags: |
|
|
||||||
${{ env.IMAGE_NAME }}:latest
|
|
||||||
${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
name: Lint
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches: [ main ]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
run-linters:
|
|
||||||
if: startsWith(github.actor, 'codegen-sh')
|
|
||||||
name: Run linters
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
# Give the default GITHUB_TOKEN write permission to commit and push the
|
|
||||||
# added or changed files to the repository.
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20.x
|
|
||||||
cache: 'yarn'
|
|
||||||
- run: yarn install --frozen-lockfile
|
|
||||||
- run: yarn lint --fix
|
|
||||||
|
|
||||||
- name: Commit changes
|
|
||||||
uses: stefanzweifel/git-auto-commit-action@v5
|
|
||||||
with:
|
|
||||||
commit_message: 'Applied automatic fixes'
|
|
||||||
@@ -24,6 +24,6 @@ jobs:
|
|||||||
operations-per-run: 60
|
operations-per-run: 60
|
||||||
stale-issue-label: stale
|
stale-issue-label: stale
|
||||||
stale-pr-label: stale
|
stale-pr-label: stale
|
||||||
exempt-issue-labels: "security,pinned,A1"
|
exempt-issue-labels: "security,pinned"
|
||||||
- name: Print outputs
|
- name: Print outputs
|
||||||
run: echo ${{ join(steps.stale.outputs.*, ',') }}
|
run: echo ${{ join(steps.stale.outputs.*, ',') }}
|
||||||
|
|||||||
+1
-3
@@ -2,14 +2,12 @@ dist
|
|||||||
build
|
build
|
||||||
node_modules/*
|
node_modules/*
|
||||||
.env
|
.env
|
||||||
.env.local
|
|
||||||
.env.production
|
|
||||||
.log
|
.log
|
||||||
.vscode/*
|
.vscode/*
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
stats.json
|
stats.json
|
||||||
.DS_Store
|
.DS_Store
|
||||||
data/*
|
fakes3/*
|
||||||
.idea
|
.idea
|
||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
|
|||||||
+10
-13
@@ -1,20 +1,20 @@
|
|||||||
{
|
{
|
||||||
"workerIdleMemoryLimit": "0.75",
|
"workerIdleMemoryLimit": "0.75",
|
||||||
"maxWorkers": "50%",
|
|
||||||
"projects": [
|
"projects": [
|
||||||
{
|
{
|
||||||
"displayName": "server",
|
"displayName": "server",
|
||||||
"roots": ["<rootDir>/server", "<rootDir>/plugins"],
|
"roots": ["<rootDir>/server", "<rootDir>/plugins"],
|
||||||
"moduleNameMapper": {
|
"moduleNameMapper": {
|
||||||
"^@server/(.*)$": "<rootDir>/server/$1",
|
"^@server/(.*)$": "<rootDir>/server/$1",
|
||||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
"^@shared/(.*)$": "<rootDir>/shared/$1"
|
||||||
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
|
|
||||||
},
|
},
|
||||||
"setupFiles": ["<rootDir>/__mocks__/console.js"],
|
"setupFiles": [
|
||||||
|
"<rootDir>/__mocks__/console.js",
|
||||||
|
"<rootDir>/server/test/env.ts"
|
||||||
|
],
|
||||||
"setupFilesAfterEnv": ["<rootDir>/server/test/setup.ts"],
|
"setupFilesAfterEnv": ["<rootDir>/server/test/setup.ts"],
|
||||||
"globalSetup": "<rootDir>/server/test/globalSetup.js",
|
"testEnvironment": "node",
|
||||||
"globalTeardown": "<rootDir>/server/test/globalTeardown.js",
|
"runner": "@getoutline/jest-runner-serial"
|
||||||
"testEnvironment": "node"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"displayName": "app",
|
"displayName": "app",
|
||||||
@@ -23,8 +23,7 @@
|
|||||||
"^~/(.*)$": "<rootDir>/app/$1",
|
"^~/(.*)$": "<rootDir>/app/$1",
|
||||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
|
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
|
||||||
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js",
|
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
|
||||||
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
|
|
||||||
},
|
},
|
||||||
"modulePaths": ["<rootDir>/app"],
|
"modulePaths": ["<rootDir>/app"],
|
||||||
"setupFiles": ["<rootDir>/__mocks__/window.js"],
|
"setupFiles": ["<rootDir>/__mocks__/window.js"],
|
||||||
@@ -39,8 +38,7 @@
|
|||||||
"roots": ["<rootDir>/shared"],
|
"roots": ["<rootDir>/shared"],
|
||||||
"moduleNameMapper": {
|
"moduleNameMapper": {
|
||||||
"^@server/(.*)$": "<rootDir>/server/$1",
|
"^@server/(.*)$": "<rootDir>/server/$1",
|
||||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
"^@shared/(.*)$": "<rootDir>/shared/$1"
|
||||||
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
|
|
||||||
},
|
},
|
||||||
"setupFiles": ["<rootDir>/__mocks__/console.js"],
|
"setupFiles": ["<rootDir>/__mocks__/console.js"],
|
||||||
"setupFilesAfterEnv": ["<rootDir>/shared/test/setup.ts"],
|
"setupFilesAfterEnv": ["<rootDir>/shared/test/setup.ts"],
|
||||||
@@ -53,8 +51,7 @@
|
|||||||
"^~/(.*)$": "<rootDir>/app/$1",
|
"^~/(.*)$": "<rootDir>/app/$1",
|
||||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
|
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
|
||||||
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js",
|
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
|
||||||
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
|
|
||||||
},
|
},
|
||||||
"setupFiles": ["<rootDir>/__mocks__/window.js"],
|
"setupFiles": ["<rootDir>/__mocks__/window.js"],
|
||||||
"testEnvironment": "jsdom",
|
"testEnvironment": "jsdom",
|
||||||
|
|||||||
+2
-3
@@ -1,6 +1,4 @@
|
|||||||
require("dotenv").config({
|
require('dotenv').config({ silent: true });
|
||||||
path: process.env.NODE_ENV === "test" ? ".env.test" : ".env",
|
|
||||||
});
|
|
||||||
|
|
||||||
var path = require('path');
|
var path = require('path');
|
||||||
|
|
||||||
@@ -8,4 +6,5 @@ module.exports = {
|
|||||||
'config': path.resolve('server/config', 'database.json'),
|
'config': path.resolve('server/config', 'database.json'),
|
||||||
'migrations-path': path.resolve('server', 'migrations'),
|
'migrations-path': path.resolve('server', 'migrations'),
|
||||||
'models-path': path.resolve('server', 'models'),
|
'models-path': path.resolve('server', 'models'),
|
||||||
|
'seeders-path': path.resolve('server/models', 'fixtures'),
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-23
@@ -1,17 +1,19 @@
|
|||||||
ARG APP_PATH=/opt/outline
|
ARG APP_PATH=/opt/outline
|
||||||
FROM outlinewiki/outline-base AS base
|
FROM outlinewiki/outline-base as base
|
||||||
|
|
||||||
ARG APP_PATH
|
ARG APP_PATH
|
||||||
WORKDIR $APP_PATH
|
WORKDIR $APP_PATH
|
||||||
|
|
||||||
# ---
|
# ---
|
||||||
FROM node:20-slim AS runner
|
FROM node:18-alpine AS runner
|
||||||
|
|
||||||
|
RUN apk update && apk add --no-cache curl && apk add --no-cache ca-certificates
|
||||||
|
|
||||||
LABEL org.opencontainers.image.source="https://github.com/outline/outline"
|
LABEL org.opencontainers.image.source="https://github.com/outline/outline"
|
||||||
|
|
||||||
ARG APP_PATH
|
ARG APP_PATH
|
||||||
WORKDIR $APP_PATH
|
WORKDIR $APP_PATH
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV production
|
||||||
|
|
||||||
COPY --from=base $APP_PATH/build ./build
|
COPY --from=base $APP_PATH/build ./build
|
||||||
COPY --from=base $APP_PATH/server ./server
|
COPY --from=base $APP_PATH/server ./server
|
||||||
@@ -20,28 +22,11 @@ COPY --from=base $APP_PATH/.sequelizerc ./.sequelizerc
|
|||||||
COPY --from=base $APP_PATH/node_modules ./node_modules
|
COPY --from=base $APP_PATH/node_modules ./node_modules
|
||||||
COPY --from=base $APP_PATH/package.json ./package.json
|
COPY --from=base $APP_PATH/package.json ./package.json
|
||||||
|
|
||||||
# Install wget to healthcheck the server
|
RUN addgroup -g 1001 -S nodejs && \
|
||||||
RUN apt-get update \
|
adduser -S nodejs -u 1001 && \
|
||||||
&& apt-get install -y wget \
|
chown -R nodejs:nodejs $APP_PATH/build
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Create a non-root user compatible with Debian and BusyBox based images
|
|
||||||
RUN addgroup --gid 1001 nodejs && \
|
|
||||||
adduser --uid 1001 --ingroup nodejs nodejs && \
|
|
||||||
chown -R nodejs:nodejs $APP_PATH/build && \
|
|
||||||
mkdir -p /var/lib/outline && \
|
|
||||||
chown -R nodejs:nodejs /var/lib/outline
|
|
||||||
|
|
||||||
ENV FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
|
|
||||||
RUN mkdir -p "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
|
|
||||||
chown -R nodejs:nodejs "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
|
|
||||||
chmod 1777 "$FILE_STORAGE_LOCAL_ROOT_DIR"
|
|
||||||
|
|
||||||
VOLUME /var/lib/outline/data
|
|
||||||
|
|
||||||
USER nodejs
|
USER nodejs
|
||||||
|
|
||||||
HEALTHCHECK --interval=1m CMD wget -qO- "http://localhost:${PORT:-3000}/_health" | grep -q "OK" || exit 1
|
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["yarn", "start"]
|
CMD ["yarn", "start"]
|
||||||
|
|||||||
+1
-3
@@ -1,5 +1,5 @@
|
|||||||
ARG APP_PATH=/opt/outline
|
ARG APP_PATH=/opt/outline
|
||||||
FROM node:20-slim AS deps
|
FROM node:18-alpine AS deps
|
||||||
|
|
||||||
ARG APP_PATH
|
ARG APP_PATH
|
||||||
WORKDIR $APP_PATH
|
WORKDIR $APP_PATH
|
||||||
@@ -17,5 +17,3 @@ RUN rm -rf node_modules
|
|||||||
|
|
||||||
RUN yarn install --production=true --frozen-lockfile --network-timeout 1000000 && \
|
RUN yarn install --production=true --frozen-lockfile --network-timeout 1000000 && \
|
||||||
yarn cache clean
|
yarn cache clean
|
||||||
|
|
||||||
ENV PORT=3000
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ Business Source License 1.1
|
|||||||
Parameters
|
Parameters
|
||||||
|
|
||||||
Licensor: General Outline, Inc.
|
Licensor: General Outline, Inc.
|
||||||
Licensed Work: Outline 0.82.0
|
Licensed Work: Outline 0.64.0
|
||||||
The Licensed Work is (c) 2025 General Outline, Inc.
|
The Licensed Work is (c) 2020 General Outline, Inc.
|
||||||
Additional Use Grant: You may make use of the Licensed Work, provided that
|
Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||||
you may not use the Licensed Work for a Document
|
you may not use the Licensed Work for a Document
|
||||||
Service.
|
Service.
|
||||||
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
|
|||||||
Licensed Work by creating teams and documents
|
Licensed Work by creating teams and documents
|
||||||
controlled by such third parties.
|
controlled by such third parties.
|
||||||
|
|
||||||
Change Date: 2029-02-15
|
Change Date: 2026-05-23
|
||||||
|
|
||||||
Change License: Apache License, Version 2.0
|
Change License: Apache License, Version 2.0
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
up:
|
up:
|
||||||
docker compose up -d redis postgres
|
docker-compose up -d redis postgres s3
|
||||||
yarn install-local-ssl
|
yarn install-local-ssl
|
||||||
yarn install --pure-lockfile
|
yarn install --pure-lockfile
|
||||||
yarn dev:watch
|
yarn dev:watch
|
||||||
|
|
||||||
build:
|
build:
|
||||||
docker compose build --pull outline
|
docker-compose build --pull outline
|
||||||
|
|
||||||
test:
|
test:
|
||||||
docker compose up -d redis postgres
|
docker-compose up -d redis postgres s3
|
||||||
NODE_ENV=test yarn sequelize db:drop
|
yarn sequelize db:drop --env=test
|
||||||
NODE_ENV=test yarn sequelize db:create
|
yarn sequelize db:create --env=test
|
||||||
NODE_ENV=test yarn sequelize db:migrate
|
yarn sequelize db:migrate --env=test
|
||||||
yarn test
|
yarn test
|
||||||
|
|
||||||
watch:
|
watch:
|
||||||
docker compose up -d redis postgres
|
docker-compose up -d redis postgres s3
|
||||||
NODE_ENV=test yarn sequelize db:drop
|
yarn sequelize db:drop --env=test
|
||||||
NODE_ENV=test yarn sequelize db:create
|
yarn sequelize db:create --env=test
|
||||||
NODE_ENV=test yarn sequelize db:migrate
|
yarn sequelize db:migrate --env=test
|
||||||
yarn test:watch
|
yarn test:watch
|
||||||
|
|
||||||
destroy:
|
destroy:
|
||||||
docker compose stop
|
docker-compose stop
|
||||||
docker compose rm -f
|
docker-compose rm -f
|
||||||
|
|
||||||
.PHONY: up build destroy test watch # let's go to reserve rules names
|
.PHONY: up build destroy test watch # let's go to reserve rules names
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ There is a short guide for [setting up a development environment](https://docs.g
|
|||||||
|
|
||||||
Outline is built and maintained by a small team – we'd love your help to fix bugs and add features!
|
Outline is built and maintained by a small team – we'd love your help to fix bugs and add features!
|
||||||
|
|
||||||
Before submitting a pull request _please_ discuss with the core team by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues) – we'd also love to hear from you in the [discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written. This will result in a much higher likelihood of your code being accepted.
|
Before submitting a pull request _please_ discuss with the core team by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues) – we'd also love to hear from you in the [discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written. This will result in a much higher liklihood of your code being accepted.
|
||||||
|
|
||||||
If you’re looking for ways to get started, here's a list of ways to help us improve Outline:
|
If you’re looking for ways to get started, here's a list of ways to help us improve Outline:
|
||||||
|
|
||||||
@@ -96,10 +96,6 @@ Or to run migrations on test database:
|
|||||||
yarn sequelize db:migrate --env test
|
yarn sequelize db:migrate --env test
|
||||||
```
|
```
|
||||||
|
|
||||||
# Activity
|
## License
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
# License
|
|
||||||
|
|
||||||
Outline is [BSL 1.1 licensed](LICENSE).
|
Outline is [BSL 1.1 licensed](LICENSE).
|
||||||
|
|||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
export default null;
|
|
||||||
@@ -3,13 +3,7 @@
|
|||||||
"description": "Open source wiki and knowledge base for growing teams",
|
"description": "Open source wiki and knowledge base for growing teams",
|
||||||
"website": "https://www.getoutline.com/",
|
"website": "https://www.getoutline.com/",
|
||||||
"repository": "https://github.com/outline/outline",
|
"repository": "https://github.com/outline/outline",
|
||||||
"keywords": [
|
"keywords": ["wiki", "team", "node", "markdown", "slack"],
|
||||||
"wiki",
|
|
||||||
"team",
|
|
||||||
"node",
|
|
||||||
"markdown",
|
|
||||||
"slack"
|
|
||||||
],
|
|
||||||
"success_url": "/",
|
"success_url": "/",
|
||||||
"formation": {
|
"formation": {
|
||||||
"web": {
|
"web": {
|
||||||
@@ -39,11 +33,6 @@
|
|||||||
"generator": "secret",
|
"generator": "secret",
|
||||||
"required": true
|
"required": true
|
||||||
},
|
},
|
||||||
"UTILS_SECRET": {
|
|
||||||
"description": "A 32-character secret key, generate with openssl rand -hex 32",
|
|
||||||
"generator": "secret",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
"ENABLE_UPDATES": {
|
"ENABLE_UPDATES": {
|
||||||
"value": "true",
|
"value": "true",
|
||||||
"required": true
|
"required": true
|
||||||
@@ -92,14 +81,6 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"required": false
|
"required": false
|
||||||
},
|
},
|
||||||
"OIDC_DISABLE_REDIRECT": {
|
|
||||||
"description": "Prevent the app from automatically redirecting to the OIDC login page",
|
|
||||||
"required": false
|
|
||||||
},
|
|
||||||
"OIDC_LOGOUT_URI": {
|
|
||||||
"description": "",
|
|
||||||
"required": false
|
|
||||||
},
|
|
||||||
"OIDC_USERNAME_CLAIM": {
|
"OIDC_USERNAME_CLAIM": {
|
||||||
"description": "Specify which claims to derive user information from. Supports any valid JSON path with the JWT payload",
|
"description": "Specify which claims to derive user information from. Supports any valid JSON path with the JWT payload",
|
||||||
"value": "preferred_username",
|
"value": "preferred_username",
|
||||||
@@ -147,6 +128,11 @@
|
|||||||
"description": "Live web link to your bucket. For CNAMEs, https://yourbucket.example.com",
|
"description": "Live web link to your bucket. For CNAMEs, https://yourbucket.example.com",
|
||||||
"required": false
|
"required": false
|
||||||
},
|
},
|
||||||
|
"AWS_S3_UPLOAD_MAX_SIZE": {
|
||||||
|
"description": "Maximum file upload size in bytes",
|
||||||
|
"value": "26214400",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
"AWS_S3_FORCE_PATH_STYLE": {
|
"AWS_S3_FORCE_PATH_STYLE": {
|
||||||
"description": "Use path-style URL's for connecting to S3 instead of subdomain. This is useful for S3-compatible storage.",
|
"description": "Use path-style URL's for connecting to S3 instead of subdomain. This is useful for S3-compatible storage.",
|
||||||
"value": "true",
|
"value": "true",
|
||||||
@@ -162,19 +148,10 @@
|
|||||||
"description": "S3 canned ACL for document attachments",
|
"description": "S3 canned ACL for document attachments",
|
||||||
"required": false
|
"required": false
|
||||||
},
|
},
|
||||||
"FILE_STORAGE_UPLOAD_MAX_SIZE": {
|
|
||||||
"description": "Maximum file upload size in bytes",
|
|
||||||
"value": "26214400",
|
|
||||||
"required": false
|
|
||||||
},
|
|
||||||
"SMTP_HOST": {
|
"SMTP_HOST": {
|
||||||
"description": "smtp.example.com (optional)",
|
"description": "smtp.example.com (optional)",
|
||||||
"required": false
|
"required": false
|
||||||
},
|
},
|
||||||
"SMTP_SERVICE": {
|
|
||||||
"description": "Well-known SMTP service name for nodemailer (optional, e.g. 'gmail', 'SES')",
|
|
||||||
"required": false
|
|
||||||
},
|
|
||||||
"SMTP_PORT": {
|
"SMTP_PORT": {
|
||||||
"description": "1234 (optional)",
|
"description": "1234 (optional)",
|
||||||
"required": false
|
"required": false
|
||||||
@@ -222,4 +199,4 @@
|
|||||||
"required": false
|
"required": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -2,10 +2,10 @@
|
|||||||
"extends": [
|
"extends": [
|
||||||
"../.eslintrc",
|
"../.eslintrc",
|
||||||
"plugin:react/recommended",
|
"plugin:react/recommended",
|
||||||
"plugin:react-hooks/recommended"
|
"plugin:react-hooks/recommended",
|
||||||
],
|
],
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"eslint-plugin-react-hooks"
|
"eslint-plugin-react-hooks",
|
||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"jest": true,
|
"jest": true,
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import { PlusIcon } from "outline-icons";
|
|
||||||
import * as React from "react";
|
|
||||||
import stores from "~/stores";
|
|
||||||
import ApiKeyNew from "~/scenes/ApiKeyNew";
|
|
||||||
import { createAction } from "..";
|
|
||||||
import { SettingsSection } from "../sections";
|
|
||||||
|
|
||||||
export const createApiKey = createAction({
|
|
||||||
name: ({ t }) => t("New API key"),
|
|
||||||
analyticsName: "New API key",
|
|
||||||
section: SettingsSection,
|
|
||||||
icon: <PlusIcon />,
|
|
||||||
keywords: "create",
|
|
||||||
visible: () =>
|
|
||||||
stores.policies.abilities(stores.auth.team?.id || "").createApiKey,
|
|
||||||
perform: ({ t, event }) => {
|
|
||||||
event?.preventDefault();
|
|
||||||
event?.stopPropagation();
|
|
||||||
|
|
||||||
stores.dialogs.openModal({
|
|
||||||
title: t("New API key"),
|
|
||||||
content: <ApiKeyNew onSubmit={stores.dialogs.closeAllModals} />,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,33 +1,23 @@
|
|||||||
import {
|
import {
|
||||||
ArchiveIcon,
|
|
||||||
CollectionIcon,
|
CollectionIcon,
|
||||||
EditIcon,
|
EditIcon,
|
||||||
PadlockIcon,
|
PadlockIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
RestoreIcon,
|
|
||||||
SearchIcon,
|
|
||||||
ShapesIcon,
|
|
||||||
StarredIcon,
|
StarredIcon,
|
||||||
SubscribeIcon,
|
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
UnstarredIcon,
|
UnstarredIcon,
|
||||||
UnsubscribeIcon,
|
|
||||||
} from "outline-icons";
|
} from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { toast } from "sonner";
|
import stores from "~/stores";
|
||||||
import Collection from "~/models/Collection";
|
import Collection from "~/models/Collection";
|
||||||
import { CollectionEdit } from "~/components/Collection/CollectionEdit";
|
import CollectionEdit from "~/scenes/CollectionEdit";
|
||||||
import { CollectionNew } from "~/components/Collection/CollectionNew";
|
import CollectionNew from "~/scenes/CollectionNew";
|
||||||
|
import CollectionPermissions from "~/scenes/CollectionPermissions";
|
||||||
import CollectionDeleteDialog from "~/components/CollectionDeleteDialog";
|
import CollectionDeleteDialog from "~/components/CollectionDeleteDialog";
|
||||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
|
||||||
import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
|
import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
|
||||||
import SharePopover from "~/components/Sharing/Collection/SharePopover";
|
|
||||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
|
||||||
import { createAction } from "~/actions";
|
import { createAction } from "~/actions";
|
||||||
import { ActiveCollectionSection, CollectionSection } from "~/actions/sections";
|
import { CollectionSection } from "~/actions/sections";
|
||||||
import { setPersistedState } from "~/hooks/usePersistedState";
|
|
||||||
import history from "~/utils/history";
|
import history from "~/utils/history";
|
||||||
import { newTemplatePath, searchPath } from "~/utils/routeHelpers";
|
|
||||||
|
|
||||||
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => (
|
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => (
|
||||||
<DynamicCollectionIcon collection={collection} />
|
<DynamicCollectionIcon collection={collection} />
|
||||||
@@ -44,11 +34,11 @@ export const openCollection = createAction({
|
|||||||
return collections.map((collection) => ({
|
return collections.map((collection) => ({
|
||||||
// Note: using url which includes the slug rather than id here to bust
|
// Note: using url which includes the slug rather than id here to bust
|
||||||
// cache if the collection is renamed
|
// cache if the collection is renamed
|
||||||
id: collection.path,
|
id: collection.url,
|
||||||
name: collection.name,
|
name: collection.name,
|
||||||
icon: <ColorCollectionIcon collection={collection} />,
|
icon: <ColorCollectionIcon collection={collection} />,
|
||||||
section: CollectionSection,
|
section: CollectionSection,
|
||||||
perform: () => history.push(collection.path),
|
perform: () => history.push(collection.url),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -61,7 +51,7 @@ export const createCollection = createAction({
|
|||||||
keywords: "create",
|
keywords: "create",
|
||||||
visible: ({ stores }) =>
|
visible: ({ stores }) =>
|
||||||
stores.policies.abilities(stores.auth.team?.id || "").createCollection,
|
stores.policies.abilities(stores.auth.team?.id || "").createCollection,
|
||||||
perform: ({ t, event, stores }) => {
|
perform: ({ t, event }) => {
|
||||||
event?.preventDefault();
|
event?.preventDefault();
|
||||||
event?.stopPropagation();
|
event?.stopPropagation();
|
||||||
stores.dialogs.openModal({
|
stores.dialogs.openModal({
|
||||||
@@ -75,12 +65,12 @@ export const editCollection = createAction({
|
|||||||
name: ({ t, isContextMenu }) =>
|
name: ({ t, isContextMenu }) =>
|
||||||
isContextMenu ? `${t("Edit")}…` : t("Edit collection"),
|
isContextMenu ? `${t("Edit")}…` : t("Edit collection"),
|
||||||
analyticsName: "Edit collection",
|
analyticsName: "Edit collection",
|
||||||
section: ActiveCollectionSection,
|
section: CollectionSection,
|
||||||
icon: <EditIcon />,
|
icon: <EditIcon />,
|
||||||
visible: ({ activeCollectionId, stores }) =>
|
visible: ({ stores, activeCollectionId }) =>
|
||||||
!!activeCollectionId &&
|
!!activeCollectionId &&
|
||||||
stores.policies.abilities(activeCollectionId).update,
|
stores.policies.abilities(activeCollectionId).update,
|
||||||
perform: ({ t, activeCollectionId, stores }) => {
|
perform: ({ t, activeCollectionId }) => {
|
||||||
if (!activeCollectionId) {
|
if (!activeCollectionId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -101,62 +91,27 @@ export const editCollectionPermissions = createAction({
|
|||||||
name: ({ t, isContextMenu }) =>
|
name: ({ t, isContextMenu }) =>
|
||||||
isContextMenu ? `${t("Permissions")}…` : t("Collection permissions"),
|
isContextMenu ? `${t("Permissions")}…` : t("Collection permissions"),
|
||||||
analyticsName: "Collection permissions",
|
analyticsName: "Collection permissions",
|
||||||
section: ActiveCollectionSection,
|
section: CollectionSection,
|
||||||
icon: <PadlockIcon />,
|
icon: <PadlockIcon />,
|
||||||
visible: ({ activeCollectionId, stores }) =>
|
visible: ({ stores, activeCollectionId }) =>
|
||||||
!!activeCollectionId &&
|
!!activeCollectionId &&
|
||||||
stores.policies.abilities(activeCollectionId).update,
|
stores.policies.abilities(activeCollectionId).update,
|
||||||
perform: ({ t, activeCollectionId, stores }) => {
|
perform: ({ t, activeCollectionId }) => {
|
||||||
if (!activeCollectionId) {
|
if (!activeCollectionId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const collection = stores.collections.get(activeCollectionId);
|
|
||||||
if (!collection) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
stores.dialogs.openModal({
|
stores.dialogs.openModal({
|
||||||
title: t("Share this collection"),
|
title: t("Collection permissions"),
|
||||||
style: { marginBottom: -12 },
|
content: <CollectionPermissions collectionId={activeCollectionId} />,
|
||||||
content: (
|
|
||||||
<SharePopover
|
|
||||||
collection={collection}
|
|
||||||
onRequestClose={stores.dialogs.closeAllModals}
|
|
||||||
visible
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const searchInCollection = createAction({
|
|
||||||
name: ({ t }) => t("Search in collection"),
|
|
||||||
analyticsName: "Search collection",
|
|
||||||
section: ActiveCollectionSection,
|
|
||||||
icon: <SearchIcon />,
|
|
||||||
visible: ({ activeCollectionId, stores }) => {
|
|
||||||
if (!activeCollectionId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const collection = stores.collections.get(activeCollectionId);
|
|
||||||
|
|
||||||
if (!collection?.isActive) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return stores.policies.abilities(activeCollectionId).readDocument;
|
|
||||||
},
|
|
||||||
|
|
||||||
perform: ({ activeCollectionId }) => {
|
|
||||||
history.push(searchPath({ collectionId: activeCollectionId }));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const starCollection = createAction({
|
export const starCollection = createAction({
|
||||||
name: ({ t }) => t("Star"),
|
name: ({ t }) => t("Star"),
|
||||||
analyticsName: "Star collection",
|
analyticsName: "Star collection",
|
||||||
section: ActiveCollectionSection,
|
section: CollectionSection,
|
||||||
icon: <StarredIcon />,
|
icon: <StarredIcon />,
|
||||||
keywords: "favorite bookmark",
|
keywords: "favorite bookmark",
|
||||||
visible: ({ activeCollectionId, stores }) => {
|
visible: ({ activeCollectionId, stores }) => {
|
||||||
@@ -176,14 +131,13 @@ export const starCollection = createAction({
|
|||||||
|
|
||||||
const collection = stores.collections.get(activeCollectionId);
|
const collection = stores.collections.get(activeCollectionId);
|
||||||
await collection?.star();
|
await collection?.star();
|
||||||
setPersistedState(getHeaderExpandedKey("starred"), true);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const unstarCollection = createAction({
|
export const unstarCollection = createAction({
|
||||||
name: ({ t }) => t("Unstar"),
|
name: ({ t }) => t("Unstar"),
|
||||||
analyticsName: "Unstar collection",
|
analyticsName: "Unstar collection",
|
||||||
section: ActiveCollectionSection,
|
section: CollectionSection,
|
||||||
icon: <UnstarredIcon />,
|
icon: <UnstarredIcon />,
|
||||||
keywords: "unfavorite unbookmark",
|
keywords: "unfavorite unbookmark",
|
||||||
visible: ({ activeCollectionId, stores }) => {
|
visible: ({ activeCollectionId, stores }) => {
|
||||||
@@ -206,137 +160,10 @@ export const unstarCollection = createAction({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const subscribeCollection = createAction({
|
|
||||||
name: ({ t }) => t("Subscribe"),
|
|
||||||
analyticsName: "Subscribe to collection",
|
|
||||||
section: ActiveCollectionSection,
|
|
||||||
icon: <SubscribeIcon />,
|
|
||||||
visible: ({ activeCollectionId, stores }) => {
|
|
||||||
if (!activeCollectionId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const collection = stores.collections.get(activeCollectionId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
!collection?.isSubscribed &&
|
|
||||||
stores.policies.abilities(activeCollectionId).subscribe
|
|
||||||
);
|
|
||||||
},
|
|
||||||
perform: async ({ activeCollectionId, stores, t }) => {
|
|
||||||
if (!activeCollectionId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const collection = stores.collections.get(activeCollectionId);
|
|
||||||
|
|
||||||
await collection?.subscribe();
|
|
||||||
|
|
||||||
toast.success(t("Subscribed to document notifications"));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const unsubscribeCollection = createAction({
|
|
||||||
name: ({ t }) => t("Unsubscribe"),
|
|
||||||
analyticsName: "Unsubscribe from collection",
|
|
||||||
section: ActiveCollectionSection,
|
|
||||||
icon: <UnsubscribeIcon />,
|
|
||||||
visible: ({ activeCollectionId, stores }) => {
|
|
||||||
if (!activeCollectionId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const collection = stores.collections.get(activeCollectionId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
!!collection?.isSubscribed &&
|
|
||||||
stores.policies.abilities(activeCollectionId).unsubscribe
|
|
||||||
);
|
|
||||||
},
|
|
||||||
perform: async ({ activeCollectionId, currentUserId, stores, t }) => {
|
|
||||||
if (!activeCollectionId || !currentUserId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const collection = stores.collections.get(activeCollectionId);
|
|
||||||
|
|
||||||
await collection?.unsubscribe();
|
|
||||||
|
|
||||||
toast.success(t("Unsubscribed from document notifications"));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const archiveCollection = createAction({
|
|
||||||
name: ({ t }) => `${t("Archive")}…`,
|
|
||||||
analyticsName: "Archive collection",
|
|
||||||
section: CollectionSection,
|
|
||||||
icon: <ArchiveIcon />,
|
|
||||||
visible: ({ activeCollectionId, stores }) => {
|
|
||||||
if (!activeCollectionId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return !!stores.policies.abilities(activeCollectionId).archive;
|
|
||||||
},
|
|
||||||
perform: async ({ activeCollectionId, stores, t }) => {
|
|
||||||
const { dialogs, collections } = stores;
|
|
||||||
if (!activeCollectionId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const collection = collections.get(activeCollectionId);
|
|
||||||
if (!collection) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dialogs.openModal({
|
|
||||||
title: t("Archive collection"),
|
|
||||||
content: (
|
|
||||||
<ConfirmationDialog
|
|
||||||
onSubmit={async () => {
|
|
||||||
await collection.archive();
|
|
||||||
toast.success(t("Collection archived"));
|
|
||||||
}}
|
|
||||||
submitText={t("Archive")}
|
|
||||||
savingText={`${t("Archiving")}…`}
|
|
||||||
>
|
|
||||||
{t(
|
|
||||||
"Archiving this collection will also archive all documents within it. Documents from the collection will no longer be visible in search results."
|
|
||||||
)}
|
|
||||||
</ConfirmationDialog>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const restoreCollection = createAction({
|
|
||||||
name: ({ t }) => t("Restore"),
|
|
||||||
analyticsName: "Restore collection",
|
|
||||||
section: CollectionSection,
|
|
||||||
icon: <RestoreIcon />,
|
|
||||||
visible: ({ activeCollectionId, stores }) => {
|
|
||||||
if (!activeCollectionId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return !!stores.policies.abilities(activeCollectionId).restore;
|
|
||||||
},
|
|
||||||
perform: async ({ activeCollectionId, stores, t }) => {
|
|
||||||
if (!activeCollectionId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const collection = stores.collections.get(activeCollectionId);
|
|
||||||
if (!collection) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await collection.restore();
|
|
||||||
toast.success(t("Collection restored"));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const deleteCollection = createAction({
|
export const deleteCollection = createAction({
|
||||||
name: ({ t }) => `${t("Delete")}…`,
|
name: ({ t }) => t("Delete"),
|
||||||
analyticsName: "Delete collection",
|
analyticsName: "Delete collection",
|
||||||
section: ActiveCollectionSection,
|
section: CollectionSection,
|
||||||
dangerous: true,
|
|
||||||
icon: <TrashIcon />,
|
icon: <TrashIcon />,
|
||||||
visible: ({ activeCollectionId, stores }) => {
|
visible: ({ activeCollectionId, stores }) => {
|
||||||
if (!activeCollectionId) {
|
if (!activeCollectionId) {
|
||||||
@@ -344,7 +171,7 @@ export const deleteCollection = createAction({
|
|||||||
}
|
}
|
||||||
return stores.policies.abilities(activeCollectionId).delete;
|
return stores.policies.abilities(activeCollectionId).delete;
|
||||||
},
|
},
|
||||||
perform: ({ activeCollectionId, t, stores }) => {
|
perform: ({ activeCollectionId, stores, t }) => {
|
||||||
if (!activeCollectionId) {
|
if (!activeCollectionId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -355,6 +182,7 @@ export const deleteCollection = createAction({
|
|||||||
}
|
}
|
||||||
|
|
||||||
stores.dialogs.openModal({
|
stores.dialogs.openModal({
|
||||||
|
isCentered: true,
|
||||||
title: t("Delete collection"),
|
title: t("Delete collection"),
|
||||||
content: (
|
content: (
|
||||||
<CollectionDeleteDialog
|
<CollectionDeleteDialog
|
||||||
@@ -366,33 +194,10 @@ export const deleteCollection = createAction({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createTemplate = createAction({
|
|
||||||
name: ({ t }) => t("New template"),
|
|
||||||
analyticsName: "New template",
|
|
||||||
section: ActiveCollectionSection,
|
|
||||||
icon: <ShapesIcon />,
|
|
||||||
keywords: "new create template",
|
|
||||||
visible: ({ activeCollectionId, stores }) =>
|
|
||||||
!!(
|
|
||||||
!!activeCollectionId &&
|
|
||||||
stores.policies.abilities(activeCollectionId).createDocument
|
|
||||||
),
|
|
||||||
perform: ({ activeCollectionId, event }) => {
|
|
||||||
if (!activeCollectionId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
event?.preventDefault();
|
|
||||||
event?.stopPropagation();
|
|
||||||
history.push(newTemplatePath(activeCollectionId));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const rootCollectionActions = [
|
export const rootCollectionActions = [
|
||||||
openCollection,
|
openCollection,
|
||||||
createCollection,
|
createCollection,
|
||||||
starCollection,
|
starCollection,
|
||||||
unstarCollection,
|
unstarCollection,
|
||||||
subscribeCollection,
|
|
||||||
unsubscribeCollection,
|
|
||||||
deleteCollection,
|
deleteCollection,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
import { DoneIcon, SmileyIcon, TrashIcon } from "outline-icons";
|
|
||||||
import * as React from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import stores from "~/stores";
|
|
||||||
import Comment from "~/models/Comment";
|
|
||||||
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
|
|
||||||
import ViewReactionsDialog from "~/components/Reactions/ViewReactionsDialog";
|
|
||||||
import history from "~/utils/history";
|
|
||||||
import { createAction } from "..";
|
|
||||||
import { DocumentSection } from "../sections";
|
|
||||||
|
|
||||||
export const deleteCommentFactory = ({
|
|
||||||
comment,
|
|
||||||
onDelete,
|
|
||||||
}: {
|
|
||||||
comment: Comment;
|
|
||||||
onDelete: () => void;
|
|
||||||
}) =>
|
|
||||||
createAction({
|
|
||||||
name: ({ t }) => `${t("Delete")}…`,
|
|
||||||
analyticsName: "Delete comment",
|
|
||||||
section: DocumentSection,
|
|
||||||
icon: <TrashIcon />,
|
|
||||||
keywords: "trash",
|
|
||||||
dangerous: true,
|
|
||||||
visible: () => stores.policies.abilities(comment.id).delete,
|
|
||||||
perform: ({ t, event }) => {
|
|
||||||
event?.preventDefault();
|
|
||||||
event?.stopPropagation();
|
|
||||||
|
|
||||||
stores.dialogs.openModal({
|
|
||||||
title: t("Delete comment"),
|
|
||||||
content: <CommentDeleteDialog comment={comment} onSubmit={onDelete} />,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const resolveCommentFactory = ({
|
|
||||||
comment,
|
|
||||||
onResolve,
|
|
||||||
}: {
|
|
||||||
comment: Comment;
|
|
||||||
onResolve: () => void;
|
|
||||||
}) =>
|
|
||||||
createAction({
|
|
||||||
name: ({ t }) => t("Mark as resolved"),
|
|
||||||
analyticsName: "Resolve thread",
|
|
||||||
section: DocumentSection,
|
|
||||||
icon: <DoneIcon outline />,
|
|
||||||
visible: () =>
|
|
||||||
stores.policies.abilities(comment.id).resolve &&
|
|
||||||
stores.policies.abilities(comment.documentId).update,
|
|
||||||
perform: async ({ t }) => {
|
|
||||||
await comment.resolve();
|
|
||||||
|
|
||||||
const locationState = history.location.state as Record<string, unknown>;
|
|
||||||
history.replace({
|
|
||||||
...history.location,
|
|
||||||
state: {
|
|
||||||
sidebarContext: locationState["sidebarContext"],
|
|
||||||
commentId: undefined,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
onResolve();
|
|
||||||
toast.success(t("Thread resolved"));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const unresolveCommentFactory = ({
|
|
||||||
comment,
|
|
||||||
onUnresolve,
|
|
||||||
}: {
|
|
||||||
comment: Comment;
|
|
||||||
onUnresolve: () => void;
|
|
||||||
}) =>
|
|
||||||
createAction({
|
|
||||||
name: ({ t }) => t("Mark as unresolved"),
|
|
||||||
analyticsName: "Unresolve thread",
|
|
||||||
section: DocumentSection,
|
|
||||||
icon: <DoneIcon outline />,
|
|
||||||
visible: () =>
|
|
||||||
stores.policies.abilities(comment.id).unresolve &&
|
|
||||||
stores.policies.abilities(comment.documentId).update,
|
|
||||||
perform: async () => {
|
|
||||||
await comment.unresolve();
|
|
||||||
|
|
||||||
const locationState = history.location.state as Record<string, unknown>;
|
|
||||||
history.replace({
|
|
||||||
...history.location,
|
|
||||||
state: {
|
|
||||||
sidebarContext: locationState["sidebarContext"],
|
|
||||||
commentId: undefined,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
onUnresolve();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const viewCommentReactionsFactory = ({
|
|
||||||
comment,
|
|
||||||
}: {
|
|
||||||
comment: Comment;
|
|
||||||
}) =>
|
|
||||||
createAction({
|
|
||||||
name: ({ t }) => `${t("View reactions")}`,
|
|
||||||
analyticsName: "View comment reactions",
|
|
||||||
section: DocumentSection,
|
|
||||||
icon: <SmileyIcon />,
|
|
||||||
visible: () =>
|
|
||||||
stores.policies.abilities(comment.id).read &&
|
|
||||||
comment.reactions.length > 0,
|
|
||||||
perform: ({ t, event }) => {
|
|
||||||
event?.preventDefault();
|
|
||||||
event?.stopPropagation();
|
|
||||||
|
|
||||||
stores.dialogs.openModal({
|
|
||||||
title: t("Reactions"),
|
|
||||||
content: <ViewReactionsDialog model={comment} />,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,153 +1,38 @@
|
|||||||
import copy from "copy-to-clipboard";
|
import { ToolsIcon, TrashIcon, UserIcon } from "outline-icons";
|
||||||
import {
|
|
||||||
BeakerIcon,
|
|
||||||
CopyIcon,
|
|
||||||
EditIcon,
|
|
||||||
ToolsIcon,
|
|
||||||
TrashIcon,
|
|
||||||
UserIcon,
|
|
||||||
} from "outline-icons";
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { toast } from "sonner";
|
import stores from "~/stores";
|
||||||
import { createAction } from "~/actions";
|
import { createAction } from "~/actions";
|
||||||
import { DeveloperSection } from "~/actions/sections";
|
import { DeveloperSection } from "~/actions/sections";
|
||||||
import env from "~/env";
|
import env from "~/env";
|
||||||
import { client } from "~/utils/ApiClient";
|
import { client } from "~/utils/ApiClient";
|
||||||
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
|
|
||||||
import Logger from "~/utils/Logger";
|
import Logger from "~/utils/Logger";
|
||||||
import { deleteAllDatabases } from "~/utils/developer";
|
import { deleteAllDatabases } from "~/utils/developer";
|
||||||
import history from "~/utils/history";
|
|
||||||
import { homePath } from "~/utils/routeHelpers";
|
|
||||||
|
|
||||||
export const copyId = createAction({
|
|
||||||
name: ({ t }) => t("Copy ID"),
|
|
||||||
icon: <CopyIcon />,
|
|
||||||
keywords: "uuid",
|
|
||||||
section: DeveloperSection,
|
|
||||||
children: ({
|
|
||||||
currentTeamId,
|
|
||||||
currentUserId,
|
|
||||||
activeCollectionId,
|
|
||||||
activeDocumentId,
|
|
||||||
}) => {
|
|
||||||
function copyAndToast(text: string | null | undefined) {
|
|
||||||
if (text) {
|
|
||||||
copy(text);
|
|
||||||
toast.success("Copied to clipboard");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
createAction({
|
|
||||||
name: "Copy User ID",
|
|
||||||
section: DeveloperSection,
|
|
||||||
icon: <CopyIcon />,
|
|
||||||
visible: () => !!currentUserId,
|
|
||||||
perform: () => copyAndToast(currentUserId),
|
|
||||||
}),
|
|
||||||
createAction({
|
|
||||||
name: "Copy Team ID",
|
|
||||||
section: DeveloperSection,
|
|
||||||
icon: <CopyIcon />,
|
|
||||||
visible: () => !!currentTeamId,
|
|
||||||
perform: () => copyAndToast(currentTeamId),
|
|
||||||
}),
|
|
||||||
createAction({
|
|
||||||
name: "Copy Collection ID",
|
|
||||||
icon: <CopyIcon />,
|
|
||||||
section: DeveloperSection,
|
|
||||||
visible: () => !!activeCollectionId,
|
|
||||||
perform: () => copyAndToast(activeCollectionId),
|
|
||||||
}),
|
|
||||||
createAction({
|
|
||||||
name: "Copy Document ID",
|
|
||||||
icon: <CopyIcon />,
|
|
||||||
section: DeveloperSection,
|
|
||||||
visible: () => !!activeDocumentId,
|
|
||||||
perform: () => copyAndToast(activeDocumentId),
|
|
||||||
}),
|
|
||||||
createAction({
|
|
||||||
name: "Copy Team ID",
|
|
||||||
icon: <CopyIcon />,
|
|
||||||
section: DeveloperSection,
|
|
||||||
visible: () => !!currentTeamId,
|
|
||||||
perform: () => copyAndToast(currentTeamId),
|
|
||||||
}),
|
|
||||||
createAction({
|
|
||||||
name: "Copy Release ID",
|
|
||||||
icon: <CopyIcon />,
|
|
||||||
section: DeveloperSection,
|
|
||||||
visible: () => !!env.VERSION,
|
|
||||||
perform: () => copyAndToast(env.VERSION),
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function generateRandomText() {
|
|
||||||
const characters =
|
|
||||||
"abcdefghijklmno pqrstuvwxyzABCDEFGHIJKL MNOPQRSTUVWXYZ 0123456789\n";
|
|
||||||
let text = "";
|
|
||||||
for (let i = 0; i < Math.floor(Math.random() * 10) + 1; i++) {
|
|
||||||
text += characters.charAt(Math.floor(Math.random() * characters.length));
|
|
||||||
}
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const startTyping = createAction({
|
|
||||||
name: "Start automatic typing",
|
|
||||||
icon: <EditIcon />,
|
|
||||||
section: DeveloperSection,
|
|
||||||
visible: ({ activeDocumentId }) =>
|
|
||||||
!!activeDocumentId && env.ENVIRONMENT === "development",
|
|
||||||
perform: () => {
|
|
||||||
const intervalId = setInterval(() => {
|
|
||||||
const text = generateRandomText();
|
|
||||||
document.execCommand("insertText", false, text);
|
|
||||||
}, 250);
|
|
||||||
|
|
||||||
window.addEventListener("keydown", (event) => {
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
intervalId && clearInterval(intervalId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
toast.info("Automatic typing started, press Escape to stop");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const clearIndexedDB = createAction({
|
export const clearIndexedDB = createAction({
|
||||||
name: ({ t }) => t("Clear IndexedDB cache"),
|
name: ({ t }) => t("Delete IndexedDB cache"),
|
||||||
icon: <TrashIcon />,
|
icon: <TrashIcon />,
|
||||||
keywords: "cache clear database",
|
keywords: "cache clear database",
|
||||||
section: DeveloperSection,
|
section: DeveloperSection,
|
||||||
perform: async ({ t }) => {
|
perform: async ({ t }) => {
|
||||||
history.push(homePath());
|
|
||||||
await deleteAllDatabases();
|
await deleteAllDatabases();
|
||||||
toast.success(t("IndexedDB cache cleared"));
|
stores.toasts.showToast(t("IndexedDB cache deleted"));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createTestUsers = createAction({
|
export const createTestUsers = createAction({
|
||||||
name: "Create 10 test users",
|
name: "Create test users",
|
||||||
icon: <UserIcon />,
|
icon: <UserIcon />,
|
||||||
section: DeveloperSection,
|
section: DeveloperSection,
|
||||||
visible: () => env.ENVIRONMENT === "development",
|
visible: () => env.ENVIRONMENT === "development",
|
||||||
perform: async () => {
|
perform: async () => {
|
||||||
const count = 10;
|
const count = 10;
|
||||||
await client.post("/developer.create_test_users", { count });
|
|
||||||
toast.message(`${count} test users created`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const createToast = createAction({
|
try {
|
||||||
name: "Create toast",
|
await client.post("/developer.create_test_users", { count });
|
||||||
section: DeveloperSection,
|
stores.toasts.showToast(`${count} test users created`);
|
||||||
visible: () => env.ENVIRONMENT === "development",
|
} catch (err) {
|
||||||
perform: () => {
|
stores.toasts.showToast(err.message, { type: "error" });
|
||||||
toast.message("Hello world", {
|
}
|
||||||
duration: 30000,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -155,9 +40,9 @@ export const toggleDebugLogging = createAction({
|
|||||||
name: ({ t }) => t("Toggle debug logging"),
|
name: ({ t }) => t("Toggle debug logging"),
|
||||||
icon: <ToolsIcon />,
|
icon: <ToolsIcon />,
|
||||||
section: DeveloperSection,
|
section: DeveloperSection,
|
||||||
perform: ({ t }) => {
|
perform: async ({ t }) => {
|
||||||
Logger.debugLoggingEnabled = !Logger.debugLoggingEnabled;
|
Logger.debugLoggingEnabled = !Logger.debugLoggingEnabled;
|
||||||
toast.message(
|
stores.toasts.showToast(
|
||||||
Logger.debugLoggingEnabled
|
Logger.debugLoggingEnabled
|
||||||
? t("Debug logging enabled")
|
? t("Debug logging enabled")
|
||||||
: t("Debug logging disabled")
|
: t("Debug logging disabled")
|
||||||
@@ -165,45 +50,13 @@ export const toggleDebugLogging = createAction({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const toggleFeatureFlag = createAction({
|
|
||||||
name: "Toggle feature flag",
|
|
||||||
icon: <BeakerIcon />,
|
|
||||||
section: DeveloperSection,
|
|
||||||
visible: () => env.ENVIRONMENT === "development",
|
|
||||||
children: Object.values(Feature).map((flag) =>
|
|
||||||
createAction({
|
|
||||||
id: `flag-${flag}`,
|
|
||||||
name: flag,
|
|
||||||
selected: () => FeatureFlags.isEnabled(flag),
|
|
||||||
section: DeveloperSection,
|
|
||||||
perform: () => {
|
|
||||||
if (FeatureFlags.isEnabled(flag)) {
|
|
||||||
FeatureFlags.disable(flag);
|
|
||||||
toast.success(`Disabled feature flag: ${flag}`);
|
|
||||||
} else {
|
|
||||||
FeatureFlags.enable(flag);
|
|
||||||
toast.success(`Enabled feature flag: ${flag}`);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const developer = createAction({
|
export const developer = createAction({
|
||||||
name: ({ t }) => t("Development"),
|
name: ({ t }) => t("Development"),
|
||||||
keywords: "debug",
|
keywords: "debug",
|
||||||
icon: <ToolsIcon />,
|
icon: <ToolsIcon />,
|
||||||
iconInContextMenu: false,
|
iconInContextMenu: false,
|
||||||
section: DeveloperSection,
|
section: DeveloperSection,
|
||||||
children: [
|
children: [clearIndexedDB, toggleDebugLogging, createTestUsers],
|
||||||
copyId,
|
|
||||||
toggleDebugLogging,
|
|
||||||
toggleFeatureFlag,
|
|
||||||
createToast,
|
|
||||||
createTestUsers,
|
|
||||||
clearIndexedDB,
|
|
||||||
startTyping,
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const rootDeveloperActions = [developer];
|
export const rootDeveloperActions = [developer];
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,32 +3,37 @@ import {
|
|||||||
SearchIcon,
|
SearchIcon,
|
||||||
ArchiveIcon,
|
ArchiveIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
|
EditIcon,
|
||||||
OpenIcon,
|
OpenIcon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
|
ShapesIcon,
|
||||||
KeyboardIcon,
|
KeyboardIcon,
|
||||||
EmailIcon,
|
EmailIcon,
|
||||||
LogoutIcon,
|
LogoutIcon,
|
||||||
ProfileIcon,
|
ProfileIcon,
|
||||||
BrowserIcon,
|
BrowserIcon,
|
||||||
ShapesIcon,
|
|
||||||
DraftsIcon,
|
|
||||||
} from "outline-icons";
|
} from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { UrlHelper } from "@shared/utils/UrlHelper";
|
import {
|
||||||
import { isMac } from "@shared/utils/browser";
|
developersUrl,
|
||||||
|
changelogUrl,
|
||||||
|
feedbackUrl,
|
||||||
|
githubIssuesUrl,
|
||||||
|
} from "@shared/utils/urlHelpers";
|
||||||
import stores from "~/stores";
|
import stores from "~/stores";
|
||||||
import SearchQuery from "~/models/SearchQuery";
|
import SearchQuery from "~/models/SearchQuery";
|
||||||
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
|
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
|
||||||
import { createAction } from "~/actions";
|
import { createAction } from "~/actions";
|
||||||
import { NavigationSection, RecentSearchesSection } from "~/actions/sections";
|
import { NavigationSection, RecentSearchesSection } from "~/actions/sections";
|
||||||
import env from "~/env";
|
|
||||||
import Desktop from "~/utils/Desktop";
|
import Desktop from "~/utils/Desktop";
|
||||||
|
import { isMac } from "~/utils/browser";
|
||||||
import history from "~/utils/history";
|
import history from "~/utils/history";
|
||||||
import isCloudHosted from "~/utils/isCloudHosted";
|
import isCloudHosted from "~/utils/isCloudHosted";
|
||||||
import {
|
import {
|
||||||
homePath,
|
homePath,
|
||||||
searchPath,
|
searchPath,
|
||||||
draftsPath,
|
draftsPath,
|
||||||
|
templatesPath,
|
||||||
archivePath,
|
archivePath,
|
||||||
trashPath,
|
trashPath,
|
||||||
settingsPath,
|
settingsPath,
|
||||||
@@ -50,25 +55,25 @@ export const navigateToRecentSearchQuery = (searchQuery: SearchQuery) =>
|
|||||||
name: searchQuery.query,
|
name: searchQuery.query,
|
||||||
analyticsName: "Navigate to recent search query",
|
analyticsName: "Navigate to recent search query",
|
||||||
icon: <SearchIcon />,
|
icon: <SearchIcon />,
|
||||||
perform: () => history.push(searchPath({ query: searchQuery.query })),
|
perform: () => history.push(searchPath(searchQuery.query)),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const navigateToDrafts = createAction({
|
export const navigateToDrafts = createAction({
|
||||||
name: ({ t }) => t("Drafts"),
|
name: ({ t }) => t("Drafts"),
|
||||||
analyticsName: "Navigate to drafts",
|
analyticsName: "Navigate to drafts",
|
||||||
section: NavigationSection,
|
section: NavigationSection,
|
||||||
icon: <DraftsIcon />,
|
icon: <EditIcon />,
|
||||||
perform: () => history.push(draftsPath()),
|
perform: () => history.push(draftsPath()),
|
||||||
visible: ({ location }) => location.pathname !== draftsPath(),
|
visible: ({ location }) => location.pathname !== draftsPath(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const navigateToSearch = createAction({
|
export const navigateToTemplates = createAction({
|
||||||
name: ({ t }) => t("Search"),
|
name: ({ t }) => t("Templates"),
|
||||||
analyticsName: "Navigate to search",
|
analyticsName: "Navigate to templates",
|
||||||
section: NavigationSection,
|
section: NavigationSection,
|
||||||
icon: <SearchIcon />,
|
icon: <ShapesIcon />,
|
||||||
perform: () => history.push(searchPath()),
|
perform: () => history.push(templatesPath()),
|
||||||
visible: ({ location }) => location.pathname !== searchPath(),
|
visible: ({ location }) => location.pathname !== templatesPath(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const navigateToArchive = createAction({
|
export const navigateToArchive = createAction({
|
||||||
@@ -96,16 +101,8 @@ export const navigateToSettings = createAction({
|
|||||||
section: NavigationSection,
|
section: NavigationSection,
|
||||||
shortcut: ["g", "s"],
|
shortcut: ["g", "s"],
|
||||||
icon: <SettingsIcon />,
|
icon: <SettingsIcon />,
|
||||||
visible: () => stores.policies.abilities(stores.auth.team?.id || "").update,
|
visible: ({ stores }) =>
|
||||||
perform: () => history.push(settingsPath()),
|
stores.policies.abilities(stores.auth.team?.id || "").update,
|
||||||
});
|
|
||||||
|
|
||||||
export const navigateToWorkspaceSettings = createAction({
|
|
||||||
name: ({ t }) => t("Settings"),
|
|
||||||
analyticsName: "Navigate to workspace settings",
|
|
||||||
section: NavigationSection,
|
|
||||||
icon: <SettingsIcon />,
|
|
||||||
visible: () => stores.policies.abilities(stores.auth.team?.id || "").update,
|
|
||||||
perform: () => history.push(settingsPath("details")),
|
perform: () => history.push(settingsPath("details")),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -118,15 +115,6 @@ export const navigateToProfileSettings = createAction({
|
|||||||
perform: () => history.push(settingsPath()),
|
perform: () => history.push(settingsPath()),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const navigateToTemplateSettings = createAction({
|
|
||||||
name: ({ t }) => t("Templates"),
|
|
||||||
analyticsName: "Navigate to template settings",
|
|
||||||
section: NavigationSection,
|
|
||||||
iconInContextMenu: false,
|
|
||||||
icon: <ShapesIcon />,
|
|
||||||
perform: () => history.push(settingsPath("templates")),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const navigateToNotificationSettings = createAction({
|
export const navigateToNotificationSettings = createAction({
|
||||||
name: ({ t }) => t("Notifications"),
|
name: ({ t }) => t("Notifications"),
|
||||||
analyticsName: "Navigate to notification settings",
|
analyticsName: "Navigate to notification settings",
|
||||||
@@ -145,22 +133,13 @@ export const navigateToAccountPreferences = createAction({
|
|||||||
perform: () => history.push(settingsPath("preferences")),
|
perform: () => history.push(settingsPath("preferences")),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const openDocumentation = createAction({
|
|
||||||
name: ({ t }) => t("Documentation"),
|
|
||||||
analyticsName: "Open documentation",
|
|
||||||
section: NavigationSection,
|
|
||||||
iconInContextMenu: false,
|
|
||||||
icon: <OpenIcon />,
|
|
||||||
perform: () => window.open(UrlHelper.guide),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const openAPIDocumentation = createAction({
|
export const openAPIDocumentation = createAction({
|
||||||
name: ({ t }) => t("API documentation"),
|
name: ({ t }) => t("API documentation"),
|
||||||
analyticsName: "Open API documentation",
|
analyticsName: "Open API documentation",
|
||||||
section: NavigationSection,
|
section: NavigationSection,
|
||||||
iconInContextMenu: false,
|
iconInContextMenu: false,
|
||||||
icon: <OpenIcon />,
|
icon: <OpenIcon />,
|
||||||
perform: () => window.open(UrlHelper.developers),
|
perform: () => window.open(developersUrl()),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const toggleSidebar = createAction({
|
export const toggleSidebar = createAction({
|
||||||
@@ -168,7 +147,7 @@ export const toggleSidebar = createAction({
|
|||||||
analyticsName: "Toggle sidebar",
|
analyticsName: "Toggle sidebar",
|
||||||
keywords: "hide show navigation",
|
keywords: "hide show navigation",
|
||||||
section: NavigationSection,
|
section: NavigationSection,
|
||||||
perform: () => stores.ui.toggleCollapsedSidebar(),
|
perform: ({ stores }) => stores.ui.toggleCollapsedSidebar(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const openFeedbackUrl = createAction({
|
export const openFeedbackUrl = createAction({
|
||||||
@@ -177,14 +156,14 @@ export const openFeedbackUrl = createAction({
|
|||||||
section: NavigationSection,
|
section: NavigationSection,
|
||||||
iconInContextMenu: false,
|
iconInContextMenu: false,
|
||||||
icon: <EmailIcon />,
|
icon: <EmailIcon />,
|
||||||
perform: () => window.open(UrlHelper.contact),
|
perform: () => window.open(feedbackUrl()),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const openBugReportUrl = createAction({
|
export const openBugReportUrl = createAction({
|
||||||
name: ({ t }) => t("Report a bug"),
|
name: ({ t }) => t("Report a bug"),
|
||||||
analyticsName: "Open bug report",
|
analyticsName: "Open bug report",
|
||||||
section: NavigationSection,
|
section: NavigationSection,
|
||||||
perform: () => window.open(UrlHelper.github),
|
perform: () => window.open(githubIssuesUrl()),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const openChangelog = createAction({
|
export const openChangelog = createAction({
|
||||||
@@ -193,7 +172,7 @@ export const openChangelog = createAction({
|
|||||||
section: NavigationSection,
|
section: NavigationSection,
|
||||||
iconInContextMenu: false,
|
iconInContextMenu: false,
|
||||||
icon: <OpenIcon />,
|
icon: <OpenIcon />,
|
||||||
perform: () => window.open(UrlHelper.changelog),
|
perform: () => window.open(changelogUrl()),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const openKeyboardShortcuts = createAction({
|
export const openKeyboardShortcuts = createAction({
|
||||||
@@ -231,23 +210,16 @@ export const logout = createAction({
|
|||||||
analyticsName: "Log out",
|
analyticsName: "Log out",
|
||||||
section: NavigationSection,
|
section: NavigationSection,
|
||||||
icon: <LogoutIcon />,
|
icon: <LogoutIcon />,
|
||||||
perform: async () => {
|
perform: () => stores.auth.logout(),
|
||||||
await stores.auth.logout();
|
|
||||||
if (env.OIDC_LOGOUT_URI) {
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.replace(env.OIDC_LOGOUT_URI);
|
|
||||||
}, 200);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const rootNavigationActions = [
|
export const rootNavigationActions = [
|
||||||
navigateToHome,
|
navigateToHome,
|
||||||
navigateToDrafts,
|
navigateToDrafts,
|
||||||
|
navigateToTemplates,
|
||||||
navigateToArchive,
|
navigateToArchive,
|
||||||
navigateToTrash,
|
navigateToTrash,
|
||||||
downloadApp,
|
downloadApp,
|
||||||
openDocumentation,
|
|
||||||
openAPIDocumentation,
|
openAPIDocumentation,
|
||||||
openFeedbackUrl,
|
openFeedbackUrl,
|
||||||
openBugReportUrl,
|
openBugReportUrl,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ArchiveIcon, MarkAsReadIcon } from "outline-icons";
|
import { MarkAsReadIcon } from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { createAction } from "..";
|
import { createAction } from "..";
|
||||||
import { NotificationSection } from "../sections";
|
import { NotificationSection } from "../sections";
|
||||||
@@ -13,17 +13,4 @@ export const markNotificationsAsRead = createAction({
|
|||||||
visible: ({ stores }) => stores.notifications.approximateUnreadCount > 0,
|
visible: ({ stores }) => stores.notifications.approximateUnreadCount > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const markNotificationsAsArchived = createAction({
|
export const rootNotificationActions = [markNotificationsAsRead];
|
||||||
name: ({ t }) => t("Archive all notifications"),
|
|
||||||
analyticsName: "Mark notifications as archived",
|
|
||||||
section: NotificationSection,
|
|
||||||
icon: <ArchiveIcon />,
|
|
||||||
iconInContextMenu: false,
|
|
||||||
perform: ({ stores }) => stores.notifications.markAllAsArchived(),
|
|
||||||
visible: ({ stores }) => stores.notifications.orderedData.length > 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const rootNotificationActions = [
|
|
||||||
markNotificationsAsRead,
|
|
||||||
markNotificationsAsArchived,
|
|
||||||
];
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import copy from "copy-to-clipboard";
|
|||||||
import { LinkIcon, RestoreIcon } from "outline-icons";
|
import { LinkIcon, RestoreIcon } from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { matchPath } from "react-router-dom";
|
import { matchPath } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
|
||||||
import stores from "~/stores";
|
import stores from "~/stores";
|
||||||
import { createAction } from "~/actions";
|
import { createAction } from "~/actions";
|
||||||
import { RevisionSection } from "~/actions/sections";
|
import { RevisionSection } from "~/actions/sections";
|
||||||
@@ -17,7 +16,7 @@ export const restoreRevision = createAction({
|
|||||||
analyticsName: "Restore revision",
|
analyticsName: "Restore revision",
|
||||||
icon: <RestoreIcon />,
|
icon: <RestoreIcon />,
|
||||||
section: RevisionSection,
|
section: RevisionSection,
|
||||||
visible: ({ activeDocumentId }) =>
|
visible: ({ activeDocumentId, stores }) =>
|
||||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).update,
|
!!activeDocumentId && stores.policies.abilities(activeDocumentId).update,
|
||||||
perform: async ({ event, location, activeDocumentId }) => {
|
perform: async ({ event, location, activeDocumentId }) => {
|
||||||
event?.preventDefault();
|
event?.preventDefault();
|
||||||
@@ -47,7 +46,7 @@ export const copyLinkToRevision = createAction({
|
|||||||
analyticsName: "Copy link to revision",
|
analyticsName: "Copy link to revision",
|
||||||
icon: <LinkIcon />,
|
icon: <LinkIcon />,
|
||||||
section: RevisionSection,
|
section: RevisionSection,
|
||||||
perform: async ({ activeDocumentId, t }) => {
|
perform: async ({ activeDocumentId, stores, t }) => {
|
||||||
if (!activeDocumentId) {
|
if (!activeDocumentId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -69,7 +68,9 @@ export const copyLinkToRevision = createAction({
|
|||||||
copy(url, {
|
copy(url, {
|
||||||
format: "text/plain",
|
format: "text/plain",
|
||||||
onCopy: () => {
|
onCopy: () => {
|
||||||
toast.message(t("Link copied"));
|
stores.toasts.showToast(t("Link copied"), {
|
||||||
|
type: "info",
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { ArrowIcon, PlusIcon } from "outline-icons";
|
import { PlusIcon } from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { stringToColor } from "@shared/utils/color";
|
import { stringToColor } from "@shared/utils/color";
|
||||||
import RootStore from "~/stores/RootStore";
|
import RootStore from "~/stores/RootStore";
|
||||||
import { LoginDialog } from "~/scenes/Login/components/LoginDialog";
|
|
||||||
import TeamNew from "~/scenes/TeamNew";
|
import TeamNew from "~/scenes/TeamNew";
|
||||||
import TeamLogo from "~/components/TeamLogo";
|
import TeamLogo from "~/components/TeamLogo";
|
||||||
import { createAction } from "~/actions";
|
import { createAction } from "~/actions";
|
||||||
import { ActionContext } from "~/types";
|
import { ActionContext } from "~/types";
|
||||||
import Desktop from "~/utils/Desktop";
|
|
||||||
import { TeamSection } from "../sections";
|
import { TeamSection } from "../sections";
|
||||||
|
|
||||||
export const createTeamsList = ({ stores }: { stores: RootStore }) =>
|
export const createTeamsList = ({ stores }: { stores: RootStore }) =>
|
||||||
@@ -62,33 +60,14 @@ export const createTeam = createAction({
|
|||||||
user &&
|
user &&
|
||||||
stores.dialogs.openModal({
|
stores.dialogs.openModal({
|
||||||
title: t("Create a workspace"),
|
title: t("Create a workspace"),
|
||||||
fullscreen: true,
|
|
||||||
content: <TeamNew user={user} />,
|
content: <TeamNew user={user} />,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const desktopLoginTeam = createAction({
|
|
||||||
name: ({ t }) => t("Login to workspace"),
|
|
||||||
analyticsName: "Login to workspace",
|
|
||||||
keywords: "change switch workspace organization team",
|
|
||||||
section: TeamSection,
|
|
||||||
icon: <ArrowIcon />,
|
|
||||||
visible: () => Desktop.isElectron(),
|
|
||||||
perform: ({ t, event, stores }) => {
|
|
||||||
event?.preventDefault();
|
|
||||||
event?.stopPropagation();
|
|
||||||
|
|
||||||
stores.dialogs.openModal({
|
|
||||||
title: t("Login to workspace"),
|
|
||||||
content: <LoginDialog />,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const StyledTeamLogo = styled(TeamLogo)`
|
const StyledTeamLogo = styled(TeamLogo)`
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
border: 0;
|
border: 0;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const rootTeamActions = [switchTeam, createTeam, desktopLoginTeam];
|
export const rootTeamActions = [switchTeam, createTeam];
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
import { PlusIcon } from "outline-icons";
|
import { PlusIcon } from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { UserRole } from "@shared/types";
|
|
||||||
import { UserRoleHelper } from "@shared/utils/UserRoleHelper";
|
|
||||||
import stores from "~/stores";
|
import stores from "~/stores";
|
||||||
import User from "~/models/User";
|
|
||||||
import Invite from "~/scenes/Invite";
|
import Invite from "~/scenes/Invite";
|
||||||
import {
|
import { UserDeleteDialog } from "~/components/UserDialogs";
|
||||||
UserChangeRoleDialog,
|
|
||||||
UserDeleteDialog,
|
|
||||||
} from "~/components/UserDialogs";
|
|
||||||
import { createAction } from "~/actions";
|
import { createAction } from "~/actions";
|
||||||
import { UserSection } from "~/actions/sections";
|
import { UserSection } from "~/actions/sections";
|
||||||
|
|
||||||
@@ -18,51 +12,16 @@ export const inviteUser = createAction({
|
|||||||
icon: <PlusIcon />,
|
icon: <PlusIcon />,
|
||||||
keywords: "team member workspace user",
|
keywords: "team member workspace user",
|
||||||
section: UserSection,
|
section: UserSection,
|
||||||
visible: () =>
|
visible: ({ stores }) =>
|
||||||
stores.policies.abilities(stores.auth.team?.id || "").inviteUser,
|
stores.policies.abilities(stores.auth.team?.id || "").inviteUser,
|
||||||
perform: ({ t }) => {
|
perform: ({ t }) => {
|
||||||
stores.dialogs.openModal({
|
stores.dialogs.openModal({
|
||||||
title: t("Invite to workspace"),
|
title: t("Invite people"),
|
||||||
content: <Invite onSubmit={stores.dialogs.closeAllModals} />,
|
content: <Invite onSubmit={stores.dialogs.closeAllModals} />,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateUserRoleActionFactory = (user: User, role: UserRole) =>
|
|
||||||
createAction({
|
|
||||||
name: ({ t }) =>
|
|
||||||
UserRoleHelper.isRoleHigher(role, user!.role)
|
|
||||||
? `${t("Promote to {{ role }}", {
|
|
||||||
role: UserRoleHelper.displayName(role, t),
|
|
||||||
})}…`
|
|
||||||
: `${t("Demote to {{ role }}", {
|
|
||||||
role: UserRoleHelper.displayName(role, t),
|
|
||||||
})}…`,
|
|
||||||
analyticsName: "Update user role",
|
|
||||||
section: UserSection,
|
|
||||||
visible: () => {
|
|
||||||
const can = stores.policies.abilities(user.id);
|
|
||||||
|
|
||||||
return UserRoleHelper.isRoleHigher(role, user.role)
|
|
||||||
? can.promote
|
|
||||||
: UserRoleHelper.isRoleLower(role, user.role)
|
|
||||||
? can.demote
|
|
||||||
: false;
|
|
||||||
},
|
|
||||||
perform: ({ t }) => {
|
|
||||||
stores.dialogs.openModal({
|
|
||||||
title: t("Update role"),
|
|
||||||
content: (
|
|
||||||
<UserChangeRoleDialog
|
|
||||||
user={user}
|
|
||||||
role={role}
|
|
||||||
onSubmit={stores.dialogs.closeAllModals}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const deleteUserActionFactory = (userId: string) =>
|
export const deleteUserActionFactory = (userId: string) =>
|
||||||
createAction({
|
createAction({
|
||||||
name: ({ t }) => `${t("Delete user")}…`,
|
name: ({ t }) => `${t("Delete user")}…`,
|
||||||
@@ -70,7 +29,7 @@ export const deleteUserActionFactory = (userId: string) =>
|
|||||||
keywords: "leave",
|
keywords: "leave",
|
||||||
dangerous: true,
|
dangerous: true,
|
||||||
section: UserSection,
|
section: UserSection,
|
||||||
visible: () => stores.policies.abilities(userId).delete,
|
visible: ({ stores }) => stores.policies.abilities(userId).delete,
|
||||||
perform: ({ t }) => {
|
perform: ({ t }) => {
|
||||||
const user = stores.users.get(userId);
|
const user = stores.users.get(userId);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -79,6 +38,7 @@ export const deleteUserActionFactory = (userId: string) =>
|
|||||||
|
|
||||||
stores.dialogs.openModal({
|
stores.dialogs.openModal({
|
||||||
title: t("Delete user"),
|
title: t("Delete user"),
|
||||||
|
isCentered: true,
|
||||||
content: (
|
content: (
|
||||||
<UserDeleteDialog
|
<UserDeleteDialog
|
||||||
user={user}
|
user={user}
|
||||||
|
|||||||
+12
-27
@@ -1,6 +1,5 @@
|
|||||||
import flattenDeep from "lodash/flattenDeep";
|
import { flattenDeep } from "lodash";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Optional } from "utility-types";
|
import { Optional } from "utility-types";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import {
|
import {
|
||||||
@@ -74,7 +73,15 @@ export function actionToMenuItem(
|
|||||||
icon,
|
icon,
|
||||||
visible,
|
visible,
|
||||||
dangerous: action.dangerous,
|
dangerous: action.dangerous,
|
||||||
onClick: () => performAction(action, context),
|
onClick: () => {
|
||||||
|
try {
|
||||||
|
action.perform?.(context);
|
||||||
|
} catch (err) {
|
||||||
|
context.stores.toasts.showToast(err.message, {
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
selected: action.selected?.(context),
|
selected: action.selected?.(context),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -98,11 +105,6 @@ export function actionToKBar(
|
|||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const sectionPriority =
|
|
||||||
typeof action.section !== "string" && "priority" in action.section
|
|
||||||
? (action.section.priority as number) ?? 0
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: action.id,
|
id: action.id,
|
||||||
@@ -113,25 +115,8 @@ export function actionToKBar(
|
|||||||
keywords: action.keywords ?? "",
|
keywords: action.keywords ?? "",
|
||||||
shortcut: action.shortcut || [],
|
shortcut: action.shortcut || [],
|
||||||
icon: resolvedIcon,
|
icon: resolvedIcon,
|
||||||
priority: (1 + (action.priority ?? 0)) * (1 + (sectionPriority ?? 0)),
|
perform: action.perform ? () => action.perform?.(context) : undefined,
|
||||||
perform: action.perform
|
|
||||||
? () => performAction(action, context)
|
|
||||||
: undefined,
|
|
||||||
},
|
},
|
||||||
].concat(
|
|
||||||
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
|
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
|
||||||
children.map((child) => ({ ...child, parent: child.parent ?? action.id }))
|
].concat(children.map((child) => ({ ...child, parent: action.id })));
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function performAction(action: Action, context: ActionContext) {
|
|
||||||
const result = action.perform?.(context);
|
|
||||||
|
|
||||||
if (result instanceof Promise) {
|
|
||||||
return result.catch((err: Error) => {
|
|
||||||
toast.error(err.message);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,32 +2,10 @@ import { ActionContext } from "~/types";
|
|||||||
|
|
||||||
export const CollectionSection = ({ t }: ActionContext) => t("Collection");
|
export const CollectionSection = ({ t }: ActionContext) => t("Collection");
|
||||||
|
|
||||||
export const CollectionsSection = ({ t }: ActionContext) => t("Collections");
|
|
||||||
|
|
||||||
export const ActiveCollectionSection = ({ t, stores }: ActionContext) => {
|
|
||||||
const activeCollection = stores.collections.active;
|
|
||||||
return `${t("Collection")} · ${activeCollection?.name}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
ActiveCollectionSection.priority = 0.8;
|
|
||||||
|
|
||||||
export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
|
export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
|
||||||
|
|
||||||
export const DocumentSection = ({ t }: ActionContext) => t("Document");
|
export const DocumentSection = ({ t }: ActionContext) => t("Document");
|
||||||
|
|
||||||
export const DocumentsSection = ({ t }: ActionContext) => t("Documents");
|
|
||||||
|
|
||||||
export const ActiveDocumentSection = ({ t, stores }: ActionContext) => {
|
|
||||||
const activeDocument = stores.documents.active;
|
|
||||||
return `${t("Document")} · ${activeDocument?.titleWithDefault}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
ActiveDocumentSection.priority = 0.9;
|
|
||||||
|
|
||||||
export const RecentSection = ({ t }: ActionContext) => t("Recently viewed");
|
|
||||||
|
|
||||||
RecentSection.priority = 1;
|
|
||||||
|
|
||||||
export const RevisionSection = ({ t }: ActionContext) => t("Revision");
|
export const RevisionSection = ({ t }: ActionContext) => t("Revision");
|
||||||
|
|
||||||
export const SettingsSection = ({ t }: ActionContext) => t("Settings");
|
export const SettingsSection = ({ t }: ActionContext) => t("Settings");
|
||||||
@@ -38,13 +16,7 @@ export const NotificationSection = ({ t }: ActionContext) => t("Notification");
|
|||||||
|
|
||||||
export const UserSection = ({ t }: ActionContext) => t("People");
|
export const UserSection = ({ t }: ActionContext) => t("People");
|
||||||
|
|
||||||
UserSection.priority = 0.5;
|
|
||||||
|
|
||||||
export const TeamSection = ({ t }: ActionContext) => t("Workspace");
|
export const TeamSection = ({ t }: ActionContext) => t("Workspace");
|
||||||
|
|
||||||
export const RecentSearchesSection = ({ t }: ActionContext) =>
|
export const RecentSearchesSection = ({ t }: ActionContext) =>
|
||||||
t("Recent searches");
|
t("Recent searches");
|
||||||
|
|
||||||
RecentSearchesSection.priority = -0.1;
|
|
||||||
|
|
||||||
export const TrashSection = ({ t }: ActionContext) => t("Trash");
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
/* eslint-disable react/prop-types */
|
/* eslint-disable react/prop-types */
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import Tooltip, { Props as TooltipProps } from "~/components/Tooltip";
|
import Tooltip, { Props as TooltipProps } from "~/components/Tooltip";
|
||||||
import { performAction } from "~/actions";
|
|
||||||
import useIsMounted from "~/hooks/useIsMounted";
|
|
||||||
import { Action, ActionContext } from "~/types";
|
import { Action, ActionContext } from "~/types";
|
||||||
|
|
||||||
export type Props = React.HTMLAttributes<HTMLButtonElement> & {
|
export type Props = React.HTMLAttributes<HTMLButtonElement> & {
|
||||||
@@ -26,7 +24,6 @@ const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
|
|||||||
{ action, context, tooltip, hideOnActionDisabled, ...rest }: Props,
|
{ action, context, tooltip, hideOnActionDisabled, ...rest }: Props,
|
||||||
ref: React.Ref<HTMLButtonElement>
|
ref: React.Ref<HTMLButtonElement>
|
||||||
) {
|
) {
|
||||||
const isMounted = useIsMounted();
|
|
||||||
const [executing, setExecuting] = React.useState(false);
|
const [executing, setExecuting] = React.useState(false);
|
||||||
const disabled = rest.disabled;
|
const disabled = rest.disabled;
|
||||||
|
|
||||||
@@ -63,12 +60,10 @@ const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
|
|||||||
? (ev) => {
|
? (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
const response = performAction(action, actionContext);
|
const response = action.perform?.(actionContext);
|
||||||
if (response?.finally) {
|
if (response?.finally) {
|
||||||
setExecuting(true);
|
setExecuting(true);
|
||||||
void response.finally(
|
response.finally(() => setExecuting(false));
|
||||||
() => isMounted() && setExecuting(false)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
: rest.onClick
|
: rest.onClick
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const Actions = styled(Flex)`
|
|||||||
left: 0;
|
left: 0;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background: ${s("background")};
|
background: ${s("background")};
|
||||||
|
transition: ${s("backgroundTransition")};
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
backdrop-filter: blur(20px);
|
backdrop-filter: blur(20px);
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
/* eslint-disable prefer-rest-params */
|
/* eslint-disable prefer-rest-params */
|
||||||
/* global ga */
|
/* global ga */
|
||||||
import escape from "lodash/escape";
|
import { escape } from "lodash";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { IntegrationService, PublicEnv } from "@shared/types";
|
import { IntegrationService } from "@shared/types";
|
||||||
import env from "~/env";
|
import env from "~/env";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
// TODO: Refactor this component to allow injection from plugins
|
|
||||||
const Analytics: React.FC = ({ children }: Props) => {
|
const Analytics: React.FC = ({ children }: Props) => {
|
||||||
// Google Analytics 3
|
// Google Analytics 3
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -44,16 +43,12 @@ const Analytics: React.FC = ({ children }: Props) => {
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const measurementIds = [];
|
const measurementIds = [];
|
||||||
|
|
||||||
|
if (env.analytics.service === IntegrationService.GoogleAnalytics) {
|
||||||
|
measurementIds.push(escape(env.analytics.settings?.measurementId));
|
||||||
|
}
|
||||||
if (env.GOOGLE_ANALYTICS_ID?.startsWith("G-")) {
|
if (env.GOOGLE_ANALYTICS_ID?.startsWith("G-")) {
|
||||||
measurementIds.push(env.GOOGLE_ANALYTICS_ID);
|
measurementIds.push(env.GOOGLE_ANALYTICS_ID);
|
||||||
}
|
}
|
||||||
|
|
||||||
(env.analytics as PublicEnv["analytics"]).forEach((integration) => {
|
|
||||||
if (integration.service === IntegrationService.GoogleAnalytics) {
|
|
||||||
measurementIds.push(escape(integration.settings?.measurementId));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (measurementIds.length === 0) {
|
if (measurementIds.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -80,50 +75,6 @@ const Analytics: React.FC = ({ children }: Props) => {
|
|||||||
document.getElementsByTagName("head")[0]?.appendChild(script);
|
document.getElementsByTagName("head")[0]?.appendChild(script);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Matomo
|
|
||||||
React.useEffect(() => {
|
|
||||||
(env.analytics as PublicEnv["analytics"]).forEach((integration) => {
|
|
||||||
if (integration.service !== IntegrationService.Matomo) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// @ts-expect-error - Matomo global variable
|
|
||||||
const _paq = (window._paq = window._paq || []);
|
|
||||||
_paq.push(["trackPageView"]);
|
|
||||||
_paq.push(["enableLinkTracking"]);
|
|
||||||
(function () {
|
|
||||||
const u = integration.settings?.instanceUrl;
|
|
||||||
_paq.push(["setTrackerUrl", u + "matomo.php"]);
|
|
||||||
_paq.push(["setSiteId", integration.settings?.measurementId]);
|
|
||||||
const d = document,
|
|
||||||
g = d.createElement("script"),
|
|
||||||
s = d.getElementsByTagName("script")[0];
|
|
||||||
g.type = "text/javascript";
|
|
||||||
g.async = true;
|
|
||||||
g.src = u + "matomo.js";
|
|
||||||
s.parentNode?.insertBefore(g, s);
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Umami
|
|
||||||
React.useEffect(() => {
|
|
||||||
(env.analytics as PublicEnv["analytics"]).forEach((integration) => {
|
|
||||||
if (integration.service !== IntegrationService.Umami) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const script = document.createElement("script");
|
|
||||||
script.defer = true;
|
|
||||||
script.src = `${integration.settings?.instanceUrl}${integration.settings?.scriptName}`;
|
|
||||||
script.setAttribute(
|
|
||||||
"data-website-id",
|
|
||||||
integration.settings?.measurementId
|
|
||||||
);
|
|
||||||
document.getElementsByTagName("head")[0]?.appendChild(script);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,50 +1,54 @@
|
|||||||
import { RovingTabIndexProvider } from "@getoutline/react-roving-tabindex";
|
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
useCompositeState,
|
||||||
|
Composite,
|
||||||
|
CompositeStateReturn,
|
||||||
|
} from "reakit/Composite";
|
||||||
|
|
||||||
type Props = React.HTMLAttributes<HTMLDivElement> & {
|
type Props = React.HTMLAttributes<HTMLDivElement> & {
|
||||||
children: () => React.ReactNode;
|
children: (composite: CompositeStateReturn) => React.ReactNode;
|
||||||
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
|
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||||
items: unknown[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function ArrowKeyNavigation(
|
function ArrowKeyNavigation(
|
||||||
{ children, onEscape, items, ...rest }: Props,
|
{ children, onEscape, ...rest }: Props,
|
||||||
ref: React.RefObject<HTMLDivElement>
|
ref: React.RefObject<HTMLDivElement>
|
||||||
) {
|
) {
|
||||||
|
const composite = useCompositeState();
|
||||||
|
|
||||||
const handleKeyDown = React.useCallback(
|
const handleKeyDown = React.useCallback(
|
||||||
(ev: React.KeyboardEvent<HTMLDivElement>) => {
|
(ev) => {
|
||||||
if (onEscape) {
|
if (onEscape) {
|
||||||
if (ev.nativeEvent.isComposing) {
|
if (ev.nativeEvent.isComposing) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ev.key === "Escape" || ev.key === "Backspace") {
|
if (ev.key === "Escape") {
|
||||||
ev.preventDefault();
|
|
||||||
onEscape(ev);
|
onEscape(ev);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
ev.key === "ArrowUp" &&
|
ev.key === "ArrowUp" &&
|
||||||
// If the first item is focused and the user presses ArrowUp
|
composite.currentId === composite.items[0].id
|
||||||
ev.currentTarget.firstElementChild === document.activeElement
|
|
||||||
) {
|
) {
|
||||||
onEscape(ev);
|
onEscape(ev);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onEscape]
|
[composite.currentId, composite.items, onEscape]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RovingTabIndexProvider
|
<Composite
|
||||||
options={{ focusOnClick: true, direction: "both" }}
|
{...rest}
|
||||||
items={items}
|
{...composite}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
role="menu"
|
||||||
|
ref={ref}
|
||||||
>
|
>
|
||||||
<div {...rest} onKeyDown={handleKeyDown} ref={ref}>
|
{children(composite)}
|
||||||
{children()}
|
</Composite>
|
||||||
</div>
|
|
||||||
</RovingTabIndexProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
size?: number;
|
||||||
|
fill?: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function SlackLogo({ size = 34, fill = "#FFF", className }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
fill={fill}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 34 34"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<g stroke="none" strokeWidth="1" fillRule="evenodd">
|
||||||
|
<g transform="translate(0.000000, 17.822581)">
|
||||||
|
<path d="M7.23870968,3.61935484 C7.23870968,5.56612903 5.6483871,7.15645161 3.7016129,7.15645161 C1.75483871,7.15645161 0.164516129,5.56612903 0.164516129,3.61935484 C0.164516129,1.67258065 1.75483871,0.0822580645 3.7016129,0.0822580645 L7.23870968,0.0822580645 L7.23870968,3.61935484 Z" />
|
||||||
|
<path d="M9.02096774,3.61935484 C9.02096774,1.67258065 10.6112903,0.0822580645 12.5580645,0.0822580645 C14.5048387,0.0822580645 16.0951613,1.67258065 16.0951613,3.61935484 L16.0951613,12.4758065 C16.0951613,14.4225806 14.5048387,16.0129032 12.5580645,16.0129032 C10.6112903,16.0129032 9.02096774,14.4225806 9.02096774,12.4758065 C9.02096774,12.4758065 9.02096774,3.61935484 9.02096774,3.61935484 Z" />
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path d="M12.5580645,7.23870968 C10.6112903,7.23870968 9.02096774,5.6483871 9.02096774,3.7016129 C9.02096774,1.75483871 10.6112903,0.164516129 12.5580645,0.164516129 C14.5048387,0.164516129 16.0951613,1.75483871 16.0951613,3.7016129 L16.0951613,7.23870968 L12.5580645,7.23870968 Z" />
|
||||||
|
<path d="M12.5580645,9.02096774 C14.5048387,9.02096774 16.0951613,10.6112903 16.0951613,12.5580645 C16.0951613,14.5048387 14.5048387,16.0951613 12.5580645,16.0951613 L3.7016129,16.0951613 C1.75483871,16.0951613 0.164516129,14.5048387 0.164516129,12.5580645 C0.164516129,10.6112903 1.75483871,9.02096774 3.7016129,9.02096774 C3.7016129,9.02096774 12.5580645,9.02096774 12.5580645,9.02096774 Z" />
|
||||||
|
</g>
|
||||||
|
<g transform="translate(17.822581, 0.000000)">
|
||||||
|
<path d="M8.93870968,12.5580645 C8.93870968,10.6112903 10.5290323,9.02096774 12.4758065,9.02096774 C14.4225806,9.02096774 16.0129032,10.6112903 16.0129032,12.5580645 C16.0129032,14.5048387 14.4225806,16.0951613 12.4758065,16.0951613 L8.93870968,16.0951613 L8.93870968,12.5580645 Z" />
|
||||||
|
<path d="M7.15645161,12.5580645 C7.15645161,14.5048387 5.56612903,16.0951613 3.61935484,16.0951613 C1.67258065,16.0951613 0.0822580645,14.5048387 0.0822580645,12.5580645 L0.0822580645,3.7016129 C0.0822580645,1.75483871 1.67258065,0.164516129 3.61935484,0.164516129 C5.56612903,0.164516129 7.15645161,1.75483871 7.15645161,3.7016129 L7.15645161,12.5580645 Z" />
|
||||||
|
</g>
|
||||||
|
<g transform="translate(17.822581, 17.822581)">
|
||||||
|
<path d="M3.61935484,8.93870968 C5.56612903,8.93870968 7.15645161,10.5290323 7.15645161,12.4758065 C7.15645161,14.4225806 5.56612903,16.0129032 3.61935484,16.0129032 C1.67258065,16.0129032 0.0822580645,14.4225806 0.0822580645,12.4758065 L0.0822580645,8.93870968 L3.61935484,8.93870968 Z" />
|
||||||
|
<path d="M3.61935484,7.15645161 C1.67258065,7.15645161 0.0822580645,5.56612903 0.0822580645,3.61935484 C0.0822580645,1.67258065 1.67258065,0.0822580645 3.61935484,0.0822580645 L12.4758065,0.0822580645 C14.4225806,0.0822580645 16.0129032,1.67258065 16.0129032,3.61935484 C16.0129032,5.56612903 14.4225806,7.15645161 12.4758065,7.15645161 L3.61935484,7.15645161 Z" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SlackLogo;
|
||||||
@@ -2,10 +2,8 @@ import { observer } from "mobx-react";
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Redirect } from "react-router-dom";
|
import { Redirect } from "react-router-dom";
|
||||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import { changeLanguage } from "~/utils/language";
|
import { changeLanguage } from "~/utils/language";
|
||||||
import { logoutPath } from "~/utils/routeHelpers";
|
|
||||||
import LoadingIndicator from "./LoadingIndicator";
|
import LoadingIndicator from "./LoadingIndicator";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -15,11 +13,10 @@ type Props = {
|
|||||||
const Authenticated = ({ children }: Props) => {
|
const Authenticated = ({ children }: Props) => {
|
||||||
const { auth } = useStores();
|
const { auth } = useStores();
|
||||||
const { i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
const language = auth.user?.language;
|
||||||
const language = user?.language;
|
|
||||||
|
|
||||||
// Watching for language changes here as this is the earliest point we might have the user
|
// Watching for language changes here as this is the earliest point we have
|
||||||
// available and means we can start loading translations faster
|
// the user available and means we can start loading translations faster
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
void changeLanguage(language, i18n);
|
void changeLanguage(language, i18n);
|
||||||
}, [i18n, language]);
|
}, [i18n, language]);
|
||||||
@@ -33,7 +30,7 @@ const Authenticated = ({ children }: Props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void auth.logout(true);
|
void auth.logout(true);
|
||||||
return <Redirect to={logoutPath()} />;
|
return <Redirect to="/" />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default observer(Authenticated);
|
export default observer(Authenticated);
|
||||||
|
|||||||
@@ -1,22 +1,17 @@
|
|||||||
import { AnimatePresence } from "framer-motion";
|
import { AnimatePresence } from "framer-motion";
|
||||||
import { observer } from "mobx-react";
|
import { observer, useLocalStore } from "mobx-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {
|
import { Switch, Route, useLocation, matchPath } from "react-router-dom";
|
||||||
Switch,
|
|
||||||
Route,
|
|
||||||
useLocation,
|
|
||||||
matchPath,
|
|
||||||
Redirect,
|
|
||||||
} from "react-router-dom";
|
|
||||||
import { TeamPreference } from "@shared/types";
|
import { TeamPreference } from "@shared/types";
|
||||||
import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
|
import ErrorSuspended from "~/scenes/ErrorSuspended";
|
||||||
|
import DocumentContext from "~/components/DocumentContext";
|
||||||
|
import type { DocumentContextValue } from "~/components/DocumentContext";
|
||||||
import Layout from "~/components/Layout";
|
import Layout from "~/components/Layout";
|
||||||
import RegisterKeyDown from "~/components/RegisterKeyDown";
|
import RegisterKeyDown from "~/components/RegisterKeyDown";
|
||||||
import Sidebar from "~/components/Sidebar";
|
import Sidebar from "~/components/Sidebar";
|
||||||
import SidebarRight from "~/components/Sidebar/Right";
|
import SidebarRight from "~/components/Sidebar/Right";
|
||||||
import SettingsSidebar from "~/components/Sidebar/Settings";
|
import SettingsSidebar from "~/components/Sidebar/Settings";
|
||||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
import type { Editor as TEditor } from "~/editor";
|
||||||
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
|
|
||||||
import usePolicy from "~/hooks/usePolicy";
|
import usePolicy from "~/hooks/usePolicy";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import history from "~/utils/history";
|
import history from "~/utils/history";
|
||||||
@@ -29,9 +24,7 @@ import {
|
|||||||
matchDocumentSlug as slug,
|
matchDocumentSlug as slug,
|
||||||
matchDocumentInsights,
|
matchDocumentInsights,
|
||||||
} from "~/utils/routeHelpers";
|
} from "~/utils/routeHelpers";
|
||||||
import { DocumentContextProvider } from "./DocumentContext";
|
|
||||||
import Fade from "./Fade";
|
import Fade from "./Fade";
|
||||||
import { PortalContext } from "./Portal";
|
|
||||||
|
|
||||||
const DocumentComments = lazyWithRetry(
|
const DocumentComments = lazyWithRetry(
|
||||||
() => import("~/scenes/Document/components/Comments")
|
() => import("~/scenes/Document/components/Comments")
|
||||||
@@ -51,11 +44,14 @@ type Props = {
|
|||||||
const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||||
const { ui, auth } = useStores();
|
const { ui, auth } = useStores();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const layoutRef = React.useRef<HTMLDivElement>(null);
|
const can = usePolicy(ui.activeCollectionId);
|
||||||
const can = usePolicy(ui.activeDocumentId);
|
const { user, team } = auth;
|
||||||
const canCollection = usePolicy(ui.activeCollectionId);
|
const documentContext = useLocalStore<DocumentContextValue>(() => ({
|
||||||
const team = useCurrentTeam();
|
editor: null,
|
||||||
const [spendPostLoginPath] = usePostLoginPath();
|
setEditor: (editor: TEditor) => {
|
||||||
|
documentContext.editor = editor;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
const goToSearch = (ev: KeyboardEvent) => {
|
const goToSearch = (ev: KeyboardEvent) => {
|
||||||
if (!ev.metaKey && !ev.ctrlKey) {
|
if (!ev.metaKey && !ev.ctrlKey) {
|
||||||
@@ -70,7 +66,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { activeCollectionId } = ui;
|
const { activeCollectionId } = ui;
|
||||||
if (!activeCollectionId || !canCollection.createDocument) {
|
if (!activeCollectionId || !can.createDocument) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
history.push(newDocumentPath(activeCollectionId));
|
history.push(newDocumentPath(activeCollectionId));
|
||||||
@@ -80,35 +76,29 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
|||||||
return <ErrorSuspended />;
|
return <ErrorSuspended />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const postLoginPath = spendPostLoginPath();
|
const showSidebar = auth.authenticated && user && team;
|
||||||
if (postLoginPath) {
|
|
||||||
return <Redirect to={postLoginPath} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sidebar = (
|
const sidebar = showSidebar ? (
|
||||||
<Fade>
|
<Fade>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path={settingsPath()} component={SettingsSidebar} />
|
<Route path={settingsPath()} component={SettingsSidebar} />
|
||||||
<Route component={Sidebar} />
|
<Route component={Sidebar} />
|
||||||
</Switch>
|
</Switch>
|
||||||
</Fade>
|
</Fade>
|
||||||
);
|
) : undefined;
|
||||||
|
|
||||||
const showHistory =
|
const showHistory = !!matchPath(location.pathname, {
|
||||||
!!matchPath(location.pathname, {
|
path: matchDocumentHistory,
|
||||||
path: matchDocumentHistory,
|
});
|
||||||
}) && can.listRevisions;
|
const showInsights = !!matchPath(location.pathname, {
|
||||||
const showInsights =
|
path: matchDocumentInsights,
|
||||||
!!matchPath(location.pathname, {
|
});
|
||||||
path: matchDocumentInsights,
|
|
||||||
}) && can.listViews;
|
|
||||||
const showComments =
|
const showComments =
|
||||||
!showInsights &&
|
!showInsights &&
|
||||||
!showHistory &&
|
!showHistory &&
|
||||||
can.comment &&
|
|
||||||
ui.activeDocumentId &&
|
ui.activeDocumentId &&
|
||||||
ui.commentsExpanded &&
|
ui.commentsExpanded.includes(ui.activeDocumentId) &&
|
||||||
team.getPreference(TeamPreference.Commenting);
|
team?.getPreference(TeamPreference.Commenting);
|
||||||
|
|
||||||
const sidebarRight = (
|
const sidebarRight = (
|
||||||
<AnimatePresence
|
<AnimatePresence
|
||||||
@@ -130,24 +120,17 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentContextProvider>
|
<DocumentContext.Provider value={documentContext}>
|
||||||
<PortalContext.Provider value={layoutRef.current}>
|
<Layout title={team?.name} sidebar={sidebar} sidebarRight={sidebarRight}>
|
||||||
<Layout
|
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
|
||||||
title={team.name}
|
<RegisterKeyDown trigger="t" handler={goToSearch} />
|
||||||
sidebar={sidebar}
|
<RegisterKeyDown trigger="/" handler={goToSearch} />
|
||||||
sidebarRight={sidebarRight}
|
{children}
|
||||||
ref={layoutRef}
|
<React.Suspense fallback={null}>
|
||||||
>
|
<CommandBar />
|
||||||
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
|
</React.Suspense>
|
||||||
<RegisterKeyDown trigger="t" handler={goToSearch} />
|
</Layout>
|
||||||
<RegisterKeyDown trigger="/" handler={goToSearch} />
|
</DocumentContext.Provider>
|
||||||
{children}
|
|
||||||
<React.Suspense fallback={null}>
|
|
||||||
<CommandBar />
|
|
||||||
</React.Suspense>
|
|
||||||
</Layout>
|
|
||||||
</PortalContext.Provider>
|
|
||||||
</DocumentContextProvider>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,10 @@ import Initials from "./Initials";
|
|||||||
|
|
||||||
export enum AvatarSize {
|
export enum AvatarSize {
|
||||||
Small = 16,
|
Small = 16,
|
||||||
Toast = 18,
|
|
||||||
Medium = 24,
|
Medium = 24,
|
||||||
Large = 28,
|
Large = 32,
|
||||||
XLarge = 32,
|
XLarge = 48,
|
||||||
XXLarge = 48,
|
XXLarge = 64,
|
||||||
Upload = 64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IAvatar {
|
export interface IAvatar {
|
||||||
@@ -21,37 +19,36 @@ export interface IAvatar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/** The size of the avatar */
|
|
||||||
size: AvatarSize;
|
size: AvatarSize;
|
||||||
/** The source of the avatar image, if not passing a model. */
|
|
||||||
src?: string;
|
src?: string;
|
||||||
/** The avatar model, if not passing a source. */
|
|
||||||
model?: IAvatar;
|
model?: IAvatar;
|
||||||
/** The alt text for the image */
|
|
||||||
alt?: string;
|
alt?: string;
|
||||||
/** Optional click handler */
|
showBorder?: boolean;
|
||||||
onClick?: React.MouseEventHandler<HTMLImageElement>;
|
onClick?: React.MouseEventHandler<HTMLImageElement>;
|
||||||
/** Optional class name */
|
|
||||||
className?: string;
|
className?: string;
|
||||||
/** Optional style */
|
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
};
|
};
|
||||||
|
|
||||||
function Avatar(props: Props) {
|
function Avatar(props: Props) {
|
||||||
const { model, style, ...rest } = props;
|
const { showBorder, model, style, ...rest } = props;
|
||||||
const src = props.src || model?.avatarUrl;
|
const src = props.src || model?.avatarUrl;
|
||||||
const [error, handleError] = useBoolean(false);
|
const [error, handleError] = useBoolean(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Relative style={style}>
|
<Relative style={style}>
|
||||||
{src && !error ? (
|
{src && !error ? (
|
||||||
<CircleImg onError={handleError} src={src} {...rest} />
|
<CircleImg
|
||||||
|
onError={handleError}
|
||||||
|
src={src}
|
||||||
|
$showBorder={showBorder}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
) : model ? (
|
) : model ? (
|
||||||
<Initials color={model.color} {...rest}>
|
<Initials color={model.color} $showBorder={showBorder} {...rest}>
|
||||||
{model.initial}
|
{model.initial}
|
||||||
</Initials>
|
</Initials>
|
||||||
) : (
|
) : (
|
||||||
<Initials {...rest} />
|
<Initials $showBorder={showBorder} {...rest} />
|
||||||
)}
|
)}
|
||||||
</Relative>
|
</Relative>
|
||||||
);
|
);
|
||||||
@@ -67,11 +64,15 @@ const Relative = styled.div`
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const CircleImg = styled.img<{ size: number }>`
|
const CircleImg = styled.img<{ size: number; $showBorder?: boolean }>`
|
||||||
display: block;
|
display: block;
|
||||||
width: ${(props) => props.size}px;
|
width: ${(props) => props.size}px;
|
||||||
height: ${(props) => props.size}px;
|
height: ${(props) => props.size}px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
border: ${(props) =>
|
||||||
|
props.$showBorder === false
|
||||||
|
? "none"
|
||||||
|
: `2px solid ${props.theme.background}`};
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -4,46 +4,18 @@ import { useTranslation } from "react-i18next";
|
|||||||
import styled, { css } from "styled-components";
|
import styled, { css } from "styled-components";
|
||||||
import { s } from "@shared/styles";
|
import { s } from "@shared/styles";
|
||||||
import User from "~/models/User";
|
import User from "~/models/User";
|
||||||
|
import Avatar from "~/components/Avatar";
|
||||||
import Tooltip from "~/components/Tooltip";
|
import Tooltip from "~/components/Tooltip";
|
||||||
import Avatar, { AvatarSize } from "./Avatar";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Props for the AvatarWithPresence component
|
|
||||||
*/
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/** The user to display the avatar for */
|
|
||||||
user: User;
|
user: User;
|
||||||
/** Whether the user is currently present in the document */
|
|
||||||
isPresent: boolean;
|
isPresent: boolean;
|
||||||
/** Whether the user is currently editing the document */
|
|
||||||
isEditing: boolean;
|
isEditing: boolean;
|
||||||
/** Whether the user is currently observing the document */
|
|
||||||
isObserving: boolean;
|
isObserving: boolean;
|
||||||
/** Whether this avatar represents the current user */
|
|
||||||
isCurrentUser: boolean;
|
isCurrentUser: boolean;
|
||||||
/** Optional click handler for the avatar */
|
|
||||||
onClick?: React.MouseEventHandler<HTMLImageElement>;
|
onClick?: React.MouseEventHandler<HTMLImageElement>;
|
||||||
/** Size of the avatar, defaults to AvatarSize.Large */
|
|
||||||
size?: AvatarSize;
|
|
||||||
/** Optional inline styles to apply to the avatar wrapper */
|
|
||||||
style?: React.CSSProperties;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* AvatarWithPresence component displays a user's avatar with visual indicators
|
|
||||||
* for their current status (present, editing, observing).
|
|
||||||
*
|
|
||||||
* The component shows different visual states:
|
|
||||||
* - Present users have full opacity
|
|
||||||
* - Non-present users have reduced opacity
|
|
||||||
* - Observing users have a colored border matching their user color
|
|
||||||
* - Hovering shows a colored border
|
|
||||||
*
|
|
||||||
* A tooltip displays the user's name and current status.
|
|
||||||
*
|
|
||||||
* @param props - Component properties
|
|
||||||
* @returns React component
|
|
||||||
*/
|
|
||||||
function AvatarWithPresence({
|
function AvatarWithPresence({
|
||||||
onClick,
|
onClick,
|
||||||
user,
|
user,
|
||||||
@@ -51,8 +23,6 @@ function AvatarWithPresence({
|
|||||||
isEditing,
|
isEditing,
|
||||||
isObserving,
|
isObserving,
|
||||||
isCurrentUser,
|
isCurrentUser,
|
||||||
size = AvatarSize.Large,
|
|
||||||
style,
|
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const status = isPresent
|
const status = isPresent
|
||||||
@@ -64,7 +34,7 @@ function AvatarWithPresence({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={
|
tooltip={
|
||||||
<Centered>
|
<Centered>
|
||||||
<strong>{user.name}</strong> {isCurrentUser && `(${t("You")})`}
|
<strong>{user.name}</strong> {isCurrentUser && `(${t("You")})`}
|
||||||
{status && (
|
{status && (
|
||||||
@@ -77,47 +47,29 @@ function AvatarWithPresence({
|
|||||||
}
|
}
|
||||||
placement="bottom"
|
placement="bottom"
|
||||||
>
|
>
|
||||||
<AvatarPresence
|
<AvatarWrapper
|
||||||
$isPresent={isPresent}
|
$isPresent={isPresent}
|
||||||
$isObserving={isObserving}
|
$isObserving={isObserving}
|
||||||
$color={user.color}
|
$color={user.color}
|
||||||
style={style}
|
|
||||||
>
|
>
|
||||||
<Avatar model={user} onClick={onClick} size={size} />
|
<Avatar model={user} onClick={onClick} size={32} />
|
||||||
</AvatarPresence>
|
</AvatarWrapper>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Centered container for tooltip content
|
|
||||||
*/
|
|
||||||
const Centered = styled.div`
|
const Centered = styled.div`
|
||||||
text-align: center;
|
text-align: center;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
/**
|
|
||||||
* Props for the AvatarPresence styled component
|
|
||||||
*/
|
|
||||||
type AvatarWrapperProps = {
|
type AvatarWrapperProps = {
|
||||||
/** Whether the user is currently present */
|
|
||||||
$isPresent: boolean;
|
$isPresent: boolean;
|
||||||
/** Whether the user is currently observing */
|
|
||||||
$isObserving: boolean;
|
$isObserving: boolean;
|
||||||
/** The user's color for border highlighting */
|
|
||||||
$color: string;
|
$color: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
const AvatarWrapper = styled.div<AvatarWrapperProps>`
|
||||||
* Styled component that wraps the Avatar and provides visual indicators
|
|
||||||
* for the user's presence status.
|
|
||||||
*
|
|
||||||
* - Adjusts opacity based on presence
|
|
||||||
* - Adds colored borders for observing users
|
|
||||||
* - Handles hover effects
|
|
||||||
*/
|
|
||||||
const AvatarPresence = styled.div<AvatarWrapperProps>`
|
|
||||||
opacity: ${(props) => (props.$isPresent ? 1 : 0.5)};
|
opacity: ${(props) => (props.$isPresent ? 1 : 0.5)};
|
||||||
transition: opacity 250ms ease-in-out;
|
transition: opacity 250ms ease-in-out;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import { GroupIcon } from "outline-icons";
|
|
||||||
import * as React from "react";
|
|
||||||
import { useTheme } from "styled-components";
|
|
||||||
import Squircle from "@shared/components/Squircle";
|
|
||||||
import Group from "~/models/Group";
|
|
||||||
import { AvatarSize } from "../Avatar/Avatar";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
/** The group to show an avatar for */
|
|
||||||
group: Group;
|
|
||||||
/** The size of the icon, 24px is default to match standard avatars */
|
|
||||||
size?: number;
|
|
||||||
/** The color of the avatar */
|
|
||||||
color?: string;
|
|
||||||
/** The background color of the avatar */
|
|
||||||
backgroundColor?: string;
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function GroupAvatar({
|
|
||||||
color,
|
|
||||||
backgroundColor,
|
|
||||||
size = AvatarSize.Medium,
|
|
||||||
className,
|
|
||||||
}: Props) {
|
|
||||||
const theme = useTheme();
|
|
||||||
return (
|
|
||||||
<Squircle color={color ?? theme.text} size={size} className={className}>
|
|
||||||
<GroupIcon
|
|
||||||
color={backgroundColor ?? theme.background}
|
|
||||||
size={size * 0.75}
|
|
||||||
/>
|
|
||||||
</Squircle>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,33 +1,26 @@
|
|||||||
import { getLuminance } from "polished";
|
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { s } from "@shared/styles";
|
|
||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
|
|
||||||
const Initials = styled(Flex)<{
|
const Initials = styled(Flex)<{
|
||||||
/** The color of the background, defaults to textTertiary. */
|
|
||||||
color?: string;
|
color?: string;
|
||||||
/** Content is only used to calculate font size, use children to render. */
|
|
||||||
content?: string;
|
|
||||||
/** The size of the avatar */
|
|
||||||
size: number;
|
size: number;
|
||||||
|
$showBorder?: boolean;
|
||||||
}>`
|
}>`
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
color: ${(props) =>
|
color: #fff;
|
||||||
getLuminance(props.color ?? props.theme.textTertiary) > 0.5
|
background-color: ${(props) => props.color};
|
||||||
? s("black50")
|
|
||||||
: s("white75")};
|
|
||||||
background-color: ${(props) => props.color ?? props.theme.textTertiary};
|
|
||||||
width: ${(props) => props.size}px;
|
width: ${(props) => props.size}px;
|
||||||
height: ${(props) => props.size}px;
|
height: ${(props) => props.size}px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
border: 2px solid
|
||||||
|
${(props) =>
|
||||||
|
props.$showBorder === false ? "transparent" : props.theme.background};
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
font-size: ${(props) => props.size / 2}px;
|
||||||
// adjust font size down for each additional character
|
|
||||||
font-size: ${(props) => props.size / 2 - (props.content?.length ?? 0)}px;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import Avatar, { IAvatar, AvatarSize } from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
import AvatarWithPresence from "./AvatarWithPresence";
|
import AvatarWithPresence from "./AvatarWithPresence";
|
||||||
import { GroupAvatar } from "./GroupAvatar";
|
|
||||||
|
|
||||||
export { Avatar, GroupAvatar, AvatarSize, AvatarWithPresence };
|
export { AvatarWithPresence };
|
||||||
|
|
||||||
export type { IAvatar };
|
export default Avatar;
|
||||||
|
|||||||
@@ -34,17 +34,16 @@ const Link = styled.a`
|
|||||||
fill: ${s("text")};
|
fill: ${s("text")};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: ${s("sidebarBackground")};
|
||||||
|
}
|
||||||
|
|
||||||
${breakpoint("tablet")`
|
${breakpoint("tablet")`
|
||||||
z-index: ${depths.sidebar + 1};
|
z-index: ${depths.sidebar + 1};
|
||||||
background: ${s("sidebarBackground")};
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
right: 0;
|
left: 0;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: ${s("sidebarControlHoverBackground")};
|
|
||||||
}
|
|
||||||
`};
|
`};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -5,19 +5,20 @@ import styled from "styled-components";
|
|||||||
import { s, ellipsis } from "@shared/styles";
|
import { s, ellipsis } from "@shared/styles";
|
||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
import BreadcrumbMenu from "~/menus/BreadcrumbMenu";
|
import BreadcrumbMenu from "~/menus/BreadcrumbMenu";
|
||||||
import { undraggableOnDesktop } from "~/styles";
|
|
||||||
import { MenuInternalLink } from "~/types";
|
import { MenuInternalLink } from "~/types";
|
||||||
|
|
||||||
type Props = React.PropsWithChildren<{
|
type Props = {
|
||||||
items: MenuInternalLink[];
|
items: MenuInternalLink[];
|
||||||
max?: number;
|
max?: number;
|
||||||
highlightFirstItem?: boolean;
|
highlightFirstItem?: boolean;
|
||||||
}>;
|
};
|
||||||
|
|
||||||
function Breadcrumb(
|
function Breadcrumb({
|
||||||
{ items, highlightFirstItem, children, max = 2 }: Props,
|
items,
|
||||||
ref: React.RefObject<HTMLDivElement> | null
|
highlightFirstItem,
|
||||||
) {
|
children,
|
||||||
|
max = 2,
|
||||||
|
}: React.PropsWithChildren<Props>) {
|
||||||
const totalItems = items.length;
|
const totalItems = items.length;
|
||||||
const topLevelItems: MenuInternalLink[] = [...items];
|
const topLevelItems: MenuInternalLink[] = [...items];
|
||||||
let overflowItems;
|
let overflowItems;
|
||||||
@@ -35,13 +36,9 @@ function Breadcrumb(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex justify="flex-start" align="center" ref={ref}>
|
<Flex justify="flex-start" align="center">
|
||||||
{topLevelItems.map((item, index) => (
|
{topLevelItems.map((item, index) => (
|
||||||
<React.Fragment
|
<React.Fragment key={String(item.to) || index}>
|
||||||
key={
|
|
||||||
(typeof item.to === "string" ? item.to : item.to.pathname) || index
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{item.icon}
|
{item.icon}
|
||||||
{item.to ? (
|
{item.to ? (
|
||||||
<Item
|
<Item
|
||||||
@@ -69,8 +66,6 @@ const Slash = styled(GoToIcon)`
|
|||||||
|
|
||||||
const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
|
const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
|
||||||
${ellipsis()}
|
${ellipsis()}
|
||||||
${undraggableOnDesktop()}
|
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-shrink: 1;
|
flex-shrink: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -90,4 +85,4 @@ const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default React.forwardRef<HTMLDivElement, Props>(Breadcrumb);
|
export default Breadcrumb;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { LocationDescriptor } from "history";
|
import { LocationDescriptor } from "history";
|
||||||
import { DisclosureIcon } from "outline-icons";
|
import { ExpandedIcon } from "outline-icons";
|
||||||
import { darken, lighten, transparentize } from "polished";
|
import { darken, lighten, transparentize } from "polished";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
@@ -25,7 +25,7 @@ const RealButton = styled(ActionButton)<RealProps>`
|
|||||||
background: ${s("accent")};
|
background: ${s("accent")};
|
||||||
color: ${s("accentText")};
|
color: ${s("accentText")};
|
||||||
box-shadow: rgba(0, 0, 0, 0.2) 0px 1px 2px;
|
box-shadow: rgba(0, 0, 0, 0.2) 0px 1px 2px;
|
||||||
border-radius: 6px;
|
border-radius: 4px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
@@ -49,8 +49,8 @@ const RealButton = styled(ActionButton)<RealProps>`
|
|||||||
&:disabled {
|
&:disabled {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
color: ${(props) => transparentize(0.3, props.theme.accentText)};
|
color: ${(props) => transparentize(0.5, props.theme.accentText)};
|
||||||
background: ${(props) => transparentize(0.1, props.theme.accent)};
|
background: ${(props) => lighten(0.2, props.theme.accent)};
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
fill: ${(props) => props.theme.white50};
|
fill: ${(props) => props.theme.white50};
|
||||||
@@ -80,10 +80,6 @@ const RealButton = styled(ActionButton)<RealProps>`
|
|||||||
} 0 0 0 1px inset;
|
} 0 0 0 1px inset;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
box-shadow: ${`rgba(0, 0, 0, 0.07) 0px 1px 2px, ${props.theme.inputBorderFocused} 0 0 0 1px inset`};
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
color: ${props.theme.textTertiary};
|
color: ${props.theme.textTertiary};
|
||||||
background: none;
|
background: none;
|
||||||
@@ -109,7 +105,7 @@ const RealButton = styled(ActionButton)<RealProps>`
|
|||||||
background: ${lighten(0.05, props.theme.danger)};
|
background: ${lighten(0.05, props.theme.danger)};
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus-visible {
|
&.focus-visible {
|
||||||
outline-color: ${darken(0.2, props.theme.danger)} !important;
|
outline-color: ${darken(0.2, props.theme.danger)} !important;
|
||||||
}
|
}
|
||||||
`};
|
`};
|
||||||
@@ -175,7 +171,7 @@ const Button = <T extends React.ElementType = "button">(
|
|||||||
danger,
|
danger,
|
||||||
...rest
|
...rest
|
||||||
} = props;
|
} = props;
|
||||||
const hasText = !!children || value !== undefined;
|
const hasText = children !== undefined || value !== undefined;
|
||||||
const ic = hideIcon ? undefined : action?.icon ?? icon;
|
const ic = hideIcon ? undefined : action?.icon ?? icon;
|
||||||
const hasIcon = ic !== undefined;
|
const hasIcon = ic !== undefined;
|
||||||
|
|
||||||
@@ -193,14 +189,10 @@ const Button = <T extends React.ElementType = "button">(
|
|||||||
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
|
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
|
||||||
{hasIcon && ic}
|
{hasIcon && ic}
|
||||||
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
|
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
|
||||||
{disclosure && <StyledDisclosureIcon />}
|
{disclosure && <ExpandedIcon />}
|
||||||
</Inner>
|
</Inner>
|
||||||
</RealButton>
|
</RealButton>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const StyledDisclosureIcon = styled(DisclosureIcon)`
|
|
||||||
opacity: 0.8;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default React.forwardRef(Button);
|
export default React.forwardRef(Button);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import breakpoint from "styled-components-breakpoint";
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
maxWidth?: string;
|
|
||||||
withStickyHeader?: boolean;
|
withStickyHeader?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -19,24 +18,18 @@ const Container = styled.div<Props>`
|
|||||||
`};
|
`};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
type ContentProps = { $maxWidth?: string };
|
const Content = styled.div`
|
||||||
|
max-width: 46em;
|
||||||
const Content = styled.div<ContentProps>`
|
|
||||||
max-width: ${(props) => props.$maxWidth ?? "46em"};
|
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
|
||||||
${breakpoint("desktopLarge")`
|
${breakpoint("desktopLarge")`
|
||||||
max-width: ${(props: ContentProps) => props.$maxWidth ?? "52em"};
|
max-width: 52em;
|
||||||
`};
|
`};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const CenteredContent: React.FC<Props> = ({
|
const CenteredContent: React.FC<Props> = ({ children, ...rest }: Props) => (
|
||||||
children,
|
|
||||||
maxWidth,
|
|
||||||
...rest
|
|
||||||
}: Props) => (
|
|
||||||
<Container {...rest}>
|
<Container {...rest}>
|
||||||
<Content $maxWidth={maxWidth}>{children}</Content>
|
<Content>{children}</Content>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { changeLanguage } from "~/utils/language";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
locale: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ChangeLanguage({ locale }: Props) {
|
|
||||||
const { i18n } = useTranslation();
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
void changeLanguage(locale, i18n);
|
|
||||||
}, [locale, i18n]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
import filter from "lodash/filter";
|
import { sortBy, filter, uniq, isEqual } from "lodash";
|
||||||
import isEqual from "lodash/isEqual";
|
|
||||||
import orderBy from "lodash/orderBy";
|
|
||||||
import uniq from "lodash/uniq";
|
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
import { AvatarSize, AvatarWithPresence } from "~/components/Avatar";
|
import AvatarWithPresence from "~/components/Avatar/AvatarWithPresence";
|
||||||
import DocumentViews from "~/components/DocumentViews";
|
import DocumentViews from "~/components/DocumentViews";
|
||||||
import Facepile from "~/components/Facepile";
|
import Facepile from "~/components/Facepile";
|
||||||
import NudeButton from "~/components/NudeButton";
|
import NudeButton from "~/components/NudeButton";
|
||||||
@@ -16,18 +13,10 @@ import useCurrentUser from "~/hooks/useCurrentUser";
|
|||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/** The document to display live collaborators for */
|
|
||||||
document: Document;
|
document: Document;
|
||||||
/** The maximum number of collaborators to display, defaults to 6 */
|
|
||||||
limit?: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Displays a list of live collaborators for a document, including their avatars
|
|
||||||
* and presence status.
|
|
||||||
*/
|
|
||||||
function Collaborators(props: Props) {
|
function Collaborators(props: Props) {
|
||||||
const { limit = 6 } = props;
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const user = useCurrentUser();
|
const user = useCurrentUser();
|
||||||
const currentUserId = user?.id;
|
const currentUserId = user?.id;
|
||||||
@@ -47,18 +36,17 @@ function Collaborators(props: Props) {
|
|||||||
// ensure currently present via websocket are always ordered first
|
// ensure currently present via websocket are always ordered first
|
||||||
const collaborators = React.useMemo(
|
const collaborators = React.useMemo(
|
||||||
() =>
|
() =>
|
||||||
orderBy(
|
sortBy(
|
||||||
filter(
|
filter(
|
||||||
users.all,
|
users.orderedData,
|
||||||
(u) =>
|
(user) =>
|
||||||
(presentIds.includes(u.id) ||
|
(presentIds.includes(user.id) ||
|
||||||
document.collaboratorIds.includes(u.id)) &&
|
document.collaboratorIds.includes(user.id)) &&
|
||||||
!u.isSuspended
|
!user.isSuspended
|
||||||
),
|
),
|
||||||
[(u) => presentIds.includes(u.id), "id"],
|
(user) => presentIds.includes(user.id)
|
||||||
["asc", "asc"]
|
|
||||||
),
|
),
|
||||||
[document.collaboratorIds, users.all, presentIds]
|
[document.collaboratorIds, users.orderedData, presentIds]
|
||||||
);
|
);
|
||||||
|
|
||||||
// load any users we don't yet have in memory
|
// load any users we don't yet have in memory
|
||||||
@@ -78,56 +66,43 @@ function Collaborators(props: Props) {
|
|||||||
placement: "bottom-end",
|
placement: "bottom-end",
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderAvatar = React.useCallback(
|
|
||||||
({ model: collaborator, ...rest }) => {
|
|
||||||
const isPresent = presentIds.includes(collaborator.id);
|
|
||||||
const isEditing = editingIds.includes(collaborator.id);
|
|
||||||
const isObserving = ui.observingUserId === collaborator.id;
|
|
||||||
const isObservable = collaborator.id !== currentUserId;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AvatarWithPresence
|
|
||||||
{...rest}
|
|
||||||
key={collaborator.id}
|
|
||||||
user={collaborator}
|
|
||||||
isPresent={isPresent}
|
|
||||||
isEditing={isEditing}
|
|
||||||
isObserving={isObserving}
|
|
||||||
isCurrentUser={currentUserId === collaborator.id}
|
|
||||||
onClick={
|
|
||||||
isObservable
|
|
||||||
? (ev) => {
|
|
||||||
if (isPresent) {
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
ui.setObservingUser(
|
|
||||||
isObserving ? undefined : collaborator.id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[presentIds, ui, currentUserId, editingIds]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PopoverDisclosure {...popover}>
|
<PopoverDisclosure {...popover}>
|
||||||
{(popoverProps) => (
|
{(props) => (
|
||||||
<NudeButton
|
<NudeButton width={collaborators.length * 32} height={32} {...props}>
|
||||||
width={Math.min(collaborators.length, limit) * AvatarSize.Large}
|
|
||||||
height={AvatarSize.Large}
|
|
||||||
{...popoverProps}
|
|
||||||
>
|
|
||||||
<Facepile
|
<Facepile
|
||||||
size={AvatarSize.Large}
|
|
||||||
limit={limit}
|
|
||||||
overflow={Math.max(0, collaborators.length - limit)}
|
|
||||||
users={collaborators}
|
users={collaborators}
|
||||||
renderAvatar={renderAvatar}
|
renderAvatar={(collaborator) => {
|
||||||
|
const isPresent = presentIds.includes(collaborator.id);
|
||||||
|
const isEditing = editingIds.includes(collaborator.id);
|
||||||
|
const isObserving = ui.observingUserId === collaborator.id;
|
||||||
|
const isObservable = collaborator.id !== user.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AvatarWithPresence
|
||||||
|
key={collaborator.id}
|
||||||
|
user={collaborator}
|
||||||
|
isPresent={isPresent}
|
||||||
|
isEditing={isEditing}
|
||||||
|
isObserving={isObserving}
|
||||||
|
isCurrentUser={currentUserId === collaborator.id}
|
||||||
|
onClick={
|
||||||
|
isObservable
|
||||||
|
? (ev) => {
|
||||||
|
if (isPresent) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
ui.setObservingUser(
|
||||||
|
isObserving ? undefined : collaborator.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</NudeButton>
|
</NudeButton>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import { observer } from "mobx-react";
|
|
||||||
import * as React from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import useStores from "~/hooks/useStores";
|
|
||||||
import { CollectionForm, FormData } from "./CollectionForm";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
collectionId: string;
|
|
||||||
onSubmit: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CollectionEdit = observer(function CollectionEdit_({
|
|
||||||
collectionId,
|
|
||||||
onSubmit,
|
|
||||||
}: Props) {
|
|
||||||
const { collections } = useStores();
|
|
||||||
const collection = collections.get(collectionId);
|
|
||||||
|
|
||||||
const handleSubmit = React.useCallback(
|
|
||||||
async (data: FormData) => {
|
|
||||||
try {
|
|
||||||
await collection?.save(data);
|
|
||||||
onSubmit?.();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error.message);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[collection, onSubmit]
|
|
||||||
);
|
|
||||||
|
|
||||||
return <CollectionForm collection={collection} handleSubmit={handleSubmit} />;
|
|
||||||
});
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
import { observer } from "mobx-react";
|
|
||||||
import * as React from "react";
|
|
||||||
import { Controller, useForm } from "react-hook-form";
|
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
|
||||||
import styled from "styled-components";
|
|
||||||
import Icon from "@shared/components/Icon";
|
|
||||||
import { randomElement } from "@shared/random";
|
|
||||||
import { CollectionPermission } from "@shared/types";
|
|
||||||
import { IconLibrary } from "@shared/utils/IconLibrary";
|
|
||||||
import { colorPalette } from "@shared/utils/collections";
|
|
||||||
import { CollectionValidation } from "@shared/validations";
|
|
||||||
import Collection from "~/models/Collection";
|
|
||||||
import Button from "~/components/Button";
|
|
||||||
import Flex from "~/components/Flex";
|
|
||||||
import Input from "~/components/Input";
|
|
||||||
import InputSelectPermission from "~/components/InputSelectPermission";
|
|
||||||
import Switch from "~/components/Switch";
|
|
||||||
import Text from "~/components/Text";
|
|
||||||
import useBoolean from "~/hooks/useBoolean";
|
|
||||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
|
||||||
import { EmptySelectValue } from "~/types";
|
|
||||||
|
|
||||||
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
|
|
||||||
|
|
||||||
export interface FormData {
|
|
||||||
name: string;
|
|
||||||
icon: string;
|
|
||||||
color: string | null;
|
|
||||||
sharing: boolean;
|
|
||||||
permission: CollectionPermission | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CollectionForm = observer(function CollectionForm_({
|
|
||||||
handleSubmit,
|
|
||||||
collection,
|
|
||||||
}: {
|
|
||||||
handleSubmit: (data: FormData) => void;
|
|
||||||
collection?: Collection;
|
|
||||||
}) {
|
|
||||||
const team = useCurrentTeam();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
|
|
||||||
|
|
||||||
const iconColor = React.useMemo(
|
|
||||||
() => collection?.color ?? randomElement(colorPalette),
|
|
||||||
[collection?.color]
|
|
||||||
);
|
|
||||||
|
|
||||||
const fallbackIcon = <Icon value="collection" color={iconColor} />;
|
|
||||||
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
handleSubmit: formHandleSubmit,
|
|
||||||
formState,
|
|
||||||
watch,
|
|
||||||
control,
|
|
||||||
setValue,
|
|
||||||
setFocus,
|
|
||||||
} = useForm<FormData>({
|
|
||||||
mode: "all",
|
|
||||||
defaultValues: {
|
|
||||||
name: collection?.name ?? "",
|
|
||||||
icon: collection?.icon,
|
|
||||||
sharing: collection?.sharing ?? true,
|
|
||||||
permission: collection?.permission,
|
|
||||||
color: iconColor,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const values = watch();
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
// If the user hasn't picked an icon yet, go ahead and suggest one based on
|
|
||||||
// the name of the collection. It's the little things sometimes.
|
|
||||||
if (!hasOpenedIconPicker && !collection) {
|
|
||||||
setValue(
|
|
||||||
"icon",
|
|
||||||
IconLibrary.findIconByKeyword(values.name) ??
|
|
||||||
values.icon ??
|
|
||||||
"collection"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [collection, hasOpenedIconPicker, setValue, values.name, values.icon]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
setTimeout(() => setFocus("name", { shouldSelect: true }), 100);
|
|
||||||
}, [setFocus]);
|
|
||||||
|
|
||||||
const handleIconChange = React.useCallback(
|
|
||||||
(icon: string, color: string | null) => {
|
|
||||||
if (icon !== values.icon) {
|
|
||||||
setFocus("name");
|
|
||||||
}
|
|
||||||
|
|
||||||
setValue("icon", icon);
|
|
||||||
setValue("color", color);
|
|
||||||
},
|
|
||||||
[setFocus, setValue, values.icon]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={formHandleSubmit(handleSubmit)}>
|
|
||||||
<Text as="p">
|
|
||||||
<Trans>
|
|
||||||
Collections are used to group documents and choose permissions
|
|
||||||
</Trans>
|
|
||||||
.
|
|
||||||
</Text>
|
|
||||||
<Flex gap={8}>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder={t("Name")}
|
|
||||||
{...register("name", {
|
|
||||||
required: true,
|
|
||||||
maxLength: CollectionValidation.maxNameLength,
|
|
||||||
})}
|
|
||||||
prefix={
|
|
||||||
<React.Suspense fallback={fallbackIcon}>
|
|
||||||
<StyledIconPicker
|
|
||||||
icon={values.icon}
|
|
||||||
color={values.color ?? iconColor}
|
|
||||||
initial={values.name[0]}
|
|
||||||
popoverPosition="right"
|
|
||||||
onOpen={setHasOpenedIconPicker}
|
|
||||||
onChange={handleIconChange}
|
|
||||||
/>
|
|
||||||
</React.Suspense>
|
|
||||||
}
|
|
||||||
autoComplete="off"
|
|
||||||
autoFocus
|
|
||||||
flex
|
|
||||||
/>
|
|
||||||
</Flex>
|
|
||||||
|
|
||||||
{/* Following controls are available in create flow, but moved elsewhere for edit */}
|
|
||||||
{!collection && (
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="permission"
|
|
||||||
render={({ field }) => (
|
|
||||||
<InputSelectPermission
|
|
||||||
ref={field.ref}
|
|
||||||
value={field.value}
|
|
||||||
onChange={(
|
|
||||||
value: CollectionPermission | typeof EmptySelectValue
|
|
||||||
) => {
|
|
||||||
field.onChange(value === EmptySelectValue ? null : value);
|
|
||||||
}}
|
|
||||||
note={t(
|
|
||||||
"The default access for workspace members, you can share with more users or groups later."
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{team.sharing && (
|
|
||||||
<Switch
|
|
||||||
id="sharing"
|
|
||||||
label={t("Public document sharing")}
|
|
||||||
note={t(
|
|
||||||
"Allow documents within this collection to be shared publicly on the internet."
|
|
||||||
)}
|
|
||||||
{...register("sharing")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Flex justify="flex-end">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={formState.isSubmitting || !formState.isValid}
|
|
||||||
>
|
|
||||||
{collection
|
|
||||||
? formState.isSubmitting
|
|
||||||
? `${t("Saving")}…`
|
|
||||||
: t("Save")
|
|
||||||
: formState.isSubmitting
|
|
||||||
? `${t("Creating")}…`
|
|
||||||
: t("Create")}
|
|
||||||
</Button>
|
|
||||||
</Flex>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const StyledIconPicker = styled(IconPicker)`
|
|
||||||
margin-left: 4px;
|
|
||||||
margin-right: 4px;
|
|
||||||
`;
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { runInAction } from "mobx";
|
|
||||||
import { observer } from "mobx-react";
|
|
||||||
import * as React from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import useStores from "~/hooks/useStores";
|
|
||||||
import history from "~/utils/history";
|
|
||||||
import { CollectionForm, FormData } from "./CollectionForm";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
onSubmit: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CollectionNew = observer(function CollectionNew_({
|
|
||||||
onSubmit,
|
|
||||||
}: Props) {
|
|
||||||
const { collections } = useStores();
|
|
||||||
const handleSubmit = React.useCallback(
|
|
||||||
async (data: FormData) => {
|
|
||||||
try {
|
|
||||||
const collection = await collections.save(data);
|
|
||||||
// Avoid flash of loading state for the new collection, we know it's empty.
|
|
||||||
runInAction(() => {
|
|
||||||
collection.documents = [];
|
|
||||||
});
|
|
||||||
onSubmit?.();
|
|
||||||
history.push(collection.path);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error.message);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[collections, onSubmit]
|
|
||||||
);
|
|
||||||
|
|
||||||
return <CollectionForm handleSubmit={handleSubmit} />;
|
|
||||||
});
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { ArchiveIcon } from "outline-icons";
|
|
||||||
import * as React from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import Collection from "~/models/Collection";
|
|
||||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
|
||||||
import { MenuInternalLink } from "~/types";
|
|
||||||
import { archivePath, collectionPath } from "~/utils/routeHelpers";
|
|
||||||
import Breadcrumb from "./Breadcrumb";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
collection: Collection;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CollectionBreadcrumb: React.FC<Props> = ({ collection }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const items = React.useMemo(() => {
|
|
||||||
const collectionNode: MenuInternalLink = {
|
|
||||||
type: "route",
|
|
||||||
title: collection.name,
|
|
||||||
icon: <CollectionIcon collection={collection} expanded />,
|
|
||||||
to: collectionPath(collection.path),
|
|
||||||
};
|
|
||||||
|
|
||||||
const category: MenuInternalLink | undefined = collection.isArchived
|
|
||||||
? {
|
|
||||||
type: "route",
|
|
||||||
icon: <ArchiveIcon />,
|
|
||||||
title: t("Archive"),
|
|
||||||
to: archivePath(),
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const output = [];
|
|
||||||
if (category) {
|
|
||||||
output.push(category);
|
|
||||||
}
|
|
||||||
|
|
||||||
output.push(collectionNode);
|
|
||||||
|
|
||||||
return output;
|
|
||||||
}, [collection, t]);
|
|
||||||
|
|
||||||
return <Breadcrumb items={items} highlightFirstItem />;
|
|
||||||
};
|
|
||||||
@@ -2,7 +2,6 @@ import { observer } from "mobx-react";
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation, Trans } from "react-i18next";
|
import { useTranslation, Trans } from "react-i18next";
|
||||||
import { useHistory } from "react-router-dom";
|
import { useHistory } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
|
||||||
import Collection from "~/models/Collection";
|
import Collection from "~/models/Collection";
|
||||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||||
import Text from "~/components/Text";
|
import Text from "~/components/Text";
|
||||||
@@ -23,14 +22,11 @@ function CollectionDeleteDialog({ collection, onSubmit }: Props) {
|
|||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
const redirect = collection.id === ui.activeCollectionId;
|
const redirect = collection.id === ui.activeCollectionId;
|
||||||
|
await collection.delete();
|
||||||
|
onSubmit();
|
||||||
if (redirect) {
|
if (redirect) {
|
||||||
history.push(homePath());
|
history.push(homePath());
|
||||||
}
|
}
|
||||||
|
|
||||||
await collection.delete();
|
|
||||||
onSubmit();
|
|
||||||
toast.success(t("Collection deleted"));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -41,7 +37,7 @@ function CollectionDeleteDialog({ collection, onSubmit }: Props) {
|
|||||||
danger
|
danger
|
||||||
>
|
>
|
||||||
<>
|
<>
|
||||||
<Text as="p" type="secondary">
|
<Text type="secondary">
|
||||||
<Trans
|
<Trans
|
||||||
defaults="Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however all published documents within will be moved to the trash."
|
defaults="Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however all published documents within will be moved to the trash."
|
||||||
values={{
|
values={{
|
||||||
@@ -53,7 +49,7 @@ function CollectionDeleteDialog({ collection, onSubmit }: Props) {
|
|||||||
/>
|
/>
|
||||||
</Text>
|
</Text>
|
||||||
{team.defaultCollectionId === collection.id ? (
|
{team.defaultCollectionId === collection.id ? (
|
||||||
<Text as="p" type="secondary">
|
<Text type="secondary">
|
||||||
<Trans
|
<Trans
|
||||||
defaults="Also, <em>{{collectionName}}</em> is being used as the start view – deleting it will reset the start view to the Home page."
|
defaults="Also, <em>{{collectionName}}</em> is being used as the start view – deleting it will reset the start view to the Home page."
|
||||||
values={{
|
values={{
|
||||||
|
|||||||
@@ -1,22 +1,19 @@
|
|||||||
import debounce from "lodash/debounce";
|
import debounce from "lodash/debounce";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
|
import { transparentize } from "polished";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { richExtensions } from "@shared/editor/nodes";
|
|
||||||
import { s } from "@shared/styles";
|
import { s } from "@shared/styles";
|
||||||
import { CollectionValidation } from "@shared/validations";
|
|
||||||
import Collection from "~/models/Collection";
|
import Collection from "~/models/Collection";
|
||||||
|
import Arrow from "~/components/Arrow";
|
||||||
|
import ButtonLink from "~/components/ButtonLink";
|
||||||
import Editor from "~/components/Editor";
|
import Editor from "~/components/Editor";
|
||||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||||
import { withUIExtensions } from "~/editor/extensions";
|
import NudeButton from "~/components/NudeButton";
|
||||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
|
||||||
import usePolicy from "~/hooks/usePolicy";
|
import usePolicy from "~/hooks/usePolicy";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import Text from "./Text";
|
import useToasts from "~/hooks/useToasts";
|
||||||
|
|
||||||
const extensions = withUIExtensions(richExtensions);
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
collection: Collection;
|
collection: Collection;
|
||||||
@@ -24,64 +21,211 @@ type Props = {
|
|||||||
|
|
||||||
function CollectionDescription({ collection }: Props) {
|
function CollectionDescription({ collection }: Props) {
|
||||||
const { collections } = useStores();
|
const { collections } = useStores();
|
||||||
|
const { showToast } = useToasts();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const user = useCurrentUser({ rejectOnEmpty: true });
|
const [isExpanded, setExpanded] = React.useState(false);
|
||||||
|
const [isEditing, setEditing] = React.useState(false);
|
||||||
|
const [isDirty, setDirty] = React.useState(false);
|
||||||
const can = usePolicy(collection);
|
const can = usePolicy(collection);
|
||||||
|
|
||||||
|
const handleStartEditing = React.useCallback(() => {
|
||||||
|
setEditing(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleStopEditing = React.useCallback(() => {
|
||||||
|
setEditing(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClickDisclosure = React.useCallback(
|
||||||
|
(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (isExpanded && document.activeElement) {
|
||||||
|
// @ts-expect-error ts-migrate(2339) FIXME: Property 'blur' does not exist on type 'Element'.
|
||||||
|
document.activeElement.blur();
|
||||||
|
}
|
||||||
|
|
||||||
|
setExpanded(!isExpanded);
|
||||||
|
},
|
||||||
|
[isExpanded]
|
||||||
|
);
|
||||||
|
|
||||||
const handleSave = React.useMemo(
|
const handleSave = React.useMemo(
|
||||||
() =>
|
() =>
|
||||||
debounce(async (getValue) => {
|
debounce(async (getValue) => {
|
||||||
try {
|
try {
|
||||||
await collection.save({
|
await collection.save({
|
||||||
data: getValue(false),
|
description: getValue(),
|
||||||
});
|
});
|
||||||
|
setDirty(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(t("Sorry, an error occurred saving the collection"));
|
showToast(
|
||||||
|
t("Sorry, an error occurred saving the collection", {
|
||||||
|
type: "error",
|
||||||
|
})
|
||||||
|
);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}, 1000),
|
}, 1000),
|
||||||
[collection, t]
|
[collection, showToast, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
const childRef = React.useRef<HTMLDivElement>(null);
|
const handleChange = React.useCallback(
|
||||||
const childOffsetHeight = childRef.current?.offsetHeight || 0;
|
async (getValue) => {
|
||||||
const editorStyle = React.useMemo(
|
setDirty(true);
|
||||||
() => ({
|
await handleSave(getValue);
|
||||||
padding: "0 32px",
|
},
|
||||||
margin: "0 -32px",
|
[handleSave]
|
||||||
paddingBottom: `calc(50vh - ${childOffsetHeight}px)`,
|
|
||||||
}),
|
|
||||||
[childOffsetHeight]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setEditing(false);
|
||||||
|
}, [collection.id]);
|
||||||
|
const placeholder = `${t("Add a description")}…`;
|
||||||
|
const key = isEditing || isDirty ? "draft" : collection.updatedAt;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<MaxHeight data-editing={isEditing} data-expanded={isExpanded}>
|
||||||
{collections.isSaving && <LoadingIndicator />}
|
<Input data-editing={isEditing} data-expanded={isExpanded}>
|
||||||
{(collection.hasDescription || can.update) && (
|
<span onClick={can.update ? handleStartEditing : undefined}>
|
||||||
<React.Suspense fallback={<Placeholder>Loading…</Placeholder>}>
|
{collections.isSaving && <LoadingIndicator />}
|
||||||
<Editor
|
{collection.hasDescription || isEditing || isDirty ? (
|
||||||
defaultValue={collection.data}
|
<React.Suspense
|
||||||
onChange={handleSave}
|
fallback={
|
||||||
placeholder={`${t("Add a description")}…`}
|
<Placeholder
|
||||||
extensions={extensions}
|
onClick={() => {
|
||||||
maxLength={CollectionValidation.maxDescriptionLength}
|
//
|
||||||
canUpdate={can.update}
|
}}
|
||||||
readOnly={!can.update}
|
>
|
||||||
userId={user.id}
|
Loading…
|
||||||
editorStyle={editorStyle}
|
</Placeholder>
|
||||||
embedsDisabled
|
}
|
||||||
/>
|
>
|
||||||
<div ref={childRef} />
|
<Editor
|
||||||
</React.Suspense>
|
key={key}
|
||||||
|
defaultValue={collection.description || ""}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
readOnly={!isEditing}
|
||||||
|
autoFocus={isEditing}
|
||||||
|
onBlur={handleStopEditing}
|
||||||
|
maxLength={1000}
|
||||||
|
embedsDisabled
|
||||||
|
canUpdate
|
||||||
|
/>
|
||||||
|
</React.Suspense>
|
||||||
|
) : (
|
||||||
|
can.update && (
|
||||||
|
<Placeholder
|
||||||
|
onClick={() => {
|
||||||
|
//
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{placeholder}
|
||||||
|
</Placeholder>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Input>
|
||||||
|
{!isEditing && (
|
||||||
|
<Disclosure
|
||||||
|
onClick={handleClickDisclosure}
|
||||||
|
aria-label={isExpanded ? t("Collapse") : t("Expand")}
|
||||||
|
size={30}
|
||||||
|
>
|
||||||
|
<Arrow />
|
||||||
|
</Disclosure>
|
||||||
)}
|
)}
|
||||||
</>
|
</MaxHeight>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const Placeholder = styled(Text)`
|
const Disclosure = styled(NudeButton)`
|
||||||
|
opacity: 0;
|
||||||
|
color: ${s("divider")};
|
||||||
|
position: absolute;
|
||||||
|
top: calc(25vh - 50px);
|
||||||
|
left: 50%;
|
||||||
|
z-index: 1;
|
||||||
|
transform: rotate(-90deg) translateX(-50%);
|
||||||
|
transition: opacity 100ms ease-in-out;
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
color: ${s("sidebarText")};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Placeholder = styled(ButtonLink)`
|
||||||
color: ${s("placeholder")};
|
color: ${s("placeholder")};
|
||||||
cursor: text;
|
cursor: text;
|
||||||
min-height: 27px;
|
min-height: 27px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const MaxHeight = styled.div`
|
||||||
|
position: relative;
|
||||||
|
max-height: 25vh;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: -12px -8px -8px;
|
||||||
|
padding: 8px;
|
||||||
|
|
||||||
|
&[data-editing="true"],
|
||||||
|
&[data-expanded="true"] {
|
||||||
|
max-height: initial;
|
||||||
|
overflow: initial;
|
||||||
|
|
||||||
|
${Disclosure} {
|
||||||
|
top: initial;
|
||||||
|
bottom: 0;
|
||||||
|
transform: rotate(90deg) translateX(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover ${Disclosure} {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Input = styled.div`
|
||||||
|
margin: -8px;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: ${s("backgroundTransition")};
|
||||||
|
|
||||||
|
&:after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: calc(25vh - 50px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 50px;
|
||||||
|
pointer-events: none;
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
${(props) => transparentize(1, props.theme.background)} 0%,
|
||||||
|
${s("background")} 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-editing="true"],
|
||||||
|
&[data-expanded="true"] {
|
||||||
|
&:after {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-editing="true"] {
|
||||||
|
background: ${s("secondaryBackground")};
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-menu-trigger,
|
||||||
|
.heading-anchor {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
export default observer(CollectionDescription);
|
export default observer(CollectionDescription);
|
||||||
|
|||||||
@@ -6,31 +6,31 @@ import { Portal } from "react-portal";
|
|||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import breakpoint from "styled-components-breakpoint";
|
import breakpoint from "styled-components-breakpoint";
|
||||||
import { depths, s } from "@shared/styles";
|
import { depths, s } from "@shared/styles";
|
||||||
|
import CommandBarResults from "~/components/CommandBarResults";
|
||||||
import SearchActions from "~/components/SearchActions";
|
import SearchActions from "~/components/SearchActions";
|
||||||
import rootActions from "~/actions/root";
|
import rootActions from "~/actions/root";
|
||||||
import useCommandBarActions from "~/hooks/useCommandBarActions";
|
import useCommandBarActions from "~/hooks/useCommandBarActions";
|
||||||
import CommandBarResults from "./CommandBarResults";
|
import useSettingsActions from "~/hooks/useSettingsActions";
|
||||||
import useRecentDocumentActions from "./useRecentDocumentActions";
|
import { CommandBarAction } from "~/types";
|
||||||
import useSettingsAction from "./useSettingsAction";
|
|
||||||
import useTemplatesAction from "./useTemplatesAction";
|
|
||||||
|
|
||||||
function CommandBar() {
|
function CommandBar() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const recentDocumentActions = useRecentDocumentActions();
|
const settingsActions = useSettingsActions();
|
||||||
const settingsAction = useSettingsAction();
|
|
||||||
const templatesAction = useTemplatesAction();
|
|
||||||
const commandBarActions = React.useMemo(
|
const commandBarActions = React.useMemo(
|
||||||
() => [
|
() => [...rootActions, settingsActions],
|
||||||
...recentDocumentActions,
|
[settingsActions]
|
||||||
...rootActions,
|
|
||||||
templatesAction,
|
|
||||||
settingsAction,
|
|
||||||
],
|
|
||||||
[recentDocumentActions, settingsAction, templatesAction]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
useCommandBarActions(commandBarActions);
|
useCommandBarActions(commandBarActions);
|
||||||
|
|
||||||
|
const { rootAction } = useKBar((state) => ({
|
||||||
|
rootAction: state.currentRootActionId
|
||||||
|
? (state.actions[
|
||||||
|
state.currentRootActionId
|
||||||
|
] as unknown as CommandBarAction)
|
||||||
|
: undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<KBarPortal>
|
<KBarPortal>
|
||||||
@@ -38,7 +38,11 @@ function CommandBar() {
|
|||||||
<Animator>
|
<Animator>
|
||||||
<SearchActions />
|
<SearchActions />
|
||||||
<SearchInput
|
<SearchInput
|
||||||
defaultPlaceholder={`${t("Type a command or search")}…`}
|
placeholder={`${
|
||||||
|
rootAction?.placeholder ||
|
||||||
|
rootAction?.name ||
|
||||||
|
t("Type a command or search")
|
||||||
|
}…`}
|
||||||
/>
|
/>
|
||||||
<CommandBarResults />
|
<CommandBarResults />
|
||||||
</Animator>
|
</Animator>
|
||||||
@@ -69,23 +73,16 @@ const Positioner = styled(KBarPositioner)`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const SearchInput = styled(KBarSearch)`
|
const SearchInput = styled(KBarSearch)`
|
||||||
position: relative;
|
padding: 16px 20px;
|
||||||
padding: 16px 12px;
|
width: 100%;
|
||||||
margin: 0 8px;
|
|
||||||
width: calc(100% - 16px);
|
|
||||||
outline: none;
|
outline: none;
|
||||||
border: none;
|
border: none;
|
||||||
background: ${s("menuBackground")};
|
background: ${s("menuBackground")};
|
||||||
color: ${s("text")};
|
color: ${s("text")};
|
||||||
|
|
||||||
&:not(:last-child) {
|
|
||||||
border-bottom: 1px solid ${s("inputBorder")};
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled,
|
&:disabled,
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: ${s("placeholder")};
|
color: ${s("placeholder")};
|
||||||
opacity: 1;
|
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import CommandBar from "./CommandBar";
|
|
||||||
|
|
||||||
export default CommandBar;
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { DocumentIcon } from "outline-icons";
|
|
||||||
import * as React from "react";
|
|
||||||
import Icon from "@shared/components/Icon";
|
|
||||||
import { createAction } from "~/actions";
|
|
||||||
import { RecentSection } from "~/actions/sections";
|
|
||||||
import useStores from "~/hooks/useStores";
|
|
||||||
import history from "~/utils/history";
|
|
||||||
import { documentPath } from "~/utils/routeHelpers";
|
|
||||||
|
|
||||||
const useRecentDocumentActions = (count = 6) => {
|
|
||||||
const { documents, ui } = useStores();
|
|
||||||
|
|
||||||
return React.useMemo(
|
|
||||||
() =>
|
|
||||||
documents.recentlyViewed
|
|
||||||
.filter((document) => document.id !== ui.activeDocumentId)
|
|
||||||
.slice(0, count)
|
|
||||||
.map((item) =>
|
|
||||||
createAction({
|
|
||||||
name: item.titleWithDefault,
|
|
||||||
analyticsName: "Recently viewed document",
|
|
||||||
section: RecentSection,
|
|
||||||
icon: item.icon ? (
|
|
||||||
<Icon value={item.icon} color={item.color ?? undefined} />
|
|
||||||
) : (
|
|
||||||
<DocumentIcon />
|
|
||||||
),
|
|
||||||
perform: () => history.push(documentPath(item)),
|
|
||||||
})
|
|
||||||
),
|
|
||||||
[count, ui.activeDocumentId, documents.recentlyViewed]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useRecentDocumentActions;
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import { NewDocumentIcon, ShapesIcon } from "outline-icons";
|
|
||||||
import * as React from "react";
|
|
||||||
import Icon from "@shared/components/Icon";
|
|
||||||
import { createAction } from "~/actions";
|
|
||||||
import {
|
|
||||||
ActiveCollectionSection,
|
|
||||||
DocumentSection,
|
|
||||||
TeamSection,
|
|
||||||
} from "~/actions/sections";
|
|
||||||
import useStores from "~/hooks/useStores";
|
|
||||||
import history from "~/utils/history";
|
|
||||||
import { newDocumentPath } from "~/utils/routeHelpers";
|
|
||||||
|
|
||||||
const useTemplatesAction = () => {
|
|
||||||
const { documents } = useStores();
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
void documents.fetchAllTemplates();
|
|
||||||
}, [documents]);
|
|
||||||
|
|
||||||
const actions = React.useMemo(
|
|
||||||
() =>
|
|
||||||
documents.templatesAlphabetical.map((template) =>
|
|
||||||
createAction({
|
|
||||||
name: template.titleWithDefault,
|
|
||||||
analyticsName: "New document",
|
|
||||||
section: template.isWorkspaceTemplate
|
|
||||||
? TeamSection
|
|
||||||
: ActiveCollectionSection,
|
|
||||||
icon: template.icon ? (
|
|
||||||
<Icon value={template.icon} color={template.color ?? undefined} />
|
|
||||||
) : (
|
|
||||||
<NewDocumentIcon />
|
|
||||||
),
|
|
||||||
keywords: "create",
|
|
||||||
visible: ({ currentTeamId, activeCollectionId, stores }) => {
|
|
||||||
if (activeCollectionId) {
|
|
||||||
return (
|
|
||||||
stores.policies.abilities(activeCollectionId).createDocument &&
|
|
||||||
(template.collectionId === activeCollectionId ||
|
|
||||||
template.isWorkspaceTemplate)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
!!currentTeamId &&
|
|
||||||
stores.policies.abilities(currentTeamId).createDocument &&
|
|
||||||
template.isWorkspaceTemplate
|
|
||||||
);
|
|
||||||
},
|
|
||||||
perform: ({ activeCollectionId, sidebarContext }) =>
|
|
||||||
history.push(
|
|
||||||
newDocumentPath(template.collectionId ?? activeCollectionId, {
|
|
||||||
templateId: template.id,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
sidebarContext,
|
|
||||||
}
|
|
||||||
),
|
|
||||||
})
|
|
||||||
),
|
|
||||||
[documents.templatesAlphabetical]
|
|
||||||
);
|
|
||||||
|
|
||||||
const newFromTemplate = React.useMemo(
|
|
||||||
() =>
|
|
||||||
createAction({
|
|
||||||
id: "templates",
|
|
||||||
name: ({ t }) => t("New from template"),
|
|
||||||
placeholder: ({ t }) => t("Choose a template"),
|
|
||||||
section: DocumentSection,
|
|
||||||
icon: <ShapesIcon />,
|
|
||||||
visible: ({ currentTeamId, activeCollectionId, stores }) => {
|
|
||||||
if (activeCollectionId) {
|
|
||||||
return stores.policies.abilities(activeCollectionId).createDocument;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
!!currentTeamId &&
|
|
||||||
stores.policies.abilities(currentTeamId).createDocument
|
|
||||||
);
|
|
||||||
},
|
|
||||||
children: () => actions,
|
|
||||||
}),
|
|
||||||
[actions]
|
|
||||||
);
|
|
||||||
|
|
||||||
return newFromTemplate;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useTemplatesAction;
|
|
||||||
@@ -3,10 +3,9 @@ import { ArrowIcon, BackIcon } from "outline-icons";
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import styled, { css, useTheme } from "styled-components";
|
import styled, { css, useTheme } from "styled-components";
|
||||||
import { s, ellipsis } from "@shared/styles";
|
import { s, ellipsis } from "@shared/styles";
|
||||||
import { normalizeKeyDisplay } from "@shared/utils/keyboard";
|
|
||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
import Key from "~/components/Key";
|
import Key from "~/components/Key";
|
||||||
import Text from "~/components/Text";
|
import Text from "./Text";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
action: ActionImpl;
|
action: ActionImpl;
|
||||||
@@ -63,15 +62,15 @@ function CommandBarItem(
|
|||||||
{index > 0 ? (
|
{index > 0 ? (
|
||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
<Text size="xsmall" type="secondary">
|
<Text size="xsmall" as="span" type="secondary">
|
||||||
then
|
then
|
||||||
</Text>{" "}
|
</Text>{" "}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
""
|
""
|
||||||
)}
|
)}
|
||||||
{sc.split("+").map((key) => (
|
{sc.split("+").map((s) => (
|
||||||
<Key key={key}>{normalizeKeyDisplay(key)}</Key>
|
<Key key={s}>{s}</Key>
|
||||||
))}
|
))}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
+7
-12
@@ -1,16 +1,12 @@
|
|||||||
import { useMatches, KBarResults } from "kbar";
|
import { useMatches, KBarResults } from "kbar";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import Text from "~/components/Text";
|
import { s } from "@shared/styles";
|
||||||
import CommandBarItem from "./CommandBarItem";
|
import CommandBarItem from "~/components/CommandBarItem";
|
||||||
|
|
||||||
export default function CommandBarResults() {
|
export default function CommandBarResults() {
|
||||||
const { results, rootActionId } = useMatches();
|
const { results, rootActionId } = useMatches();
|
||||||
|
|
||||||
if (results.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<KBarResults
|
<KBarResults
|
||||||
@@ -18,9 +14,7 @@ export default function CommandBarResults() {
|
|||||||
maxHeight={400}
|
maxHeight={400}
|
||||||
onRender={({ item, active }) =>
|
onRender={({ item, active }) =>
|
||||||
typeof item === "string" ? (
|
typeof item === "string" ? (
|
||||||
<Header type="tertiary" size="xsmall" ellipsis>
|
<Header>{item}</Header>
|
||||||
{item}
|
|
||||||
</Header>
|
|
||||||
) : (
|
) : (
|
||||||
<CommandBarItem
|
<CommandBarItem
|
||||||
action={item}
|
action={item}
|
||||||
@@ -41,10 +35,11 @@ const Container = styled.div`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Header = styled(Text).attrs({ as: "h3" })`
|
const Header = styled.h3`
|
||||||
letter-spacing: 0.03em;
|
font-size: 13px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 16px 0 4px 20px;
|
padding: 16px 0 4px 20px;
|
||||||
|
color: ${s("textTertiary")};
|
||||||
height: 36px;
|
height: 36px;
|
||||||
cursor: default;
|
|
||||||
`;
|
`;
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation, Trans } from "react-i18next";
|
import { useTranslation, Trans } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
|
||||||
import Comment from "~/models/Comment";
|
import Comment from "~/models/Comment";
|
||||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||||
import Text from "~/components/Text";
|
import Text from "~/components/Text";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
|
import useToasts from "~/hooks/useToasts";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
comment: Comment;
|
comment: Comment;
|
||||||
@@ -14,6 +14,7 @@ type Props = {
|
|||||||
|
|
||||||
function CommentDeleteDialog({ comment, onSubmit }: Props) {
|
function CommentDeleteDialog({ comment, onSubmit }: Props) {
|
||||||
const { comments } = useStores();
|
const { comments } = useStores();
|
||||||
|
const { showToast } = useToasts();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const hasChildComments = comments.inThread(comment.id).length > 1;
|
const hasChildComments = comments.inThread(comment.id).length > 1;
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@ function CommentDeleteDialog({ comment, onSubmit }: Props) {
|
|||||||
await comment.delete();
|
await comment.delete();
|
||||||
onSubmit?.();
|
onSubmit?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message);
|
showToast(err.message, { type: "error" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ function CommentDeleteDialog({ comment, onSubmit }: Props) {
|
|||||||
savingText={`${t("Deleting")}…`}
|
savingText={`${t("Deleting")}…`}
|
||||||
danger
|
danger
|
||||||
>
|
>
|
||||||
<Text as="p" type="secondary">
|
<Text type="secondary">
|
||||||
{hasChildComments ? (
|
{hasChildComments ? (
|
||||||
<Trans>
|
<Trans>
|
||||||
Are you sure you want to permanently delete this entire comment
|
Are you sure you want to permanently delete this entire comment
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
import { observer } from "mobx-react";
|
|
||||||
import * as React from "react";
|
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { CollectionPermission, NavigationNode } from "@shared/types";
|
|
||||||
import type Collection from "~/models/Collection";
|
|
||||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
|
||||||
import useStores from "~/hooks/useStores";
|
|
||||||
import { AuthorizationError } from "~/utils/errors";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
/** The navigation node to move, must represent a document. */
|
|
||||||
item: NavigationNode;
|
|
||||||
/** The collection to move the document to. */
|
|
||||||
collection: Collection;
|
|
||||||
/** The parent document to move the document under. */
|
|
||||||
parentDocumentId?: string | null;
|
|
||||||
/** The index to move the document to. */
|
|
||||||
index?: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function ConfirmMoveDialog({ collection, item, ...rest }: Props) {
|
|
||||||
const { documents, dialogs, collections } = useStores();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const prevCollection = collections.get(item.collectionId!);
|
|
||||||
const accessMapping: Record<Partial<CollectionPermission> | "null", string> =
|
|
||||||
{
|
|
||||||
[CollectionPermission.Admin]: t("manage access"),
|
|
||||||
[CollectionPermission.ReadWrite]: t("view and edit access"),
|
|
||||||
[CollectionPermission.Read]: t("view only access"),
|
|
||||||
null: t("no access"),
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
try {
|
|
||||||
await documents.move({
|
|
||||||
documentId: item.id,
|
|
||||||
collectionId: collection.id,
|
|
||||||
...rest,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof AuthorizationError) {
|
|
||||||
toast.error(
|
|
||||||
t(
|
|
||||||
"You do not have permission to move {{ documentName }} to the {{ collectionName }} collection",
|
|
||||||
{
|
|
||||||
documentName: item.title,
|
|
||||||
collectionName: collection.name,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
toast.error(err.message);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
dialogs.closeAllModals();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ConfirmationDialog
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
submitText={t("Move document")}
|
|
||||||
savingText={`${t("Moving")}…`}
|
|
||||||
>
|
|
||||||
<Trans
|
|
||||||
defaults="Moving the document <em>{{ title }}</em> to the {{ newCollectionName }} collection will change permission for all workspace members from <em>{{ prevPermission }}</em> to <em>{{ newPermission }}</em>."
|
|
||||||
values={{
|
|
||||||
title: item.title,
|
|
||||||
prevCollectionName: prevCollection?.name,
|
|
||||||
newCollectionName: collection.name,
|
|
||||||
prevPermission: accessMapping[prevCollection?.permission || "null"],
|
|
||||||
newPermission: accessMapping[collection.permission || "null"],
|
|
||||||
}}
|
|
||||||
components={{
|
|
||||||
em: <strong />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ConfirmationDialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default observer(ConfirmMoveDialog);
|
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
import Text from "~/components/Text";
|
import Text from "~/components/Text";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
|
import useToasts from "~/hooks/useToasts";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/** Callback when the dialog is submitted. Return false to prevent closing. */
|
/** Callback when the dialog is submitted */
|
||||||
onSubmit: () => Promise<void | boolean> | void;
|
onSubmit: () => Promise<void> | void;
|
||||||
/** Text to display on the submit button */
|
/** Text to display on the submit button */
|
||||||
submitText?: string;
|
submitText?: string;
|
||||||
/** Text to display while the form is saving */
|
/** Text to display while the form is saving */
|
||||||
@@ -30,45 +29,41 @@ const ConfirmationDialog: React.FC<Props> = ({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [isSaving, setIsSaving] = React.useState(false);
|
const [isSaving, setIsSaving] = React.useState(false);
|
||||||
const { t } = useTranslation();
|
|
||||||
const { dialogs } = useStores();
|
const { dialogs } = useStores();
|
||||||
|
const { showToast } = useToasts();
|
||||||
|
|
||||||
const handleSubmit = React.useCallback(
|
const handleSubmit = React.useCallback(
|
||||||
async (ev: React.SyntheticEvent) => {
|
async (ev: React.SyntheticEvent) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
const res = await onSubmit();
|
await onSubmit();
|
||||||
if (res === false) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
dialogs.closeAllModals();
|
dialogs.closeAllModals();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message);
|
showToast(err.message, {
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onSubmit, dialogs]
|
[onSubmit, dialogs, showToast]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit}>
|
<Flex column>
|
||||||
<Flex gap={12} column>
|
<form onSubmit={handleSubmit}>
|
||||||
<Text type="secondary">{children}</Text>
|
<Text type="secondary">{children}</Text>
|
||||||
|
<Button
|
||||||
<Flex justify="flex-end">
|
type="submit"
|
||||||
<Button
|
disabled={isSaving || disabled}
|
||||||
type="submit"
|
danger={danger}
|
||||||
disabled={isSaving || disabled}
|
autoFocus
|
||||||
danger={danger}
|
>
|
||||||
autoFocus
|
{isSaving && savingText ? savingText : submitText}
|
||||||
>
|
</Button>
|
||||||
{isSaving && savingText ? savingText : submitText ?? t("Confirm")}
|
</form>
|
||||||
</Button>
|
</Flex>
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
</form>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,8 @@ import { observer } from "mobx-react";
|
|||||||
import { DisconnectedIcon } from "outline-icons";
|
import { DisconnectedIcon } from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import styled from "styled-components";
|
import styled, { useTheme } from "styled-components";
|
||||||
import breakpoint from "styled-components-breakpoint";
|
import breakpoint from "styled-components-breakpoint";
|
||||||
import {
|
|
||||||
AuthenticationFailed,
|
|
||||||
AuthorizationFailed,
|
|
||||||
DocumentTooLarge,
|
|
||||||
TooManyConnections,
|
|
||||||
} from "@shared/collaboration/CloseEvents";
|
|
||||||
import Fade from "~/components/Fade";
|
import Fade from "~/components/Fade";
|
||||||
import NudeButton from "~/components/NudeButton";
|
import NudeButton from "~/components/NudeButton";
|
||||||
import Tooltip from "~/components/Tooltip";
|
import Tooltip from "~/components/Tooltip";
|
||||||
@@ -17,56 +11,24 @@ import useStores from "~/hooks/useStores";
|
|||||||
|
|
||||||
function ConnectionStatus() {
|
function ConnectionStatus() {
|
||||||
const { ui } = useStores();
|
const { ui } = useStores();
|
||||||
|
const theme = useTheme();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const codeToMessage = {
|
|
||||||
[DocumentTooLarge.code]: {
|
|
||||||
title: t("Document is too large"),
|
|
||||||
body: t(
|
|
||||||
"This document has reached the maximum size and can no longer be edited"
|
|
||||||
),
|
|
||||||
},
|
|
||||||
[AuthenticationFailed.code]: {
|
|
||||||
title: t("Authentication failed"),
|
|
||||||
body: t("Please try logging out and back in again"),
|
|
||||||
},
|
|
||||||
[AuthorizationFailed.code]: {
|
|
||||||
title: t("Authorization failed"),
|
|
||||||
body: t("You may have lost access to this document, try reloading"),
|
|
||||||
},
|
|
||||||
[TooManyConnections.code]: {
|
|
||||||
title: t("Too many users connected to document"),
|
|
||||||
body: t("Your edits will sync once other users leave the document"),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const message = ui.multiplayerErrorCode
|
|
||||||
? codeToMessage[ui.multiplayerErrorCode as keyof typeof codeToMessage]
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return ui.multiplayerStatus === "connecting" ||
|
return ui.multiplayerStatus === "connecting" ||
|
||||||
ui.multiplayerStatus === "disconnected" ? (
|
ui.multiplayerStatus === "disconnected" ? (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={
|
tooltip={
|
||||||
message ? (
|
<Centered>
|
||||||
<Centered>
|
<strong>{t("Server connection lost")}</strong>
|
||||||
<strong>{message.title}</strong>
|
<br />
|
||||||
<br />
|
{t("Edits you make will sync once you’re online")}
|
||||||
{message.body}
|
</Centered>
|
||||||
</Centered>
|
|
||||||
) : (
|
|
||||||
<Centered>
|
|
||||||
<strong>{t("Server connection lost")}</strong>
|
|
||||||
<br />
|
|
||||||
{t("Edits you make will sync once you’re online")}
|
|
||||||
</Centered>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
placement="bottom"
|
placement="bottom"
|
||||||
>
|
>
|
||||||
<Button>
|
<Button>
|
||||||
<Fade>
|
<Fade>
|
||||||
<DisconnectedIcon />
|
<DisconnectedIcon color={theme.sidebarText} />
|
||||||
</Fade>
|
</Fade>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -77,7 +39,7 @@ const Button = styled(NudeButton)`
|
|||||||
display: none;
|
display: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
margin: 20px;
|
margin: 24px;
|
||||||
transform: translateX(-32px);
|
transform: translateX(-32px);
|
||||||
|
|
||||||
${breakpoint("tablet")`
|
${breakpoint("tablet")`
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ type Props = Omit<React.HTMLAttributes<HTMLSpanElement>, "ref" | "onChange"> & {
|
|||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
onClick?: React.MouseEventHandler<HTMLDivElement>;
|
onClick?: React.MouseEventHandler<HTMLDivElement>;
|
||||||
onChange?: (text: string) => void;
|
onChange?: (text: string) => void;
|
||||||
onFocus?: React.FocusEventHandler<HTMLSpanElement> | undefined;
|
|
||||||
onBlur?: React.FocusEventHandler<HTMLSpanElement> | undefined;
|
onBlur?: React.FocusEventHandler<HTMLSpanElement> | undefined;
|
||||||
onInput?: React.FormEventHandler<HTMLSpanElement> | undefined;
|
onInput?: React.FormEventHandler<HTMLSpanElement> | undefined;
|
||||||
onKeyDown?: React.KeyboardEventHandler<HTMLSpanElement> | undefined;
|
onKeyDown?: React.KeyboardEventHandler<HTMLSpanElement> | undefined;
|
||||||
@@ -36,7 +35,6 @@ const ContentEditable = React.forwardRef(function _ContentEditable(
|
|||||||
disabled,
|
disabled,
|
||||||
onChange,
|
onChange,
|
||||||
onInput,
|
onInput,
|
||||||
onFocus,
|
|
||||||
onBlur,
|
onBlur,
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
value,
|
value,
|
||||||
@@ -145,13 +143,11 @@ const ContentEditable = React.forwardRef(function _ContentEditable(
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className} dir={dir} onClick={onClick} tabIndex={-1}>
|
<div className={className} dir={dir} onClick={onClick}>
|
||||||
{children}
|
|
||||||
<Content
|
<Content
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
contentEditable={!disabled && !readOnly}
|
contentEditable={!disabled && !readOnly}
|
||||||
onInput={wrappedEvent(onInput)}
|
onInput={wrappedEvent(onInput)}
|
||||||
onFocus={wrappedEvent(onFocus)}
|
|
||||||
onBlur={wrappedEvent(onBlur)}
|
onBlur={wrappedEvent(onBlur)}
|
||||||
onKeyDown={wrappedEvent(onKeyDown)}
|
onKeyDown={wrappedEvent(onKeyDown)}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
@@ -162,6 +158,7 @@ const ContentEditable = React.forwardRef(function _ContentEditable(
|
|||||||
>
|
>
|
||||||
{innerValue}
|
{innerValue}
|
||||||
</Content>
|
</Content>
|
||||||
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -182,6 +179,7 @@ function placeCaret(element: HTMLElement, atStart: boolean) {
|
|||||||
|
|
||||||
const Content = styled.span`
|
const Content = styled.span`
|
||||||
background: ${s("background")};
|
background: ${s("background")};
|
||||||
|
transition: ${s("backgroundTransition")};
|
||||||
color: ${s("text")};
|
color: ${s("text")};
|
||||||
-webkit-text-fill-color: ${s("text")};
|
-webkit-text-fill-color: ${s("text")};
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
import { LocationDescriptor } from "history";
|
import { LocationDescriptor } from "history";
|
||||||
import { CheckmarkIcon } from "outline-icons";
|
import { CheckmarkIcon } from "outline-icons";
|
||||||
import { ellipsis, transparentize } from "polished";
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { mergeRefs } from "react-merge-refs";
|
import { mergeRefs } from "react-merge-refs";
|
||||||
import { MenuItem as BaseMenuItem } from "reakit/Menu";
|
import { MenuItem as BaseMenuItem } from "reakit/Menu";
|
||||||
import styled, { css } from "styled-components";
|
import styled, { css } from "styled-components";
|
||||||
import breakpoint from "styled-components-breakpoint";
|
import breakpoint from "styled-components-breakpoint";
|
||||||
import { s } from "@shared/styles";
|
import MenuIconWrapper from "../MenuIconWrapper";
|
||||||
import Text from "../Text";
|
|
||||||
import MenuIconWrapper from "./MenuIconWrapper";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
id?: string;
|
id?: string;
|
||||||
onClick?: (event: React.MouseEvent) => void | Promise<void>;
|
onClick?: (event: React.SyntheticEvent) => void | Promise<void>;
|
||||||
onPointerMove?: (event: React.MouseEvent) => void | Promise<void>;
|
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -24,7 +20,7 @@ type Props = {
|
|||||||
as?: string | React.ComponentType<any>;
|
as?: string | React.ComponentType<any>;
|
||||||
hide?: () => void;
|
hide?: () => void;
|
||||||
level?: number;
|
level?: number;
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactElement;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
ref?: React.LegacyRef<HTMLButtonElement> | undefined;
|
ref?: React.LegacyRef<HTMLButtonElement> | undefined;
|
||||||
};
|
};
|
||||||
@@ -32,7 +28,6 @@ type Props = {
|
|||||||
const MenuItem = (
|
const MenuItem = (
|
||||||
{
|
{
|
||||||
onClick,
|
onClick,
|
||||||
onPointerMove,
|
|
||||||
children,
|
children,
|
||||||
active,
|
active,
|
||||||
selected,
|
selected,
|
||||||
@@ -46,43 +41,43 @@ const MenuItem = (
|
|||||||
) => {
|
) => {
|
||||||
const content = React.useCallback(
|
const content = React.useCallback(
|
||||||
(props) => {
|
(props) => {
|
||||||
// Preventing default mousedown otherwise menu items do not work in Firefox,
|
|
||||||
// which triggers the hideOnClickOutside handler first via mousedown – hiding
|
|
||||||
// and un-rendering the menu contents.
|
|
||||||
const preventDefault = (ev: React.MouseEvent) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClick = async (ev: React.MouseEvent) => {
|
const handleClick = async (ev: React.MouseEvent) => {
|
||||||
hide?.();
|
hide?.();
|
||||||
|
|
||||||
if (onClick) {
|
if (onClick) {
|
||||||
preventDefault(ev);
|
ev.preventDefault();
|
||||||
await onClick(ev);
|
await onClick(ev);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Preventing default mousedown otherwise menu items do not work in Firefox,
|
||||||
|
// which triggers the hideOnClickOutside handler first via mousedown – hiding
|
||||||
|
// and un-rendering the menu contents.
|
||||||
|
const handleMouseDown = (ev: React.MouseEvent) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuAnchor
|
<MenuAnchor
|
||||||
{...props}
|
{...props}
|
||||||
$active={active}
|
$active={active}
|
||||||
as={onClick ? "button" : as}
|
as={onClick ? "button" : as}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onPointerDown={preventDefault}
|
onMouseDown={handleMouseDown}
|
||||||
onMouseDown={preventDefault}
|
|
||||||
ref={mergeRefs([
|
ref={mergeRefs([
|
||||||
ref,
|
ref,
|
||||||
props.ref as React.RefObject<HTMLAnchorElement>,
|
props.ref as React.RefObject<HTMLAnchorElement>,
|
||||||
])}
|
])}
|
||||||
>
|
>
|
||||||
{selected !== undefined && (
|
{selected !== undefined && (
|
||||||
<SelectedWrapper aria-hidden>
|
<>
|
||||||
{selected ? <CheckmarkIcon /> : <Spacer />}
|
{selected ? <CheckmarkIcon /> : <Spacer />}
|
||||||
</SelectedWrapper>
|
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{icon && <MenuIconWrapper aria-hidden>{icon}</MenuIconWrapper>}
|
{icon && <MenuIconWrapper>{icon}</MenuIconWrapper>}
|
||||||
<Title>{children}</Title>
|
{children}
|
||||||
</MenuAnchor>
|
</MenuAnchor>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -92,7 +87,6 @@ const MenuItem = (
|
|||||||
return (
|
return (
|
||||||
<BaseMenuItem
|
<BaseMenuItem
|
||||||
onClick={disabled ? undefined : onClick}
|
onClick={disabled ? undefined : onClick}
|
||||||
onPointerMove={disabled ? undefined : onPointerMove}
|
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
hide={hide}
|
hide={hide}
|
||||||
{...rest}
|
{...rest}
|
||||||
@@ -108,14 +102,6 @@ const Spacer = styled.svg`
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Title = styled.div`
|
|
||||||
${ellipsis()}
|
|
||||||
flex-grow: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
type MenuAnchorProps = {
|
type MenuAnchorProps = {
|
||||||
level?: number;
|
level?: number;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -144,6 +130,10 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
|
svg:not(:last-child) {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
opacity: ${(props) => (props.disabled ? ".5" : 1)};
|
opacity: ${(props) => (props.disabled ? ".5" : 1)};
|
||||||
@@ -158,23 +148,15 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
|
|||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
&:hover,
|
&:hover,
|
||||||
&:focus,
|
&:focus,
|
||||||
&:focus-visible {
|
&.focus-visible {
|
||||||
color: ${props.theme.accentText};
|
color: ${props.theme.accentText};
|
||||||
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
|
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
|
||||||
outline-color: ${
|
|
||||||
props.dangerous ? props.theme.danger : props.theme.accent
|
|
||||||
};
|
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
cursor: var(--pointer);
|
cursor: var(--pointer);
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
color: ${props.theme.accentText};
|
|
||||||
fill: ${props.theme.accentText};
|
fill: ${props.theme.accentText};
|
||||||
}
|
}
|
||||||
|
|
||||||
${Text} {
|
|
||||||
color: ${transparentize(0.5, props.theme.accentText)};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
@@ -205,13 +187,4 @@ export const MenuAnchor = styled.a`
|
|||||||
${MenuAnchorCSS}
|
${MenuAnchorCSS}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const SelectedWrapper = styled.span`
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
margin-right: 4px;
|
|
||||||
margin-left: -8px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
color: ${s("textSecondary")};
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default React.forwardRef<HTMLAnchorElement, Props>(MenuItem);
|
export default React.forwardRef<HTMLAnchorElement, Props>(MenuItem);
|
||||||
|
|||||||
@@ -2,17 +2,17 @@ import * as React from "react";
|
|||||||
import { useMousePosition } from "~/hooks/useMousePosition";
|
import { useMousePosition } from "~/hooks/useMousePosition";
|
||||||
|
|
||||||
type Positions = {
|
type Positions = {
|
||||||
/** Sub-menu x */
|
/* Sub-menu x */
|
||||||
x: number;
|
x: number;
|
||||||
/** Sub-menu y */
|
/* Sub-menu y */
|
||||||
y: number;
|
y: number;
|
||||||
/** Sub-menu height */
|
/* Sub-menu height */
|
||||||
h: number;
|
h: number;
|
||||||
/** Sub-menu width */
|
/* Sub-menu width */
|
||||||
w: number;
|
w: number;
|
||||||
/** Mouse x */
|
/* Mouse x */
|
||||||
mouseX: number;
|
mouseX: number;
|
||||||
/** Mouse y */
|
/* Mouse y */
|
||||||
mouseY: number;
|
mouseY: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import {
|
|||||||
MenuStateReturn,
|
MenuStateReturn,
|
||||||
} from "reakit/Menu";
|
} from "reakit/Menu";
|
||||||
import styled, { useTheme } from "styled-components";
|
import styled, { useTheme } from "styled-components";
|
||||||
import MenuIconWrapper from "~/components/ContextMenu/MenuIconWrapper";
|
|
||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
|
import MenuIconWrapper from "~/components/MenuIconWrapper";
|
||||||
import { actionToMenuItem } from "~/actions";
|
import { actionToMenuItem } from "~/actions";
|
||||||
import useActionContext from "~/hooks/useActionContext";
|
import useActionContext from "~/hooks/useActionContext";
|
||||||
import {
|
import {
|
||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
MenuHeading,
|
MenuHeading,
|
||||||
MenuItem as TMenuItem,
|
MenuItem as TMenuItem,
|
||||||
} from "~/types";
|
} from "~/types";
|
||||||
import Tooltip from "../Tooltip";
|
|
||||||
import Header from "./Header";
|
import Header from "./Header";
|
||||||
import MenuItem, { MenuAnchor } from "./MenuItem";
|
import MenuItem, { MenuAnchor } from "./MenuItem";
|
||||||
import MouseSafeArea from "./MouseSafeArea";
|
import MouseSafeArea from "./MouseSafeArea";
|
||||||
@@ -31,7 +30,6 @@ type Props = Omit<MenuStateReturn, "items"> & {
|
|||||||
actions?: (Action | MenuSeparator | MenuHeading)[];
|
actions?: (Action | MenuSeparator | MenuHeading)[];
|
||||||
context?: Partial<ActionContext>;
|
context?: Partial<ActionContext>;
|
||||||
items?: TMenuItem[];
|
items?: TMenuItem[];
|
||||||
showIcons?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const Disclosure = styled(ExpandedIcon)`
|
const Disclosure = styled(ExpandedIcon)`
|
||||||
@@ -100,7 +98,7 @@ export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function Template({ items, actions, context, showIcons, ...menu }: Props) {
|
function Template({ items, actions, context, ...menu }: Props) {
|
||||||
const ctx = useActionContext({
|
const ctx = useActionContext({
|
||||||
isContextMenu: true,
|
isContextMenu: true,
|
||||||
});
|
});
|
||||||
@@ -126,10 +124,9 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
|
|||||||
if (
|
if (
|
||||||
iconIsPresentInAnyMenuItem &&
|
iconIsPresentInAnyMenuItem &&
|
||||||
item.type !== "separator" &&
|
item.type !== "separator" &&
|
||||||
item.type !== "heading" &&
|
item.type !== "heading"
|
||||||
showIcons !== false
|
|
||||||
) {
|
) {
|
||||||
item.icon = item.icon || <MenuIconWrapper aria-hidden />;
|
item.icon = item.icon || <MenuIconWrapper />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.type === "route") {
|
if (item.type === "route") {
|
||||||
@@ -141,7 +138,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
|
|||||||
key={index}
|
key={index}
|
||||||
disabled={item.disabled}
|
disabled={item.disabled}
|
||||||
selected={item.selected}
|
selected={item.selected}
|
||||||
icon={showIcons !== false ? item.icon : undefined}
|
icon={item.icon}
|
||||||
{...menu}
|
{...menu}
|
||||||
>
|
>
|
||||||
{item.title}
|
{item.title}
|
||||||
@@ -159,7 +156,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
|
|||||||
selected={item.selected}
|
selected={item.selected}
|
||||||
level={item.level}
|
level={item.level}
|
||||||
target={item.href.startsWith("#") ? undefined : "_blank"}
|
target={item.href.startsWith("#") ? undefined : "_blank"}
|
||||||
icon={showIcons !== false ? item.icon : undefined}
|
icon={item.icon}
|
||||||
{...menu}
|
{...menu}
|
||||||
>
|
>
|
||||||
{item.title}
|
{item.title}
|
||||||
@@ -168,7 +165,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (item.type === "button") {
|
if (item.type === "button") {
|
||||||
const menuItem = (
|
return (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
as="button"
|
as="button"
|
||||||
id={`${item.title}-${index}`}
|
id={`${item.title}-${index}`}
|
||||||
@@ -177,20 +174,12 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
|
|||||||
selected={item.selected}
|
selected={item.selected}
|
||||||
dangerous={item.dangerous}
|
dangerous={item.dangerous}
|
||||||
key={index}
|
key={index}
|
||||||
icon={showIcons !== false ? item.icon : undefined}
|
icon={item.icon}
|
||||||
{...menu}
|
{...menu}
|
||||||
>
|
>
|
||||||
{item.title}
|
{item.title}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
);
|
);
|
||||||
|
|
||||||
return item.tooltip ? (
|
|
||||||
<Tooltip content={item.tooltip} placement={"bottom"}>
|
|
||||||
<div>{menuItem}</div>
|
|
||||||
</Tooltip>
|
|
||||||
) : (
|
|
||||||
<>{menuItem}</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.type === "submenu") {
|
if (item.type === "submenu") {
|
||||||
@@ -201,12 +190,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
|
|||||||
id={`${item.title}-${index}`}
|
id={`${item.title}-${index}`}
|
||||||
templateItems={item.items}
|
templateItems={item.items}
|
||||||
parentMenuState={menu}
|
parentMenuState={menu}
|
||||||
title={
|
title={<Title title={item.title} icon={item.icon} />}
|
||||||
<Title
|
|
||||||
title={item.title}
|
|
||||||
icon={showIcons !== false ? item.icon : undefined}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
{...menu}
|
{...menu}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -217,7 +201,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (item.type === "heading") {
|
if (item.type === "heading") {
|
||||||
return <Header key={index}>{item.title}</Header>;
|
return <Header>{item.title}</Header>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const _exhaustiveCheck: never = item;
|
const _exhaustiveCheck: never = item;
|
||||||
@@ -236,7 +220,7 @@ function Title({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Flex align="center">
|
<Flex align="center">
|
||||||
{icon && <MenuIconWrapper aria-hidden>{icon}</MenuIconWrapper>}
|
{icon && <MenuIconWrapper>{icon}</MenuIconWrapper>}
|
||||||
{title}
|
{title}
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import styled, { DefaultTheme } from "styled-components";
|
|||||||
import breakpoint from "styled-components-breakpoint";
|
import breakpoint from "styled-components-breakpoint";
|
||||||
import { depths, s } from "@shared/styles";
|
import { depths, s } from "@shared/styles";
|
||||||
import Scrollable from "~/components/Scrollable";
|
import Scrollable from "~/components/Scrollable";
|
||||||
import useEventListener from "~/hooks/useEventListener";
|
|
||||||
import useMenuContext from "~/hooks/useMenuContext";
|
import useMenuContext from "~/hooks/useMenuContext";
|
||||||
import useMenuHeight from "~/hooks/useMenuHeight";
|
import useMenuHeight from "~/hooks/useMenuHeight";
|
||||||
import useMobile from "~/hooks/useMobile";
|
import useMobile from "~/hooks/useMobile";
|
||||||
@@ -39,8 +38,6 @@ export type Placement =
|
|||||||
|
|
||||||
type Props = MenuStateReturn & {
|
type Props = MenuStateReturn & {
|
||||||
"aria-label"?: string;
|
"aria-label"?: string;
|
||||||
/** Reference to the rendered menu div element */
|
|
||||||
menuRef?: React.RefObject<HTMLDivElement>;
|
|
||||||
/** The parent menu state if this is a submenu. */
|
/** The parent menu state if this is a submenu. */
|
||||||
parentMenuState?: Omit<MenuStateReturn, "items">;
|
parentMenuState?: Omit<MenuStateReturn, "items">;
|
||||||
/** Called when the context menu is opened. */
|
/** Called when the context menu is opened. */
|
||||||
@@ -49,15 +46,10 @@ type Props = MenuStateReturn & {
|
|||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
/** Called when the context menu is clicked. */
|
/** Called when the context menu is clicked. */
|
||||||
onClick?: (ev: React.MouseEvent) => void;
|
onClick?: (ev: React.MouseEvent) => void;
|
||||||
/** The maximum width of the context menu. */
|
|
||||||
maxWidth?: number;
|
|
||||||
/** The minimum height of the context menu. */
|
|
||||||
minHeight?: number;
|
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ContextMenu: React.FC<Props> = ({
|
const ContextMenu: React.FC<Props> = ({
|
||||||
menuRef,
|
|
||||||
children,
|
children,
|
||||||
onOpen,
|
onOpen,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -65,6 +57,11 @@ const ContextMenu: React.FC<Props> = ({
|
|||||||
...rest
|
...rest
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const previousVisible = usePrevious(rest.visible);
|
const previousVisible = usePrevious(rest.visible);
|
||||||
|
const maxHeight = useMenuHeight({
|
||||||
|
visible: rest.visible,
|
||||||
|
elementRef: rest.unstable_disclosureRef,
|
||||||
|
});
|
||||||
|
const backgroundRef = React.useRef<HTMLDivElement>(null);
|
||||||
const { ui } = useStores();
|
const { ui } = useStores();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { setIsMenuOpen } = useMenuContext();
|
const { setIsMenuOpen } = useMenuContext();
|
||||||
@@ -102,6 +99,21 @@ const ContextMenu: React.FC<Props> = ({
|
|||||||
t,
|
t,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// We must manually manage scroll lock for iOS support so that the scrollable
|
||||||
|
// element can be passed into body-scroll-lock. See:
|
||||||
|
// https://github.com/ariakit/ariakit/issues/469
|
||||||
|
React.useEffect(() => {
|
||||||
|
const scrollElement = backgroundRef.current;
|
||||||
|
if (rest.visible && scrollElement && !isSubMenu) {
|
||||||
|
disableBodyScroll(scrollElement, {
|
||||||
|
reserveScrollBarGap: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
scrollElement && !isSubMenu && enableBodyScroll(scrollElement);
|
||||||
|
};
|
||||||
|
}, [isSubMenu, rest.visible]);
|
||||||
|
|
||||||
// Perf win – don't render anything until the menu has been opened
|
// Perf win – don't render anything until the menu has been opened
|
||||||
if (!rest.visible && !previousVisible) {
|
if (!rest.visible && !previousVisible) {
|
||||||
return null;
|
return null;
|
||||||
@@ -111,132 +123,52 @@ const ContextMenu: React.FC<Props> = ({
|
|||||||
// trigger and the bottom of the window
|
// trigger and the bottom of the window
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Menu
|
<Menu hideOnClickOutside={!isMobile} preventBodyScroll={false} {...rest}>
|
||||||
ref={menuRef}
|
{(props) => {
|
||||||
hideOnClickOutside={!isMobile}
|
// kind of hacky, but this is an effective way of telling which way
|
||||||
preventBodyScroll={false}
|
// the menu will _actually_ be placed when taking into account screen
|
||||||
{...rest}
|
// positioning.
|
||||||
>
|
const topAnchor = props.style?.top === "0";
|
||||||
{(props) => (
|
// @ts-expect-error ts-migrate(2339) FIXME: Property 'placement' does not exist on type 'Extra... Remove this comment to see the full error message
|
||||||
<InnerContextMenu
|
const rightAnchor = props.placement === "bottom-end";
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
menuProps={props as any}
|
return (
|
||||||
{...rest}
|
<>
|
||||||
isSubMenu={isSubMenu}
|
{isMobile && (
|
||||||
>
|
<Backdrop
|
||||||
{children}
|
onClick={(ev) => {
|
||||||
</InnerContextMenu>
|
ev.preventDefault();
|
||||||
)}
|
ev.stopPropagation();
|
||||||
|
rest.hide?.();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Position {...props}>
|
||||||
|
<Background
|
||||||
|
dir="auto"
|
||||||
|
topAnchor={topAnchor}
|
||||||
|
rightAnchor={rightAnchor}
|
||||||
|
ref={backgroundRef}
|
||||||
|
hiddenScrollbars
|
||||||
|
style={
|
||||||
|
topAnchor
|
||||||
|
? {
|
||||||
|
maxHeight,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{rest.visible || rest.animating ? children : null}
|
||||||
|
</Background>
|
||||||
|
</Position>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
</Menu>
|
</Menu>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type InnerContextMenuProps = MenuStateReturn & {
|
|
||||||
isSubMenu: boolean;
|
|
||||||
menuProps: { style?: React.CSSProperties; placement: string };
|
|
||||||
children: React.ReactNode;
|
|
||||||
maxWidth?: number;
|
|
||||||
minHeight?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Inner context menu allows deferring expensive window measurement hooks etc
|
|
||||||
* until the menu is actually opened.
|
|
||||||
*/
|
|
||||||
const InnerContextMenu = (props: InnerContextMenuProps) => {
|
|
||||||
const { menuProps } = props;
|
|
||||||
// kind of hacky, but this is an effective way of telling which way
|
|
||||||
// the menu will _actually_ be placed when taking into account screen
|
|
||||||
// positioning.
|
|
||||||
const topAnchor =
|
|
||||||
menuProps.style?.top === "0" || menuProps.style?.position === "fixed";
|
|
||||||
const rightAnchor = menuProps.placement === "bottom-end";
|
|
||||||
const backgroundRef = React.useRef<HTMLDivElement>(null);
|
|
||||||
const isMobile = useMobile();
|
|
||||||
|
|
||||||
const maxHeight = useMenuHeight({
|
|
||||||
visible: props.visible,
|
|
||||||
elementRef: props.unstable_disclosureRef,
|
|
||||||
});
|
|
||||||
|
|
||||||
// We must manually manage scroll lock for iOS support so that the scrollable
|
|
||||||
// element can be passed into body-scroll-lock. See:
|
|
||||||
// https://github.com/ariakit/ariakit/issues/469
|
|
||||||
React.useEffect(() => {
|
|
||||||
const scrollElement = backgroundRef.current;
|
|
||||||
if (props.visible && scrollElement && !props.isSubMenu) {
|
|
||||||
disableBodyScroll(scrollElement, {
|
|
||||||
reserveScrollBarGap: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
scrollElement && !props.isSubMenu && enableBodyScroll(scrollElement);
|
|
||||||
};
|
|
||||||
}, [props.isSubMenu, props.visible]);
|
|
||||||
|
|
||||||
useEventListener(
|
|
||||||
"animationstart",
|
|
||||||
(event) => {
|
|
||||||
if (event.target instanceof HTMLElement) {
|
|
||||||
const parent = event.target.parentElement;
|
|
||||||
if (parent) {
|
|
||||||
parent.style.pointerEvents = "none";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
backgroundRef.current
|
|
||||||
);
|
|
||||||
|
|
||||||
useEventListener(
|
|
||||||
"animationend",
|
|
||||||
(event) => {
|
|
||||||
if (event.target instanceof HTMLElement) {
|
|
||||||
const parent = event.target.parentElement;
|
|
||||||
if (parent) {
|
|
||||||
parent.style.pointerEvents = "auto";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
backgroundRef.current
|
|
||||||
);
|
|
||||||
|
|
||||||
const style =
|
|
||||||
topAnchor && !isMobile
|
|
||||||
? {
|
|
||||||
maxHeight,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{isMobile && (
|
|
||||||
<Backdrop
|
|
||||||
onClick={(ev) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
props.hide?.();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Position {...menuProps}>
|
|
||||||
<Background
|
|
||||||
dir="auto"
|
|
||||||
maxWidth={props.maxWidth}
|
|
||||||
minHeight={props.minHeight}
|
|
||||||
topAnchor={topAnchor}
|
|
||||||
rightAnchor={rightAnchor}
|
|
||||||
ref={backgroundRef}
|
|
||||||
hiddenScrollbars
|
|
||||||
style={style}
|
|
||||||
>
|
|
||||||
{props.visible || props.animating ? props.children : null}
|
|
||||||
</Background>
|
|
||||||
</Position>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ContextMenu;
|
export default ContextMenu;
|
||||||
|
|
||||||
export const Backdrop = styled.div`
|
export const Backdrop = styled.div`
|
||||||
@@ -254,16 +186,6 @@ export const Position = styled.div`
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: ${depths.menu};
|
z-index: ${depths.menu};
|
||||||
|
|
||||||
// Note: pointer events are re-enabled after the animation ends, see event listeners above
|
|
||||||
pointer-events: none;
|
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
transition-delay: 250ms;
|
|
||||||
transition-property: outline-width;
|
|
||||||
transition-duration: 0;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* overrides make mobile-first coding style challenging
|
* overrides make mobile-first coding style challenging
|
||||||
* so we explicitly define mobile breakpoint here
|
* so we explicitly define mobile breakpoint here
|
||||||
@@ -281,8 +203,6 @@ export const Position = styled.div`
|
|||||||
type BackgroundProps = {
|
type BackgroundProps = {
|
||||||
topAnchor?: boolean;
|
topAnchor?: boolean;
|
||||||
rightAnchor?: boolean;
|
rightAnchor?: boolean;
|
||||||
maxWidth?: number;
|
|
||||||
minHeight?: number;
|
|
||||||
theme: DefaultTheme;
|
theme: DefaultTheme;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -294,8 +214,9 @@ export const Background = styled(Scrollable)<BackgroundProps>`
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
min-height: ${(props) => props.minHeight || 44}px;
|
min-height: 44px;
|
||||||
max-height: 75vh;
|
max-height: 75vh;
|
||||||
|
pointer-events: all;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
@@ -307,8 +228,7 @@ export const Background = styled(Scrollable)<BackgroundProps>`
|
|||||||
props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease;
|
props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease;
|
||||||
transform-origin: ${(props: BackgroundProps) =>
|
transform-origin: ${(props: BackgroundProps) =>
|
||||||
props.rightAnchor ? "75%" : "25%"} 0;
|
props.rightAnchor ? "75%" : "25%"} 0;
|
||||||
max-width: ${(props: BackgroundProps) => props.maxWidth ?? 276}px;
|
max-width: 276px;
|
||||||
max-height: 100vh;
|
|
||||||
background: ${(props: BackgroundProps) => props.theme.menuBackground};
|
background: ${(props: BackgroundProps) => props.theme.menuBackground};
|
||||||
box-shadow: ${(props: BackgroundProps) => props.theme.menuShadow};
|
box-shadow: ${(props: BackgroundProps) => props.theme.menuShadow};
|
||||||
`};
|
`};
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import copy from "copy-to-clipboard";
|
import copy from "copy-to-clipboard";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { mergeRefs } from "react-merge-refs";
|
|
||||||
import env from "~/env";
|
import env from "~/env";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -10,43 +9,32 @@ type Props = {
|
|||||||
onCopy?: () => void;
|
onCopy?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function CopyToClipboard(props: Props, ref: React.Ref<HTMLElement>) {
|
class CopyToClipboard extends React.PureComponent<Props> {
|
||||||
const { text, onCopy, children, ...rest } = props;
|
onClick = (ev: React.SyntheticEvent) => {
|
||||||
|
const { text, onCopy, children } = this.props;
|
||||||
|
const elem = React.Children.only(children);
|
||||||
|
|
||||||
const onClick = React.useCallback(
|
copy(text, {
|
||||||
(ev: React.MouseEvent<HTMLElement>) => {
|
debug: env.ENVIRONMENT !== "production",
|
||||||
const elem = React.Children.only(children);
|
format: "text/plain",
|
||||||
|
});
|
||||||
|
|
||||||
copy(text, {
|
onCopy?.();
|
||||||
debug: env.ENVIRONMENT !== "production",
|
|
||||||
format: "text/plain",
|
|
||||||
});
|
|
||||||
|
|
||||||
onCopy?.();
|
if (elem && elem.props && typeof elem.props.onClick === "function") {
|
||||||
|
elem.props.onClick(ev);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (elem && elem.props && typeof elem.props.onClick === "function") {
|
render() {
|
||||||
elem.props.onClick(ev);
|
const { text, onCopy, children, ...rest } = this.props;
|
||||||
} else {
|
const elem = React.Children.only(children);
|
||||||
ev.preventDefault();
|
if (!elem) {
|
||||||
ev.stopPropagation();
|
return null;
|
||||||
}
|
}
|
||||||
},
|
|
||||||
[children, onCopy, text]
|
|
||||||
);
|
|
||||||
|
|
||||||
const elem = React.Children.only(children);
|
return React.cloneElement(elem, { ...rest, onClick: this.onClick });
|
||||||
if (!elem) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return React.cloneElement(elem, {
|
|
||||||
...rest,
|
|
||||||
ref:
|
|
||||||
"ref" in elem
|
|
||||||
? mergeRefs([elem.ref as React.MutableRefObject<HTMLElement>, ref])
|
|
||||||
: ref,
|
|
||||||
onClick,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default React.forwardRef(CopyToClipboard);
|
export default CopyToClipboard;
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import { HomeIcon } from "outline-icons";
|
import { HomeIcon } from "outline-icons";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { Optional } from "utility-types";
|
||||||
|
import Flex from "~/components/Flex";
|
||||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||||
import { InputSelectNew, Option } from "~/components/InputSelectNew";
|
import InputSelect from "~/components/InputSelect";
|
||||||
|
import { IconWrapper } from "~/components/Sidebar/components/SidebarLink";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
|
import useToasts from "~/hooks/useToasts";
|
||||||
|
|
||||||
type DefaultCollectionInputSelectProps = {
|
type DefaultCollectionInputSelectProps = Optional<
|
||||||
|
React.ComponentProps<typeof InputSelect>
|
||||||
|
> & {
|
||||||
onSelectCollection: (collection: string) => void;
|
onSelectCollection: (collection: string) => void;
|
||||||
defaultCollectionId: string | null;
|
defaultCollectionId: string | null;
|
||||||
};
|
};
|
||||||
@@ -14,11 +19,13 @@ type DefaultCollectionInputSelectProps = {
|
|||||||
const DefaultCollectionInputSelect = ({
|
const DefaultCollectionInputSelect = ({
|
||||||
onSelectCollection,
|
onSelectCollection,
|
||||||
defaultCollectionId,
|
defaultCollectionId,
|
||||||
|
...rest
|
||||||
}: DefaultCollectionInputSelectProps) => {
|
}: DefaultCollectionInputSelectProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { collections } = useStores();
|
const { collections } = useStores();
|
||||||
const [fetching, setFetching] = useState(false);
|
const [fetching, setFetching] = useState(false);
|
||||||
const [fetchError, setFetchError] = useState();
|
const [fetchError, setFetchError] = useState();
|
||||||
|
const { showToast } = useToasts();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
@@ -29,8 +36,11 @@ const DefaultCollectionInputSelect = ({
|
|||||||
limit: 100,
|
limit: 100,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(
|
showToast(
|
||||||
t("Collections could not be loaded, please reload the app")
|
t("Collections could not be loaded, please reload the app"),
|
||||||
|
{
|
||||||
|
type: "error",
|
||||||
|
}
|
||||||
);
|
);
|
||||||
setFetchError(error);
|
setFetchError(error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -39,30 +49,40 @@ const DefaultCollectionInputSelect = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
void fetchData();
|
void fetchData();
|
||||||
}, [fetchError, t, fetching, collections]);
|
}, [showToast, fetchError, t, fetching, collections]);
|
||||||
|
|
||||||
const options: Option[] = React.useMemo(
|
const options = React.useMemo(
|
||||||
() =>
|
() =>
|
||||||
collections.nonPrivate.reduce(
|
collections.publicCollections.reduce(
|
||||||
(acc, collection) => [
|
(acc, collection) => [
|
||||||
...acc,
|
...acc,
|
||||||
{
|
{
|
||||||
type: "item",
|
label: (
|
||||||
label: collection.name,
|
<Flex align="center">
|
||||||
|
<IconWrapper>
|
||||||
|
<CollectionIcon collection={collection} />
|
||||||
|
</IconWrapper>
|
||||||
|
{collection.name}
|
||||||
|
</Flex>
|
||||||
|
),
|
||||||
value: collection.id,
|
value: collection.id,
|
||||||
icon: <CollectionIcon collection={collection} />,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
type: "item",
|
label: (
|
||||||
label: t("Home"),
|
<Flex align="center">
|
||||||
|
<IconWrapper>
|
||||||
|
<HomeIcon />
|
||||||
|
</IconWrapper>
|
||||||
|
{t("Home")}
|
||||||
|
</Flex>
|
||||||
|
),
|
||||||
value: "home",
|
value: "home",
|
||||||
icon: <HomeIcon />,
|
|
||||||
},
|
},
|
||||||
] satisfies Option[]
|
]
|
||||||
),
|
),
|
||||||
[collections.nonPrivate, t]
|
[collections.publicCollections, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (fetching) {
|
if (fetching) {
|
||||||
@@ -70,14 +90,13 @@ const DefaultCollectionInputSelect = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InputSelectNew
|
<InputSelect
|
||||||
options={options}
|
|
||||||
value={defaultCollectionId ?? "home"}
|
value={defaultCollectionId ?? "home"}
|
||||||
|
options={options}
|
||||||
onChange={onSelectCollection}
|
onChange={onSelectCollection}
|
||||||
ariaLabel={t("Default collection")}
|
ariaLabel={t("Default collection")}
|
||||||
label={t("Start view")}
|
|
||||||
hideLabel
|
|
||||||
short
|
short
|
||||||
|
{...rest}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useHistory } from "react-router-dom";
|
import { useHistory } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
|
||||||
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
|
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
|
||||||
import { useDesktopTitlebar } from "~/hooks/useDesktopTitlebar";
|
import { useDesktopTitlebar } from "~/hooks/useDesktopTitlebar";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
|
import useToasts from "~/hooks/useToasts";
|
||||||
import Desktop from "~/utils/Desktop";
|
import Desktop from "~/utils/Desktop";
|
||||||
|
|
||||||
export default function DesktopEventHandler() {
|
export default function DesktopEventHandler() {
|
||||||
@@ -12,7 +12,7 @@ export default function DesktopEventHandler() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const { dialogs } = useStores();
|
const { dialogs } = useStores();
|
||||||
const hasDisabledUpdateMessage = React.useRef(false);
|
const { showToast } = useToasts();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
Desktop.bridge?.redirect((path: string, replace = false) => {
|
Desktop.bridge?.redirect((path: string, replace = false) => {
|
||||||
@@ -24,16 +24,11 @@ export default function DesktopEventHandler() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Desktop.bridge?.updateDownloaded(() => {
|
Desktop.bridge?.updateDownloaded(() => {
|
||||||
if (hasDisabledUpdateMessage.current) {
|
showToast("An update is ready to install.", {
|
||||||
return;
|
type: "info",
|
||||||
}
|
timeout: Infinity,
|
||||||
|
|
||||||
hasDisabledUpdateMessage.current = true;
|
|
||||||
toast.message("An update is ready to install.", {
|
|
||||||
duration: Infinity,
|
|
||||||
dismissible: true,
|
|
||||||
action: {
|
action: {
|
||||||
label: t("Install now"),
|
text: "Install now",
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
void Desktop.bridge?.restartAndInstall();
|
void Desktop.bridge?.restartAndInstall();
|
||||||
},
|
},
|
||||||
@@ -55,7 +50,7 @@ export default function DesktopEventHandler() {
|
|||||||
content: <KeyboardShortcuts />,
|
content: <KeyboardShortcuts />,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, [t, history, dialogs]);
|
}, [t, history, dialogs, showToast]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,13 +22,9 @@ function Dialogs() {
|
|||||||
<Modal
|
<Modal
|
||||||
key={id}
|
key={id}
|
||||||
isOpen={modal.isOpen}
|
isOpen={modal.isOpen}
|
||||||
fullscreen={modal.fullscreen ?? false}
|
isCentered={modal.isCentered}
|
||||||
onRequestClose={() => {
|
onRequestClose={() => dialogs.closeModal(id)}
|
||||||
modal.onClose?.();
|
|
||||||
dialogs.closeModal(id);
|
|
||||||
}}
|
|
||||||
title={modal.title}
|
title={modal.title}
|
||||||
style={modal.style}
|
|
||||||
>
|
>
|
||||||
{modal.content}
|
{modal.content}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -3,16 +3,18 @@ import { ArchiveIcon, GoToIcon, ShapesIcon, TrashIcon } from "outline-icons";
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import Icon from "@shared/components/Icon";
|
|
||||||
import type { NavigationNode } from "@shared/types";
|
import type { NavigationNode } from "@shared/types";
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
import Breadcrumb from "~/components/Breadcrumb";
|
import Breadcrumb from "~/components/Breadcrumb";
|
||||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
|
||||||
import usePolicy from "~/hooks/usePolicy";
|
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import { MenuInternalLink } from "~/types";
|
import { MenuInternalLink } from "~/types";
|
||||||
import { archivePath, settingsPath, trashPath } from "~/utils/routeHelpers";
|
import {
|
||||||
|
archivePath,
|
||||||
|
collectionPath,
|
||||||
|
templatesPath,
|
||||||
|
trashPath,
|
||||||
|
} from "~/utils/routeHelpers";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
@@ -41,57 +43,53 @@ function useCategory(document: Document): MenuInternalLink | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.template) {
|
if (document.isTemplate) {
|
||||||
return {
|
return {
|
||||||
type: "route",
|
type: "route",
|
||||||
icon: <ShapesIcon />,
|
icon: <ShapesIcon />,
|
||||||
title: t("Templates"),
|
title: t("Templates"),
|
||||||
to: settingsPath("templates"),
|
to: templatesPath(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DocumentBreadcrumb(
|
const DocumentBreadcrumb: React.FC<Props> = ({
|
||||||
{ document, children, onlyText }: Props,
|
document,
|
||||||
ref: React.RefObject<HTMLDivElement> | null
|
children,
|
||||||
) {
|
onlyText,
|
||||||
|
}: Props) => {
|
||||||
const { collections } = useStores();
|
const { collections } = useStores();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const category = useCategory(document);
|
const category = useCategory(document);
|
||||||
const sidebarContext = useLocationSidebarContext();
|
|
||||||
const collection = document.collectionId
|
const collection = document.collectionId
|
||||||
? collections.get(document.collectionId)
|
? collections.get(document.collectionId)
|
||||||
: undefined;
|
: undefined;
|
||||||
const can = usePolicy(collection);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
void document.loadRelations({ withoutPolicies: true });
|
|
||||||
}, [document]);
|
|
||||||
|
|
||||||
let collectionNode: MenuInternalLink | undefined;
|
let collectionNode: MenuInternalLink | undefined;
|
||||||
|
|
||||||
if (collection && can.readDocument) {
|
if (collection) {
|
||||||
collectionNode = {
|
collectionNode = {
|
||||||
type: "route",
|
type: "route",
|
||||||
title: collection.name,
|
title: collection.name,
|
||||||
icon: <CollectionIcon collection={collection} expanded />,
|
icon: <CollectionIcon collection={collection} expanded />,
|
||||||
to: {
|
to: collectionPath(collection.url),
|
||||||
pathname: collection.path,
|
|
||||||
state: { sidebarContext },
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
} else if (document.isCollectionDeleted) {
|
} else if (document.collectionId && !collection) {
|
||||||
collectionNode = {
|
collectionNode = {
|
||||||
type: "route",
|
type: "route",
|
||||||
title: t("Deleted Collection"),
|
title: t("Deleted Collection"),
|
||||||
icon: undefined,
|
icon: undefined,
|
||||||
to: "",
|
to: collectionPath("deleted-collection"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const path = document.pathTo;
|
const path = React.useMemo(
|
||||||
|
() => collection?.pathToDocument(document.id).slice(0, -1) || [],
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[collection, document, document.collectionId, document.parentDocumentId]
|
||||||
|
);
|
||||||
|
|
||||||
const items = React.useMemo(() => {
|
const items = React.useMemo(() => {
|
||||||
const output = [];
|
const output = [];
|
||||||
@@ -104,25 +102,15 @@ function DocumentBreadcrumb(
|
|||||||
output.push(collectionNode);
|
output.push(collectionNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
path.slice(0, -1).forEach((node: NavigationNode) => {
|
path.forEach((node: NavigationNode) => {
|
||||||
const title = node.title || t("Untitled");
|
|
||||||
output.push({
|
output.push({
|
||||||
type: "route",
|
type: "route",
|
||||||
title: node.icon ? (
|
title: node.title,
|
||||||
<>
|
to: node.url,
|
||||||
<StyledIcon value={node.icon} color={node.color} /> {title}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
title
|
|
||||||
),
|
|
||||||
to: {
|
|
||||||
pathname: node.url,
|
|
||||||
state: { sidebarContext },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return output;
|
return output;
|
||||||
}, [t, path, category, sidebarContext, collectionNode]);
|
}, [path, category, collectionNode]);
|
||||||
|
|
||||||
if (!collections.isLoaded) {
|
if (!collections.isLoaded) {
|
||||||
return null;
|
return null;
|
||||||
@@ -132,10 +120,10 @@ function DocumentBreadcrumb(
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{collection?.name}
|
{collection?.name}
|
||||||
{path.slice(0, -1).map((node: NavigationNode) => (
|
{path.map((node: NavigationNode) => (
|
||||||
<React.Fragment key={node.id}>
|
<React.Fragment key={node.id}>
|
||||||
<SmallSlash />
|
<SmallSlash />
|
||||||
{node.title || t("Untitled")}
|
{node.title}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
@@ -143,15 +131,11 @@ function DocumentBreadcrumb(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Breadcrumb items={items} ref={ref} highlightFirstItem>
|
<Breadcrumb items={items} highlightFirstItem>
|
||||||
{children}
|
{children}
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
const StyledIcon = styled(Icon)`
|
|
||||||
margin-right: 2px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const SmallSlash = styled(GoToIcon)`
|
const SmallSlash = styled(GoToIcon)`
|
||||||
width: 12px;
|
width: 12px;
|
||||||
@@ -163,4 +147,4 @@ const SmallSlash = styled(GoToIcon)`
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default observer(React.forwardRef(DocumentBreadcrumb));
|
export default observer(DocumentBreadcrumb);
|
||||||
|
|||||||
@@ -1,26 +1,23 @@
|
|||||||
import { useSortable } from "@dnd-kit/sortable";
|
import { useSortable } from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import { subDays } from "date-fns";
|
|
||||||
import { m } from "framer-motion";
|
import { m } from "framer-motion";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { CloseIcon, DocumentIcon, ClockIcon, EyeIcon } from "outline-icons";
|
import { CloseIcon, DocumentIcon, ClockIcon } from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import styled, { useTheme } from "styled-components";
|
import styled, { useTheme } from "styled-components";
|
||||||
import Icon from "@shared/components/Icon";
|
import { s, ellipsis } from "@shared/styles";
|
||||||
import Squircle from "@shared/components/Squircle";
|
|
||||||
import { s, hover, ellipsis } from "@shared/styles";
|
|
||||||
import { IconType } from "@shared/types";
|
|
||||||
import { determineIconType } from "@shared/utils/icon";
|
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
import Pin from "~/models/Pin";
|
import Pin from "~/models/Pin";
|
||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
import NudeButton from "~/components/NudeButton";
|
import NudeButton from "~/components/NudeButton";
|
||||||
import Time from "~/components/Time";
|
import Time from "~/components/Time";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import { useTextStats } from "~/hooks/useTextStats";
|
import { hover } from "~/styles";
|
||||||
import CollectionIcon from "./Icons/CollectionIcon";
|
import CollectionIcon from "./Icons/CollectionIcon";
|
||||||
|
import EmojiIcon from "./Icons/EmojiIcon";
|
||||||
|
import Squircle from "./Squircle";
|
||||||
import Text from "./Text";
|
import Text from "./Text";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
|
|
||||||
@@ -40,7 +37,6 @@ function DocumentCard(props: Props) {
|
|||||||
const { collections } = useStores();
|
const { collections } = useStores();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { document, pin, canUpdatePin, isDraggable } = props;
|
const { document, pin, canUpdatePin, isDraggable } = props;
|
||||||
const pinnedToHome = React.useRef(!pin?.collectionId).current;
|
|
||||||
const collection = document.collectionId
|
const collection = document.collectionId
|
||||||
? collections.get(document.collectionId)
|
? collections.get(document.collectionId)
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -56,8 +52,6 @@ function DocumentCard(props: Props) {
|
|||||||
disabled: !isDraggable || !canUpdatePin,
|
disabled: !isDraggable || !canUpdatePin,
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasEmojiInTitle = determineIconType(document.icon) === IconType.Emoji;
|
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
transition,
|
||||||
@@ -72,10 +66,6 @@ function DocumentCard(props: Props) {
|
|||||||
[pin]
|
[pin]
|
||||||
);
|
);
|
||||||
|
|
||||||
// If the document was updated within the last 7 days, show a timestamp instead of reading time
|
|
||||||
const isRecentlyUpdated =
|
|
||||||
new Date(document.updatedAt) > subDays(new Date(), 7);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Reorderable
|
<Reorderable
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
@@ -119,22 +109,15 @@ function DocumentCard(props: Props) {
|
|||||||
<path d="M19.5 19.5H6C2.96243 19.5 0.5 17.0376 0.5 14V0.5H0.792893L19.5 19.2071V19.5Z" />
|
<path d="M19.5 19.5H6C2.96243 19.5 0.5 17.0376 0.5 14V0.5H0.792893L19.5 19.2071V19.5Z" />
|
||||||
</Fold>
|
</Fold>
|
||||||
|
|
||||||
{document.icon ? (
|
{document.emoji ? (
|
||||||
<DocumentSquircle
|
<Squircle color={theme.slateLight}>
|
||||||
icon={document.icon}
|
<EmojiIcon emoji={document.emoji} size={26} />
|
||||||
color={document.color ?? undefined}
|
</Squircle>
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<Squircle
|
<Squircle color={collection?.color}>
|
||||||
color={
|
|
||||||
collection?.color ??
|
|
||||||
(pinnedToHome ? theme.slateLight : theme.slateDark)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{collection?.icon &&
|
{collection?.icon &&
|
||||||
collection?.icon !== "letter" &&
|
|
||||||
collection?.icon !== "collection" &&
|
collection?.icon !== "collection" &&
|
||||||
pinnedToHome ? (
|
!pin?.collectionId ? (
|
||||||
<CollectionIcon collection={collection} color="white" />
|
<CollectionIcon collection={collection} color="white" />
|
||||||
) : (
|
) : (
|
||||||
<DocumentIcon color="white" />
|
<DocumentIcon color="white" />
|
||||||
@@ -143,26 +126,25 @@ function DocumentCard(props: Props) {
|
|||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<Heading dir={document.dir}>
|
<Heading dir={document.dir}>
|
||||||
{hasEmojiInTitle
|
{document.emoji
|
||||||
? document.titleWithDefault.replace(document.icon!, "")
|
? document.titleWithDefault.replace(document.emoji, "")
|
||||||
: document.titleWithDefault}
|
: document.titleWithDefault}
|
||||||
</Heading>
|
</Heading>
|
||||||
<DocumentMeta size="xsmall">
|
<DocumentMeta size="xsmall">
|
||||||
{isRecentlyUpdated ? (
|
<Clock size={18} />
|
||||||
<>
|
<Time
|
||||||
<Clock size={18} />
|
dateTime={document.updatedAt}
|
||||||
<Time dateTime={document.updatedAt} addSuffix shorten />
|
tooltipDelay={500}
|
||||||
</>
|
addSuffix
|
||||||
) : (
|
shorten
|
||||||
<ReadingTime document={document} />
|
/>
|
||||||
)}
|
|
||||||
</DocumentMeta>
|
</DocumentMeta>
|
||||||
</div>
|
</div>
|
||||||
</Content>
|
</Content>
|
||||||
{canUpdatePin && (
|
{canUpdatePin && (
|
||||||
<Actions dir={document.dir} gap={4}>
|
<Actions dir={document.dir} gap={4}>
|
||||||
{!isDragging && pin && (
|
{!isDragging && pin && (
|
||||||
<Tooltip content={t("Unpin")}>
|
<Tooltip tooltip={t("Unpin")}>
|
||||||
<PinButton onClick={handleUnpin} aria-label={t("Unpin")}>
|
<PinButton onClick={handleUnpin} aria-label={t("Unpin")}>
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
</PinButton>
|
</PinButton>
|
||||||
@@ -176,39 +158,6 @@ function DocumentCard(props: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ReadingTime = ({ document }: { document: Document }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const markdown = React.useMemo(() => document.toMarkdown(), [document]);
|
|
||||||
const stats = useTextStats(markdown);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<EyeIcon size={18} />
|
|
||||||
{t(`{{ minutes }}m read`, {
|
|
||||||
minutes: stats.total.readingTime,
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const DocumentSquircle = ({
|
|
||||||
icon,
|
|
||||||
color,
|
|
||||||
}: {
|
|
||||||
icon: string;
|
|
||||||
color?: string;
|
|
||||||
}) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const iconType = determineIconType(icon)!;
|
|
||||||
const squircleColor = iconType === IconType.SVG ? color : theme.slateLight;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Squircle color={squircleColor}>
|
|
||||||
<Icon value={icon} color={theme.white} forceColor />
|
|
||||||
</Squircle>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const Clock = styled(ClockIcon)`
|
const Clock = styled(ClockIcon)`
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
`;
|
`;
|
||||||
@@ -330,8 +279,8 @@ const Heading = styled.h3`
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
color: ${s("text")};
|
color: ${s("text")};
|
||||||
font-family: ${s("fontFamily")};
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
|
||||||
font-weight: 500;
|
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default observer(DocumentCard);
|
export default observer(DocumentCard);
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Editor } from "~/editor";
|
||||||
|
|
||||||
|
export type DocumentContextValue = {
|
||||||
|
/** The current editor instance for this document. */
|
||||||
|
editor: Editor | null;
|
||||||
|
/** Set the current editor instance for this document. */
|
||||||
|
setEditor: (editor: Editor) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DocumentContext = React.createContext<DocumentContextValue>({
|
||||||
|
editor: null,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
|
setEditor() {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useDocumentContext = () => React.useContext(DocumentContext);
|
||||||
|
|
||||||
|
export default DocumentContext;
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import { action, computed, observable } from "mobx";
|
|
||||||
import React, { PropsWithChildren } from "react";
|
|
||||||
import { Heading } from "@shared/utils/ProsemirrorHelper";
|
|
||||||
import Document from "~/models/Document";
|
|
||||||
import { Editor } from "~/editor";
|
|
||||||
|
|
||||||
class DocumentContext {
|
|
||||||
/** The current document */
|
|
||||||
document?: Document;
|
|
||||||
|
|
||||||
/** The editor instance for this document */
|
|
||||||
editor?: Editor;
|
|
||||||
|
|
||||||
@observable
|
|
||||||
isEditorInitialized: boolean = false;
|
|
||||||
|
|
||||||
@observable
|
|
||||||
headings: Heading[] = [];
|
|
||||||
|
|
||||||
@computed
|
|
||||||
get hasHeadings() {
|
|
||||||
return this.headings.length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
|
||||||
setDocument = (document: Document) => {
|
|
||||||
this.document = document;
|
|
||||||
this.updateState();
|
|
||||||
};
|
|
||||||
|
|
||||||
@action
|
|
||||||
setEditor = (editor: Editor) => {
|
|
||||||
this.editor = editor;
|
|
||||||
this.updateState();
|
|
||||||
};
|
|
||||||
|
|
||||||
@action
|
|
||||||
setEditorInitialized = (initialized: boolean) => {
|
|
||||||
this.isEditorInitialized = initialized;
|
|
||||||
};
|
|
||||||
|
|
||||||
@action
|
|
||||||
updateState = () => {
|
|
||||||
this.updateHeadings();
|
|
||||||
this.updateTasks();
|
|
||||||
};
|
|
||||||
|
|
||||||
private updateHeadings() {
|
|
||||||
const currHeadings = this.editor?.getHeadings() ?? [];
|
|
||||||
const hasChanged =
|
|
||||||
currHeadings.map((h) => h.level + h.title).join("") !==
|
|
||||||
this.headings.map((h) => h.level + h.title).join("");
|
|
||||||
|
|
||||||
if (hasChanged) {
|
|
||||||
this.headings = currHeadings;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateTasks() {
|
|
||||||
const tasks = this.editor?.getTasks() ?? [];
|
|
||||||
const total = tasks.length ?? 0;
|
|
||||||
const completed = tasks.filter((t) => t.completed).length ?? 0;
|
|
||||||
this.document?.updateTasks(total, completed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const Context = React.createContext<DocumentContext | null>(null);
|
|
||||||
|
|
||||||
export const useDocumentContext = () => {
|
|
||||||
const ctx = React.useContext(Context);
|
|
||||||
if (!ctx) {
|
|
||||||
throw new Error(
|
|
||||||
"useDocumentContext must be used within DocumentContextProvider"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return ctx;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DocumentContextProvider = ({
|
|
||||||
children,
|
|
||||||
}: PropsWithChildren<unknown>) => {
|
|
||||||
const context = React.useMemo(() => new DocumentContext(), []);
|
|
||||||
return <Context.Provider value={context}>{children}</Context.Provider>;
|
|
||||||
};
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
import flatten from "lodash/flatten";
|
|
||||||
import { observer } from "mobx-react";
|
|
||||||
import * as React from "react";
|
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import styled from "styled-components";
|
|
||||||
import { NavigationNode } from "@shared/types";
|
|
||||||
import Document from "~/models/Document";
|
|
||||||
import { FlexContainer, Footer, StyledText } from "~/scenes/DocumentMove";
|
|
||||||
import Button from "~/components/Button";
|
|
||||||
import DocumentExplorer from "~/components/DocumentExplorer";
|
|
||||||
import useCollectionTrees from "~/hooks/useCollectionTrees";
|
|
||||||
import useStores from "~/hooks/useStores";
|
|
||||||
import { flattenTree } from "~/utils/tree";
|
|
||||||
import Switch from "./Switch";
|
|
||||||
import Text from "./Text";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
/** The original document to duplicate */
|
|
||||||
document: Document;
|
|
||||||
onSubmit: (documents: Document[]) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function DocumentCopy({ document, onSubmit }: Props) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { policies } = useStores();
|
|
||||||
const collectionTrees = useCollectionTrees();
|
|
||||||
const [publish, setPublish] = React.useState<boolean>(!!document.publishedAt);
|
|
||||||
const [recursive, setRecursive] = React.useState<boolean>(true);
|
|
||||||
const [selectedPath, selectPath] = React.useState<NavigationNode | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
const items = React.useMemo(() => {
|
|
||||||
const nodes = flatten(collectionTrees.map(flattenTree)).filter((node) =>
|
|
||||||
node.collectionId
|
|
||||||
? policies.get(node.collectionId)?.abilities.createDocument
|
|
||||||
: true
|
|
||||||
);
|
|
||||||
|
|
||||||
if (document.isTemplate) {
|
|
||||||
return nodes
|
|
||||||
.filter((node) => node.type === "collection")
|
|
||||||
.map((node) => ({ ...node, children: [] }));
|
|
||||||
}
|
|
||||||
return nodes;
|
|
||||||
}, [policies, collectionTrees, document.isTemplate]);
|
|
||||||
|
|
||||||
const handlePublishChange = React.useCallback(
|
|
||||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setPublish(ev.target.checked);
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRecursiveChange = React.useCallback(
|
|
||||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setRecursive(ev.target.checked);
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const copy = async () => {
|
|
||||||
if (!selectedPath) {
|
|
||||||
toast.message(t("Select a location to copy"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await document.duplicate({
|
|
||||||
publish,
|
|
||||||
recursive,
|
|
||||||
title: document.title,
|
|
||||||
collectionId: selectedPath.collectionId,
|
|
||||||
...(selectedPath.type === "document"
|
|
||||||
? { parentDocumentId: selectedPath.id }
|
|
||||||
: {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
toast.success(t("Document copied"));
|
|
||||||
onSubmit(result);
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(t("Couldn’t copy the document, try again?"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FlexContainer column>
|
|
||||||
<DocumentExplorer
|
|
||||||
items={items}
|
|
||||||
onSubmit={copy}
|
|
||||||
onSelect={selectPath}
|
|
||||||
defaultValue={document.parentDocumentId || document.collectionId || ""}
|
|
||||||
/>
|
|
||||||
<OptionsContainer>
|
|
||||||
{!document.isTemplate && (
|
|
||||||
<>
|
|
||||||
{document.collectionId && (
|
|
||||||
<Text size="small">
|
|
||||||
<Switch
|
|
||||||
name="publish"
|
|
||||||
label={t("Publish")}
|
|
||||||
labelPosition="right"
|
|
||||||
checked={publish}
|
|
||||||
onChange={handlePublishChange}
|
|
||||||
/>
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{document.publishedAt && document.childDocuments.length > 0 && (
|
|
||||||
<Text size="small">
|
|
||||||
<Switch
|
|
||||||
name="recursive"
|
|
||||||
label={t("Include nested documents")}
|
|
||||||
labelPosition="right"
|
|
||||||
checked={recursive}
|
|
||||||
onChange={handleRecursiveChange}
|
|
||||||
/>
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</OptionsContainer>
|
|
||||||
<Footer justify="space-between" align="center" gap={8}>
|
|
||||||
<StyledText type="secondary">
|
|
||||||
{selectedPath ? (
|
|
||||||
<Trans
|
|
||||||
defaults="Copy to <em>{{ location }}</em>"
|
|
||||||
values={{ location: selectedPath.title }}
|
|
||||||
components={{ em: <strong /> }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
t("Select a location to copy")
|
|
||||||
)}
|
|
||||||
</StyledText>
|
|
||||||
<Button disabled={!selectedPath} onClick={copy}>
|
|
||||||
{t("Copy")}
|
|
||||||
</Button>
|
|
||||||
</Footer>
|
|
||||||
</FlexContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const OptionsContainer = styled.div`
|
|
||||||
margin: 16px 0 8px 0;
|
|
||||||
padding-left: 24px;
|
|
||||||
padding-right: 24px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default observer(DocumentCopy);
|
|
||||||
@@ -1,45 +1,41 @@
|
|||||||
import FuzzySearch from "fuzzy-search";
|
import FuzzySearch from "fuzzy-search";
|
||||||
import concat from "lodash/concat";
|
import { includes, difference, concat, filter, map, fill } from "lodash";
|
||||||
import difference from "lodash/difference";
|
|
||||||
import fill from "lodash/fill";
|
|
||||||
import filter from "lodash/filter";
|
|
||||||
import includes from "lodash/includes";
|
|
||||||
import map from "lodash/map";
|
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { StarredIcon, DocumentIcon } from "outline-icons";
|
import { StarredIcon, DocumentIcon } from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import AutoSizer from "react-virtualized-auto-sizer";
|
import AutoSizer from "react-virtualized-auto-sizer";
|
||||||
import { FixedSizeList as List } from "react-window";
|
import { FixedSizeList as List } from "react-window";
|
||||||
import scrollIntoView from "scroll-into-view-if-needed";
|
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||||
import styled, { useTheme } from "styled-components";
|
import styled, { useTheme } from "styled-components";
|
||||||
import breakpoint from "styled-components-breakpoint";
|
import breakpoint from "styled-components-breakpoint";
|
||||||
import Icon from "@shared/components/Icon";
|
import { NavigationNode } from "@shared/types";
|
||||||
import { NavigationNode, NavigationNodeType } from "@shared/types";
|
import parseTitle from "@shared/utils/parseTitle";
|
||||||
import { isModKey } from "@shared/utils/keyboard";
|
|
||||||
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
|
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
|
||||||
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
|
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
|
||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||||
|
import EmojiIcon from "~/components/Icons/EmojiIcon";
|
||||||
import { Outline } from "~/components/Input";
|
import { Outline } from "~/components/Input";
|
||||||
import InputSearch from "~/components/InputSearch";
|
import InputSearch from "~/components/InputSearch";
|
||||||
import Text from "~/components/Text";
|
import Text from "~/components/Text";
|
||||||
import useMobile from "~/hooks/useMobile";
|
import useMobile from "~/hooks/useMobile";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
|
import { isModKey } from "~/utils/keyboard";
|
||||||
import { ancestors, descendants } from "~/utils/tree";
|
import { ancestors, descendants } from "~/utils/tree";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/** Action taken upon submission of selected item, could be publish, move etc. */
|
/** Action taken upon submission of selected item, could be publish, move etc. */
|
||||||
onSubmit: () => void;
|
onSubmit: () => void;
|
||||||
|
|
||||||
/** A side-effect of item selection */
|
/** A side-effect of item selection */
|
||||||
onSelect: (item: NavigationNode | null) => void;
|
onSelect: (item: NavigationNode | null) => void;
|
||||||
|
|
||||||
/** Items to be shown in explorer */
|
/** Items to be shown in explorer */
|
||||||
items: NavigationNode[];
|
items: NavigationNode[];
|
||||||
/** Automatically expand to and select item with the given id */
|
|
||||||
defaultValue?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||||
const isMobile = useMobile();
|
const isMobile = useMobile();
|
||||||
const { collections, documents } = useStores();
|
const { collections, documents } = useStores();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -47,25 +43,12 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
|||||||
|
|
||||||
const [searchTerm, setSearchTerm] = React.useState<string>();
|
const [searchTerm, setSearchTerm] = React.useState<string>();
|
||||||
const [selectedNode, selectNode] = React.useState<NavigationNode | null>(
|
const [selectedNode, selectNode] = React.useState<NavigationNode | null>(
|
||||||
() => {
|
null
|
||||||
const node =
|
|
||||||
defaultValue && items.find((item) => item.id === defaultValue);
|
|
||||||
return node || null;
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
const [initialScrollOffset, setInitialScrollOffset] =
|
const [initialScrollOffset, setInitialScrollOffset] =
|
||||||
React.useState<number>(0);
|
React.useState<number>(0);
|
||||||
const [activeNode, setActiveNode] = React.useState<number>(0);
|
const [activeNode, setActiveNode] = React.useState<number>(0);
|
||||||
const [expandedNodes, setExpandedNodes] = React.useState<string[]>(() => {
|
const [expandedNodes, setExpandedNodes] = React.useState<string[]>([]);
|
||||||
if (defaultValue) {
|
|
||||||
const node = items.find((item) => item.id === defaultValue);
|
|
||||||
if (node) {
|
|
||||||
return ancestors(node).map((node) => node.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
});
|
|
||||||
|
|
||||||
const [itemRefs, setItemRefs] = React.useState<
|
const [itemRefs, setItemRefs] = React.useState<
|
||||||
React.RefObject<HTMLSpanElement>[]
|
React.RefObject<HTMLSpanElement>[]
|
||||||
>([]);
|
>([]);
|
||||||
@@ -78,10 +61,6 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
|||||||
const VERTICAL_PADDING = 6;
|
const VERTICAL_PADDING = 6;
|
||||||
const HORIZONTAL_PADDING = 24;
|
const HORIZONTAL_PADDING = 24;
|
||||||
|
|
||||||
const recentlyViewedItemIds = documents.recentlyViewed
|
|
||||||
.slice(0, 5)
|
|
||||||
.map((item) => item.id);
|
|
||||||
|
|
||||||
const searchIndex = React.useMemo(
|
const searchIndex = React.useMemo(
|
||||||
() =>
|
() =>
|
||||||
new FuzzySearch(items, ["title"], {
|
new FuzzySearch(items, ["title"], {
|
||||||
@@ -111,15 +90,6 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
|||||||
onSelect(selectedNode);
|
onSelect(selectedNode);
|
||||||
}, [selectedNode, onSelect]);
|
}, [selectedNode, onSelect]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (defaultValue && selectedNode && listRef) {
|
|
||||||
const index = nodes.findIndex((node) => node.id === selectedNode.id);
|
|
||||||
if (index > 0) {
|
|
||||||
setTimeout(() => listRef.current?.scrollToItem(index, "center"), 50);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
function getNodes() {
|
function getNodes() {
|
||||||
function includeDescendants(item: NavigationNode): NavigationNode[] {
|
function includeDescendants(item: NavigationNode): NavigationNode[] {
|
||||||
return expandedNodes.includes(item.id)
|
return expandedNodes.includes(item.id)
|
||||||
@@ -130,18 +100,11 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
|||||||
return searchTerm
|
return searchTerm
|
||||||
? searchIndex.search(searchTerm)
|
? searchIndex.search(searchTerm)
|
||||||
: items
|
: items
|
||||||
.filter((item) => recentlyViewedItemIds.includes(item.id))
|
.filter((item) => item.type === "collection")
|
||||||
.concat(
|
|
||||||
items.filter((item) => item.type === NavigationNodeType.Collection)
|
|
||||||
)
|
|
||||||
.flatMap(includeDescendants);
|
.flatMap(includeDescendants);
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodes = getNodes();
|
const nodes = getNodes();
|
||||||
const baseDepth = nodes.reduce(
|
|
||||||
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
|
|
||||||
Infinity
|
|
||||||
);
|
|
||||||
|
|
||||||
const scrollNodeIntoView = React.useCallback(
|
const scrollNodeIntoView = React.useCallback(
|
||||||
(node: number) => {
|
(node: number) => {
|
||||||
@@ -237,91 +200,84 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const ListItem = observer(
|
const ListItem = ({
|
||||||
({
|
index,
|
||||||
index,
|
data,
|
||||||
data,
|
style,
|
||||||
style,
|
}: {
|
||||||
}: {
|
index: number;
|
||||||
index: number;
|
data: NavigationNode[];
|
||||||
data: NavigationNode[];
|
style: React.CSSProperties;
|
||||||
style: React.CSSProperties;
|
}) => {
|
||||||
}) => {
|
const node = data[index];
|
||||||
const node = data[index];
|
const isCollection = node.type === "collection";
|
||||||
const isCollection = node.type === "collection";
|
let icon, title, path;
|
||||||
let renderedIcon,
|
|
||||||
title: string,
|
|
||||||
icon: string | undefined,
|
|
||||||
color: string | undefined,
|
|
||||||
path;
|
|
||||||
|
|
||||||
if (isCollection) {
|
if (isCollection) {
|
||||||
const col = collections.get(node.collectionId as string);
|
const col = collections.get(node.collectionId as string);
|
||||||
renderedIcon = col && (
|
icon = col && (
|
||||||
<CollectionIcon collection={col} expanded={isExpanded(index)} />
|
<CollectionIcon collection={col} expanded={isExpanded(index)} />
|
||||||
);
|
);
|
||||||
title = node.title;
|
title = node.title;
|
||||||
|
} else {
|
||||||
|
const doc = documents.get(node.id);
|
||||||
|
const { strippedTitle, emoji } = parseTitle(node.title);
|
||||||
|
title = strippedTitle;
|
||||||
|
|
||||||
|
if (emoji) {
|
||||||
|
icon = <EmojiIcon emoji={emoji} />;
|
||||||
|
} else if (doc?.isStarred) {
|
||||||
|
icon = <StarredIcon color={theme.yellow} />;
|
||||||
} else {
|
} else {
|
||||||
const doc = documents.get(node.id);
|
icon = <DocumentIcon color={theme.textSecondary} />;
|
||||||
icon = doc?.icon ?? node.icon ?? node.emoji;
|
|
||||||
color = doc?.color ?? node.color;
|
|
||||||
title = doc?.title ?? node.title;
|
|
||||||
|
|
||||||
if (icon) {
|
|
||||||
renderedIcon = <Icon value={icon} color={color} />;
|
|
||||||
} else if (doc?.isStarred) {
|
|
||||||
renderedIcon = <StarredIcon color={theme.yellow} />;
|
|
||||||
} else {
|
|
||||||
renderedIcon = <DocumentIcon color={theme.textSecondary} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
path = ancestors(node)
|
|
||||||
.map((a) => a.title)
|
|
||||||
.join(" / ");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return searchTerm ? (
|
path = ancestors(node)
|
||||||
<DocumentExplorerSearchResult
|
.map((a) => parseTitle(a.title).strippedTitle)
|
||||||
selected={isSelected(index)}
|
.join(" / ");
|
||||||
active={activeNode === index}
|
|
||||||
style={{
|
|
||||||
...style,
|
|
||||||
top: (style.top as number) + VERTICAL_PADDING,
|
|
||||||
left: (style.left as number) + HORIZONTAL_PADDING,
|
|
||||||
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
|
|
||||||
}}
|
|
||||||
onPointerMove={() => setActiveNode(index)}
|
|
||||||
onClick={() => toggleSelect(index)}
|
|
||||||
icon={renderedIcon}
|
|
||||||
title={title}
|
|
||||||
path={path}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<DocumentExplorerNode
|
|
||||||
style={{
|
|
||||||
...style,
|
|
||||||
top: (style.top as number) + VERTICAL_PADDING,
|
|
||||||
left: (style.left as number) + HORIZONTAL_PADDING,
|
|
||||||
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
|
|
||||||
}}
|
|
||||||
onPointerMove={() => setActiveNode(index)}
|
|
||||||
onClick={() => toggleSelect(index)}
|
|
||||||
onDisclosureClick={(ev) => {
|
|
||||||
ev.stopPropagation();
|
|
||||||
toggleCollapse(index);
|
|
||||||
}}
|
|
||||||
selected={isSelected(index)}
|
|
||||||
active={activeNode === index}
|
|
||||||
expanded={isExpanded(index)}
|
|
||||||
icon={renderedIcon}
|
|
||||||
title={title}
|
|
||||||
depth={(node.depth ?? 0) - baseDepth}
|
|
||||||
hasChildren={hasChildren(index)}
|
|
||||||
ref={itemRefs[index]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
return searchTerm ? (
|
||||||
|
<DocumentExplorerSearchResult
|
||||||
|
selected={isSelected(index)}
|
||||||
|
active={activeNode === index}
|
||||||
|
style={{
|
||||||
|
...style,
|
||||||
|
top: (style.top as number) + VERTICAL_PADDING,
|
||||||
|
left: (style.left as number) + HORIZONTAL_PADDING,
|
||||||
|
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
|
||||||
|
}}
|
||||||
|
onPointerMove={() => setActiveNode(index)}
|
||||||
|
onClick={() => toggleSelect(index)}
|
||||||
|
icon={icon}
|
||||||
|
title={title}
|
||||||
|
path={path}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DocumentExplorerNode
|
||||||
|
style={{
|
||||||
|
...style,
|
||||||
|
top: (style.top as number) + VERTICAL_PADDING,
|
||||||
|
left: (style.left as number) + HORIZONTAL_PADDING,
|
||||||
|
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
|
||||||
|
}}
|
||||||
|
onPointerMove={() => setActiveNode(index)}
|
||||||
|
onClick={() => toggleSelect(index)}
|
||||||
|
onDisclosureClick={(ev) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
toggleCollapse(index);
|
||||||
|
}}
|
||||||
|
selected={isSelected(index)}
|
||||||
|
active={activeNode === index}
|
||||||
|
expanded={isExpanded(index)}
|
||||||
|
icon={icon}
|
||||||
|
title={title}
|
||||||
|
depth={node.depth as number}
|
||||||
|
hasChildren={hasChildren(index)}
|
||||||
|
ref={itemRefs[index]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const focusSearchInput = () => {
|
const focusSearchInput = () => {
|
||||||
inputSearchRef.current?.focus();
|
inputSearchRef.current?.focus();
|
||||||
@@ -427,9 +383,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
|||||||
</AutoSizer>
|
</AutoSizer>
|
||||||
) : (
|
) : (
|
||||||
<FlexContainer>
|
<FlexContainer>
|
||||||
<Text as="p" type="secondary">
|
<Text type="secondary">{t("No results found")}.</Text>
|
||||||
{t("No results found")}.
|
|
||||||
</Text>
|
|
||||||
</FlexContainer>
|
</FlexContainer>
|
||||||
)}
|
)}
|
||||||
</ListContainer>
|
</ListContainer>
|
||||||
|
|||||||
@@ -41,9 +41,9 @@ function DocumentExplorerNode(
|
|||||||
) {
|
) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const OFFSET = 12;
|
const OFFSET = 12;
|
||||||
const DISCLOSURE = 20;
|
const ICON_SIZE = 24;
|
||||||
|
|
||||||
const width = depth ? depth * DISCLOSURE + OFFSET : DISCLOSURE;
|
const width = depth ? depth * ICON_SIZE + OFFSET : ICON_SIZE;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Node
|
<Node
|
||||||
@@ -120,7 +120,6 @@ export const Node = styled.span<{
|
|||||||
color: ${props.theme.white};
|
color: ${props.theme.white};
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
color: ${props.theme.white};
|
|
||||||
fill: ${props.theme.white};
|
fill: ${props.theme.white};
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import scrollIntoView from "scroll-into-view-if-needed";
|
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { ellipsis } from "@shared/styles";
|
import { ellipsis } from "@shared/styles";
|
||||||
import { Node as SearchResult } from "~/components/DocumentExplorerNode";
|
import { Node as SearchResult } from "~/components/DocumentExplorerNode";
|
||||||
|
|||||||
@@ -1,30 +1,29 @@
|
|||||||
import {
|
|
||||||
useFocusEffect,
|
|
||||||
useRovingTabIndex,
|
|
||||||
} from "@getoutline/react-roving-tabindex";
|
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
|
import { PlusIcon } from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import { CompositeStateReturn, CompositeItem } from "reakit/Composite";
|
||||||
import styled, { css } from "styled-components";
|
import styled, { css } from "styled-components";
|
||||||
import breakpoint from "styled-components-breakpoint";
|
import breakpoint from "styled-components-breakpoint";
|
||||||
import EventBoundary from "@shared/components/EventBoundary";
|
import { s } from "@shared/styles";
|
||||||
import Icon from "@shared/components/Icon";
|
|
||||||
import { s, hover } from "@shared/styles";
|
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
import Badge from "~/components/Badge";
|
import Badge from "~/components/Badge";
|
||||||
|
import Button from "~/components/Button";
|
||||||
import DocumentMeta from "~/components/DocumentMeta";
|
import DocumentMeta from "~/components/DocumentMeta";
|
||||||
|
import EventBoundary from "~/components/EventBoundary";
|
||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
import Highlight from "~/components/Highlight";
|
import Highlight from "~/components/Highlight";
|
||||||
import NudeButton from "~/components/NudeButton";
|
import NudeButton from "~/components/NudeButton";
|
||||||
import StarButton, { AnimatedStar } from "~/components/Star";
|
import StarButton, { AnimatedStar } from "~/components/Star";
|
||||||
import Tooltip from "~/components/Tooltip";
|
import Tooltip from "~/components/Tooltip";
|
||||||
import useBoolean from "~/hooks/useBoolean";
|
import useBoolean from "~/hooks/useBoolean";
|
||||||
|
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
import usePolicy from "~/hooks/usePolicy";
|
||||||
import DocumentMenu from "~/menus/DocumentMenu";
|
import DocumentMenu from "~/menus/DocumentMenu";
|
||||||
import { documentPath } from "~/utils/routeHelpers";
|
import { hover } from "~/styles";
|
||||||
import { determineSidebarContext } from "./Sidebar/components/SidebarContext";
|
import { newDocumentPath } from "~/utils/routeHelpers";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
document: Document;
|
document: Document;
|
||||||
@@ -36,13 +35,14 @@ type Props = {
|
|||||||
showPin?: boolean;
|
showPin?: boolean;
|
||||||
showDraft?: boolean;
|
showDraft?: boolean;
|
||||||
showTemplate?: boolean;
|
showTemplate?: boolean;
|
||||||
};
|
} & CompositeStateReturn;
|
||||||
|
|
||||||
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
|
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
|
||||||
|
|
||||||
function replaceResultMarks(tag: string) {
|
function replaceResultMarks(tag: string) {
|
||||||
// don't use SEARCH_RESULT_REGEX directly here as it causes an infinite loop
|
// don't use SEARCH_RESULT_REGEX here as it causes
|
||||||
return tag.replace(new RegExp(SEARCH_RESULT_REGEX.source), "$1");
|
// an infinite loop to trigger a regex inside it's own callback
|
||||||
|
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
|
||||||
}
|
}
|
||||||
|
|
||||||
function DocumentListItem(
|
function DocumentListItem(
|
||||||
@@ -51,18 +51,9 @@ function DocumentListItem(
|
|||||||
) {
|
) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const user = useCurrentUser();
|
const user = useCurrentUser();
|
||||||
const locationSidebarContext = useLocationSidebarContext();
|
const team = useCurrentTeam();
|
||||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||||
|
|
||||||
let itemRef: React.Ref<HTMLAnchorElement> =
|
|
||||||
React.useRef<HTMLAnchorElement>(null);
|
|
||||||
if (ref) {
|
|
||||||
itemRef = ref;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { focused, ...rovingTabIndex } = useRovingTabIndex(itemRef, false);
|
|
||||||
useFocusEffect(focused, itemRef);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
document,
|
document,
|
||||||
showParentDocuments,
|
showParentDocuments,
|
||||||
@@ -78,57 +69,51 @@ function DocumentListItem(
|
|||||||
const queryIsInTitle =
|
const queryIsInTitle =
|
||||||
!!highlight &&
|
!!highlight &&
|
||||||
!!document.title.toLowerCase().includes(highlight.toLowerCase());
|
!!document.title.toLowerCase().includes(highlight.toLowerCase());
|
||||||
const canStar = !document.isArchived && !document.isTemplate;
|
const canStar =
|
||||||
|
!document.isDraft && !document.isArchived && !document.isTemplate;
|
||||||
const sidebarContext = determineSidebarContext({
|
const can = usePolicy(team);
|
||||||
document,
|
const canCollection = usePolicy(document.collectionId);
|
||||||
user,
|
|
||||||
currentContext: locationSidebarContext,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentLink
|
<CompositeItem
|
||||||
ref={itemRef}
|
as={DocumentLink}
|
||||||
|
ref={ref}
|
||||||
dir={document.dir}
|
dir={document.dir}
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
$isStarred={document.isStarred}
|
$isStarred={document.isStarred}
|
||||||
$menuOpen={menuOpen}
|
$menuOpen={menuOpen}
|
||||||
to={{
|
to={{
|
||||||
pathname: documentPath(document),
|
pathname: document.url,
|
||||||
state: {
|
state: {
|
||||||
title: document.titleWithDefault,
|
title: document.titleWithDefault,
|
||||||
sidebarContext,
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
{...rest}
|
{...rest}
|
||||||
{...rovingTabIndex}
|
|
||||||
>
|
>
|
||||||
<Content>
|
<Content>
|
||||||
<Heading dir={document.dir}>
|
<Heading dir={document.dir}>
|
||||||
{document.icon && (
|
|
||||||
<>
|
|
||||||
<Icon value={document.icon} color={document.color ?? undefined} />
|
|
||||||
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Title
|
<Title
|
||||||
text={document.titleWithDefault}
|
text={document.titleWithDefault}
|
||||||
highlight={highlight}
|
highlight={highlight}
|
||||||
dir={document.dir}
|
dir={document.dir}
|
||||||
/>
|
/>
|
||||||
{document.isBadgedNew && document.createdBy?.id !== user.id && (
|
{document.isBadgedNew && document.createdBy.id !== user.id && (
|
||||||
<Badge yellow>{t("New")}</Badge>
|
<Badge yellow>{t("New")}</Badge>
|
||||||
)}
|
)}
|
||||||
{document.isDraft && showDraft && (
|
|
||||||
<Tooltip content={t("Only visible to you")} placement="top">
|
|
||||||
<Badge>{t("Draft")}</Badge>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
{canStar && (
|
{canStar && (
|
||||||
<StarPositioner>
|
<StarPositioner>
|
||||||
<StarButton document={document} />
|
<StarButton document={document} />
|
||||||
</StarPositioner>
|
</StarPositioner>
|
||||||
)}
|
)}
|
||||||
|
{document.isDraft && showDraft && (
|
||||||
|
<Tooltip
|
||||||
|
tooltip={t("Only visible to you")}
|
||||||
|
delay={500}
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<Badge>{t("Draft")}</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
{document.isTemplate && showTemplate && (
|
{document.isTemplate && showTemplate && (
|
||||||
<Badge primary>{t("Template")}</Badge>
|
<Badge primary>{t("Template")}</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -150,6 +135,25 @@ function DocumentListItem(
|
|||||||
/>
|
/>
|
||||||
</Content>
|
</Content>
|
||||||
<Actions>
|
<Actions>
|
||||||
|
{document.isTemplate &&
|
||||||
|
!document.isArchived &&
|
||||||
|
!document.isDeleted &&
|
||||||
|
can.createDocument &&
|
||||||
|
canCollection.update && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
as={Link}
|
||||||
|
to={newDocumentPath(document.collectionId, {
|
||||||
|
templateId: document.id,
|
||||||
|
})}
|
||||||
|
icon={<PlusIcon />}
|
||||||
|
neutral
|
||||||
|
>
|
||||||
|
{t("New doc")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<DocumentMenu
|
<DocumentMenu
|
||||||
document={document}
|
document={document}
|
||||||
showPin={showPin}
|
showPin={showPin}
|
||||||
@@ -158,7 +162,7 @@ function DocumentListItem(
|
|||||||
modal={false}
|
modal={false}
|
||||||
/>
|
/>
|
||||||
</Actions>
|
</Actions>
|
||||||
</DocumentLink>
|
</CompositeItem>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,8 +262,8 @@ const Heading = styled.h3<{ rtl?: boolean }>`
|
|||||||
margin-bottom: 0.25em;
|
margin-bottom: 0.25em;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
color: ${s("text")};
|
color: ${s("text")};
|
||||||
font-family: ${s("fontFamily")};
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
|
||||||
font-weight: 500;
|
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StarPositioner = styled(Flex)`
|
const StarPositioner = styled(Flex)`
|
||||||
@@ -275,12 +279,10 @@ const Title = styled(Highlight)`
|
|||||||
|
|
||||||
const ResultContext = styled(Highlight)`
|
const ResultContext = styled(Highlight)`
|
||||||
display: block;
|
display: block;
|
||||||
color: ${s("textSecondary")};
|
color: ${s("textTertiary")};
|
||||||
font-size: 15px;
|
font-size: 14px;
|
||||||
margin-top: -0.25em;
|
margin-top: -0.25em;
|
||||||
margin-bottom: 0.25em;
|
margin-bottom: 0.25em;
|
||||||
max-height: 90px;
|
|
||||||
overflow: hidden;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default observer(React.forwardRef(DocumentListItem));
|
export default observer(React.forwardRef(DocumentListItem));
|
||||||
|
|||||||
@@ -95,21 +95,6 @@ const DocumentMeta: React.FC<Props> = ({
|
|||||||
<Time dateTime={archivedAt} addSuffix />
|
<Time dateTime={archivedAt} addSuffix />
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
} else if (
|
|
||||||
document.sourceMetadata &&
|
|
||||||
document.sourceMetadata?.importedAt &&
|
|
||||||
document.sourceMetadata.importedAt >= updatedAt
|
|
||||||
) {
|
|
||||||
content = (
|
|
||||||
<span>
|
|
||||||
{document.sourceMetadata.createdByName
|
|
||||||
? t("{{ userName }} updated", {
|
|
||||||
userName: document.sourceMetadata.createdByName,
|
|
||||||
})
|
|
||||||
: t("Imported")}{" "}
|
|
||||||
<Time dateTime={createdAt} addSuffix />
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
} else if (createdAt === updatedAt) {
|
} else if (createdAt === updatedAt) {
|
||||||
content = (
|
content = (
|
||||||
<span>
|
<span>
|
||||||
@@ -128,6 +113,15 @@ const DocumentMeta: React.FC<Props> = ({
|
|||||||
<Time dateTime={publishedAt} addSuffix />
|
<Time dateTime={publishedAt} addSuffix />
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
} else if (isDraft) {
|
||||||
|
content = (
|
||||||
|
<span>
|
||||||
|
{lastUpdatedByCurrentUser
|
||||||
|
? t("You saved")
|
||||||
|
: t("{{ userName }} saved", { userName })}{" "}
|
||||||
|
<Time dateTime={updatedAt} addSuffix />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
content = (
|
content = (
|
||||||
<Modified highlight={modifiedSinceViewed && !lastUpdatedByCurrentUser}>
|
<Modified highlight={modifiedSinceViewed && !lastUpdatedByCurrentUser}>
|
||||||
@@ -140,7 +134,7 @@ const DocumentMeta: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nestedDocumentsCount = collection
|
const nestedDocumentsCount = collection
|
||||||
? collection.getChildrenForDocument(document.id).length
|
? collection.getDocumentChildren(document.id).length
|
||||||
: 0;
|
: 0;
|
||||||
const canShowProgressBar = isTasks && !isTemplate;
|
const canShowProgressBar = isTasks && !isTemplate;
|
||||||
|
|
||||||
@@ -168,13 +162,7 @@ const DocumentMeta: React.FC<Props> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container
|
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
|
||||||
align="center"
|
|
||||||
rtl={document.dir === "rtl"}
|
|
||||||
{...rest}
|
|
||||||
dir="ltr"
|
|
||||||
lang=""
|
|
||||||
>
|
|
||||||
{to ? (
|
{to ? (
|
||||||
<Link to={to} replace={replace}>
|
<Link to={to} replace={replace}>
|
||||||
{content}
|
{content}
|
||||||
@@ -185,9 +173,9 @@ const DocumentMeta: React.FC<Props> = ({
|
|||||||
{showCollection && collection && (
|
{showCollection && collection && (
|
||||||
<span>
|
<span>
|
||||||
{t("in")}
|
{t("in")}
|
||||||
<Strong>
|
<strong>
|
||||||
<DocumentBreadcrumb document={document} onlyText />
|
<DocumentBreadcrumb document={document} onlyText />
|
||||||
</Strong>
|
</strong>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{showParentDocuments && nestedDocumentsCount > 0 && (
|
{showParentDocuments && nestedDocumentsCount > 0 && (
|
||||||
@@ -210,10 +198,6 @@ const DocumentMeta: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Strong = styled.strong`
|
|
||||||
font-weight: 550;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Container = styled(Flex)<{ rtl?: boolean }>`
|
const Container = styled(Flex)<{ rtl?: boolean }>`
|
||||||
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||||
color: ${s("textTertiary")};
|
color: ${s("textTertiary")};
|
||||||
|
|||||||
@@ -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 { documentPath } 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(documentPath(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);
|
||||||
@@ -1,15 +1,13 @@
|
|||||||
import compact from "lodash/compact";
|
import { sortBy } from "lodash";
|
||||||
import sortBy from "lodash/sortBy";
|
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { dateLocale, dateToRelative } from "@shared/utils/date";
|
import { dateToRelative } from "@shared/utils/date";
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
import User from "~/models/User";
|
import User from "~/models/User";
|
||||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
import Avatar from "~/components/Avatar";
|
||||||
import ListItem from "~/components/List/Item";
|
import ListItem from "~/components/List/Item";
|
||||||
import PaginatedList from "~/components/PaginatedList";
|
import PaginatedList from "~/components/PaginatedList";
|
||||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -20,9 +18,6 @@ type Props = {
|
|||||||
function DocumentViews({ document, isOpen }: Props) {
|
function DocumentViews({ document, isOpen }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { views, presence } = useStores();
|
const { views, presence } = useStores();
|
||||||
const user = useCurrentUser();
|
|
||||||
const locale = dateLocale(user.language);
|
|
||||||
|
|
||||||
const documentPresence = presence.get(document.id);
|
const documentPresence = presence.get(document.id);
|
||||||
const documentPresenceArray = documentPresence
|
const documentPresenceArray = documentPresence
|
||||||
? Array.from(documentPresence.values())
|
? Array.from(documentPresence.values())
|
||||||
@@ -36,10 +31,10 @@ function DocumentViews({ document, isOpen }: Props) {
|
|||||||
const documentViews = views.inDocument(document.id);
|
const documentViews = views.inDocument(document.id);
|
||||||
const sortedViews = sortBy(
|
const sortedViews = sortBy(
|
||||||
documentViews,
|
documentViews,
|
||||||
(view) => !presentIds.includes(view.userId)
|
(view) => !presentIds.includes(view.user.id)
|
||||||
);
|
);
|
||||||
const users = React.useMemo(
|
const users = React.useMemo(
|
||||||
() => compact(sortedViews.map((v) => v.user)),
|
() => sortedViews.map((v) => v.user),
|
||||||
[sortedViews]
|
[sortedViews]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -50,20 +45,16 @@ function DocumentViews({ document, isOpen }: Props) {
|
|||||||
aria-label={t("Viewers")}
|
aria-label={t("Viewers")}
|
||||||
items={users}
|
items={users}
|
||||||
renderItem={(model: User) => {
|
renderItem={(model: User) => {
|
||||||
const view = documentViews.find((v) => v.userId === model.id);
|
const view = documentViews.find((v) => v.user.id === model.id);
|
||||||
const isPresent = presentIds.includes(model.id);
|
const isPresent = presentIds.includes(model.id);
|
||||||
const isEditing = editingIds.includes(model.id);
|
const isEditing = editingIds.includes(model.id);
|
||||||
const subtitle = isPresent
|
const subtitle = isPresent
|
||||||
? isEditing
|
? isEditing
|
||||||
? t("Currently editing")
|
? t("Currently editing")
|
||||||
: t("Currently viewing")
|
: t("Currently viewing")
|
||||||
: t("Viewed {{ timeAgo }}", {
|
: t("Viewed {{ timeAgo }} ago", {
|
||||||
timeAgo: dateToRelative(
|
timeAgo: dateToRelative(
|
||||||
view ? Date.parse(view.lastViewedAt) : new Date(),
|
view ? Date.parse(view.lastViewedAt) : new Date()
|
||||||
{
|
|
||||||
addSuffix: true,
|
|
||||||
locale,
|
|
||||||
}
|
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
@@ -71,13 +62,7 @@ function DocumentViews({ document, isOpen }: Props) {
|
|||||||
key={model.id}
|
key={model.id}
|
||||||
title={model.name}
|
title={model.name}
|
||||||
subtitle={subtitle}
|
subtitle={subtitle}
|
||||||
image={
|
image={<Avatar key={model.id} model={model} size={32} />}
|
||||||
<Avatar
|
|
||||||
key={model.id}
|
|
||||||
model={model}
|
|
||||||
size={AvatarSize.Large}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
border={false}
|
border={false}
|
||||||
small
|
small
|
||||||
/>
|
/>
|
||||||
|
|||||||
+190
-65
@@ -1,26 +1,36 @@
|
|||||||
import difference from "lodash/difference";
|
import { deburr, difference, sortBy } from "lodash";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
|
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
|
||||||
import { TextSelection } from "prosemirror-state";
|
import { TextSelection } from "prosemirror-state";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { mergeRefs } from "react-merge-refs";
|
import { mergeRefs } from "react-merge-refs";
|
||||||
|
import { useHistory } from "react-router-dom";
|
||||||
import { Optional } from "utility-types";
|
import { Optional } from "utility-types";
|
||||||
import insertFiles from "@shared/editor/commands/insertFiles";
|
import insertFiles from "@shared/editor/commands/insertFiles";
|
||||||
import EditorContainer from "@shared/editor/components/Styles";
|
|
||||||
import { AttachmentPreset } from "@shared/types";
|
import { AttachmentPreset } from "@shared/types";
|
||||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
import { Heading } from "@shared/utils/ProsemirrorHelper";
|
||||||
|
import { dateLocale, dateToRelative } from "@shared/utils/date";
|
||||||
import { getDataTransferFiles } from "@shared/utils/files";
|
import { getDataTransferFiles } from "@shared/utils/files";
|
||||||
|
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||||
|
import { isInternalUrl } from "@shared/utils/urls";
|
||||||
import { AttachmentValidation } from "@shared/validations";
|
import { AttachmentValidation } from "@shared/validations";
|
||||||
|
import Document from "~/models/Document";
|
||||||
import ClickablePadding from "~/components/ClickablePadding";
|
import ClickablePadding from "~/components/ClickablePadding";
|
||||||
import ErrorBoundary from "~/components/ErrorBoundary";
|
import ErrorBoundary from "~/components/ErrorBoundary";
|
||||||
|
import HoverPreview from "~/components/HoverPreview";
|
||||||
import type { Props as EditorProps, Editor as SharedEditor } from "~/editor";
|
import type { Props as EditorProps, Editor as SharedEditor } from "~/editor";
|
||||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
|
||||||
import useDictionary from "~/hooks/useDictionary";
|
import useDictionary from "~/hooks/useDictionary";
|
||||||
import useEditorClickHandlers from "~/hooks/useEditorClickHandlers";
|
|
||||||
import useEmbeds from "~/hooks/useEmbeds";
|
import useEmbeds from "~/hooks/useEmbeds";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import { uploadFile, uploadFileFromUrl } from "~/utils/files";
|
import useToasts from "~/hooks/useToasts";
|
||||||
|
import useUserLocale from "~/hooks/useUserLocale";
|
||||||
|
import { NotFoundError } from "~/utils/errors";
|
||||||
|
import { uploadFile } from "~/utils/files";
|
||||||
|
import { isModKey } from "~/utils/keyboard";
|
||||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||||
|
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
||||||
|
import { isHash } from "~/utils/urls";
|
||||||
|
import DocumentBreadcrumb from "./DocumentBreadcrumb";
|
||||||
|
|
||||||
const LazyLoadedEditor = lazyWithRetry(() => import("~/editor"));
|
const LazyLoadedEditor = lazyWithRetry(() => import("~/editor"));
|
||||||
|
|
||||||
@@ -31,41 +41,153 @@ export type Props = Optional<
|
|||||||
| "onClickLink"
|
| "onClickLink"
|
||||||
| "embeds"
|
| "embeds"
|
||||||
| "dictionary"
|
| "dictionary"
|
||||||
|
| "onShowToast"
|
||||||
| "extensions"
|
| "extensions"
|
||||||
> & {
|
> & {
|
||||||
shareId?: string | undefined;
|
shareId?: string | undefined;
|
||||||
embedsDisabled?: boolean;
|
embedsDisabled?: boolean;
|
||||||
|
onHeadingsChange?: (headings: Heading[]) => void;
|
||||||
onSynced?: () => Promise<void>;
|
onSynced?: () => Promise<void>;
|
||||||
onPublish?: (event: React.MouseEvent) => void;
|
onPublish?: (event: React.MouseEvent) => any;
|
||||||
editorStyle?: React.CSSProperties;
|
editorStyle?: React.CSSProperties;
|
||||||
};
|
};
|
||||||
|
|
||||||
function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||||
const { id, shareId, onChange, onCreateCommentMark, onDeleteCommentMark } =
|
const {
|
||||||
props;
|
id,
|
||||||
const { comments } = useStores();
|
shareId,
|
||||||
|
onChange,
|
||||||
|
onHeadingsChange,
|
||||||
|
onCreateCommentMark,
|
||||||
|
onDeleteCommentMark,
|
||||||
|
} = props;
|
||||||
|
const userLocale = useUserLocale();
|
||||||
|
const locale = dateLocale(userLocale);
|
||||||
|
const { auth, comments, documents } = useStores();
|
||||||
|
const { showToast } = useToasts();
|
||||||
const dictionary = useDictionary();
|
const dictionary = useDictionary();
|
||||||
const embeds = useEmbeds(!shareId);
|
const embeds = useEmbeds(!shareId);
|
||||||
|
const history = useHistory();
|
||||||
const localRef = React.useRef<SharedEditor>();
|
const localRef = React.useRef<SharedEditor>();
|
||||||
const preferences = useCurrentUser({ rejectOnEmpty: false })?.preferences;
|
const preferences = auth.user?.preferences;
|
||||||
|
const previousHeadings = React.useRef<Heading[] | null>(null);
|
||||||
|
const [activeLinkElement, setActiveLink] =
|
||||||
|
React.useState<HTMLAnchorElement | null>(null);
|
||||||
const previousCommentIds = React.useRef<string[]>();
|
const previousCommentIds = React.useRef<string[]>();
|
||||||
|
|
||||||
|
const handleLinkActive = React.useCallback((element: HTMLAnchorElement) => {
|
||||||
|
setActiveLink(element);
|
||||||
|
return false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLinkInactive = React.useCallback(() => {
|
||||||
|
setActiveLink(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 = dateToRelative(Date.parse(document.updatedAt), {
|
||||||
|
addSuffix: true,
|
||||||
|
shorten: true,
|
||||||
|
locale,
|
||||||
|
});
|
||||||
|
|
||||||
|
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) => ({
|
||||||
|
title: document.title,
|
||||||
|
subtitle: <DocumentBreadcrumb document={document} onlyText />,
|
||||||
|
url: document.url,
|
||||||
|
})),
|
||||||
|
(document) =>
|
||||||
|
deburr(document.title)
|
||||||
|
.toLowerCase()
|
||||||
|
.startsWith(deburr(term).toLowerCase())
|
||||||
|
? -1
|
||||||
|
: 1
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[documents]
|
||||||
|
);
|
||||||
|
|
||||||
const handleUploadFile = React.useCallback(
|
const handleUploadFile = React.useCallback(
|
||||||
async (file: File | string) => {
|
async (file: File) => {
|
||||||
const options = {
|
const result = await uploadFile(file, {
|
||||||
documentId: id,
|
documentId: id,
|
||||||
preset: AttachmentPreset.DocumentAttachment,
|
preset: AttachmentPreset.DocumentAttachment,
|
||||||
};
|
});
|
||||||
const result =
|
|
||||||
file instanceof File
|
|
||||||
? await uploadFile(file, options)
|
|
||||||
: await uploadFileFromUrl(file, options);
|
|
||||||
return result.url;
|
return result.url;
|
||||||
},
|
},
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { handleClickLink } = useEditorClickHandlers({ shareId });
|
const handleClickLink = React.useCallback(
|
||||||
|
(href: string, event: MouseEvent) => {
|
||||||
|
// on page hash
|
||||||
|
if (isHash(href)) {
|
||||||
|
window.location.href = href;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInternalUrl(href) && !isModKey(event) && !event.shiftKey) {
|
||||||
|
// relative
|
||||||
|
let navigateTo = href;
|
||||||
|
|
||||||
|
// probably absolute
|
||||||
|
if (href[0] !== "/") {
|
||||||
|
try {
|
||||||
|
const url = new URL(href);
|
||||||
|
navigateTo = url.pathname + url.hash;
|
||||||
|
} catch (err) {
|
||||||
|
navigateTo = href;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link to our own API should be opened in a new tab, not in the app
|
||||||
|
if (navigateTo.startsWith("/api/")) {
|
||||||
|
window.open(href, "_blank");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're navigating to an internal document link then prepend the
|
||||||
|
// share route to the URL so that the document is loaded in context
|
||||||
|
if (shareId && navigateTo.includes("/doc/")) {
|
||||||
|
navigateTo = sharedDocumentPath(shareId, navigateTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
history.push(navigateTo);
|
||||||
|
} else if (href) {
|
||||||
|
window.open(href, "_blank");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[history, shareId]
|
||||||
|
);
|
||||||
|
|
||||||
const focusAtEnd = React.useCallback(() => {
|
const focusAtEnd = React.useCallback(() => {
|
||||||
localRef?.current?.focusAtEnd();
|
localRef?.current?.focusAtEnd();
|
||||||
@@ -111,10 +233,11 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
|||||||
(file) => !AttachmentValidation.imageContentTypes.includes(file.type)
|
(file) => !AttachmentValidation.imageContentTypes.includes(file.type)
|
||||||
);
|
);
|
||||||
|
|
||||||
return insertFiles(view, event, pos, files, {
|
insertFiles(view, event, pos, files, {
|
||||||
uploadFile: handleUploadFile,
|
uploadFile: handleUploadFile,
|
||||||
onFileUploadStart: props.onFileUploadStart,
|
onFileUploadStart: props.onFileUploadStart,
|
||||||
onFileUploadStop: props.onFileUploadStop,
|
onFileUploadStop: props.onFileUploadStop,
|
||||||
|
onShowToast: showToast,
|
||||||
dictionary,
|
dictionary,
|
||||||
isAttachment,
|
isAttachment,
|
||||||
});
|
});
|
||||||
@@ -125,6 +248,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
|||||||
props.onFileUploadStop,
|
props.onFileUploadStop,
|
||||||
dictionary,
|
dictionary,
|
||||||
handleUploadFile,
|
handleUploadFile,
|
||||||
|
showToast,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -137,9 +261,24 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Calculate if headings have changed and trigger callback if so
|
||||||
|
const updateHeadings = React.useCallback(() => {
|
||||||
|
if (onHeadingsChange) {
|
||||||
|
const headings = localRef?.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [localRef, onHeadingsChange]);
|
||||||
|
|
||||||
const updateComments = React.useCallback(() => {
|
const updateComments = React.useCallback(() => {
|
||||||
if (onCreateCommentMark && onDeleteCommentMark && localRef.current) {
|
if (onCreateCommentMark && onDeleteCommentMark) {
|
||||||
const commentMarks = localRef.current.getComments();
|
const commentMarks = localRef.current?.getComments();
|
||||||
const commentIds = comments.orderedData.map((c) => c.id);
|
const commentIds = comments.orderedData.map((c) => c.id);
|
||||||
const commentMarkIds = commentMarks?.map((c) => c.id);
|
const commentMarkIds = commentMarks?.map((c) => c.id);
|
||||||
const newCommentIds = difference(
|
const newCommentIds = difference(
|
||||||
@@ -149,7 +288,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
newCommentIds.forEach((commentId) => {
|
newCommentIds.forEach((commentId) => {
|
||||||
const mark = commentMarks.find((c) => c.id === commentId);
|
const mark = commentMarks?.find((c) => c.id === commentId);
|
||||||
if (mark) {
|
if (mark) {
|
||||||
onCreateCommentMark(mark.id, mark.userId);
|
onCreateCommentMark(mark.id, mark.userId);
|
||||||
}
|
}
|
||||||
@@ -171,68 +310,54 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
|||||||
const handleChange = React.useCallback(
|
const handleChange = React.useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
onChange?.(event);
|
onChange?.(event);
|
||||||
|
updateHeadings();
|
||||||
updateComments();
|
updateComments();
|
||||||
},
|
},
|
||||||
[onChange, updateComments]
|
[onChange, updateComments, updateHeadings]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRefChanged = React.useCallback(
|
const handleRefChanged = React.useCallback(
|
||||||
(node: SharedEditor | null) => {
|
(node: SharedEditor | null) => {
|
||||||
if (node) {
|
if (node) {
|
||||||
|
updateHeadings();
|
||||||
updateComments();
|
updateComments();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[updateComments]
|
[updateComments, updateHeadings]
|
||||||
);
|
);
|
||||||
|
|
||||||
const paragraphs = React.useMemo(() => {
|
|
||||||
if (props.readOnly && typeof props.value === "object") {
|
|
||||||
return ProsemirrorHelper.getPlainParagraphs(props.value);
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}, [props.readOnly, props.value]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary component="div" reloadOnChunkMissing>
|
<ErrorBoundary component="div" reloadOnChunkMissing>
|
||||||
<>
|
<>
|
||||||
{paragraphs ? (
|
<LazyLoadedEditor
|
||||||
<EditorContainer
|
ref={mergeRefs([ref, localRef, handleRefChanged])}
|
||||||
rtl={props.dir === "rtl"}
|
uploadFile={handleUploadFile}
|
||||||
grow={props.grow}
|
onShowToast={showToast}
|
||||||
style={props.style}
|
embeds={embeds}
|
||||||
editorStyle={props.editorStyle}
|
userPreferences={preferences}
|
||||||
>
|
dictionary={dictionary}
|
||||||
<div className="ProseMirror">
|
{...props}
|
||||||
{paragraphs.map((paragraph, index) => (
|
onHoverLink={handleLinkActive}
|
||||||
<p key={index} dir="auto">
|
onClickLink={handleClickLink}
|
||||||
{paragraph.content?.map((content) => content.text)}
|
onSearchLink={handleSearchLink}
|
||||||
</p>
|
onChange={handleChange}
|
||||||
))}
|
placeholder={props.placeholder || ""}
|
||||||
</div>
|
defaultValue={props.defaultValue || ""}
|
||||||
</EditorContainer>
|
/>
|
||||||
) : (
|
|
||||||
<LazyLoadedEditor
|
|
||||||
key={props.extensions?.length || 0}
|
|
||||||
ref={mergeRefs([ref, localRef, handleRefChanged])}
|
|
||||||
uploadFile={handleUploadFile}
|
|
||||||
embeds={embeds}
|
|
||||||
userPreferences={preferences}
|
|
||||||
dictionary={dictionary}
|
|
||||||
{...props}
|
|
||||||
onClickLink={handleClickLink}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder={props.placeholder || ""}
|
|
||||||
defaultValue={props.defaultValue || ""}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{props.editorStyle?.paddingBottom && !props.readOnly && (
|
{props.editorStyle?.paddingBottom && !props.readOnly && (
|
||||||
<ClickablePadding
|
<ClickablePadding
|
||||||
onClick={props.readOnly ? undefined : focusAtEnd}
|
onClick={focusAtEnd}
|
||||||
onDrop={props.readOnly ? undefined : handleDrop}
|
onDrop={handleDrop}
|
||||||
onDragOver={props.readOnly ? undefined : handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
minHeight={props.editorStyle.paddingBottom}
|
minHeight={props.editorStyle.paddingBottom}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{activeLinkElement && !shareId && (
|
||||||
|
<HoverPreview
|
||||||
|
element={activeLinkElement}
|
||||||
|
onClose={handleLinkInactive}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
import styled from "styled-components";
|
|
||||||
import { s } from "@shared/styles";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
/** Width of the containing element. */
|
|
||||||
width?: number | string;
|
|
||||||
/** Height of the containing element. */
|
|
||||||
height?: number | string;
|
|
||||||
/** Controls the rendered emoji size. */
|
|
||||||
size?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Emoji = styled.span<Props>`
|
|
||||||
font-family: ${s("fontFamilyEmoji")};
|
|
||||||
width: ${({ width }) =>
|
|
||||||
typeof width === "string" ? width : width ? `${width}px` : "auto"};
|
|
||||||
height: ${({ height }) =>
|
|
||||||
typeof height === "string" ? height : height ? `${height}px` : "auto"};
|
|
||||||
font-size: ${({ size }) => size && `${size}px`};
|
|
||||||
`;
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import Text from "~/components/Text";
|
import { s } from "@shared/styles";
|
||||||
|
|
||||||
const Empty = styled(Text).attrs({
|
const Empty = styled.p`
|
||||||
type: "tertiary",
|
color: ${s("textTertiary")};
|
||||||
selectable: false,
|
user-select: none;
|
||||||
})``;
|
`;
|
||||||
|
|
||||||
export default Empty;
|
export default Empty;
|
||||||
|
|||||||
@@ -4,10 +4,9 @@ import * as React from "react";
|
|||||||
import { withTranslation, Trans, WithTranslation } from "react-i18next";
|
import { withTranslation, Trans, WithTranslation } from "react-i18next";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { s } from "@shared/styles";
|
import { s } from "@shared/styles";
|
||||||
import { UrlHelper } from "@shared/utils/UrlHelper";
|
import { githubIssuesUrl, feedbackUrl } from "@shared/utils/urlHelpers";
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import CenteredContent from "~/components/CenteredContent";
|
import CenteredContent from "~/components/CenteredContent";
|
||||||
import Heading from "~/components/Heading";
|
|
||||||
import PageTitle from "~/components/PageTitle";
|
import PageTitle from "~/components/PageTitle";
|
||||||
import Text from "~/components/Text";
|
import Text from "~/components/Text";
|
||||||
import env from "~/env";
|
import env from "~/env";
|
||||||
@@ -58,7 +57,7 @@ class ErrorBoundary extends React.Component<Props> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
handleReportBug = () => {
|
handleReportBug = () => {
|
||||||
window.open(isCloudHosted ? UrlHelper.contact : UrlHelper.github);
|
window.open(isCloudHosted ? feedbackUrl() : githubIssuesUrl());
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
@@ -78,12 +77,12 @@ class ErrorBoundary extends React.Component<Props> {
|
|||||||
{showTitle && (
|
{showTitle && (
|
||||||
<>
|
<>
|
||||||
<PageTitle title={t("Module failed to load")} />
|
<PageTitle title={t("Module failed to load")} />
|
||||||
<Heading>
|
<h1>
|
||||||
<Trans>Loading Failed</Trans>
|
<Trans>Loading Failed</Trans>
|
||||||
</Heading>
|
</h1>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Text as="p" type="secondary">
|
<Text type="secondary">
|
||||||
<Trans>
|
<Trans>
|
||||||
Sorry, part of the application failed to load. This may be
|
Sorry, part of the application failed to load. This may be
|
||||||
because it was updated since you opened the tab or because of a
|
because it was updated since you opened the tab or because of a
|
||||||
@@ -102,12 +101,12 @@ class ErrorBoundary extends React.Component<Props> {
|
|||||||
{showTitle && (
|
{showTitle && (
|
||||||
<>
|
<>
|
||||||
<PageTitle title={t("Something Unexpected Happened")} />
|
<PageTitle title={t("Something Unexpected Happened")} />
|
||||||
<Heading>
|
<h1>
|
||||||
<Trans>Something Unexpected Happened</Trans>
|
<Trans>Something Unexpected Happened</Trans>
|
||||||
</Heading>
|
</h1>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Text as="p" type="secondary">
|
<Text type="secondary">
|
||||||
<Trans
|
<Trans
|
||||||
defaults="Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch."
|
defaults="Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch."
|
||||||
values={{
|
values={{
|
||||||
@@ -122,11 +121,11 @@ class ErrorBoundary extends React.Component<Props> {
|
|||||||
<Button onClick={this.handleReload}>{t("Reload")}</Button>{" "}
|
<Button onClick={this.handleReload}>{t("Reload")}</Button>{" "}
|
||||||
{this.showDetails ? (
|
{this.showDetails ? (
|
||||||
<Button onClick={this.handleReportBug} neutral>
|
<Button onClick={this.handleReportBug} neutral>
|
||||||
<Trans>Report a bug</Trans>…
|
<Trans>Report a Bug</Trans>…
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button onClick={this.handleShowDetails} neutral>
|
<Button onClick={this.handleShowDetails} neutral>
|
||||||
<Trans>Show detail</Trans>…
|
<Trans>Show Detail</Trans>…
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
@@ -139,7 +138,7 @@ class ErrorBoundary extends React.Component<Props> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Pre = styled.pre`
|
const Pre = styled.pre`
|
||||||
background: ${s("backgroundSecondary")};
|
background: ${s("secondaryBackground")};
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EventBoundary: React.FC<Props> = ({ children, className }: Props) => {
|
||||||
|
const handleClick = React.useCallback((event: React.SyntheticEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span onClick={handleClick} className={className}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EventBoundary;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user