Compare commits

..

2 Commits

Author SHA1 Message Date
Tom Moor 8e9beac59f test 2023-08-08 23:12:41 -04:00
Tom Moor a0f7c76405 Add support for SSL in development 2023-08-08 22:46:31 -04:00
2043 changed files with 61509 additions and 156420 deletions
+19 -30
View File
@@ -1,30 +1,29 @@
{ {
"presets": [ "presets": [
"@babel/preset-react",
"@babel/preset-typescript",
[ [
"@babel/preset-react", "@babel/preset-env",
{ {
"runtime": "automatic" "corejs": {
"version": "3",
"proposals": true
},
"useBuiltIns": "usage"
} }
], ]
"@babel/preset-env",
"@babel/preset-typescript"
], ],
"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": {
@@ -36,24 +35,14 @@
} }
] ]
], ],
"ignore": ["**/__mocks__", "**/*.test.ts"] "ignore": [
"**/*.test.ts"
]
}, },
"development": { "development": {
"ignore": ["**/__mocks__", "**/*.test.ts"] "ignore": [
}, "**/*.test.ts"
"test": {
"presets": [
[
"@babel/preset-env",
{
"corejs": {
"version": "3",
"proposals": true
},
"useBuiltIns": "usage"
}
]
] ]
} }
} }
} }
+180
View File
@@ -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: /.*/
+7
View File
@@ -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
+1 -1
View File
@@ -6,7 +6,6 @@ __mocks__
.DS_Store .DS_Store
.env* .env*
.eslint* .eslint*
.oxlintrc*
.log .log
Makefile Makefile
Procfile Procfile
@@ -14,4 +13,5 @@ app.json
crowdin.yml crowdin.yml
build build
docker-compose.yml docker-compose.yml
fakes3
node_modules node_modules
-13
View File
@@ -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
+125 -198
View File
@@ -1,159 +1,98 @@
# –––––––––––––––– REQUIRED ––––––––––––––––
NODE_ENV=production NODE_ENV=production
# This URL should point to the fully qualified, publicly accessible, URL. If using a # Generate a hex-encoded 32-byte random key. You should use `openssl rand -hex 32`
# proxy this will be the proxy's URL. # in your terminal to generate a random value.
URL= SECRET_KEY=generate_a_new_key
# The port to expose the Outline server on, this should match what is configured # Generate a unique random key. The format is not important but you could still use
# in your docker-compose.yml # `openssl rand -hex 32` in your terminal to produce this.
UTILS_SECRET=generate_a_new_key
# For production point these at your databases, in development the default
# should work out of the box.
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_MAX=
# Uncomment this to disable SSL for connecting to Postgres
# PGSSLMODE=disable
# For redis you can either specify an ioredis compatible url like this
REDIS_URL=redis://localhost:6379
# or alternatively, if you would like to provide additional connection options,
# use a base64 encoded JSON connection option object. Refer to the ioredis documentation
# for a list of available options.
# Example: Use Redis Sentinel for high availability
# {"sentinels":[{"host":"sentinel-0","port":26379},{"host":"sentinel-1","port":26379}],"name":"mymaster"}
# REDIS_URL=ioredis://eyJzZW50aW5lbHMiOlt7Imhvc3QiOiJzZW50aW5lbC0wIiwicG9ydCI6MjYzNzl9LHsiaG9zdCI6InNlbnRpbmVsLTEiLCJwb3J0IjoyNjM3OX1dLCJuYW1lIjoibXltYXN0ZXIifQ==
# URL should point to the fully qualified, publicly accessible URL. If using a
# proxy the port in URL and PORT may be different.
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=
# If using a Cloudfront/Cloudflare distribution or similar it can be set below. # To support uploading of images for avatars and document attachments an
# This will cause paths to javascript, stylesheets, and images to be updated to # s3-compatible storage must be provided. AWS S3 is recommended for redundancy
# the hostname defined in CDN_URL. In your CDN configuration the origin server # however if you want to keep all file storage local an alternative such as
# should be set to the same as URL. # minio (https://github.com/minio/minio) can be used.
CDN_URL=
# How many processes should be spawned. As a reasonable rule divide your servers # A more detailed guide on setting up S3 is available here:
# available memory by 512 for a rough estimate # => https://wiki.generaloutline.com/share/125de1cc-9ff6-424b-8415-0d58c809a40f
WEB_CONCURRENCY=1 #
# Generate a hex-encoded 32-byte random key. Use `openssl rand -hex 32` in your
# terminal to generate a random value.
SECRET_KEY=generate_a_new_key
# Generate a unique random key. The format is not important but you could still use
# `openssl rand -hex 32` in your terminal to generate a random value.
UTILS_SECRET=generate_a_new_key
# The default interface language. See translate.getoutline.com for a list of
# available language codes and their rough percentage translated.
DEFAULT_LANGUAGE=en_US
# ––––––––––––––––––––––––––––––––––––––
# ––––––––––––– DATABASE –––––––––––––
# ––––––––––––––––––––––––––––––––––––––
# The database URL for your production database, including username, password, and database name.
DATABASE_URL=postgres://user:pass@postgres:5432/outline
# The in-memory database pool per-process settings. Ensure that the pool size that will not exceed
# the maximum number of connections allowed by your database. Defaults to 0 and 5.
DATABASE_CONNECTION_POOL_MIN=
DATABASE_CONNECTION_POOL_MAX=
# Uncomment this line if you will not use SSL for connecting to Postgres. This is acceptable
# if the database and the application are on the same machine.
# PGSSLMODE=disable
# ––––––––––––––––––––––––––––––––––––––
# –––––––––––––– REDIS –––––––––––––––
# ––––––––––––––––––––––––––––––––––––––
# The Redis URL for your environment you can either specify an ioredis compatible url or a Base64
# encoded configuration object.
# DOCS: https://docs.getoutline.com/s/hosting/doc/redis-LGM4BFXYp4
REDIS_URL=redis://redis:6379
# ––––––––––––––––––––––––––––––––––––––
# ––––––––––– FILE STORAGE –––––––––––
# ––––––––––––––––––––––––––––––––––––––
# Specify what storage system to use. Possible value is one of "s3" or "local".
# For "local" images and document attachments will be saved on local disk, for "s3" they
# will be stored in an S3-compatible network store.
# DOCS: https://docs.getoutline.com/s/hosting/doc/file-storage-N4M0T6Ypu7
FILE_STORAGE=local
# If "local" is configured for FILE_STORAGE above, then this sets the parent directory under
# which all attachments/images are stored. Make sure that the process has permissions to
# create this path and also to write files to it.
FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
# Maximum allowed size for the uploaded attachment.
FILE_STORAGE_UPLOAD_MAX_SIZE=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 ––––––––––––––
# –––––––––––––––– SSL –––––––––––––––
# ––––––––––––––––––––––––––––––––––––––
# Base64 encoded private key and certificate for HTTPS termination. This is one
# of three ways to configure SSL and can be left empty.
# DOCS: https://docs.getoutline.com/s/hosting/doc/ssl-pzk7WO8d1n
SSL_KEY=
SSL_CERT=
# Auto-redirect to https in production. The default is true but you may set to
# false if you can be sure that SSL is terminated at an external loadbalancer.
FORCE_HTTPS=true
# ––––––––––––––––––––––––––––––––––––––
# –––––––––– AUTHENTICATION ––––––––––
# ––––––––––––––––––––––––––––––––––––––
# Third party signin credentials, at least ONE OF EITHER Google, Slack, # Third party signin credentials, at least ONE OF EITHER Google, Slack,
# Discord, or Microsoft is required for a working installation or you'll # or Microsoft is required for a working installation or you'll have no sign-in
# have no sign-in options. # options.
# Slack sign-in provider # To configure Slack auth, you'll need to create an Application at
# DOCS: https://docs.getoutline.com/s/hosting/doc/slack-sgMujR8J9J # => https://api.slack.com/apps
#
# When configuring the Client ID, add a redirect URL under "OAuth & Permissions":
# https://<URL>/auth/slack.callback
SLACK_CLIENT_ID=get_a_key_from_slack SLACK_CLIENT_ID=get_a_key_from_slack
SLACK_CLIENT_SECRET=get_the_secret_of_above_key SLACK_CLIENT_SECRET=get_the_secret_of_above_key
# Google sign-in provider # To configure Google auth, you'll need to create an OAuth Client ID at
# DOCS: https://docs.getoutline.com/s/hosting/doc/google-hOuvtCmTqQ # => https://console.cloud.google.com/apis/credentials
#
# When configuring the Client ID, add an Authorized redirect URI:
# https://<URL>/auth/google.callback
GOOGLE_CLIENT_ID= GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_SECRET=
# Microsoft Entra / Azure AD sign-in provider # To configure Microsoft/Azure auth, you'll need to create an OAuth Client. See
# DOCS: https://docs.getoutline.com/s/hosting/doc/microsoft-entra-UVz6jsIOcv # the guide for details on setting up your Azure App:
# => https://wiki.generaloutline.com/share/dfa77e56-d4d2-4b51-8ff8-84ea6608faa4
AZURE_CLIENT_ID= AZURE_CLIENT_ID=
AZURE_CLIENT_SECRET= AZURE_CLIENT_SECRET=
AZURE_RESOURCE_APP_ID= AZURE_RESOURCE_APP_ID=
# Discord sign-in provider # To configure generic OIDC auth, you'll need some kind of identity provider.
# DOCS: https://docs.getoutline.com/s/hosting/doc/discord-g4JdWFFub6 # See documentation for whichever IdP you use to acquire the following info:
DISCORD_CLIENT_ID= # Redirect URI is https://<URL>/auth/oidc.callback
DISCORD_CLIENT_SECRET=
DISCORD_SERVER_ID=
DISCORD_SERVER_ROLES=
# Generic OIDC provider
# DOCS: https://docs.getoutline.com/s/hosting/doc/oidc-8CPBm6uC0I
OIDC_CLIENT_ID= OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET= 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
@@ -166,95 +105,83 @@ OIDC_DISPLAY_NAME=OpenID Connect
OIDC_SCOPES=openid profile email OIDC_SCOPES=openid profile email
# –––––––––––––––––––––––––––––––––––––– # –––––––––––––––– OPTIONAL ––––––––––––––––
# –––––––––––––– EMAIL –––––––––––––––
# ––––––––––––––––––––––––––––––––––––––
# To support sending outgoing transactional emails such as "document updated" or # Base64 encoded private key and certificate for HTTPS termination. This is only
# email sign-in you'll need to connect an SMTP server. Service can be configured # required if you do not use an external reverse proxy. See documentation:
# with any service from this list: https://community.nodemailer.com/2-0-0-beta/setup-smtp/well-known-services/ # https://wiki.generaloutline.com/share/1c922644-40d8-41fe-98f9-df2b67239d45
# DOCS: https://docs.getoutline.com/s/hosting/doc/smtp-cqCJyZGMIB SSL_KEY=
SMTP_SERVICE= SSL_CERT=
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM_EMAIL=
# If using a Cloudfront/Cloudflare distribution or similar it can be set below.
# This will cause paths to javascript, stylesheets, and images to be updated to
# the hostname defined in CDN_URL. In your CDN configuration the origin server
# should be set to the same as URL.
CDN_URL=
# –––––––––––––––––––––––––––––––––––––– # Auto-redirect to https in production. The default is true but you may set to
# –––––––––– RATE LIMITER –––––––––––– # false if you can be sure that SSL is terminated at an external loadbalancer.
# –––––––––––––––––––––––––––––––––––––– FORCE_HTTPS=true
# Whether the rate limiter is enabled or not
RATE_LIMITER_ENABLED=true
# Individual endpoints have hardcoded rate limits that are enabled
# with the above setting, however this is a global rate limiter
# across all requests
RATE_LIMITER_REQUESTS=1000
RATE_LIMITER_DURATION_WINDOW=60
# ––––––––––––––––––––––––––––––––––––––
# ––––––––––– INTEGRATIONS –––––––––––
# ––––––––––––––––––––––––––––––––––––––
# The GitHub integration allows previewing issue and pull request links
# DOCS: https://docs.getoutline.com/s/hosting/doc/github-GchT3NNxI9
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GITHUB_WEBHOOK_SECRET=
GITHUB_APP_NAME=
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
# The Linear integration allows previewing issue links as rich mentions
LINEAR_CLIENT_ID=
LINEAR_CLIENT_SECRET=
# The GitLab integration allows previewing issue and merge request links as rich mentions
GITLAB_CLIENT_ID=
GITLAB_CLIENT_SECRET=
# For a complete Slack integration with search and posting to channels the
# following configs are also needed in addition to Slack authentication:
# DOCS: https://docs.getoutline.com/s/hosting/doc/slack-G2mc8DOJHk
SLACK_VERIFICATION_TOKEN=your_token
SLACK_APP_ID=A0XXXXXXX
SLACK_MESSAGE_ACTIONS=true
# For Dropbox integration, follow these instructions to get the key https://www.dropbox.com/developers/embedder#setup
# and do not forget to whitelist your domain name in the app settings
DROPBOX_APP_KEY=
# Optionally enable Sentry (sentry.io) to track errors and performance,
# DOCS: https://docs.getoutline.com/s/hosting/doc/sentry-jxcFttcDl5
SENTRY_DSN=
SENTRY_TUNNEL=
# Enable importing pages from a Notion workspace
# DOCS: https://docs.getoutline.com/s/hosting/doc/notion-2v6g7WY3l3
NOTION_CLIENT_ID=
NOTION_CLIENT_SECRET=
# The Iframely integration allows previews of third-party content within Outline.
# For example, hovering over an external link will show a preview.
# DOCS: https://docs.getoutline.com/s/hosting/doc/iframely-HwLF1EZ9mo
IFRAMELY_URL=
IFRAMELY_API_KEY=
# ––––––––––––––––––––––––––––––––––––––
# ––––––––––––– DEBUGGING ––––––––––––
# ––––––––––––––––––––––––––––––––––––––
# Have the installation check for updates by sending anonymized statistics to # Have the installation check for updates by sending anonymized statistics to
# the maintainers # the maintainers
ENABLE_UPDATES=true ENABLE_UPDATES=true
# Debugging categories to enable you can remove the default "http" value if # How many processes should be spawned. As a reasonable rule divide your servers
# your proxy already logs incoming http requests and this ends up being duplicative # available memory by 512 for a rough estimate
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
# requests and this ends up being duplicative
DEBUG=http DEBUG=http
# Configure lowest severity level for server logs. Should be one of # Configure lowest severity level for server logs. Should be one of
# error, warn, info, http, verbose, debug, or silly # error, warn, info, http, verbose, debug and silly
LOG_LEVEL=info LOG_LEVEL=info
# For a complete Slack integration with search and posting to channels the
# following configs are also needed, some more details
# => https://wiki.generaloutline.com/share/be25efd1-b3ef-4450-b8e5-c4a4fc11e02a
#
SLACK_VERIFICATION_TOKEN=your_token
SLACK_APP_ID=A0XXXXXXX
SLACK_MESSAGE_ACTIONS=true
# Optionally enable google analytics to track pageviews in the knowledge base
GOOGLE_ANALYTICS_ID=
# Optionally enable Sentry (sentry.io) to track errors and performance,
# and optionally add a Sentry proxy tunnel for bypassing ad blockers in the UI:
# https://docs.sentry.io/platforms/javascript/troubleshooting/#using-the-tunnel-option)
SENTRY_DSN=
SENTRY_TUNNEL=
# 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
SMTP_HOST=
SMTP_PORT=
SMTP_USERNAME=
SMTP_PASSWORD=
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
# available language codes and their rough percentage translated.
DEFAULT_LANGUAGE=en_US
# Optionally enable rate limiter at application web server
RATE_LIMITER_ENABLED=true
# Configure default throttling parameters for rate limiter
RATE_LIMITER_REQUESTS=1000
RATE_LIMITER_DURATION_WINDOW=60
# Iframely API config
IFRAMELY_URL=
IFRAMELY_API_KEY=
-31
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
server/migrations/*.js
+132
View File
@@ -0,0 +1,132 @@
{
"parser": "@typescript-eslint/parser",
"parserOptions": {
"sourceType": "module",
"extraFileExtensions": [".json"],
"project": "./tsconfig.json",
"ecmaFeatures": {
"jsx": true
}
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:import/recommended",
"plugin:import/typescript",
"plugin:prettier/recommended"
],
"plugins": [
"es",
"@typescript-eslint",
"eslint-plugin-import",
"eslint-plugin-node",
"eslint-plugin-react",
"import"
],
"rules": {
"eqeqeq": 2,
"curly": 2,
"no-console": "error",
"arrow-body-style": ["error", "as-needed"],
"spaced-comment": "error",
"object-shorthand": "error",
"no-mixed-operators": "off",
"no-useless-escape": "off",
"es/no-regexp-lookbehind-assertions": "error",
"react/self-closing-comp": ["error", {
"component": true,
"html": true
}],
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/await-thenable": "error",
"@typescript-eslint/no-misused-promises": [
"error",
{
"checksVoidReturn": false
}
],
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"args": "after-used",
"ignoreRestSiblings": true
}
],
"padding-line-between-statements": ["error", { "blankLine": "always", "prev": "*", "next": "export" }],
"lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }],
"import/no-named-as-default": "off",
"import/no-named-as-default-member": "off",
"import/newline-after-import": 2,
"import/order": [
"error",
{
"alphabetize": {
"order": "asc"
},
"pathGroups": [
{
"pattern": "@shared/**",
"group": "external",
"position": "after"
},
{
"pattern": "@server/**",
"group": "external",
"position": "after"
},
{
"pattern": "~/stores",
"group": "external",
"position": "after"
},
{
"pattern": "~/stores/**",
"group": "external",
"position": "after"
},
{
"pattern": "~/models/**",
"group": "external",
"position": "after"
},
{
"pattern": "~/scenes/**",
"group": "external",
"position": "after"
},
{
"pattern": "~/components/**",
"group": "external",
"position": "after"
},
{
"pattern": "~/**",
"group": "external",
"position": "after"
}
]
}
],
"prettier/prettier": [
"error",
{
"printWidth": 80,
"trailingComma": "es5"
}
]
},
"settings": {
"react": {
"createClass": "createReactClass",
"pragma": "React",
"version": "detect"
},
"import/parsers": {
"@typescript-eslint/parser": [".ts", ".tsx"]
},
"import/resolver": {
"typescript": {}
}
}
}
+37
View File
@@ -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]
-63
View File
@@ -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
+2 -2
View File
@@ -2,9 +2,9 @@
addReviewers: true addReviewers: true
# A list of reviewers to be added to pull requests (GitHub user name) # A list of reviewers to be added to pull requests (GitHub user name)
reviewers: reviewers:
- tommoor - tommoor
# A list of keywords to be skipped the process that add reviewers if pull requests include it # A list of keywords to be skipped the process that add reviewers if pull requests include it
skipKeywords: skipKeywords:
- wip - wip
-2
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
-59
View File
@@ -1,59 +0,0 @@
name: Auto Close Unsigned PRs
on:
schedule:
- cron: "0 0 * * *" # Run daily at midnight UTC
jobs:
close-unsigned-prs:
runs-on: ubuntu-latest
permissions:
pull-requests: write
issues: write
steps:
- name: Close unsigned PRs
uses: actions/github-script@v6
with:
script: |
const now = new Date();
const TWO_WEEKS = 14 * 24 * 60 * 60 * 1000; // 14 days in milliseconds
const prs = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open'
});
for (const pr of prs.data) {
const prCreatedAt = new Date(pr.created_at);
const prAge = now - prCreatedAt;
if (prAge < TWO_WEEKS) continue;
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number
});
const hasNotSignedComment = comments.data.some(comment =>
comment.body.toLowerCase().includes('https://cla-assistant.io/pull/badge/not_signed')
);
if (hasNotSignedComment) {
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
state: 'closed'
});
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: 'This PR has been automatically closed because it has been open for more than 14 days and has not accepted the CLA.'
});
}
}
-158
View File
@@ -1,158 +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=8192
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, 22.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 --prefer-offline
lint:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22.x
cache: "yarn"
- run: yarn install --frozen-lockfile --prefer-offline
- run: yarn lint
types:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22.x
cache: "yarn"
- run: yarn install --frozen-lockfile --prefer-offline
- run: yarn tsc
changes:
runs-on: ubuntu-latest
outputs:
config: ${{ steps.filter.outputs.config }}
server: ${{ steps.filter.outputs.server }}
app: ${{ steps.filter.outputs.app }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v2
id: filter
with:
filters: |
config:
- '.github/**'
- 'vite.config.ts'
server:
- 'server/**'
- 'shared/**'
- 'package.json'
- 'yarn.lock'
app:
- 'app/**'
- 'shared/**'
- 'package.json'
- 'yarn.lock'
test:
needs: [build, changes]
if: ${{ needs.changes.outputs.app == 'true' || needs.changes.outputs.config == 'true' }}
runs-on: ubuntu-latest
strategy:
matrix:
test-group: [app, shared]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22.x
cache: "yarn"
- run: yarn install --frozen-lockfile --prefer-offline
- run: yarn test:${{ matrix.test-group }}
test-server:
needs: [build, changes]
if: ${{ needs.changes.outputs.server == 'true' || needs.changes.outputs.config == '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
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22.x
cache: "yarn"
- run: yarn install --frozen-lockfile --prefer-offline
- 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, changes]
if: ${{ needs.changes.outputs.app == 'true' && github.repository == 'outline/outline' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22.x
cache: "yarn"
- run: yarn install --frozen-lockfile --prefer-offline
- 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 }}
webpackStatsFile: ./build/app/webpack-stats.json
+29 -29
View File
@@ -13,12 +13,12 @@ name: "CodeQL"
on: on:
push: push:
branches: [main] branches: [ main ]
pull_request: pull_request:
# The branches below must be a subset of the branches above # The branches below must be a subset of the branches above
branches: [main] branches: [ main ]
schedule: schedule:
- cron: "28 15 * * 2" - cron: '28 15 * * 2'
jobs: jobs:
analyze: analyze:
@@ -32,39 +32,39 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
language: ["javascript"] language: [ 'javascript' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Learn more about CodeQL language support at https://git.io/codeql-language-support # Learn more about CodeQL language support at https://git.io/codeql-language-support
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v2 uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v2 uses: github/codeql-action/init@v2
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file. # By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file. # Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main # queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@v2 uses: github/codeql-action/autobuild@v2
# ️ Command-line programs to run using the OS shell. # ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl # 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project # and modify them (or add more) to build your code if your project
# uses a compiled language # uses a compiled language
#- run: | #- run: |
# make bootstrap # make bootstrap
# make release # make release
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2 uses: github/codeql-action/analyze@v2
-212
View File
@@ -1,212 +0,0 @@
name: Docker
on:
push:
tags:
- "v*"
env:
IMAGE_NAME: outlinewiki/outline
BASE_IMAGE_NAME: outlinewiki/outline-base
jobs:
build-arm:
runs-on: ubicloud-standard-8-arm
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker base meta
id: base_meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.BASE_IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- 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
id: base_build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.base
labels: ${{ steps.base_meta.outputs.labels }}
tags: ${{ env.BASE_IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ env.IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
build-args: |
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}@${{ steps.base_build.outputs.digest }}
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-linux-arm64
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
build-amd:
runs-on: ubicloud-standard-8
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker base meta
id: base_meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.BASE_IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- 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
id: base_build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.base
labels: ${{ steps.base_meta.outputs.labels }}
tags: ${{ env.BASE_IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/amd64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ env.IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/amd64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
build-args: |
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}@${{ steps.base_build.outputs.digest }}
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-linux-amd64
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubicloud-standard-8
needs:
- build-amd
- build-arm
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
-30
View File
@@ -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 --prefer-offline
- run: yarn lint --fix
- name: Commit changes
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: "Applied automatic fixes"
+1 -1
View File
@@ -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
View File
@@ -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 -12
View File
@@ -1,19 +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"],
"globalTeardown": "<rootDir>/server/test/globalTeardown.js", "testEnvironment": "node",
"testEnvironment": "node" "runner": "@getoutline/jest-runner-serial"
}, },
{ {
"displayName": "app", "displayName": "app",
@@ -22,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"],
@@ -38,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"],
@@ -52,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",
-1
View File
@@ -1 +0,0 @@
22
-102
View File
@@ -1,102 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"ignorePatterns": [
"build/**",
"node_modules/**",
"public/**",
"server/migrations/**",
"server/scripts/**",
"patches/**",
"*.d.ts"
],
"rules": {
"for-direction": "error",
"no-async-promise-executor": "error",
"no-case-declarations": "error",
"no-class-assign": "error",
"no-compare-neg-zero": "error",
"no-cond-assign": "error",
"no-const-assign": "error",
"no-constant-binary-expression": "error",
"no-constant-condition": "error",
"no-control-regex": "error",
"no-debugger": "error",
"no-delete-var": "error",
"no-dupe-class-members": "error",
"no-dupe-else-if": "error",
"no-dupe-keys": "error",
"no-duplicate-case": "error",
"no-empty": "error",
"no-empty-character-class": "error",
"no-empty-pattern": "error",
"no-empty-static-block": "error",
"no-ex-assign": "error",
"no-explicit-any": "warn",
"no-extra-boolean-cast": "error",
"no-fallthrough": "error",
"no-func-assign": "error",
"no-global-assign": "error",
"no-import-assign": "error",
"no-invalid-regexp": "error",
"no-irregular-whitespace": "error",
"no-loss-of-precision": "error",
"no-new-native-nonconstructor": "error",
"no-nonoctal-decimal-escape": "error",
"no-obj-calls": "error",
"no-prototype-builtins": "error",
"no-redeclare": "error",
"no-regex-spaces": "error",
"no-self-assign": "error",
"no-setter-return": "error",
"no-shadow-restricted-names": "error",
"no-sparse-arrays": "error",
"no-this-before-super": "error",
"no-unexpected-multiline": "error",
"no-unsafe-finally": "error",
"no-unsafe-negation": "error",
"no-unsafe-optional-chaining": "error",
"no-unused-labels": "error",
"no-unused-private-class-members": "error",
"no-unused-vars": "error",
"no-useless-backreference": "error",
"no-useless-catch": "error",
"no-useless-escape": "error",
"no-with": "error",
"require-yield": "error",
"use-isnan": "error",
"valid-typeof": "error"
},
"overrides": [
{
"files": ["**/*.{js,jsx,ts,tsx}"],
"rules": {
"eqeqeq": "error",
"curly": "error",
"no-console": "error",
"arrow-body-style": ["error", "as-needed"],
"no-useless-escape": "off",
"react/react-in-jsx-scope": "off",
"react/self-closing-comp": [
"error",
{
"component": true,
"html": true
}
],
"@typescript-eslint/no-require-imports": "off",
"import/no-named-as-default": "off",
"import/no-named-as-default-member": "off",
"no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_",
"args": "after-used",
"ignoreRestSiblings": true
}
]
},
"plugins": ["eslint", "oxc", "react", "typescript", "import"]
}
]
}
-4
View File
@@ -1,4 +0,0 @@
{
"printWidth": 80,
"trailingComma": "es5"
}
+2 -3
View File
@@ -1,6 +1,4 @@
require("@dotenvx/dotenvx").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 -24
View File
@@ -1,18 +1,19 @@
ARG APP_PATH=/opt/outline ARG APP_PATH=/opt/outline
ARG BASE_IMAGE=outlinewiki/outline-base FROM outlinewiki/outline-base as base
FROM ${BASE_IMAGE} AS base
ARG APP_PATH ARG APP_PATH
WORKDIR $APP_PATH WORKDIR $APP_PATH
# --- # ---
FROM node:22-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
@@ -21,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 -6
View File
@@ -1,14 +1,11 @@
ARG APP_PATH=/opt/outline ARG APP_PATH=/opt/outline
FROM node:20 AS deps FROM node:18-alpine AS deps
ARG APP_PATH ARG APP_PATH
WORKDIR $APP_PATH WORKDIR $APP_PATH
COPY ./package.json ./yarn.lock ./ COPY ./package.json ./yarn.lock ./
COPY ./patches ./patches COPY ./patches ./patches
RUN apt-get update && apt-get install -y cmake
ENV NODE_OPTIONS="--max-old-space-size=24000"
RUN yarn install --no-optional --frozen-lockfile --network-timeout 1000000 && \ RUN yarn install --no-optional --frozen-lockfile --network-timeout 1000000 && \
yarn cache clean yarn cache clean
@@ -20,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 -3
View File
@@ -3,8 +3,8 @@ Business Source License 1.1
Parameters Parameters
Licensor: General Outline, Inc. Licensor: General Outline, Inc.
Licensed Work: Outline 0.86.1 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-08-09 Change Date: 2026-05-23
Change License: Apache License, Version 2.0 Change License: Apache License, Version 2.0
+12 -12
View File
@@ -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 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
+2 -6
View File
@@ -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 youre looking for ways to get started, here's a list of ways to help us improve Outline: If youre 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
![Alt](https://repobeats.axiom.co/api/embed/ff2e4e6918afff1acf9deb72d1ba6b071d586178.svg "Repobeats analytics image")
# License
Outline is [BSL 1.1 licensed](LICENSE). Outline is [BSL 1.1 licensed](LICENSE).
+1 -1
View File
@@ -1 +1 @@
export default ""; export default '';
+5 -5
View File
@@ -1,19 +1,19 @@
const storage = {}; const storage = {};
export default { export default {
setItem: function (key, value) { setItem: function(key, value) {
storage[key] = value || ""; storage[key] = value || '';
}, },
getItem: function (key) { getItem: function(key) {
return key in storage ? storage[key] : null; return key in storage ? storage[key] : null;
}, },
removeItem: function (key) { removeItem: function(key) {
delete storage[key]; delete storage[key];
}, },
get length() { get length() {
return Object.keys(storage).length; return Object.keys(storage).length;
}, },
key: function (i) { key: function(i) {
var keys = Object.keys(storage); var keys = Object.keys(storage);
return keys[i] || null; return keys[i] || null;
}, },
-1
View File
@@ -1 +0,0 @@
export default null;
+5 -22
View File
@@ -33,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
@@ -86,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",
@@ -141,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",
@@ -156,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
+14
View File
@@ -0,0 +1,14 @@
{
"extends": [
"../.eslintrc",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
],
"plugins": [
"eslint-plugin-react-hooks",
],
"env": {
"jest": true,
"browser": true
}
}
-34
View File
@@ -1,34 +0,0 @@
{
"extends": ["../.oxlintrc.json"],
"plugins": ["oxc", "eslint", "typescript", "react"],
"overrides": [
{
"files": ["**/*.{jsx,tsx}"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["mime-types"],
"message": "Do not use the mime-types package in the browser."
}
],
"paths": [
{
"name": "reakit/Menu",
"importNames": ["useMenuState"],
"message": "Do not use useMenuState from reakit/Menu. Use useMenuState instead."
}
]
}
]
},
"plugins": ["import"]
}
],
"env": {
"jest": true,
"browser": true
}
}
-60
View File
@@ -1,60 +0,0 @@
import { PlusIcon, TrashIcon } from "outline-icons";
import stores from "~/stores";
import ApiKey from "~/models/ApiKey";
import ApiKeyNew from "~/scenes/ApiKeyNew";
import ApiKeyRevokeDialog from "~/scenes/Settings/components/ApiKeyRevokeDialog";
import { createAction, createActionV2 } 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} />,
});
},
});
export const revokeApiKeyFactory = ({ apiKey }: { apiKey: ApiKey }) =>
createActionV2({
name: ({ t, isContextMenu }) =>
isContextMenu
? apiKey.isExpired
? t("Delete")
: `${t("Revoke")}`
: t("Revoke API key"),
analyticsName: "Revoke API key",
section: SettingsSection,
icon: <TrashIcon />,
keywords: "revoke delete remove",
dangerous: true,
perform: async ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
if (apiKey.isExpired) {
await apiKey.delete();
return;
}
stores.dialogs.openModal({
title: t("Revoke token"),
content: (
<ApiKeyRevokeDialog
onSubmit={stores.dialogs.closeAllModals}
apiKey={apiKey}
/>
),
});
},
});
+30 -302
View File
@@ -1,42 +1,23 @@
import { import {
ArchiveIcon,
CollectionIcon, CollectionIcon,
EditIcon, EditIcon,
ExportIcon,
NewDocumentIcon,
PadlockIcon, PadlockIcon,
PlusIcon, PlusIcon,
RestoreIcon,
SearchIcon,
ShapesIcon,
StarredIcon, StarredIcon,
SubscribeIcon,
TrashIcon, TrashIcon,
UnstarredIcon, UnstarredIcon,
UnsubscribeIcon,
} from "outline-icons"; } from "outline-icons";
import { toast } from "sonner"; import * as React from "react";
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 { createAction } from "~/actions";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header"; import { CollectionSection } from "~/actions/sections";
import { import history from "~/utils/history";
createAction,
createActionV2,
createInternalLinkActionV2,
} from "~/actions";
import { ActiveCollectionSection, CollectionSection } from "~/actions/sections";
import { setPersistedState } from "~/hooks/usePersistedState";
import {
newDocumentPath,
newTemplatePath,
searchPath,
} from "~/utils/routeHelpers";
import ExportDialog from "~/components/ExportDialog";
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => ( const ColorCollectionIcon = ({ collection }: { collection: Collection }) => (
<DynamicCollectionIcon collection={collection} /> <DynamicCollectionIcon collection={collection} />
@@ -53,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,
to: collection.path, perform: () => history.push(collection.url),
})); }));
}, },
}); });
@@ -70,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({
@@ -80,16 +61,16 @@ export const createCollection = createAction({
}, },
}); });
export const editCollection = createActionV2({ 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;
} }
@@ -106,73 +87,31 @@ export const editCollection = createActionV2({
}, },
}); });
export const editCollectionPermissions = createActionV2({ 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 = createInternalLinkActionV2({ export const starCollection = 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;
},
to: ({ activeCollectionId, sidebarContext }) => {
const [pathname, search] = searchPath({
collectionId: activeCollectionId,
}).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
export const starCollection = createActionV2({
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 }) => {
@@ -192,14 +131,13 @@ export const starCollection = createActionV2({
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 = createActionV2({ 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 }) => {
@@ -222,139 +160,10 @@ export const unstarCollection = createActionV2({
}, },
}); });
export const subscribeCollection = createActionV2({ export const deleteCollection = createAction({
name: ({ t }) => t("Subscribe"), name: ({ t }) => t("Delete"),
analyticsName: "Subscribe to collection",
section: ActiveCollectionSection,
icon: <SubscribeIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!!collection?.isActive &&
!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 = createActionV2({
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?.isActive &&
!!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 = createActionV2({
name: ({ t }) => `${t("Archive")}`,
analyticsName: "Archive collection",
section: ActiveCollectionSection,
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 = createActionV2({
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 = createActionV2({
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) {
@@ -362,7 +171,7 @@ export const deleteCollection = createActionV2({
} }
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;
} }
@@ -373,6 +182,7 @@ export const deleteCollection = createActionV2({
} }
stores.dialogs.openModal({ stores.dialogs.openModal({
isCentered: true,
title: t("Delete collection"), title: t("Delete collection"),
content: ( content: (
<CollectionDeleteDialog <CollectionDeleteDialog
@@ -384,92 +194,10 @@ export const deleteCollection = createActionV2({
}, },
}); });
export const exportCollection = createActionV2({
name: ({ t }) => `${t("Export")}`,
analyticsName: "Export collection",
section: ActiveCollectionSection,
icon: <ExportIcon />,
visible: ({ currentTeamId, activeCollectionId, stores }) => {
if (!currentTeamId || !activeCollectionId) {
return false;
}
return (
!!stores.policies.abilities(currentTeamId).createExport &&
!!stores.policies.abilities(activeCollectionId).export
);
},
perform: async ({ activeCollectionId, stores, t }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
if (!collection) {
return;
}
stores.dialogs.openModal({
title: t("Export collection"),
content: (
<ExportDialog
collection={collection}
onSubmit={stores.dialogs.closeAllModals}
/>
),
});
},
});
export const createDocument = createInternalLinkActionV2({
name: ({ t }) => t("New document"),
analyticsName: "New document",
section: ActiveCollectionSection,
icon: <NewDocumentIcon />,
keywords: "new create document",
visible: ({ activeCollectionId, stores }) =>
!!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).createDocument
),
to: ({ activeCollectionId, sidebarContext }) => {
const [pathname, search] = newDocumentPath(activeCollectionId).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
export const createTemplate = createInternalLinkActionV2({
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
),
to: ({ activeCollectionId, sidebarContext }) => {
const [pathname, search] = newTemplatePath(activeCollectionId).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
export const rootCollectionActions = [ export const rootCollectionActions = [
openCollection, openCollection,
createCollection, createCollection,
starCollection, starCollection,
unstarCollection, unstarCollection,
subscribeCollection,
unsubscribeCollection,
deleteCollection, deleteCollection,
]; ];
-121
View File
@@ -1,121 +0,0 @@
import { DoneIcon, SmileyIcon, TrashIcon } from "outline-icons";
import { toast } from "sonner";
import Comment from "~/models/Comment";
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
import ViewReactionsDialog from "~/components/Reactions/ViewReactionsDialog";
import history from "~/utils/history";
import { createActionV2 } from "..";
import { ActiveDocumentSection } from "../sections";
export const deleteCommentFactory = ({
comment,
onDelete,
}: {
comment: Comment;
onDelete: () => void;
}) =>
createActionV2({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete comment",
section: ActiveDocumentSection,
icon: <TrashIcon />,
keywords: "trash",
dangerous: true,
visible: ({ stores }) => stores.policies.abilities(comment.id).delete,
perform: ({ t, stores, 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;
}) =>
createActionV2({
name: ({ t }) => t("Mark as resolved"),
analyticsName: "Resolve thread",
section: ActiveDocumentSection,
icon: <DoneIcon outline />,
visible: ({ stores }) =>
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;
}) =>
createActionV2({
name: ({ t }) => t("Mark as unresolved"),
analyticsName: "Unresolve thread",
section: ActiveDocumentSection,
icon: <DoneIcon outline />,
visible: ({ stores }) =>
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;
}) =>
createActionV2({
name: ({ t }) => `${t("View reactions")}`,
analyticsName: "View comment reactions",
section: ActiveDocumentSection,
icon: <SmileyIcon />,
visible: ({ stores }) =>
stores.policies.abilities(comment.id).read &&
comment.reactions.length > 0,
perform: ({ t, stores, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("Reactions"),
content: <ViewReactionsDialog model={comment} />,
});
},
});
+15 -174
View File
@@ -1,164 +1,38 @@
import Storage from "@shared/utils/Storage"; import { ToolsIcon, TrashIcon, UserIcon } from "outline-icons";
import copy from "copy-to-clipboard"; import * as React from "react";
import { import stores from "~/stores";
BeakerIcon,
CopyIcon,
EditIcon,
ToolsIcon,
TrashIcon,
UserIcon,
} from "outline-icons";
import { toast } from "sonner";
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 clearStorage = createAction({
name: ({ t }) => t("Clear local storage"),
icon: <TrashIcon />,
keywords: "cache clear localstorage",
section: DeveloperSection,
perform: ({ t }) => {
Storage.clear();
toast.success(t("Local storage cleared"));
}, },
}); });
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,
});
}, },
}); });
@@ -166,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")
@@ -176,46 +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,
clearStorage,
startTyping,
],
}); });
export const rootDeveloperActions = [developer]; export const rootDeveloperActions = [developer];
File diff suppressed because it is too large Load Diff
-28
View File
@@ -1,28 +0,0 @@
import { TrashIcon } from "outline-icons";
import stores from "~/stores";
import { createAction } from "..";
import { SettingsSection } from "../sections";
import Integration from "~/models/Integration";
import { IntegrationType } from "@shared/types";
import { DisconnectAnalyticsDialog } from "~/components/DisconnectAnalyticsDialog";
export const disconnectAnalyticsIntegrationFactory = (
integration?: Integration<IntegrationType.Analytics>
) =>
createAction({
name: ({ t }) => t("Disconnect analytics"),
analyticsName: "Disconnect analytics",
section: SettingsSection,
icon: <TrashIcon />,
keywords: "disconnect",
visible: () => !!integration,
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("Disconnect analytics"),
content: <DisconnectAnalyticsDialog integration={integration!} />,
});
},
});
+50 -85
View File
@@ -3,35 +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,
BugIcon,
} from "outline-icons"; } from "outline-icons";
import { UrlHelper } from "@shared/utils/UrlHelper"; import * as React from "react";
import { isMac } from "@shared/utils/browser"; import {
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 { import { createAction } from "~/actions";
createAction,
createActionV2,
createExternalLinkActionV2,
createInternalLinkActionV2,
} from "~/actions";
import { NavigationSection, RecentSearchesSection } from "~/actions/sections"; import { NavigationSection, RecentSearchesSection } from "~/actions/sections";
import Desktop from "~/utils/Desktop"; import Desktop from "~/utils/Desktop";
import { isMac } from "~/utils/browser";
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,
@@ -43,7 +45,7 @@ export const navigateToHome = createAction({
section: NavigationSection, section: NavigationSection,
shortcut: ["d"], shortcut: ["d"],
icon: <HomeIcon />, icon: <HomeIcon />,
to: homePath(), perform: () => history.push(homePath()),
visible: ({ location }) => location.pathname !== homePath(), visible: ({ location }) => location.pathname !== homePath(),
}); });
@@ -53,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 />,
to: 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 />,
to: 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 />,
to: searchPath(), perform: () => history.push(templatesPath()),
visible: ({ location }) => location.pathname !== searchPath(), visible: ({ location }) => location.pathname !== templatesPath(),
}); });
export const navigateToArchive = createAction({ export const navigateToArchive = createAction({
@@ -80,7 +82,7 @@ export const navigateToArchive = createAction({
section: NavigationSection, section: NavigationSection,
shortcut: ["g", "a"], shortcut: ["g", "a"],
icon: <ArchiveIcon />, icon: <ArchiveIcon />,
to: archivePath(), perform: () => history.push(archivePath()),
visible: ({ location }) => location.pathname !== archivePath(), visible: ({ location }) => location.pathname !== archivePath(),
}); });
@@ -89,7 +91,7 @@ export const navigateToTrash = createAction({
analyticsName: "Navigate to trash", analyticsName: "Navigate to trash",
section: NavigationSection, section: NavigationSection,
icon: <TrashIcon />, icon: <TrashIcon />,
to: trashPath(), perform: () => history.push(trashPath()),
visible: ({ location }) => location.pathname !== trashPath(), visible: ({ location }) => location.pathname !== trashPath(),
}); });
@@ -99,74 +101,45 @@ 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 }) =>
to: settingsPath(), stores.policies.abilities(stores.auth.team?.id || "").update,
perform: () => history.push(settingsPath("details")),
}); });
export const navigateToWorkspaceSettings = createInternalLinkActionV2({ export const navigateToProfileSettings = createAction({
name: ({ t }) => t("Settings"),
analyticsName: "Navigate to workspace settings",
section: NavigationSection,
icon: <SettingsIcon />,
visible: () => stores.policies.abilities(stores.auth.team?.id || "").update,
to: settingsPath("details"),
});
export const navigateToProfileSettings = createInternalLinkActionV2({
name: ({ t }) => t("Profile"), name: ({ t }) => t("Profile"),
analyticsName: "Navigate to profile settings", analyticsName: "Navigate to profile settings",
section: NavigationSection, section: NavigationSection,
iconInContextMenu: false, iconInContextMenu: false,
icon: <ProfileIcon />, icon: <ProfileIcon />,
to: settingsPath(), perform: () => history.push(settingsPath()),
}); });
export const navigateToTemplateSettings = createAction({ export const navigateToNotificationSettings = createAction({
name: ({ t }) => t("Templates"), name: ({ t }) => t("Notifications"),
analyticsName: "Navigate to template settings",
section: NavigationSection,
iconInContextMenu: false,
icon: <ShapesIcon />,
to: settingsPath("templates"),
});
export const navigateToNotificationSettings = createInternalLinkActionV2({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Notification settings") : t("Notifications"),
analyticsName: "Navigate to notification settings", analyticsName: "Navigate to notification settings",
section: NavigationSection, section: NavigationSection,
iconInContextMenu: false, iconInContextMenu: false,
icon: <EmailIcon />, icon: <EmailIcon />,
to: settingsPath("notifications"), perform: () => history.push(settingsPath("notifications")),
}); });
export const navigateToAccountPreferences = createInternalLinkActionV2({ export const navigateToAccountPreferences = createAction({
name: ({ t }) => t("Preferences"), name: ({ t }) => t("Preferences"),
analyticsName: "Navigate to account preferences", analyticsName: "Navigate to account preferences",
section: NavigationSection, section: NavigationSection,
iconInContextMenu: false, iconInContextMenu: false,
icon: <SettingsIcon />, icon: <SettingsIcon />,
to: settingsPath("preferences"), perform: () => history.push(settingsPath("preferences")),
}); });
export const openDocumentation = createExternalLinkActionV2({ export const openAPIDocumentation = createAction({
name: ({ t }) => t("Documentation"),
analyticsName: "Open documentation",
section: NavigationSection,
iconInContextMenu: false,
icon: <OpenIcon />,
url: UrlHelper.guide,
target: "_blank",
});
export const openAPIDocumentation = createExternalLinkActionV2({
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 />,
url: UrlHelper.developers, perform: () => window.open(developersUrl()),
target: "_blank",
}); });
export const toggleSidebar = createAction({ export const toggleSidebar = createAction({
@@ -174,40 +147,35 @@ 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 = createExternalLinkActionV2({ export const openFeedbackUrl = createAction({
name: ({ t }) => t("Send us feedback"), name: ({ t }) => t("Send us feedback"),
analyticsName: "Open feedback", analyticsName: "Open feedback",
section: NavigationSection, section: NavigationSection,
iconInContextMenu: false, iconInContextMenu: false,
icon: <EmailIcon />, icon: <EmailIcon />,
url: UrlHelper.contact, perform: () => window.open(feedbackUrl()),
target: "_blank",
}); });
export const openBugReportUrl = createExternalLinkActionV2({ 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,
iconInContextMenu: false, perform: () => window.open(githubIssuesUrl()),
icon: <BugIcon />,
url: UrlHelper.github,
target: "_blank",
}); });
export const openChangelog = createExternalLinkActionV2({ export const openChangelog = createAction({
name: ({ t }) => t("Changelog"), name: ({ t }) => t("Changelog"),
analyticsName: "Open changelog", analyticsName: "Open changelog",
section: NavigationSection, section: NavigationSection,
iconInContextMenu: false, iconInContextMenu: false,
icon: <OpenIcon />, icon: <OpenIcon />,
url: UrlHelper.changelog, perform: () => window.open(changelogUrl()),
target: "_blank",
}); });
export const openKeyboardShortcuts = createActionV2({ export const openKeyboardShortcuts = createAction({
name: ({ t }) => t("Keyboard shortcuts"), name: ({ t }) => t("Keyboard shortcuts"),
analyticsName: "Open keyboard shortcuts", analyticsName: "Open keyboard shortcuts",
section: NavigationSection, section: NavigationSection,
@@ -232,29 +200,26 @@ export const downloadApp = createAction({
iconInContextMenu: false, iconInContextMenu: false,
icon: <BrowserIcon />, icon: <BrowserIcon />,
visible: () => !Desktop.isElectron() && isMac() && isCloudHosted, visible: () => !Desktop.isElectron() && isMac() && isCloudHosted,
to: { perform: () => {
url: "https://desktop.getoutline.com", window.open("https://desktop.getoutline.com");
target: "_blank",
}, },
}); });
export const logout = createActionV2({ export const logout = createAction({
name: ({ t }) => t("Log out"), name: ({ t }) => t("Log out"),
analyticsName: "Log out", analyticsName: "Log out",
section: NavigationSection, section: NavigationSection,
icon: <LogoutIcon />, icon: <LogoutIcon />,
perform: async () => { perform: () => stores.auth.logout(),
await stores.auth.logout({ userInitiated: true });
},
}); });
export const rootNavigationActions = [ export const rootNavigationActions = [
navigateToHome, navigateToHome,
navigateToDrafts, navigateToDrafts,
navigateToTemplates,
navigateToArchive, navigateToArchive,
navigateToTrash, navigateToTrash,
downloadApp, downloadApp,
openDocumentation,
openAPIDocumentation, openAPIDocumentation,
openFeedbackUrl, openFeedbackUrl,
openBugReportUrl, openBugReportUrl,
+4 -16
View File
@@ -1,5 +1,6 @@
import { ArchiveIcon, MarkAsReadIcon } from "outline-icons"; import { MarkAsReadIcon } from "outline-icons";
import { createAction, createActionV2 } from ".."; import * as React from "react";
import { createAction } from "..";
import { NotificationSection } from "../sections"; import { NotificationSection } from "../sections";
export const markNotificationsAsRead = createAction({ export const markNotificationsAsRead = createAction({
@@ -12,17 +13,4 @@ export const markNotificationsAsRead = createAction({
visible: ({ stores }) => stores.notifications.approximateUnreadCount > 0, visible: ({ stores }) => stores.notifications.approximateUnreadCount > 0,
}); });
export const markNotificationsAsArchived = createActionV2({ 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,
];
-24
View File
@@ -1,24 +0,0 @@
import { PlusIcon } from "outline-icons";
import stores from "~/stores";
import { OAuthClientNew } from "~/components/OAuthClient/OAuthClientNew";
import { createAction } from "..";
import { SettingsSection } from "../sections";
export const createOAuthClient = createAction({
name: ({ t }) => t("New App"),
analyticsName: "New App",
section: SettingsSection,
icon: <PlusIcon />,
keywords: "create",
visible: () =>
stores.policies.abilities(stores.auth.team?.id || "").createOAuthClient,
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("New Application"),
content: <OAuthClientNew onSubmit={stores.dialogs.closeAllModals} />,
});
},
});
+11 -41
View File
@@ -1,9 +1,9 @@
import copy from "copy-to-clipboard"; import copy from "copy-to-clipboard";
import { LinkIcon, RestoreIcon, TrashIcon } from "outline-icons"; import { LinkIcon, RestoreIcon } from "outline-icons";
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, createActionV2 } from "~/actions"; import { createAction } from "~/actions";
import { RevisionSection } from "~/actions/sections"; import { RevisionSection } from "~/actions/sections";
import history from "~/utils/history"; import history from "~/utils/history";
import { import {
@@ -11,12 +11,12 @@ import {
matchDocumentHistory, matchDocumentHistory,
} from "~/utils/routeHelpers"; } from "~/utils/routeHelpers";
export const restoreRevision = createActionV2({ export const restoreRevision = createAction({
name: ({ t }) => t("Restore"), name: ({ t }) => t("Restore revision"),
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();
@@ -41,44 +41,12 @@ export const restoreRevision = createActionV2({
}, },
}); });
export const deleteRevision = createAction({ export const copyLinkToRevision = createAction({
name: ({ t }) => t("Delete"),
analyticsName: "Delete revision",
icon: <TrashIcon />,
section: RevisionSection,
dangerous: true,
visible: ({ activeDocumentId }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).update,
perform: async ({ t, event, location, activeDocumentId }) => {
event?.preventDefault();
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
const match = matchPath<{ revisionId: string }>(location.pathname, {
path: matchDocumentHistory,
});
const revisionId = match?.params.revisionId;
if (revisionId) {
const revision = stores.revisions.get(revisionId);
await revision?.delete();
toast.success(t("This version of the document was deleted"));
history.push(documentHistoryPath(document));
}
},
});
export const copyLinkToRevision = createActionV2({
name: ({ t }) => t("Copy link"), name: ({ t }) => t("Copy link"),
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;
} }
@@ -100,7 +68,9 @@ export const copyLinkToRevision = createActionV2({
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",
});
}, },
}); });
}, },
+16 -13
View File
@@ -1,48 +1,51 @@
import { SunIcon, MoonIcon, BrowserIcon } from "outline-icons"; import { SunIcon, MoonIcon, BrowserIcon } from "outline-icons";
import * as React from "react";
import stores from "~/stores";
import { Theme } from "~/stores/UiStore"; import { Theme } from "~/stores/UiStore";
import { createActionV2, createActionV2WithChildren } from "~/actions"; import { createAction } from "~/actions";
import { SettingsSection } from "~/actions/sections"; import { SettingsSection } from "~/actions/sections";
export const changeToDarkTheme = createActionV2({ export const changeToDarkTheme = createAction({
name: ({ t }) => t("Dark"), name: ({ t }) => t("Dark"),
analyticsName: "Change to dark theme", analyticsName: "Change to dark theme",
icon: <MoonIcon />, icon: <MoonIcon />,
iconInContextMenu: false, iconInContextMenu: false,
keywords: "theme dark night", keywords: "theme dark night",
section: SettingsSection, section: SettingsSection,
selected: ({ stores }) => stores.ui.theme === "dark", selected: () => stores.ui.theme === "dark",
perform: ({ stores }) => stores.ui.setTheme(Theme.Dark), perform: () => stores.ui.setTheme(Theme.Dark),
}); });
export const changeToLightTheme = createActionV2({ export const changeToLightTheme = createAction({
name: ({ t }) => t("Light"), name: ({ t }) => t("Light"),
analyticsName: "Change to light theme", analyticsName: "Change to light theme",
icon: <SunIcon />, icon: <SunIcon />,
iconInContextMenu: false, iconInContextMenu: false,
keywords: "theme light day", keywords: "theme light day",
section: SettingsSection, section: SettingsSection,
selected: ({ stores }) => stores.ui.theme === "light", selected: () => stores.ui.theme === "light",
perform: ({ stores }) => stores.ui.setTheme(Theme.Light), perform: () => stores.ui.setTheme(Theme.Light),
}); });
export const changeToSystemTheme = createActionV2({ export const changeToSystemTheme = createAction({
name: ({ t }) => t("System"), name: ({ t }) => t("System"),
analyticsName: "Change to system theme", analyticsName: "Change to system theme",
icon: <BrowserIcon />, icon: <BrowserIcon />,
iconInContextMenu: false, iconInContextMenu: false,
keywords: "theme system default", keywords: "theme system default",
section: SettingsSection, section: SettingsSection,
selected: ({ stores }) => stores.ui.theme === "system", selected: () => stores.ui.theme === "system",
perform: ({ stores }) => stores.ui.setTheme(Theme.System), perform: () => stores.ui.setTheme(Theme.System),
}); });
export const changeTheme = createActionV2WithChildren({ export const changeTheme = createAction({
name: ({ t, isContextMenu }) => name: ({ t, isContextMenu }) =>
isContextMenu ? t("Appearance") : t("Change theme"), isContextMenu ? t("Appearance") : t("Change theme"),
analyticsName: "Change theme", analyticsName: "Change theme",
placeholder: ({ t }) => t("Change theme to"), placeholder: ({ t }) => t("Change theme to"),
icon: ({ stores }) => icon: function _Icon() {
stores.ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />, return stores.ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />;
},
keywords: "appearance display", keywords: "appearance display",
section: SettingsSection, section: SettingsSection,
children: [changeToLightTheme, changeToDarkTheme, changeToSystemTheme], children: [changeToLightTheme, changeToDarkTheme, changeToSystemTheme],
-59
View File
@@ -1,59 +0,0 @@
import copy from "copy-to-clipboard";
import Share from "~/models/Share";
import { createActionV2, createInternalLinkActionV2 } from "..";
import { ArrowIcon, CopyIcon, TrashIcon } from "outline-icons";
import { ShareSection } from "../sections";
import env from "~/env";
import { toast } from "sonner";
export const copyShareUrlFactory = ({ share }: { share: Share }) =>
createActionV2({
name: ({ t }) => t("Copy link"),
analyticsName: "Copy share link",
section: ShareSection,
icon: <CopyIcon />,
perform: ({ t }) => {
copy(share.url, {
debug: env.ENVIRONMENT !== "production",
format: "text/plain",
});
toast.success(t("Share link copied"));
},
});
export const goToShareSourceFactory = ({ share }: { share: Share }) =>
createInternalLinkActionV2({
name: ({ t }) =>
share.collectionId ? t("Go to collection") : t("Go to document"),
analyticsName: "Go to share source",
section: ShareSection,
icon: <ArrowIcon />,
to: {
pathname: share.sourcePathWithFallback,
state: { sidebarContext: "collections" }, // optimistic preference of "collections"
},
});
export const revokeShareFactory = ({
share,
can,
}: {
share: Share;
can: Record<string, boolean>;
}) =>
createActionV2({
name: ({ t }) => t("Revoke link"),
analyticsName: "Revoke share",
section: ShareSection,
icon: <TrashIcon />,
dangerous: true,
visible: !!can.revoke,
perform: async ({ t, stores }) => {
try {
await stores.shares.revoke(share);
toast.message(t("Share link revoked"));
} catch (err) {
toast.error(err.message);
}
},
});
+23 -50
View File
@@ -1,28 +1,23 @@
import { ArrowIcon, PlusIcon } from "outline-icons"; import { PlusIcon } from "outline-icons";
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 { import { createAction } from "~/actions";
createActionV2, import { ActionContext } from "~/types";
createActionV2WithChildren,
createExternalLinkActionV2,
} from "~/actions";
import { ActionContext, ExternalLinkActionV2 } from "~/types";
import Desktop from "~/utils/Desktop";
import { TeamSection } from "../sections"; import { TeamSection } from "../sections";
export const switchTeamsList = ({ stores }: { stores: RootStore }) => export const createTeamsList = ({ stores }: { stores: RootStore }) =>
stores.auth.availableTeams?.map<ExternalLinkActionV2>((session) => stores.auth.availableTeams?.map((session) => ({
createExternalLinkActionV2({ id: `switch-${session.id}`,
id: `switch-${session.id}`, name: session.name,
name: session.name, analyticsName: "Switch workspace",
analyticsName: "Switch workspace", section: TeamSection,
section: TeamSection, keywords: "change switch workspace organization team",
keywords: "change switch workspace organization team", icon: function _Icon() {
icon: ( return (
<StyledTeamLogo <StyledTeamLogo
alt={session.name} alt={session.name}
model={{ model={{
@@ -33,15 +28,13 @@ export const switchTeamsList = ({ stores }: { stores: RootStore }) =>
}} }}
size={24} size={24}
/> />
), );
visible: ({ currentTeamId }: ActionContext) => },
currentTeamId !== session.id, visible: ({ currentTeamId }: ActionContext) => currentTeamId !== session.id,
url: session.url, perform: () => (window.location.href = session.url),
target: "_self", })) ?? [];
})
) ?? [];
export const switchTeam = createActionV2WithChildren({ export const switchTeam = createAction({
name: ({ t }) => t("Switch workspace"), name: ({ t }) => t("Switch workspace"),
placeholder: ({ t }) => t("Select a workspace"), placeholder: ({ t }) => t("Select a workspace"),
analyticsName: "Switch workspace", analyticsName: "Switch workspace",
@@ -49,10 +42,10 @@ export const switchTeam = createActionV2WithChildren({
section: TeamSection, section: TeamSection,
visible: ({ stores }) => visible: ({ stores }) =>
!!stores.auth.availableTeams && stores.auth.availableTeams?.length > 1, !!stores.auth.availableTeams && stores.auth.availableTeams?.length > 1,
children: switchTeamsList, children: createTeamsList,
}); });
export const createTeam = createActionV2({ export const createTeam = createAction({
name: ({ t }) => `${t("New workspace")}`, name: ({ t }) => `${t("New workspace")}`,
analyticsName: "New workspace", analyticsName: "New workspace",
keywords: "create change switch workspace organization team", keywords: "create change switch workspace organization team",
@@ -63,32 +56,12 @@ export const createTeam = createActionV2({
perform: ({ t, event, stores }) => { perform: ({ t, event, stores }) => {
event?.preventDefault(); event?.preventDefault();
event?.stopPropagation(); event?.stopPropagation();
const { user } = stores.auth; const { user } = stores.auth;
if (user) { user &&
stores.dialogs.openModal({ stores.dialogs.openModal({
title: t("Create a workspace"), title: t("Create a workspace"),
content: <TeamNew user={user} />, content: <TeamNew user={user} />,
}); });
}
},
});
export const desktopLoginTeam = createActionV2({
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 />,
});
}, },
}); });
@@ -97,4 +70,4 @@ const StyledTeamLogo = styled(TeamLogo)`
border: 0; border: 0;
`; `;
export const rootTeamActions = [switchTeam, createTeam, desktopLoginTeam]; export const rootTeamActions = [switchTeam, createTeam];
+8 -47
View File
@@ -1,14 +1,9 @@
import { PlusIcon } from "outline-icons"; import { PlusIcon } from "outline-icons";
import { UserRole } from "@shared/types"; import * as React from "react";
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, import { createAction } from "~/actions";
UserDeleteDialog,
} from "~/components/UserDialogs";
import { createAction, createActionV2 } from "~/actions";
import { UserSection } from "~/actions/sections"; import { UserSection } from "~/actions/sections";
export const inviteUser = createAction({ export const inviteUser = createAction({
@@ -17,59 +12,24 @@ 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) =>
createActionV2({
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) =>
createActionV2({ createAction({
name: ({ t }) => `${t("Delete user")}`, name: ({ t }) => `${t("Delete user")}`,
analyticsName: "Delete user", analyticsName: "Delete user",
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) {
@@ -78,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}
+21 -351
View File
@@ -1,29 +1,17 @@
import { LocationDescriptor } from "history"; import { flattenDeep } from "lodash";
import flattenDeep from "lodash/flattenDeep"; 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 {
Action, Action,
ActionContext, ActionContext,
ActionV2, CommandBarAction,
ActionV2Group,
ActionV2Separator as TActionV2Separator,
ActionV2Variant,
ActionV2WithChildren,
ExternalLinkActionV2,
InternalLinkActionV2,
MenuExternalLink,
MenuInternalLink,
MenuItem,
MenuItemButton, MenuItemButton,
MenuItemWithChildren, MenuItemWithChildren,
} from "~/types"; } from "~/types";
import Analytics from "~/utils/Analytics"; import Analytics from "~/utils/Analytics";
import history from "~/utils/history";
import { Action as KbarAction } from "kbar";
export function resolve<T>(value: any, context: ActionContext): T { function resolve<T>(value: any, context: ActionContext): T {
return typeof value === "function" ? value(context) : value; return typeof value === "function" ? value(context) : value;
} }
@@ -32,17 +20,18 @@ export function createAction(definition: Optional<Action, "id">): Action {
...definition, ...definition,
perform: definition.perform perform: definition.perform
? (context) => { ? (context) => {
// We must use the specific analytics name here as the action name is // We muse use the specific analytics name here as the action name is
// translated and potentially contains user strings. // translated and potentially contains user strings.
if (definition.analyticsName) { if (definition.analyticsName) {
Analytics.track("perform_action", definition.analyticsName, { Analytics.track("perform_action", definition.analyticsName, {
context: context.isButton context: context.isButton
? "button" ? "button"
: context.isCommandBar : context.isCommandBar
? "commandbar" ? "commandbar"
: "contextmenu", : "contextmenu",
}); });
} }
return definition.perform?.(context); return definition.perform?.(context);
} }
: undefined, : undefined,
@@ -53,7 +42,7 @@ export function createAction(definition: Optional<Action, "id">): Action {
export function actionToMenuItem( export function actionToMenuItem(
action: Action, action: Action,
context: ActionContext context: ActionContext
): MenuItemButton | MenuExternalLink | MenuInternalLink | MenuItemWithChildren { ): MenuItemButton | MenuItemWithChildren {
const resolvedIcon = resolve<React.ReactElement<any>>(action.icon, context); const resolvedIcon = resolve<React.ReactElement<any>>(action.icon, context);
const resolvedChildren = resolve<Action[]>(action.children, context); const resolvedChildren = resolve<Action[]>(action.children, context);
const visible = action.visible ? action.visible(context) : true; const visible = action.visible ? action.visible(context) : true;
@@ -78,33 +67,21 @@ export function actionToMenuItem(
}; };
} }
if (action.to) {
return typeof action.to === "string"
? {
type: "route",
title,
icon,
visible,
to: action.to,
selected: action.selected?.(context),
}
: {
type: "link",
title,
icon,
visible,
href: action.to,
selected: action.selected?.(context),
};
}
return { return {
type: "button", type: "button",
title, title,
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),
}; };
} }
@@ -112,7 +89,7 @@ export function actionToMenuItem(
export function actionToKBar( export function actionToKBar(
action: Action, action: Action,
context: ActionContext context: ActionContext
): KbarAction[] { ): CommandBarAction[] {
if (typeof action.visible === "function" && !action.visible(context)) { if (typeof action.visible === "function" && !action.visible(context)) {
return []; return [];
} }
@@ -128,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,
@@ -143,310 +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 || action.to
? () => 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
? action.perform(context)
: action.to
? typeof action.to === "string"
? history.push(action.to)
: window.open(action.to.url, action.to.target)
: undefined;
if (result instanceof Promise) {
return result.catch((err: Error) => {
toast.error(err.message);
});
}
return result;
}
/** Actions V2 */
export const ActionV2Separator: TActionV2Separator = {
type: "action_separator",
};
export function createActionV2(
definition: Optional<Omit<ActionV2, "type" | "variant">, "id">
): ActionV2 {
return {
...definition,
type: "action",
variant: "action",
perform: definition.perform
? (context) => {
// We must use the specific analytics name here as the action name is
// translated and potentially contains user strings.
if (definition.analyticsName) {
Analytics.track("perform_action", definition.analyticsName, {
context: context.isButton
? "button"
: context.isCommandBar
? "commandbar"
: "contextmenu",
});
}
return definition.perform(context);
}
: () => {},
id: definition.id ?? uuidv4(),
};
}
export function createInternalLinkActionV2(
definition: Optional<Omit<InternalLinkActionV2, "type" | "variant">, "id">
): InternalLinkActionV2 {
return {
...definition,
type: "action",
variant: "internal_link",
id: definition.id ?? uuidv4(),
};
}
export function createExternalLinkActionV2(
definition: Optional<Omit<ExternalLinkActionV2, "type" | "variant">, "id">
): ExternalLinkActionV2 {
return {
...definition,
type: "action",
variant: "external_link",
id: definition.id ?? uuidv4(),
};
}
export function createActionV2WithChildren(
definition: Optional<Omit<ActionV2WithChildren, "type" | "variant">, "id">
): ActionV2WithChildren {
return {
...definition,
type: "action",
variant: "action_with_children",
id: definition.id ?? uuidv4(),
};
}
export function createActionV2Group(
definition: Omit<ActionV2Group, "type">
): ActionV2Group {
return {
...definition,
type: "action_group",
};
}
export function createRootMenuAction(
actions: (ActionV2Variant | ActionV2Group | TActionV2Separator)[]
): ActionV2WithChildren {
return {
id: uuidv4(),
type: "action",
variant: "action_with_children",
name: "root_action",
section: "Root",
children: actions,
};
}
export function actionV2ToMenuItem(
action: ActionV2Variant | ActionV2Group | TActionV2Separator,
context: ActionContext
): MenuItem {
switch (action.type) {
case "action": {
const title = resolve<string>(action.name, context);
const visible = resolve<boolean>(action.visible, context) ?? true;
const disabled = resolve<boolean>(action.disabled, context);
const icon =
!!action.icon && action.iconInContextMenu !== false
? resolve<React.ReactNode>(action.icon, context)
: undefined;
switch (action.variant) {
case "action":
return {
type: "button",
title,
icon,
visible,
disabled,
tooltip: resolve<React.ReactChild>(action.tooltip, context),
selected: resolve<boolean>(action.selected, context),
dangerous: action.dangerous,
onClick: () => performActionV2(action, context),
};
case "internal_link": {
const to = resolve<LocationDescriptor>(action.to, context);
return {
type: "route",
title,
icon,
visible,
disabled,
to,
};
}
case "external_link":
return {
type: "link",
title,
icon,
visible,
disabled,
href: action.target
? { url: action.url, target: action.target }
: action.url,
};
case "action_with_children": {
const children = resolve<
(ActionV2Variant | ActionV2Group | TActionV2Separator)[]
>(action.children, context);
const subMenuItems = children.map((a) =>
actionV2ToMenuItem(a, context)
);
return {
type: "submenu",
title,
icon,
items: subMenuItems,
disabled,
visible: visible && hasVisibleItems(subMenuItems),
};
}
default:
throw Error("invalid action variant");
}
}
case "action_group": {
const groupItems = action.actions.map((a) =>
actionV2ToMenuItem(a, context)
);
return {
type: "group",
title: resolve<string>(action.name, context),
visible: hasVisibleItems(groupItems),
items: groupItems,
};
}
case "action_separator":
return { type: "separator" };
}
}
export function actionV2ToKBar(
action: ActionV2Variant,
context: ActionContext
): KbarAction[] {
const visible = resolve<boolean>(action.visible, context);
if (visible === false) {
return [];
}
const name = resolve<string>(action.name, context);
const icon = resolve<React.ReactElement>(action.icon, context);
const section = resolve<string>(action.section, context);
const sectionPriority =
typeof action.section !== "string" && "priority" in action.section
? ((action.section.priority as number) ?? 0)
: 0;
const priority = (1 + (action.priority ?? 0)) * (1 + (sectionPriority ?? 0));
switch (action.variant) {
case "action":
case "internal_link":
case "external_link": {
return [
{
id: action.id,
name,
section,
keywords: action.keywords,
shortcut: action.shortcut,
icon,
priority,
perform: () => performActionV2(action, context),
},
];
}
case "action_with_children": {
const resolvedChildren = resolve<ActionV2Variant[]>(
action.children,
context
);
const children = resolvedChildren
.map((a) => actionV2ToKBar(a, context))
.flat()
.filter(Boolean);
return [
{
id: action.id,
name,
section,
keywords: action.keywords,
shortcut: action.shortcut,
icon,
priority,
},
...children.map((child) => ({
...child,
parent: child.parent ?? action.id,
})),
];
}
default:
throw Error("invalid action variant");
}
}
export async function performActionV2(
action: Exclude<ActionV2Variant, ActionV2WithChildren>,
context: ActionContext
) {
const perform =
action.variant === "action"
? () => action.perform(context)
: action.variant === "internal_link"
? () => history.push(resolve<LocationDescriptor>(action.to, context))
: () => window.open(action.url, action.target);
const result = perform();
if (result instanceof Promise) {
return result.catch((err: Error) => {
toast.error(err.message);
});
}
return result;
}
function hasVisibleItems(items: MenuItem[]) {
const applicableTypes = ["button", "link", "route", "group", "submenu"];
return items.some(
(item) => applicableTypes.includes(item.type) && item.visible
);
} }
-32
View File
@@ -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");
@@ -36,19 +14,9 @@ export const NavigationSection = ({ t }: ActionContext) => t("Navigation");
export const NotificationSection = ({ t }: ActionContext) => t("Notification"); export const NotificationSection = ({ t }: ActionContext) => t("Notification");
export const GroupSection = ({ t }: ActionContext) => t("Groups");
export const UserSection = ({ t }: ActionContext) => t("People"); export const UserSection = ({ t }: ActionContext) => t("People");
UserSection.priority = 0.5;
export const ShareSection = ({ t }: ActionContext) => t("Share");
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");
+8 -21
View File
@@ -1,14 +1,7 @@
/* oxlint-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, performActionV2, resolve } from "~/actions"; import { Action, ActionContext } from "~/types";
import useIsMounted from "~/hooks/useIsMounted";
import {
Action,
ActionContext,
ActionV2Variant,
ActionV2WithChildren,
} from "~/types";
export type Props = React.HTMLAttributes<HTMLButtonElement> & { export type Props = React.HTMLAttributes<HTMLButtonElement> & {
/** Show the button in a disabled state */ /** Show the button in a disabled state */
@@ -16,7 +9,7 @@ export type Props = React.HTMLAttributes<HTMLButtonElement> & {
/** Hide the button entirely if action is not applicable */ /** Hide the button entirely if action is not applicable */
hideOnActionDisabled?: boolean; hideOnActionDisabled?: boolean;
/** Action to use on button */ /** Action to use on button */
action?: Action | Exclude<ActionV2Variant, ActionV2WithChildren>; action?: Action;
/** Context of action, must be provided with action */ /** Context of action, must be provided with action */
context?: ActionContext; context?: ActionContext;
/** If tooltip props are provided the button will be wrapped in a tooltip */ /** If tooltip props are provided the button will be wrapped in a tooltip */
@@ -31,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;
@@ -45,8 +37,8 @@ const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
const actionContext = { ...context, isButton: true }; const actionContext = { ...context, isButton: true };
if ( if (
action.visible && action?.visible &&
!resolve<boolean>(action.visible, actionContext) && !action.visible(actionContext) &&
hideOnActionDisabled hideOnActionDisabled
) { ) {
return null; return null;
@@ -64,19 +56,14 @@ const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
disabled={disabled || executing} disabled={disabled || executing}
ref={ref} ref={ref}
onClick={ onClick={
actionContext action?.perform && actionContext
? (ev) => { ? (ev) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
const response = const response = action.perform?.(actionContext);
"variant" in action
? performActionV2(action, actionContext)
: performAction(action, 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
+1
View File
@@ -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);
+6 -55
View File
@@ -1,15 +1,14 @@
/* oxlint-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}</>;
}; };
+2
View File
@@ -1,3 +1,5 @@
import * as React from "react";
export default function Arrow() { export default function Arrow() {
return ( return (
<svg <svg
+21 -17
View File
@@ -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>
); );
} }
+41
View File
@@ -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;
+6 -13
View File
@@ -1,8 +1,7 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useEffect } 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 LoadingIndicator from "./LoadingIndicator"; import LoadingIndicator from "./LoadingIndicator";
@@ -14,12 +13,11 @@ 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
useEffect(() => { React.useEffect(() => {
void changeLanguage(language, i18n); void changeLanguage(language, i18n);
}, [i18n, language]); }, [i18n, language]);
@@ -31,12 +29,7 @@ const Authenticated = ({ children }: Props) => {
return <LoadingIndicator />; return <LoadingIndicator />;
} }
void auth.logout({ savePath: true }); void auth.logout(true);
if (auth.logoutRedirectUri) {
window.location.href = auth.logoutRedirectUri;
return null;
}
return <Redirect to="/" />; return <Redirect to="/" />;
}; };
+44 -52
View File
@@ -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";
@@ -27,10 +22,9 @@ import {
settingsPath, settingsPath,
matchDocumentHistory, matchDocumentHistory,
matchDocumentSlug as slug, matchDocumentSlug as slug,
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")
@@ -38,7 +32,9 @@ const DocumentComments = lazyWithRetry(
const DocumentHistory = lazyWithRetry( const DocumentHistory = lazyWithRetry(
() => import("~/scenes/Document/components/History") () => import("~/scenes/Document/components/History")
); );
const DocumentInsights = lazyWithRetry(
() => import("~/scenes/Document/components/Insights")
);
const CommandBar = lazyWithRetry(() => import("~/components/CommandBar")); const CommandBar = lazyWithRetry(() => import("~/components/CommandBar"));
type Props = { type Props = {
@@ -48,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) {
@@ -67,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));
@@ -77,41 +76,41 @@ 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, {
path: matchDocumentInsights,
});
const showComments = const showComments =
!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
initial={false} initial={false}
key={ui.activeDocumentId ? "active" : "inactive"} key={ui.activeDocumentId ? "active" : "inactive"}
> >
{(showHistory || showComments) && ( {(showHistory || showInsights || showComments) && (
<Route path={`/doc/${slug}`}> <Route path={`/doc/${slug}`}>
<SidebarRight> <SidebarRight>
<React.Suspense fallback={null}> <React.Suspense fallback={null}>
{showHistory && <DocumentHistory />} {showHistory && <DocumentHistory />}
{showInsights && <DocumentInsights />}
{showComments && <DocumentComments />} {showComments && <DocumentComments />}
</React.Suspense> </React.Suspense>
</SidebarRight> </SidebarRight>
@@ -121,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>
); );
}; };
+23 -42
View File
@@ -5,17 +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 enum AvatarVariant {
Round = "round",
Square = "square",
} }
export interface IAvatar { export interface IAvatar {
@@ -26,50 +19,36 @@ export interface IAvatar {
} }
type Props = { type Props = {
/** The size of the avatar */
size: AvatarSize; size: AvatarSize;
/** The variant of the avatar */
variant?: AvatarVariant;
/** 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 { const { showBorder, model, style, ...rest } = props;
model,
style,
variant = AvatarVariant.Round,
className,
...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 <Relative style={style}>
style={style}
$variant={variant}
$size={props.size}
className={className}
>
{src && !error ? ( {src && !error ? (
<Image 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>
); );
@@ -79,21 +58,23 @@ Avatar.defaultProps = {
size: AvatarSize.Medium, size: AvatarSize.Medium,
}; };
const Relative = styled.div<{ $variant: AvatarVariant; $size: AvatarSize }>` const Relative = styled.div`
position: relative; position: relative;
user-select: none; user-select: none;
flex-shrink: 0; flex-shrink: 0;
border-radius: ${(props) =>
props.$variant === AvatarVariant.Round ? "50%" : `${props.$size / 8}px`};
overflow: hidden;
width: ${(props) => props.$size}px;
height: ${(props) => props.$size}px;
`; `;
const Image = 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: ${(props) =>
props.$showBorder === false
? "none"
: `2px solid ${props.theme.background}`};
flex-shrink: 0;
overflow: hidden;
`; `;
export default Avatar; export default Avatar;
+6 -54
View File
@@ -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%;
-34
View File
@@ -1,34 +0,0 @@
import { GroupIcon } from "outline-icons";
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>
);
}
+9 -14
View File
@@ -1,31 +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%;
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: 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;
`; `;
+3 -4
View File
@@ -1,7 +1,6 @@
import Avatar, { IAvatar, AvatarSize, AvatarVariant } from "./Avatar"; import Avatar from "./Avatar";
import AvatarWithPresence from "./AvatarWithPresence"; import AvatarWithPresence from "./AvatarWithPresence";
import { GroupAvatar } from "./GroupAvatar";
export { Avatar, GroupAvatar, AvatarSize, AvatarVariant, AvatarWithPresence }; export { AvatarWithPresence };
export type { IAvatar }; export default Avatar;
+2 -2
View File
@@ -10,8 +10,8 @@ const Badge = styled.span<{ yellow?: boolean; primary?: boolean }>`
primary primary
? theme.accentText ? theme.accentText
: yellow : yellow
? theme.almostBlack ? theme.almostBlack
: theme.textTertiary}; : theme.textTertiary};
border: 1px solid border: 1px solid
${({ primary, yellow, theme }) => ${({ primary, yellow, theme }) =>
primary || yellow primary || yellow
+12 -9
View File
@@ -1,4 +1,6 @@
import * as React from "react";
import styled from "styled-components"; import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths, s } from "@shared/styles"; import { depths, s } from "@shared/styles";
import env from "~/env"; import env from "~/env";
import OutlineIcon from "./Icons/OutlineIcon"; import OutlineIcon from "./Icons/OutlineIcon";
@@ -9,7 +11,7 @@ type Props = {
function Branding({ href = env.URL }: Props) { function Branding({ href = env.URL }: Props) {
return ( return (
<Link href={href} target="_blank"> <Link href={href}>
<OutlineIcon size={20} /> <OutlineIcon size={20} />
&nbsp;{env.APP_NAME} &nbsp;{env.APP_NAME}
</Link> </Link>
@@ -32,16 +34,17 @@ const Link = styled.a`
fill: ${s("text")}; fill: ${s("text")};
} }
z-index: ${depths.sidebar + 1};
background: ${s("sidebarBackground")};
position: fixed;
bottom: 0;
right: 0;
padding: 16px;
&:hover { &:hover {
background: ${s("sidebarControlHoverBackground")}; background: ${s("sidebarBackground")};
} }
${breakpoint("tablet")`
z-index: ${depths.sidebar + 1};
position: fixed;
bottom: 0;
left: 0;
padding: 16px;
`};
`; `;
export default Branding; export default Branding;
+36 -75
View File
@@ -5,90 +5,53 @@ 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 { InternalLinkActionV2, MenuInternalLink } from "~/types";
import { actionV2ToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import { useComputed } from "~/hooks/useComputed";
type TopLevelAction = type Props = {
| InternalLinkActionV2 items: MenuInternalLink[];
| { type: "menu"; actions: InternalLinkActionV2[] };
type Props = React.PropsWithChildren<{
actions: InternalLinkActionV2[];
max?: number; max?: number;
highlightFirstItem?: boolean; highlightFirstItem?: boolean;
}>; };
function Breadcrumb( function Breadcrumb({
{ actions, highlightFirstItem, children, max = 2 }: Props, items,
ref: React.RefObject<HTMLDivElement> | null highlightFirstItem,
) { children,
const actionContext = useActionContext({ isContextMenu: true }); max = 2,
}: React.PropsWithChildren<Props>) {
const visibleActions = useComputed( const totalItems = items.length;
() => const topLevelItems: MenuInternalLink[] = [...items];
actions.filter((action) => let overflowItems;
typeof action.visible === "function"
? action.visible(actionContext)
: (action.visible ?? true)
),
[actions, actionContext]
);
const totalVisibleActions = visibleActions.length;
const topLevelActions: TopLevelAction[] = [...visibleActions];
// chop middle breadcrumbs and present a "..." menu instead // chop middle breadcrumbs and present a "..." menu instead
if (totalVisibleActions > max) { if (totalItems > max) {
const halfMax = Math.floor(max / 2); const halfMax = Math.floor(max / 2);
const menuActions = topLevelActions.splice( overflowItems = topLevelItems.splice(halfMax, totalItems - max);
halfMax,
totalVisibleActions - max
) as InternalLinkActionV2[];
topLevelActions.splice(halfMax, 0, { topLevelItems.splice(halfMax, 0, {
type: "menu", to: "",
actions: menuActions, type: "route",
title: <BreadcrumbMenu items={overflowItems as MenuInternalLink[]} />,
}); });
} }
const toBreadcrumb = React.useCallback(
(action: TopLevelAction, index: number) => {
if (action.type === "menu") {
return <BreadcrumbMenu key="menu" actions={action.actions} />;
}
const item = actionV2ToMenuItem(
action,
actionContext
) as MenuInternalLink;
return (
<>
{item.icon}
<Item
to={item.to}
$withIcon={!!item.icon}
$highlight={!!highlightFirstItem && index === 0}
>
{item.title}
</Item>
</>
);
},
[actionContext, highlightFirstItem]
);
return ( return (
<Flex justify="flex-start" align="center" ref={ref}> <Flex justify="flex-start" align="center">
{topLevelActions.map((action, index) => ( {topLevelItems.map((item, index) => (
<React.Fragment key={action.type === "menu" ? "menu" : `item-${index}`}> <React.Fragment key={String(item.to) || index}>
{toBreadcrumb(action, index)} {item.icon}
{index !== topLevelActions.length - 1 || !!children ? ( {item.to ? (
<Slash /> <Item
) : null} to={item.to}
$withIcon={!!item.icon}
$highlight={!!highlightFirstItem && index === 0}
>
{item.title}
</Item>
) : (
item.title
)}
{index !== topLevelItems.length - 1 || !!children ? <Slash /> : null}
</React.Fragment> </React.Fragment>
))} ))}
{children} {children}
@@ -103,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;
@@ -124,4 +85,4 @@ const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
} }
`; `;
export default React.forwardRef<HTMLDivElement, Props>(Breadcrumb); export default Breadcrumb;
+8 -16
View File
@@ -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,8 +171,8 @@ 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;
return ( return (
@@ -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);
+5 -12
View File
@@ -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>
); );
-17
View File
@@ -1,17 +0,0 @@
import { useEffect } 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();
useEffect(() => {
void changeLanguage(locale, i18n);
}, [locale, i18n]);
return null;
}
+1
View File
@@ -1,3 +1,4 @@
import React from "react";
import styled, { useTheme } from "styled-components"; import styled, { useTheme } from "styled-components";
const cleanPercentage = (percentage: number) => { const cleanPercentage = (percentage: number) => {
+78 -139
View File
@@ -1,177 +1,116 @@
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 { useState, useMemo, useEffect, useCallback } from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
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";
import { import Popover from "~/components/Popover";
Popover,
PopoverTrigger,
PopoverContent,
} from "~/components/primitives/Popover";
import useCurrentUser from "~/hooks/useCurrentUser"; 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;
const [requestedUserIds, setRequestedUserIds] = useState<string[]>([]); const [requestedUserIds, setRequestedUserIds] = React.useState<string[]>([]);
const { users, presence, ui } = useStores(); const { users, presence, ui } = useStores();
const { document } = props; const { document } = props;
const { observingUserId } = ui;
const documentPresence = presence.get(document.id); const documentPresence = presence.get(document.id);
const documentPresenceArray = useMemo( const documentPresenceArray = documentPresence
() => (documentPresence ? Array.from(documentPresence.values()) : []), ? Array.from(documentPresence.values())
[documentPresence] : [];
);
// Use Set for O(1) lookups and stable references const presentIds = documentPresenceArray.map((p) => p.userId);
const presentIds = useMemo( const editingIds = documentPresenceArray
() => new Set(documentPresenceArray.map((p) => p.userId)), .filter((p) => p.isEditing)
[documentPresenceArray] .map((p) => p.userId);
);
const editingIds = useMemo(
() =>
new Set(
documentPresenceArray.filter((p) => p.isEditing).map((p) => p.userId)
),
[documentPresenceArray]
);
// ensure currently present via websocket are always ordered first // ensure currently present via websocket are always ordered first
// Memoize collaboratorIds as a Set for efficient lookup const collaborators = React.useMemo(
const collaboratorIdsSet = useMemo(
() => new Set(document.collaboratorIds),
[document.collaboratorIds]
);
const collaborators = useMemo(
() => () =>
orderBy( sortBy(
filter( filter(
users.all, users.orderedData,
(u) => (user) =>
(presentIds.has(u.id) || collaboratorIdsSet.has(u.id)) && (presentIds.includes(user.id) ||
!u.isSuspended document.collaboratorIds.includes(user.id)) &&
!user.isSuspended
), ),
[(u) => presentIds.has(u.id), "id"], (user) => presentIds.includes(user.id)
["asc", "asc"]
), ),
[collaboratorIdsSet, 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
// Memoize ids to avoid unnecessary effect executions React.useEffect(() => {
const missingUserIds = useMemo( const ids = uniq([...document.collaboratorIds, ...presentIds])
() => .filter((userId) => !users.get(userId))
uniq([...document.collaboratorIds, ...Array.from(presentIds)]) .sort();
.filter((userId) => !users.get(userId))
.sort(),
[document.collaboratorIds, presentIds, users]
);
useEffect(() => { if (!isEqual(requestedUserIds, ids) && ids.length > 0) {
if ( setRequestedUserIds(ids);
!isEqual(requestedUserIds, missingUserIds) && void users.fetchPage({ ids, limit: 100 });
missingUserIds.length > 0
) {
setRequestedUserIds(missingUserIds);
void users.fetchPage({ ids: missingUserIds, limit: 100 });
} }
}, [missingUserIds, requestedUserIds, users]); }, [document, users, presentIds, document.collaboratorIds, requestedUserIds]);
// Memoize onClick handler to avoid inline function creation const popover = usePopoverState({
const handleAvatarClick = useCallback( gutter: 0,
( placement: "bottom-end",
collaboratorId: string, });
isPresent: boolean,
isObserving: boolean,
isObservable: boolean
) =>
(ev: React.MouseEvent) => {
if (isObservable && isPresent) {
ev.preventDefault();
ev.stopPropagation();
ui.setObservingUser(isObserving ? undefined : collaboratorId);
}
},
[ui]
);
const renderAvatar = useCallback(
({ model: collaborator, ...rest }) => {
const isPresent = presentIds.has(collaborator.id);
const isEditing = editingIds.has(collaborator.id);
const isObserving = observingUserId === collaborator.id;
const isObservable = collaborator.id !== currentUserId;
return (
<AvatarWithPresence
key={collaborator.id}
{...rest}
user={collaborator}
isPresent={isPresent}
isEditing={isEditing}
isObserving={isObserving}
isCurrentUser={currentUserId === collaborator.id}
onClick={
isObservable
? handleAvatarClick(
collaborator.id,
isPresent,
isObserving,
isObservable
)
: undefined
}
/>
);
},
[presentIds, editingIds, observingUserId, currentUserId, handleAvatarClick]
);
if (!document.insightsEnabled) {
return null;
}
return ( return (
<Popover> <>
<PopoverTrigger> <PopoverDisclosure {...popover}>
<NudeButton {(props) => (
width={Math.min(collaborators.length, limit) * AvatarSize.Large} <NudeButton width={collaborators.length * 32} height={32} {...props}>
height={AvatarSize.Large} <Facepile
> users={collaborators}
<Facepile renderAvatar={(collaborator) => {
size={AvatarSize.Large} const isPresent = presentIds.includes(collaborator.id);
limit={limit} const isEditing = editingIds.includes(collaborator.id);
overflow={Math.max(0, collaborators.length - limit)} const isObserving = ui.observingUserId === collaborator.id;
users={collaborators} const isObservable = collaborator.id !== user.id;
renderAvatar={renderAvatar}
/> return (
</NudeButton> <AvatarWithPresence
</PopoverTrigger> key={collaborator.id}
<PopoverContent aria-label={t("Viewers")} side="bottom" align="end"> user={collaborator}
<DocumentViews document={document} /> isPresent={isPresent}
</PopoverContent> isEditing={isEditing}
</Popover> isObserving={isObserving}
isCurrentUser={currentUserId === collaborator.id}
onClick={
isObservable
? (ev) => {
if (isPresent) {
ev.preventDefault();
ev.stopPropagation();
ui.setObservingUser(
isObserving ? undefined : collaborator.id
);
}
}
: undefined
}
/>
);
}}
/>
</NudeButton>
)}
</PopoverDisclosure>
<Popover {...popover} width={300} aria-label={t("Viewers")} tabIndex={0}>
<DocumentViews document={document} isOpen={popover.visible} />
</Popover>
</>
); );
} }
@@ -1,32 +0,0 @@
import { observer } from "mobx-react";
import { useCallback } 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 = 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,240 +0,0 @@
import uniq from "lodash/uniq";
import { observer } from "mobx-react";
import { useMemo, useEffect, useCallback, Suspense } 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, TeamPreference } 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 { createLazyComponent } from "~/components/LazyLoad";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useStores from "~/hooks/useStores";
import { EmptySelectValue } from "~/types";
const IconPicker = createLazyComponent(() => import("~/components/IconPicker"));
export interface FormData {
name: string;
icon: string;
color: string | null;
sharing: boolean;
permission: CollectionPermission | undefined;
commenting?: boolean | null;
}
const useIconColor = (collection?: Collection) => {
const { collections } = useStores();
const hasMultipleCollections = collections.orderedData.length > 1;
const collectionColors = uniq(
collections.orderedData.map((c) => c.color).filter(Boolean)
) as string[];
const iconColor = useMemo(
() =>
collection?.color ??
// If all the existing collections have the same color, use that color,
// otherwise pick a random color from the palette
(hasMultipleCollections && collectionColors.length === 1
? collectionColors[0]
: randomElement(colorPalette)),
[collection?.color]
);
return iconColor;
};
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 = useIconColor(collection);
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,
commenting: collection?.commenting ?? true,
color: iconColor,
},
});
const values = watch();
// Preload the IconPicker component on mount
useEffect(() => {
void IconPicker.preload();
}, []);
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]);
useEffect(() => {
setTimeout(() => setFocus("name", { shouldSelect: true }), 100);
}, [setFocus]);
const handleIconChange = useCallback(
(icon: string, color: string) => {
if (icon !== values.icon) {
setFocus("name");
}
setValue("icon", icon);
setValue("color", color);
},
[setFocus, setValue, values.icon]
);
const initial = values.name.charAt(0).toUpperCase();
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={
<Suspense fallback={fallbackIcon}>
<StyledIconPicker
icon={values.icon}
color={values.color ?? iconColor}
initial={initial}
popoverPosition="right"
onOpen={setHasOpenedIconPicker}
onChange={handleIconChange}
/>
</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);
}}
help={t(
"The default access for workspace members, you can share with more users or groups later."
)}
/>
)}
/>
)}
{team.sharing && (
<Controller
control={control}
name="sharing"
render={({ field }) => (
<Switch
id="sharing"
label={t("Public document sharing")}
note={t(
"Allow documents within this collection to be shared publicly on the internet."
)}
checked={field.value}
onChange={field.onChange}
/>
)}
/>
)}
{team.getPreference(TeamPreference.Commenting) && (
<Controller
control={control}
name="commenting"
render={({ field }) => (
<Switch
id="commenting"
label={t("Commenting")}
note={t("Allow commenting on documents within this collection.")}
checked={!!field.value}
onChange={field.onChange}
/>
)}
/>
)}
<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.Component)`
margin-left: 4px;
margin-right: 4px;
`;
@@ -1,35 +0,0 @@
import { runInAction } from "mobx";
import { observer } from "mobx-react";
import { useCallback } 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 = 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} />;
});
-38
View File
@@ -1,38 +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 { archivePath, collectionPath } from "~/utils/routeHelpers";
import Breadcrumb from "./Breadcrumb";
import { createInternalLinkActionV2 } from "~/actions";
import { ActiveCollectionSection } from "~/actions/sections";
type Props = {
collection: Collection;
};
export const CollectionBreadcrumb: React.FC<Props> = ({ collection }) => {
const { t } = useTranslation();
const actions = React.useMemo(
() => [
createInternalLinkActionV2({
name: t("Archive"),
section: ActiveCollectionSection,
icon: <ArchiveIcon />,
visible: collection.isArchived,
to: archivePath(),
}),
createInternalLinkActionV2({
name: collection.name,
section: ActiveCollectionSection,
icon: <CollectionIcon collection={collection} expanded />,
to: collectionPath(collection.path),
}),
],
[collection, t]
);
return <Breadcrumb actions={actions} highlightFirstItem />;
};
+5 -8
View File
@@ -1,7 +1,7 @@
import { observer } from "mobx-react"; import { observer } from "mobx-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";
@@ -22,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 (
@@ -40,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={{
@@ -52,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={{
+231
View File
@@ -0,0 +1,231 @@
import debounce from "lodash/debounce";
import { observer } from "mobx-react";
import { transparentize } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import Collection from "~/models/Collection";
import Arrow from "~/components/Arrow";
import ButtonLink from "~/components/ButtonLink";
import Editor from "~/components/Editor";
import LoadingIndicator from "~/components/LoadingIndicator";
import NudeButton from "~/components/NudeButton";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
type Props = {
collection: Collection;
};
function CollectionDescription({ collection }: Props) {
const { collections } = useStores();
const { showToast } = useToasts();
const { t } = useTranslation();
const [isExpanded, setExpanded] = React.useState(false);
const [isEditing, setEditing] = React.useState(false);
const [isDirty, setDirty] = React.useState(false);
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(
() =>
debounce(async (getValue) => {
try {
await collection.save({
description: getValue(),
});
setDirty(false);
} catch (err) {
showToast(
t("Sorry, an error occurred saving the collection", {
type: "error",
})
);
throw err;
}
}, 1000),
[collection, showToast, t]
);
const handleChange = React.useCallback(
async (getValue) => {
setDirty(true);
await handleSave(getValue);
},
[handleSave]
);
React.useEffect(() => {
setEditing(false);
}, [collection.id]);
const placeholder = `${t("Add a description")}`;
const key = isEditing || isDirty ? "draft" : collection.updatedAt;
return (
<MaxHeight data-editing={isEditing} data-expanded={isExpanded}>
<Input data-editing={isEditing} data-expanded={isExpanded}>
<span onClick={can.update ? handleStartEditing : undefined}>
{collections.isSaving && <LoadingIndicator />}
{collection.hasDescription || isEditing || isDirty ? (
<React.Suspense
fallback={
<Placeholder
onClick={() => {
//
}}
>
Loading
</Placeholder>
}
>
<Editor
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 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")};
cursor: text;
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);
@@ -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;
} }
`; `;
-3
View File
@@ -1,3 +0,0 @@
import CommandBar from "./CommandBar";
export default CommandBar;
@@ -1,34 +0,0 @@
import { DocumentIcon } from "outline-icons";
import { useMemo } from "react";
import Icon from "@shared/components/Icon";
import { createAction } from "~/actions";
import { RecentSection } from "~/actions/sections";
import useStores from "~/hooks/useStores";
import { documentPath } from "~/utils/routeHelpers";
const useRecentDocumentActions = (count = 6) => {
const { documents, ui } = useStores();
return 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 />
),
to: documentPath(item),
})
),
[count, ui.activeDocumentId, documents.recentlyViewed]
);
};
export default useRecentDocumentActions;
@@ -1,89 +0,0 @@
import { NewDocumentIcon, ShapesIcon } from "outline-icons";
import { useEffect, useMemo } 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();
useEffect(() => {
void documents.fetchAllTemplates();
}, [documents]);
const actions = 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 = 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>
))} ))}
@@ -1,15 +1,12 @@
import { useMatches, KBarResults } from "kbar"; import { useMatches, KBarResults } from "kbar";
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
@@ -17,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}
@@ -40,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;
`; `;
+5 -3
View File
@@ -1,10 +1,11 @@
import { observer } from "mobx-react"; import { observer } from "mobx-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;
@@ -13,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;
@@ -21,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" });
} }
}; };
@@ -32,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
-82
View File
@@ -1,82 +0,0 @@
import { observer } from "mobx-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);
+21 -26
View File
@@ -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>
); );
}; };
+58
View File
@@ -0,0 +1,58 @@
import { observer } from "mobx-react";
import { DisconnectedIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
import useStores from "~/hooks/useStores";
function ConnectionStatus() {
const { ui } = useStores();
const theme = useTheme();
const { t } = useTranslation();
return ui.multiplayerStatus === "connecting" ||
ui.multiplayerStatus === "disconnected" ? (
<Tooltip
tooltip={
<Centered>
<strong>{t("Server connection lost")}</strong>
<br />
{t("Edits you make will sync once youre online")}
</Centered>
}
placement="bottom"
>
<Button>
<Fade>
<DisconnectedIcon color={theme.sidebarText} />
</Fade>
</Button>
</Tooltip>
) : null;
}
const Button = styled(NudeButton)`
display: none;
position: fixed;
bottom: 0;
margin: 24px;
transform: translateX(-32px);
${breakpoint("tablet")`
display: block;
`};
@media print {
display: none;
}
`;
const Centered = styled.div`
text-align: center;
`;
export default observer(ConnectionStatus);
+3 -5
View File
@@ -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;
+24 -51
View File
@@ -1,30 +1,26 @@
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;
dangerous?: boolean; dangerous?: boolean;
to?: LocationDescriptor; to?: LocationDescriptor;
href?: string; href?: string;
target?: string; target?: "_blank";
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> &nbsp;
</>
)} )}
{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);
+6 -6
View File
@@ -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;
}; };
+25 -60
View File
@@ -3,16 +3,16 @@ 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 { import {
useMenuState,
MenuButton, MenuButton,
MenuItem as BaseMenuItem, MenuItem as BaseMenuItem,
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 { useMenuState } from "~/hooks/useMenuState";
import { import {
Action, Action,
ActionContext, ActionContext,
@@ -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)`
@@ -52,9 +50,7 @@ const SubMenu = React.forwardRef(function _Template(
) { ) {
const { t } = useTranslation(); const { t } = useTranslation();
const theme = useTheme(); const theme = useTheme();
const menu = useMenuState({ const menu = useMenuState();
parentId: parentMenuState.baseId,
});
return ( return (
<> <>
@@ -102,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,
}); });
@@ -128,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") {
@@ -140,10 +135,10 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
as={Link} as={Link}
id={`${item.title}-${index}`} id={`${item.title}-${index}`}
to={item.to} to={item.to}
key={`${item.type}-${item.title}-${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}
@@ -155,15 +150,13 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
return ( return (
<MenuItem <MenuItem
id={`${item.title}-${index}`} id={`${item.title}-${index}`}
href={typeof item.href === "string" ? item.href : item.href.url} href={item.href}
key={`${item.type}-${item.title}-${index}`} key={index}
disabled={item.disabled} disabled={item.disabled}
selected={item.selected} selected={item.selected}
level={item.level} level={item.level}
target={ target={item.href.startsWith("#") ? undefined : "_blank"}
typeof item.href === "string" ? undefined : item.href.target icon={item.icon}
}
icon={showIcons !== false ? item.icon : undefined}
{...menu} {...menu}
> >
{item.title} {item.title}
@@ -172,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}`}
@@ -180,63 +173,35 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
disabled={item.disabled} disabled={item.disabled}
selected={item.selected} selected={item.selected}
dangerous={item.dangerous} dangerous={item.dangerous}
key={`${item.type}-${item.title}-${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"}
key={`tooltip-${item.title}-${index}`}
>
<div>{menuItem}</div>
</Tooltip>
) : (
<React.Fragment key={`${item.type}-${item.title}-${index}`}>
{menuItem}
</React.Fragment>
);
} }
if (item.type === "submenu") { if (item.type === "submenu") {
// Skip rendering empty submenus return (
return item.items.length > 0 ? (
<BaseMenuItem <BaseMenuItem
key={`${item.type}-${item.title}-${index}`} key={index}
as={SubMenu} as={SubMenu}
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}
/> />
) : null;
}
if (item.type === "separator") {
return <Separator key={`separator-${index}`} />;
}
if (item.type === "heading") {
return (
<Header key={`heading-${item.title}-${index}`}>{item.title}</Header>
); );
} }
// This should never be reached for Reakit dropdown menu. if (item.type === "separator") {
// Added for exhaustiveness check. return <Separator key={index} />;
if (item.type === "group") { }
return null;
if (item.type === "heading") {
return <Header>{item.title}</Header>;
} }
const _exhaustiveCheck: never = item; const _exhaustiveCheck: never = item;
@@ -255,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>
); );
+64 -146
View File
@@ -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,134 +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";
// oxlint-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 () => {
if (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`
@@ -256,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
@@ -283,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;
}; };
@@ -296,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 {
@@ -309,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};
`}; `};
+21 -37
View File
@@ -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,47 +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 childElem = 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 ( render() {
childElem && const { text, onCopy, children, ...rest } = this.props;
childElem.props && const elem = React.Children.only(children);
typeof childElem.props.onClick === "function" if (!elem) {
) { return null;
childElem.props.onClick(ev); }
} else {
ev.preventDefault();
ev.stopPropagation();
}
},
[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;
+39 -19
View File
@@ -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 { InputSelect, Option } from "~/components/InputSelect"; 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) {
@@ -71,12 +91,12 @@ const DefaultCollectionInputSelect = ({
return ( return (
<InputSelect <InputSelect
options={options}
value={defaultCollectionId ?? "home"} value={defaultCollectionId ?? "home"}
options={options}
onChange={onSelectCollection} onChange={onSelectCollection}
label={t("Start view")} ariaLabel={t("Default collection")}
hideLabel
short short
{...rest}
/> />
); );
}; };
+3 -3
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react"; import * as React from "react";
type Props = { type Props = {
delay?: number; delay?: number;
@@ -6,9 +6,9 @@ type Props = {
}; };
export default function DelayedMount({ delay = 250, children }: Props) { export default function DelayedMount({ delay = 250, children }: Props) {
const [isShowing, setShowing] = useState(false); const [isShowing, setShowing] = React.useState(false);
useEffect(() => { React.useEffect(() => {
const timeout = setTimeout(() => setShowing(true), delay); const timeout = setTimeout(() => setShowing(true), delay);
return () => { return () => {
clearTimeout(timeout); clearTimeout(timeout);
+9 -14
View File
@@ -1,10 +1,10 @@
import { useRef, useEffect } 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,9 +12,9 @@ export default function DesktopEventHandler() {
const { t } = useTranslation(); const { t } = useTranslation();
const history = useHistory(); const history = useHistory();
const { dialogs } = useStores(); const { dialogs } = useStores();
const hasDisabledUpdateMessage = useRef(false); const { showToast } = useToasts();
useEffect(() => { React.useEffect(() => {
Desktop.bridge?.redirect((path: string, replace = false) => { Desktop.bridge?.redirect((path: string, replace = false) => {
if (replace) { if (replace) {
history.replace(path); history.replace(path);
@@ -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;
} }

Some files were not shown because too many files have changed in this diff Show More