mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
105 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e6d552b92d | |||
| cc1ec6cb71 | |||
| 3ad0e58d35 | |||
| 4c75f31b2a | |||
| 72e4fca1c4 | |||
| 495b0e1a56 | |||
| 03de720f6f | |||
| 5b7a5d751c | |||
| 73fea094a8 | |||
| 82b5b87440 | |||
| 0170b98974 | |||
| f7f1f07716 | |||
| b7e80036eb | |||
| 3ffee1239b | |||
| 22c0f18b6b | |||
| 76d9a02fee | |||
| 1752c04c06 | |||
| 084490ba6b | |||
| eaf2fd102e | |||
| 3bc566915e | |||
| 19627f4d07 | |||
| 5274b99277 | |||
| 7cabefaf34 | |||
| 81729ae72b | |||
| cd2d9fc218 | |||
| 4d7340d70b | |||
| e596b57cc2 | |||
| 58b6901b7b | |||
| b8fd239f2e | |||
| 201fbb56eb | |||
| 823b0442a2 | |||
| 4ff663e112 | |||
| e5ded0a6a5 | |||
| 784d075233 | |||
| 1c9b300e25 | |||
| 870bf1157b | |||
| d2aba1de96 | |||
| 052924d816 | |||
| 2fe887ef57 | |||
| e288a5d38e | |||
| dc5c3f5280 | |||
| 610721eed6 | |||
| d50f0986bb | |||
| 90af35d4bd | |||
| 3810373195 | |||
| 3fd893e728 | |||
| 13e3aaf861 | |||
| b43ebabbaf | |||
| 42550a003a | |||
| 08b7c11461 | |||
| 8a9a8cf751 | |||
| 2d6167e933 | |||
| 6b05b101d0 | |||
| 79fe73fbe1 | |||
| 63376ed9c8 | |||
| 0cec66b3bb | |||
| fcc73e772b | |||
| b5cb6128c4 | |||
| 261226c110 | |||
| 6fff437196 | |||
| 4f34e70d32 | |||
| 4c04bd9359 | |||
| 16c8ae6132 | |||
| 30bba3a69b | |||
| 32c1712fdc | |||
| d392149860 | |||
| 30108ebded | |||
| d0bd2baa9f | |||
| fd984774d0 | |||
| e216c68f6d | |||
| 2e2a8bcc94 | |||
| 245d14f905 | |||
| 8717d160ce | |||
| 587ba85cc9 | |||
| 80bb1ce977 | |||
| c598c61afe | |||
| 68b07eb466 | |||
| 06a149407a | |||
| b9387734c7 | |||
| 810b7908e4 | |||
| 6b76a898fa | |||
| 8ba83e2173 | |||
| 5a4b8c5faa | |||
| 3f8bdf7ac2 | |||
| 9c4b4f4989 | |||
| c5d534b2ad | |||
| bed3d1078e | |||
| 83e87254c6 | |||
| f576ddccfe | |||
| 0a674eacfa | |||
| ceac57bd64 | |||
| 97f31e3f2a | |||
| a06671e8ce | |||
| fd3c21d28b | |||
| c0c36bacbb | |||
| 7bd1ea7c40 | |||
| 5ebb1e8a61 | |||
| 96d6987858 | |||
| 3602198cd8 | |||
| 00bab31cff | |||
| 3ef2b7cf42 | |||
| 18743da2fc | |||
| fe1307d7e7 | |||
| a226889143 | |||
| 347f033802 |
@@ -1,6 +1,11 @@
|
||||
{
|
||||
"presets": [
|
||||
"@babel/preset-react",
|
||||
[
|
||||
"@babel/preset-react",
|
||||
{
|
||||
"runtime": "automatic"
|
||||
}
|
||||
],
|
||||
"@babel/preset-env",
|
||||
"@babel/preset-typescript"
|
||||
],
|
||||
@@ -60,4 +65,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+165
-140
@@ -1,48 +1,80 @@
|
||||
# –––––––––––––––– REQUIRED ––––––––––––––––
|
||||
|
||||
NODE_ENV=production
|
||||
|
||||
# Generate a hex-encoded 32-byte random key. You should 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 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@postgres:5432/outline
|
||||
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://redis: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.
|
||||
# This URL should point to the fully qualified, publicly accessible, URL. If using a
|
||||
# proxy this will be the proxy's URL.
|
||||
URL=
|
||||
|
||||
# The port to expose the Outline server on, this should match what is configured
|
||||
# in your docker-compose.yml
|
||||
PORT=3000
|
||||
|
||||
# See [documentation](docs/SERVICES.md) on running a separate collaboration
|
||||
# server, for normal operation this does not need to be set.
|
||||
COLLABORATION_URL=
|
||||
|
||||
# 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=
|
||||
|
||||
# How many processes should be spawned. As a reasonable rule divide your servers
|
||||
# available memory by 512 for a rough estimate
|
||||
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", the avatar images and document attachments will be saved on local disk.
|
||||
# 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 go. Make sure that the process has permissions to create
|
||||
# this path and also to write files to it.
|
||||
# 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.
|
||||
@@ -56,8 +88,8 @@ FILE_STORAGE_IMPORT_MAX_SIZE=
|
||||
# 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.
|
||||
# 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_SECRET_ACCESS_KEY=get_the_secret_of_above_key
|
||||
AWS_REGION=xx-xxxx-x
|
||||
@@ -67,38 +99,55 @@ AWS_S3_UPLOAD_BUCKET_NAME=bucket_name_here
|
||||
AWS_S3_FORCE_PATH_STYLE=true
|
||||
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,
|
||||
# or Microsoft is required for a working installation or you'll have no sign-in
|
||||
# options.
|
||||
# Discord, or Microsoft is required for a working installation or you'll
|
||||
# have no sign-in options.
|
||||
|
||||
# To configure Slack auth, you'll need to create an Application at
|
||||
# => https://api.slack.com/apps
|
||||
#
|
||||
# When configuring the Client ID, add a redirect URL under "OAuth & Permissions":
|
||||
# https://<URL>/auth/slack.callback
|
||||
# Slack sign-in provider
|
||||
# DOCS: https://docs.getoutline.com/s/hosting/doc/slack-sgMujR8J9J
|
||||
SLACK_CLIENT_ID=get_a_key_from_slack
|
||||
SLACK_CLIENT_SECRET=get_the_secret_of_above_key
|
||||
|
||||
# To configure Google auth, you'll need to create an OAuth Client ID at
|
||||
# => https://console.cloud.google.com/apis/credentials
|
||||
#
|
||||
# When configuring the Client ID, add an Authorized redirect URI:
|
||||
# https://<URL>/auth/google.callback
|
||||
# Google sign-in provider
|
||||
# DOCS: https://docs.getoutline.com/s/hosting/doc/google-hOuvtCmTqQ
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
# To configure Microsoft/Azure auth, you'll need to create an OAuth Client. See
|
||||
# the guide for details on setting up your Azure App:
|
||||
# => https://wiki.generaloutline.com/share/dfa77e56-d4d2-4b51-8ff8-84ea6608faa4
|
||||
# Microsoft Entra / Azure AD sign-in provider
|
||||
# DOCS: https://docs.getoutline.com/s/hosting/doc/microsoft-entra-UVz6jsIOcv
|
||||
AZURE_CLIENT_ID=
|
||||
AZURE_CLIENT_SECRET=
|
||||
AZURE_RESOURCE_APP_ID=
|
||||
|
||||
# To configure generic OIDC auth, you'll need some kind of identity provider.
|
||||
# See documentation for whichever IdP you use to acquire the following info:
|
||||
# Redirect URI is https://<URL>/auth/oidc.callback
|
||||
# Discord sign-in provider
|
||||
# DOCS: https://docs.getoutline.com/s/hosting/doc/discord-g4JdWFFub6
|
||||
DISCORD_CLIENT_ID=
|
||||
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_SECRET=
|
||||
OIDC_AUTH_URI=
|
||||
@@ -116,83 +165,54 @@ OIDC_DISPLAY_NAME=OpenID Connect
|
||||
# Space separated auth scopes.
|
||||
OIDC_SCOPES=openid profile email
|
||||
|
||||
# To configure the GitHub integration, you'll need to create a GitHub App at
|
||||
# => https://github.com/settings/apps
|
||||
#
|
||||
# When configuring the Client ID, add a redirect URL under "Permissions & events":
|
||||
# https://<URL>/api/github.callback
|
||||
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
# –––––––––––––– EMAIL –––––––––––––––
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
|
||||
# To support sending outgoing transactional emails such as "document updated" or
|
||||
# email sign-in you'll need to connect an SMTP server. Service can be configured
|
||||
# with any service from this list: https://community.nodemailer.com/2-0-0-beta/setup-smtp/well-known-services/
|
||||
# DOCS: https://docs.getoutline.com/s/hosting/doc/smtp-cqCJyZGMIB
|
||||
SMTP_SERVICE=
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM_EMAIL=
|
||||
|
||||
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
# –––––––––– RATE LIMITER ––––––––––––
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
|
||||
# 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_APP_NAME=
|
||||
GITHUB_APP_ID=
|
||||
GITHUB_APP_PRIVATE_KEY=
|
||||
|
||||
# Linear
|
||||
# The Linear integration allows previewing issue links as rich mentions
|
||||
LINEAR_CLIENT_ID=
|
||||
LINEAR_CLIENT_SECRET=
|
||||
|
||||
# To configure Discord auth, you'll need to create a Discord Application at
|
||||
# => https://discord.com/developers/applications/
|
||||
#
|
||||
# When configuring the Client ID, add a redirect URL under "OAuth2":
|
||||
# https://<URL>/auth/discord.callback
|
||||
DISCORD_CLIENT_ID=
|
||||
DISCORD_CLIENT_SECRET=
|
||||
|
||||
# DISCORD_SERVER_ID should be the ID of the Discord server that Outline is
|
||||
# integrated with.
|
||||
# Used to verify that the user is a member of the server as well as server
|
||||
# metadata such as nicknames, server icon and name.
|
||||
DISCORD_SERVER_ID=
|
||||
|
||||
# DISCORD_SERVER_ROLES should be a comma separated list of role IDs that are
|
||||
# allowed to access Outline. If this is not set, all members of the server
|
||||
# will be allowed to access Outline.
|
||||
# DISCORD_SERVER_ID and DISCORD_SERVER_ROLES must be set together.
|
||||
DISCORD_SERVER_ROLES=
|
||||
|
||||
# –––––––––––––– IMPORTS ––––––––––––––
|
||||
NOTION_CLIENT_ID=
|
||||
NOTION_CLIENT_SECRET=
|
||||
|
||||
# –––––––––––––––– OPTIONAL ––––––––––––––––
|
||||
|
||||
# Base64 encoded private key and certificate for HTTPS termination. This is only
|
||||
# required if you do not use an external reverse proxy. See documentation:
|
||||
# https://wiki.generaloutline.com/share/1c922644-40d8-41fe-98f9-df2b67239d45
|
||||
SSL_KEY=
|
||||
SSL_CERT=
|
||||
|
||||
# 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
|
||||
# false if you can be sure that SSL is terminated at an external loadbalancer.
|
||||
FORCE_HTTPS=true
|
||||
|
||||
# Have the installation check for updates by sending anonymized statistics to
|
||||
# the maintainers
|
||||
ENABLE_UPDATES=true
|
||||
|
||||
# How many processes should be spawned. As a reasonable rule divide your servers
|
||||
# available memory by 512 for a rough estimate
|
||||
WEB_CONCURRENCY=1
|
||||
|
||||
# You can remove this line if your reverse proxy already logs incoming http
|
||||
# requests and this ends up being duplicative
|
||||
DEBUG=http
|
||||
|
||||
# Configure lowest severity level for server logs. Should be one of
|
||||
# error, warn, info, http, verbose, debug and silly
|
||||
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
|
||||
#
|
||||
# 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
|
||||
@@ -202,29 +222,34 @@ SLACK_MESSAGE_ACTIONS=true
|
||||
DROPBOX_APP_KEY=
|
||||
|
||||
# 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)
|
||||
# DOCS: https://docs.getoutline.com/s/hosting/doc/sentry-jxcFttcDl5
|
||||
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_SERVICE=
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM_EMAIL=
|
||||
# Enable importing pages from a Notion workspace
|
||||
# DOCS: https://docs.getoutline.com/s/hosting/doc/notion-2v6g7WY3l3
|
||||
NOTION_CLIENT_ID=
|
||||
NOTION_CLIENT_SECRET=
|
||||
|
||||
# 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
|
||||
# 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
|
||||
# the maintainers
|
||||
ENABLE_UPDATES=true
|
||||
|
||||
# Debugging categories to enable – you can remove the default "http" value if
|
||||
# your proxy already logs incoming http requests and this ends up being duplicative
|
||||
DEBUG=http
|
||||
|
||||
# Configure lowest severity level for server logs. Should be one of
|
||||
# error, warn, info, http, verbose, debug, or silly
|
||||
LOG_LEVEL=info
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"sourceType": "module",
|
||||
"extraFileExtensions": [".json"],
|
||||
"extraFileExtensions": [
|
||||
".json"
|
||||
],
|
||||
"project": "./tsconfig.json",
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
@@ -17,6 +19,7 @@
|
||||
],
|
||||
"plugins": [
|
||||
"es",
|
||||
"react",
|
||||
"@typescript-eslint",
|
||||
"eslint-plugin-import",
|
||||
"eslint-plugin-node",
|
||||
@@ -27,21 +30,30 @@
|
||||
"eqeqeq": 2,
|
||||
"curly": 2,
|
||||
"no-console": "error",
|
||||
"arrow-body-style": ["error", "as-needed"],
|
||||
"arrow-body-style": [
|
||||
"error",
|
||||
"as-needed"
|
||||
],
|
||||
"spaced-comment": "error",
|
||||
"object-shorthand": "error",
|
||||
"no-mixed-operators": "off",
|
||||
"no-useless-escape": "off",
|
||||
"no-shadow": "off",
|
||||
"es/no-regexp-lookbehind-assertions": "error",
|
||||
"react/self-closing-comp": ["error", {
|
||||
"component": true,
|
||||
"html": true
|
||||
}],
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/self-closing-comp": [
|
||||
"error",
|
||||
{
|
||||
"component": true,
|
||||
"html": true
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/no-shadow": [
|
||||
"warn",
|
||||
{
|
||||
"allow": ["transaction"],
|
||||
"allow": [
|
||||
"transaction"
|
||||
],
|
||||
"hoist": "all",
|
||||
"ignoreTypeValueShadow": true
|
||||
}
|
||||
@@ -63,9 +75,25 @@
|
||||
"ignoreRestSiblings": true
|
||||
}
|
||||
],
|
||||
"padding-line-between-statements": ["error", { "blankLine": "always", "prev": "*", "next": "export" }],
|
||||
"lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }],
|
||||
"lodash/import-scope": ["error", "method"],
|
||||
"padding-line-between-statements": [
|
||||
"error",
|
||||
{
|
||||
"blankLine": "always",
|
||||
"prev": "*",
|
||||
"next": "export"
|
||||
}
|
||||
],
|
||||
"lines-between-class-members": [
|
||||
"error",
|
||||
"always",
|
||||
{
|
||||
"exceptAfterSingleLine": true
|
||||
}
|
||||
],
|
||||
"lodash/import-scope": [
|
||||
"error",
|
||||
"method"
|
||||
],
|
||||
"import/no-named-as-default": "off",
|
||||
"import/no-named-as-default-member": "off",
|
||||
"import/newline-after-import": 2,
|
||||
@@ -134,10 +162,13 @@
|
||||
"version": "detect"
|
||||
},
|
||||
"import/parsers": {
|
||||
"@typescript-eslint/parser": [".ts", ".tsx"]
|
||||
"@typescript-eslint/parser": [
|
||||
".ts",
|
||||
".tsx"
|
||||
]
|
||||
},
|
||||
"import/resolver": {
|
||||
"typescript": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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.'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.83.0
|
||||
Licensed Work: Outline 0.84.0
|
||||
The Licensed Work is (c) 2025 General Outline, Inc.
|
||||
Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
you may not use the Licensed Work for a Document
|
||||
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
Licensed Work by creating teams and documents
|
||||
controlled by such third parties.
|
||||
|
||||
Change Date: 2029-04-11
|
||||
Change Date: 2029-05-11
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
"plugins": [
|
||||
"eslint-plugin-react-hooks"
|
||||
],
|
||||
"rules": {
|
||||
"react/react-in-jsx-scope": "off"
|
||||
},
|
||||
"env": {
|
||||
"jest": true,
|
||||
"browser": true
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import stores from "~/stores";
|
||||
import ApiKeyNew from "~/scenes/ApiKeyNew";
|
||||
import { createAction } from "..";
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
UnstarredIcon,
|
||||
UnsubscribeIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import Collection from "~/models/Collection";
|
||||
import { CollectionEdit } from "~/components/Collection/CollectionEdit";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { DoneIcon, SmileyIcon, TrashIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import stores from "~/stores";
|
||||
import Comment from "~/models/Comment";
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
TrashIcon,
|
||||
UserIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import { createAction } from "~/actions";
|
||||
import { DeveloperSection } from "~/actions/sections";
|
||||
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
LogoutIcon,
|
||||
CaseSensitiveIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import stores from "~/stores";
|
||||
import Emoji from "~/models/Emoji";
|
||||
import { EmojiUploadDialog } from "~/components/EmojiDialogs";
|
||||
import { createAction } from "~/actions";
|
||||
import { TeamSection } from "~/actions/sections";
|
||||
|
||||
export const createEmoji = createAction({
|
||||
name: ({ t }) => `${t("Add emoji")}…`,
|
||||
analyticsName: "Create emoji",
|
||||
icon: <PlusIcon />,
|
||||
keywords: "emoji custom upload image",
|
||||
section: TeamSection,
|
||||
visible: () =>
|
||||
stores.policies.abilities(stores.auth.team?.id || "").createEmoji,
|
||||
perform: ({ t }) => {
|
||||
stores.dialogs.openModal({
|
||||
title: t("Add custom emoji"),
|
||||
content: <EmojiUploadDialog onSubmit={stores.dialogs.closeAllModals} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteEmojiActionFactory = (emoji: Emoji) =>
|
||||
createAction({
|
||||
name: ({ t }) => `${t("Delete")}…`,
|
||||
analyticsName: "Delete emoji",
|
||||
section: TeamSection,
|
||||
dangerous: true,
|
||||
visible: () => {
|
||||
const can = stores.policies.abilities(emoji.id);
|
||||
return can.delete;
|
||||
},
|
||||
perform: async () => {
|
||||
await stores.emojis.delete(emoji);
|
||||
},
|
||||
});
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
ShapesIcon,
|
||||
DraftsIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { UrlHelper } from "@shared/utils/UrlHelper";
|
||||
import { isMac } from "@shared/utils/browser";
|
||||
import stores from "~/stores";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ArchiveIcon, MarkAsReadIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { createAction } from "..";
|
||||
import { NotificationSection } from "../sections";
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
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} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import { LinkIcon, RestoreIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { LinkIcon, RestoreIcon, TrashIcon } from "outline-icons";
|
||||
import { matchPath } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import stores from "~/stores";
|
||||
@@ -13,7 +12,7 @@ import {
|
||||
} from "~/utils/routeHelpers";
|
||||
|
||||
export const restoreRevision = createAction({
|
||||
name: ({ t }) => t("Restore revision"),
|
||||
name: ({ t }) => t("Restore"),
|
||||
analyticsName: "Restore revision",
|
||||
icon: <RestoreIcon />,
|
||||
section: RevisionSection,
|
||||
@@ -42,6 +41,38 @@ export const restoreRevision = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteRevision = 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 = createAction({
|
||||
name: ({ t }) => t("Copy link"),
|
||||
analyticsName: "Copy link to revision",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { SunIcon, MoonIcon, BrowserIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import stores from "~/stores";
|
||||
import { Theme } from "~/stores/UiStore";
|
||||
import { createAction } from "~/actions";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ArrowIcon, PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { stringToColor } from "@shared/utils/color";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
@@ -11,7 +10,7 @@ import { ActionContext } from "~/types";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import { TeamSection } from "../sections";
|
||||
|
||||
export const createTeamsList = ({ stores }: { stores: RootStore }) =>
|
||||
export const switchTeamsList = ({ stores }: { stores: RootStore }) =>
|
||||
stores.auth.availableTeams?.map((session) => ({
|
||||
id: `switch-${session.id}`,
|
||||
name: session.name,
|
||||
@@ -44,7 +43,7 @@ export const switchTeam = createAction({
|
||||
section: TeamSection,
|
||||
visible: ({ stores }) =>
|
||||
!!stores.auth.availableTeams && stores.auth.availableTeams?.length > 1,
|
||||
children: createTeamsList,
|
||||
children: switchTeamsList,
|
||||
});
|
||||
|
||||
export const createTeam = createAction({
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { UserRole } from "@shared/types";
|
||||
import { UserRoleHelper } from "@shared/utils/UserRoleHelper";
|
||||
import stores from "~/stores";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import flattenDeep from "lodash/flattenDeep";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Optional } from "utility-types";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import * as React from "react";
|
||||
|
||||
export default function Arrow() {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Redirect } from "react-router-dom";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
@@ -19,7 +19,7 @@ const Authenticated = ({ children }: Props) => {
|
||||
|
||||
// Watching for language changes here as this is the earliest point we might have the user
|
||||
// available and means we can start loading translations faster
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
void changeLanguage(language, i18n);
|
||||
}, [i18n, language]);
|
||||
|
||||
|
||||
@@ -13,6 +13,11 @@ export enum AvatarSize {
|
||||
Upload = 64,
|
||||
}
|
||||
|
||||
export enum AvatarVariant {
|
||||
Round = "round",
|
||||
Square = "square",
|
||||
}
|
||||
|
||||
export interface IAvatar {
|
||||
avatarUrl: string | null;
|
||||
color?: string;
|
||||
@@ -23,6 +28,8 @@ export interface IAvatar {
|
||||
type Props = {
|
||||
/** The size of the avatar */
|
||||
size: AvatarSize;
|
||||
/** The variant of the avatar */
|
||||
variant?: AvatarVariant;
|
||||
/** The source of the avatar image, if not passing a model. */
|
||||
src?: string;
|
||||
/** The avatar model, if not passing a source. */
|
||||
@@ -38,14 +45,25 @@ type Props = {
|
||||
};
|
||||
|
||||
function Avatar(props: Props) {
|
||||
const { model, style, ...rest } = props;
|
||||
const {
|
||||
model,
|
||||
style,
|
||||
variant = AvatarVariant.Round,
|
||||
className,
|
||||
...rest
|
||||
} = props;
|
||||
const src = props.src || model?.avatarUrl;
|
||||
const [error, handleError] = useBoolean(false);
|
||||
|
||||
return (
|
||||
<Relative style={style}>
|
||||
<Relative
|
||||
style={style}
|
||||
$variant={variant}
|
||||
$size={props.size}
|
||||
className={className}
|
||||
>
|
||||
{src && !error ? (
|
||||
<CircleImg onError={handleError} src={src} {...rest} />
|
||||
<Image onError={handleError} src={src} {...rest} />
|
||||
) : model ? (
|
||||
<Initials color={model.color} {...rest}>
|
||||
{model.initial}
|
||||
@@ -61,19 +79,21 @@ Avatar.defaultProps = {
|
||||
size: AvatarSize.Medium,
|
||||
};
|
||||
|
||||
const Relative = styled.div`
|
||||
const Relative = styled.div<{ $variant: AvatarVariant; $size: AvatarSize }>`
|
||||
position: relative;
|
||||
user-select: none;
|
||||
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 CircleImg = styled.img<{ size: number }>`
|
||||
const Image = styled.img<{ size: number }>`
|
||||
display: block;
|
||||
width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
export default Avatar;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { GroupIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTheme } from "styled-components";
|
||||
import Squircle from "@shared/components/Squircle";
|
||||
import Group from "~/models/Group";
|
||||
|
||||
@@ -13,7 +13,6 @@ const Initials = styled(Flex)<{
|
||||
}>`
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: ${(props) =>
|
||||
@@ -23,7 +22,6 @@ const Initials = styled(Flex)<{
|
||||
background-color: ${(props) => props.color ?? props.theme.textTertiary};
|
||||
width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
|
||||
// adjust font size down for each additional character
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Avatar, { IAvatar, AvatarSize } from "./Avatar";
|
||||
import Avatar, { IAvatar, AvatarSize, AvatarVariant } from "./Avatar";
|
||||
import AvatarWithPresence from "./AvatarWithPresence";
|
||||
import { GroupAvatar } from "./GroupAvatar";
|
||||
|
||||
export { Avatar, GroupAvatar, AvatarSize, AvatarWithPresence };
|
||||
export { Avatar, GroupAvatar, AvatarSize, AvatarVariant, AvatarWithPresence };
|
||||
|
||||
export type { IAvatar };
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths, s } from "@shared/styles";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as React from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { changeLanguage } from "~/utils/language";
|
||||
|
||||
@@ -9,7 +9,7 @@ type Props = {
|
||||
export default function ChangeLanguage({ locale }: Props) {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
void changeLanguage(locale, i18n);
|
||||
}, [locale, i18n]);
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
|
||||
const cleanPercentage = (percentage: number) => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import isEqual from "lodash/isEqual";
|
||||
import orderBy from "lodash/orderBy";
|
||||
import uniq from "lodash/uniq";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useState, useMemo, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
||||
import Document from "~/models/Document";
|
||||
@@ -31,58 +31,97 @@ function Collaborators(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
const currentUserId = user?.id;
|
||||
const [requestedUserIds, setRequestedUserIds] = React.useState<string[]>([]);
|
||||
const [requestedUserIds, setRequestedUserIds] = useState<string[]>([]);
|
||||
const { users, presence, ui } = useStores();
|
||||
const { document } = props;
|
||||
const { observingUserId } = ui;
|
||||
const documentPresence = presence.get(document.id);
|
||||
const documentPresenceArray = documentPresence
|
||||
? Array.from(documentPresence.values())
|
||||
: [];
|
||||
|
||||
const presentIds = documentPresenceArray.map((p) => p.userId);
|
||||
const editingIds = documentPresenceArray
|
||||
.filter((p) => p.isEditing)
|
||||
.map((p) => p.userId);
|
||||
// Use Set for O(1) lookups and stable references
|
||||
const presentIds = useMemo(
|
||||
() => new Set(documentPresenceArray.map((p) => p.userId)),
|
||||
[documentPresenceArray]
|
||||
);
|
||||
const editingIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
documentPresenceArray.filter((p) => p.isEditing).map((p) => p.userId)
|
||||
),
|
||||
[documentPresenceArray]
|
||||
);
|
||||
|
||||
// ensure currently present via websocket are always ordered first
|
||||
const collaborators = React.useMemo(
|
||||
// Memoize collaboratorIds as a Set for efficient lookup
|
||||
const collaboratorIdsSet = useMemo(
|
||||
() => new Set(document.collaboratorIds),
|
||||
[document.collaboratorIds]
|
||||
);
|
||||
const collaborators = useMemo(
|
||||
() =>
|
||||
orderBy(
|
||||
filter(
|
||||
users.all,
|
||||
(u) =>
|
||||
(presentIds.includes(u.id) ||
|
||||
document.collaboratorIds.includes(u.id)) &&
|
||||
(presentIds.has(u.id) || collaboratorIdsSet.has(u.id)) &&
|
||||
!u.isSuspended
|
||||
),
|
||||
[(u) => presentIds.includes(u.id), "id"],
|
||||
[(u) => presentIds.has(u.id), "id"],
|
||||
["asc", "asc"]
|
||||
),
|
||||
[document.collaboratorIds, users.all, presentIds]
|
||||
[collaboratorIdsSet, users.all, presentIds]
|
||||
);
|
||||
|
||||
// load any users we don't yet have in memory
|
||||
React.useEffect(() => {
|
||||
const ids = uniq([...document.collaboratorIds, ...presentIds])
|
||||
.filter((userId) => !users.get(userId))
|
||||
.sort();
|
||||
// Memoize ids to avoid unnecessary effect executions
|
||||
const missingUserIds = useMemo(
|
||||
() =>
|
||||
uniq([...document.collaboratorIds, ...Array.from(presentIds)])
|
||||
.filter((userId) => !users.get(userId))
|
||||
.sort(),
|
||||
[document.collaboratorIds, presentIds, users]
|
||||
);
|
||||
|
||||
if (!isEqual(requestedUserIds, ids) && ids.length > 0) {
|
||||
setRequestedUserIds(ids);
|
||||
void users.fetchPage({ ids, limit: 100 });
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isEqual(requestedUserIds, missingUserIds) &&
|
||||
missingUserIds.length > 0
|
||||
) {
|
||||
setRequestedUserIds(missingUserIds);
|
||||
void users.fetchPage({ ids: missingUserIds, limit: 100 });
|
||||
}
|
||||
}, [document, users, presentIds, document.collaboratorIds, requestedUserIds]);
|
||||
}, [missingUserIds, requestedUserIds, users]);
|
||||
|
||||
const popover = usePopoverState({
|
||||
gutter: 0,
|
||||
placement: "bottom-end",
|
||||
});
|
||||
|
||||
const renderAvatar = React.useCallback(
|
||||
// Memoize onClick handler to avoid inline function creation
|
||||
const handleAvatarClick = useCallback(
|
||||
(
|
||||
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.includes(collaborator.id);
|
||||
const isEditing = editingIds.includes(collaborator.id);
|
||||
const isObserving = ui.observingUserId === collaborator.id;
|
||||
const isPresent = presentIds.has(collaborator.id);
|
||||
const isEditing = editingIds.has(collaborator.id);
|
||||
const isObserving = observingUserId === collaborator.id;
|
||||
const isObservable = collaborator.id !== currentUserId;
|
||||
|
||||
return (
|
||||
@@ -96,21 +135,18 @@ function Collaborators(props: Props) {
|
||||
isCurrentUser={currentUserId === collaborator.id}
|
||||
onClick={
|
||||
isObservable
|
||||
? (ev) => {
|
||||
if (isPresent) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
ui.setObservingUser(
|
||||
isObserving ? undefined : collaborator.id
|
||||
);
|
||||
}
|
||||
}
|
||||
? handleAvatarClick(
|
||||
collaborator.id,
|
||||
isPresent,
|
||||
isObserving,
|
||||
isObservable
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[presentIds, ui, currentUserId, editingIds]
|
||||
[presentIds, editingIds, observingUserId, currentUserId, handleAvatarClick]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -133,7 +169,7 @@ function Collaborators(props: Props) {
|
||||
)}
|
||||
</PopoverDisclosure>
|
||||
<Popover {...popover} width={300} aria-label={t("Viewers")} tabIndex={0}>
|
||||
<DocumentViews document={document} isOpen={popover.visible} />
|
||||
{popover.visible && <DocumentViews document={document} />}
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { CollectionForm, FormData } from "./CollectionForm";
|
||||
@@ -16,7 +16,7 @@ export const CollectionEdit = observer(function CollectionEdit_({
|
||||
const { collections } = useStores();
|
||||
const collection = collections.get(collectionId);
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
const handleSubmit = useCallback(
|
||||
async (data: FormData) => {
|
||||
try {
|
||||
await collection?.save(data);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import uniq from "lodash/uniq";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "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";
|
||||
@@ -14,13 +15,15 @@ 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 = React.lazy(() => import("~/components/IconPicker"));
|
||||
const IconPicker = createLazyComponent(() => import("~/components/IconPicker"));
|
||||
|
||||
export interface FormData {
|
||||
name: string;
|
||||
@@ -30,6 +33,26 @@ export interface FormData {
|
||||
permission: CollectionPermission | undefined;
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -42,11 +65,7 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
|
||||
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
|
||||
|
||||
const iconColor = React.useMemo(
|
||||
() => collection?.color ?? randomElement(colorPalette),
|
||||
[collection?.color]
|
||||
);
|
||||
|
||||
const iconColor = useIconColor(collection);
|
||||
const fallbackIcon = <Icon value="collection" color={iconColor} />;
|
||||
|
||||
const {
|
||||
@@ -70,7 +89,12 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
|
||||
const values = watch();
|
||||
|
||||
React.useEffect(() => {
|
||||
// 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) {
|
||||
@@ -83,11 +107,11 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
}
|
||||
}, [collection, hasOpenedIconPicker, setValue, values.name, values.icon]);
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
setTimeout(() => setFocus("name", { shouldSelect: true }), 100);
|
||||
}, [setFocus]);
|
||||
|
||||
const handleIconChange = React.useCallback(
|
||||
const handleIconChange = useCallback(
|
||||
(icon: string, color: string | null) => {
|
||||
if (icon !== values.icon) {
|
||||
setFocus("name");
|
||||
@@ -116,7 +140,7 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
maxLength: CollectionValidation.maxNameLength,
|
||||
})}
|
||||
prefix={
|
||||
<React.Suspense fallback={fallbackIcon}>
|
||||
<Suspense fallback={fallbackIcon}>
|
||||
<StyledIconPicker
|
||||
icon={values.icon}
|
||||
color={values.color ?? iconColor}
|
||||
@@ -125,7 +149,7 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
onOpen={setHasOpenedIconPicker}
|
||||
onChange={handleIconChange}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</Suspense>
|
||||
}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
@@ -184,7 +208,7 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
);
|
||||
});
|
||||
|
||||
const StyledIconPicker = styled(IconPicker)`
|
||||
const StyledIconPicker = styled(IconPicker.Component)`
|
||||
margin-left: 4px;
|
||||
margin-right: 4px;
|
||||
`;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { runInAction } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
@@ -14,7 +14,7 @@ export const CollectionNew = observer(function CollectionNew_({
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
const { collections } = useStores();
|
||||
const handleSubmit = React.useCallback(
|
||||
const handleSubmit = useCallback(
|
||||
async (data: FormData) => {
|
||||
try {
|
||||
const collection = await collections.save(data);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useMatches, KBarResults } from "kbar";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Text from "~/components/Text";
|
||||
import CommandBarItem from "./CommandBarItem";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DocumentIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useMemo } from "react";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { createAction } from "~/actions";
|
||||
import { RecentSection } from "~/actions/sections";
|
||||
@@ -10,7 +10,7 @@ import { documentPath } from "~/utils/routeHelpers";
|
||||
const useRecentDocumentActions = (count = 6) => {
|
||||
const { documents, ui } = useStores();
|
||||
|
||||
return React.useMemo(
|
||||
return useMemo(
|
||||
() =>
|
||||
documents.recentlyViewed
|
||||
.filter((document) => document.id !== ui.activeDocumentId)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SettingsIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { createAction } from "~/actions";
|
||||
import { NavigationSection } from "~/actions/sections";
|
||||
import useSettingsConfig from "~/hooks/useSettingsConfig";
|
||||
@@ -7,7 +7,7 @@ import history from "~/utils/history";
|
||||
|
||||
const useSettingsAction = () => {
|
||||
const config = useSettingsConfig();
|
||||
const actions = React.useMemo(
|
||||
const actions = useMemo(
|
||||
() =>
|
||||
config.map((item) => {
|
||||
const Icon = item.icon;
|
||||
@@ -22,7 +22,7 @@ const useSettingsAction = () => {
|
||||
[config]
|
||||
);
|
||||
|
||||
const navigateToSettings = React.useMemo(
|
||||
const navigateToSettings = useMemo(
|
||||
() =>
|
||||
createAction({
|
||||
id: "settings",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NewDocumentIcon, ShapesIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { createAction } from "~/actions";
|
||||
import {
|
||||
@@ -14,11 +14,11 @@ import { newDocumentPath } from "~/utils/routeHelpers";
|
||||
const useTemplatesAction = () => {
|
||||
const { documents } = useStores();
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
void documents.fetchAllTemplates();
|
||||
}, [documents]);
|
||||
|
||||
const actions = React.useMemo(
|
||||
const actions = useMemo(
|
||||
() =>
|
||||
documents.templatesAlphabetical.map((template) =>
|
||||
createAction({
|
||||
@@ -61,7 +61,7 @@ const useTemplatesAction = () => {
|
||||
[documents.templatesAlphabetical]
|
||||
);
|
||||
|
||||
const newFromTemplate = React.useMemo(
|
||||
const newFromTemplate = useMemo(
|
||||
() =>
|
||||
createAction({
|
||||
id: "templates",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import Comment from "~/models/Comment";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { CollectionPermission, NavigationNode } from "@shared/types";
|
||||
|
||||
@@ -138,7 +138,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
|
||||
as={Link}
|
||||
id={`${item.title}-${index}`}
|
||||
to={item.to}
|
||||
key={index}
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
icon={showIcons !== false ? item.icon : undefined}
|
||||
@@ -154,7 +154,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
|
||||
<MenuItem
|
||||
id={`${item.title}-${index}`}
|
||||
href={item.href}
|
||||
key={index}
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
level={item.level}
|
||||
@@ -176,7 +176,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
dangerous={item.dangerous}
|
||||
key={index}
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
icon={showIcons !== false ? item.icon : undefined}
|
||||
{...menu}
|
||||
>
|
||||
@@ -185,18 +185,25 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
|
||||
);
|
||||
|
||||
return item.tooltip ? (
|
||||
<Tooltip content={item.tooltip} placement={"bottom"}>
|
||||
<Tooltip
|
||||
content={item.tooltip}
|
||||
placement={"bottom"}
|
||||
key={`tooltip-${item.title}-${index}`}
|
||||
>
|
||||
<div>{menuItem}</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<>{menuItem}</>
|
||||
<React.Fragment key={`${item.type}-${item.title}-${index}`}>
|
||||
{menuItem}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "submenu") {
|
||||
return (
|
||||
// Skip rendering empty submenus
|
||||
return item.items.length > 0 ? (
|
||||
<BaseMenuItem
|
||||
key={index}
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
as={SubMenu}
|
||||
id={`${item.title}-${index}`}
|
||||
templateItems={item.items}
|
||||
@@ -209,15 +216,17 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
|
||||
}
|
||||
{...menu}
|
||||
/>
|
||||
);
|
||||
) : null;
|
||||
}
|
||||
|
||||
if (item.type === "separator") {
|
||||
return <Separator key={index} />;
|
||||
return <Separator key={`separator-${index}`} />;
|
||||
}
|
||||
|
||||
if (item.type === "heading") {
|
||||
return <Header key={index}>{item.title}</Header>;
|
||||
return (
|
||||
<Header key={`heading-${item.title}-${index}`}>{item.title}</Header>
|
||||
);
|
||||
}
|
||||
|
||||
const _exhaustiveCheck: never = item;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as React from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
type Props = {
|
||||
delay?: number;
|
||||
@@ -6,9 +6,9 @@ type Props = {
|
||||
};
|
||||
|
||||
export default function DelayedMount({ delay = 250, children }: Props) {
|
||||
const [isShowing, setShowing] = React.useState(false);
|
||||
const [isShowing, setShowing] = useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setShowing(true), delay);
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as React from "react";
|
||||
import { useRef, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
@@ -12,9 +12,9 @@ export default function DesktopEventHandler() {
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const { dialogs } = useStores();
|
||||
const hasDisabledUpdateMessage = React.useRef(false);
|
||||
const hasDisabledUpdateMessage = useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
Desktop.bridge?.redirect((path: string, replace = false) => {
|
||||
if (replace) {
|
||||
history.replace(path);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import Guide from "~/components/Guide";
|
||||
import Modal from "~/components/Modal";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
@@ -4,7 +4,7 @@ import { subDays } from "date-fns";
|
||||
import { m } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
import { CloseIcon, DocumentIcon, ClockIcon, EyeIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useRef, useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
@@ -40,7 +40,7 @@ function DocumentCard(props: Props) {
|
||||
const { collections } = useStores();
|
||||
const theme = useTheme();
|
||||
const { document, pin, canUpdatePin, isDraggable } = props;
|
||||
const pinnedToHome = React.useRef(!pin?.collectionId).current;
|
||||
const pinnedToHome = useRef(!pin?.collectionId).current;
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
@@ -63,7 +63,7 @@ function DocumentCard(props: Props) {
|
||||
transition,
|
||||
};
|
||||
|
||||
const handleUnpin = React.useCallback(
|
||||
const handleUnpin = useCallback(
|
||||
async (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
@@ -178,7 +178,7 @@ function DocumentCard(props: Props) {
|
||||
|
||||
const ReadingTime = ({ document }: { document: Document }) => {
|
||||
const { t } = useTranslation();
|
||||
const markdown = React.useMemo(() => document.toMarkdown(), [document]);
|
||||
const markdown = useMemo(() => document.toMarkdown(), [document]);
|
||||
const stats = useTextStats(markdown);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { action, computed, observable } from "mobx";
|
||||
import React, { PropsWithChildren } from "react";
|
||||
import { createContext, useContext, useMemo, PropsWithChildren } from "react";
|
||||
import { Heading } from "@shared/utils/ProsemirrorHelper";
|
||||
import Document from "~/models/Document";
|
||||
import { Editor } from "~/editor";
|
||||
@@ -64,10 +64,10 @@ class DocumentContext {
|
||||
}
|
||||
}
|
||||
|
||||
const Context = React.createContext<DocumentContext | null>(null);
|
||||
const Context = createContext<DocumentContext | null>(null);
|
||||
|
||||
export const useDocumentContext = () => {
|
||||
const ctx = React.useContext(Context);
|
||||
const ctx = useContext(Context);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"useDocumentContext must be used within DocumentContextProvider"
|
||||
@@ -79,6 +79,6 @@ export const useDocumentContext = () => {
|
||||
export const DocumentContextProvider = ({
|
||||
children,
|
||||
}: PropsWithChildren<unknown>) => {
|
||||
const context = React.useMemo(() => new DocumentContext(), []);
|
||||
const context = useMemo(() => new DocumentContext(), []);
|
||||
return <Context.Provider value={context}>{children}</Context.Provider>;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { TFunction } from "i18next";
|
||||
import { observer } from "mobx-react";
|
||||
import { DoneIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import Document from "~/models/Document";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import compact from "lodash/compact";
|
||||
import sortBy from "lodash/sortBy";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useMemo, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { dateLocale, dateToRelative } from "@shared/utils/date";
|
||||
import Document from "~/models/Document";
|
||||
@@ -14,78 +14,86 @@ import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
isOpen?: boolean;
|
||||
};
|
||||
|
||||
function DocumentViews({ document, isOpen }: Props) {
|
||||
function DocumentViews({ document }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { views, presence } = useStores();
|
||||
const user = useCurrentUser();
|
||||
const locale = dateLocale(user.language);
|
||||
|
||||
const documentPresence = presence.get(document.id);
|
||||
const documentPresenceArray = documentPresence
|
||||
? Array.from(documentPresence.values())
|
||||
: [];
|
||||
const presentIds = documentPresenceArray.map((p) => p.userId);
|
||||
const editingIds = documentPresenceArray
|
||||
.filter((p) => p.isEditing)
|
||||
.map((p) => p.userId);
|
||||
|
||||
// Use Set for O(1) lookups and stable references
|
||||
const presentIds = useMemo(
|
||||
() => new Set(documentPresenceArray.map((p) => p.userId)),
|
||||
[documentPresenceArray]
|
||||
);
|
||||
const editingIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
documentPresenceArray.filter((p) => p.isEditing).map((p) => p.userId)
|
||||
),
|
||||
[documentPresenceArray]
|
||||
);
|
||||
|
||||
// ensure currently present via websocket are always ordered first
|
||||
const documentViews = views.inDocument(document.id);
|
||||
const sortedViews = sortBy(
|
||||
documentViews,
|
||||
(view) => !presentIds.includes(view.userId)
|
||||
const documentViews = useMemo(
|
||||
() => views.inDocument(document.id),
|
||||
[views, document.id]
|
||||
);
|
||||
const users = React.useMemo(
|
||||
const sortedViews = useMemo(
|
||||
() => sortBy(documentViews, (view) => !presentIds.has(view.userId)),
|
||||
[documentViews, presentIds]
|
||||
);
|
||||
const users = useMemo(
|
||||
() => compact(sortedViews.map((v) => v.user)),
|
||||
[sortedViews]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOpen && (
|
||||
<PaginatedList<User>
|
||||
aria-label={t("Viewers")}
|
||||
items={users}
|
||||
renderItem={(model) => {
|
||||
const view = documentViews.find((v) => v.userId === model.id);
|
||||
const isPresent = presentIds.includes(model.id);
|
||||
const isEditing = editingIds.includes(model.id);
|
||||
const subtitle = isPresent
|
||||
? isEditing
|
||||
? t("Currently editing")
|
||||
: t("Currently viewing")
|
||||
: t("Viewed {{ timeAgo }}", {
|
||||
timeAgo: dateToRelative(
|
||||
view ? Date.parse(view.lastViewedAt) : new Date(),
|
||||
{
|
||||
addSuffix: true,
|
||||
locale,
|
||||
}
|
||||
),
|
||||
});
|
||||
return (
|
||||
<ListItem
|
||||
key={model.id}
|
||||
title={model.name}
|
||||
subtitle={subtitle}
|
||||
image={
|
||||
<Avatar
|
||||
key={model.id}
|
||||
model={model}
|
||||
size={AvatarSize.Large}
|
||||
/>
|
||||
}
|
||||
border={false}
|
||||
small
|
||||
/>
|
||||
);
|
||||
}}
|
||||
// Memoize renderItem for PaginatedList
|
||||
const renderItem = useCallback(
|
||||
(model: User) => {
|
||||
const view = documentViews.find((v) => v.userId === model.id);
|
||||
const isPresent = presentIds.has(model.id);
|
||||
const isEditing = editingIds.has(model.id);
|
||||
const subtitle = isPresent
|
||||
? isEditing
|
||||
? t("Currently editing")
|
||||
: t("Currently viewing")
|
||||
: t("Viewed {{ timeAgo }}", {
|
||||
timeAgo: dateToRelative(
|
||||
view ? Date.parse(view.lastViewedAt) : new Date(),
|
||||
{
|
||||
addSuffix: true,
|
||||
locale,
|
||||
}
|
||||
),
|
||||
});
|
||||
return (
|
||||
<ListItem
|
||||
key={model.id}
|
||||
title={model.name}
|
||||
subtitle={subtitle}
|
||||
image={
|
||||
<Avatar key={model.id} model={model} size={AvatarSize.Large} />
|
||||
}
|
||||
border={false}
|
||||
small
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
[documentViews, presentIds, editingIds, t, locale]
|
||||
);
|
||||
|
||||
return (
|
||||
<PaginatedList<User>
|
||||
aria-label={t("Viewers")}
|
||||
items={users}
|
||||
renderItem={renderItem}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -64,11 +64,12 @@ function EditableTitle(
|
||||
async (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setIsEditing(false);
|
||||
|
||||
const trimmedValue = value.trim();
|
||||
|
||||
if (trimmedValue === originalValue || trimmedValue.length === 0) {
|
||||
setValue(originalValue);
|
||||
setIsEditing(false);
|
||||
onCancel?.();
|
||||
return;
|
||||
}
|
||||
@@ -80,6 +81,8 @@ function EditableTitle(
|
||||
setValue(originalValue);
|
||||
toast.error(error.message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsEditing(false);
|
||||
}
|
||||
},
|
||||
[originalValue, value, onCancel, onSubmit]
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import * as React from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { AttachmentPreset } from "@shared/types";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Flex from "~/components/Flex";
|
||||
import Input from "~/components/Input";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { uploadFile } from "~/utils/files";
|
||||
|
||||
type Props = {
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
export function EmojiUploadDialog({ onSubmit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { emojis } = useStores();
|
||||
const [name, setName] = React.useState("");
|
||||
const [file, setFile] = React.useState<File | null>(null);
|
||||
const [isUploading, setIsUploading] = React.useState(false);
|
||||
|
||||
const onDrop = React.useCallback((acceptedFiles: File[]) => {
|
||||
if (acceptedFiles.length > 0) {
|
||||
setFile(acceptedFiles[0]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: [".png", ".jpg", ".jpeg", ".gif", ".webp"],
|
||||
maxFiles: 1,
|
||||
maxSize: 1024 * 1024, // 1MB
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) {
|
||||
toast.error(t("Please enter a name for the emoji"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file) {
|
||||
toast.error(t("Please select an image file"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const attachment = await uploadFile(file, {
|
||||
name: file.name,
|
||||
preset: AttachmentPreset.Avatar,
|
||||
});
|
||||
|
||||
await emojis.create({
|
||||
name: name.trim(),
|
||||
url: attachment.url,
|
||||
});
|
||||
|
||||
toast.success(t("Emoji created successfully"));
|
||||
onSubmit();
|
||||
} catch (error) {
|
||||
toast.error(t("Failed to create emoji"));
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
// Remove special characters and spaces, convert to lowercase
|
||||
const value = event.target.value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_]/g, "")
|
||||
.slice(0, 32);
|
||||
setName(value);
|
||||
};
|
||||
|
||||
const isValid = name.trim().length > 0 && file;
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
disabled={!isValid || isUploading}
|
||||
savingText={isUploading ? `${t("Uploading")}…` : undefined}
|
||||
submitText={t("Add emoji")}
|
||||
>
|
||||
<Text as="p" type="secondary">
|
||||
{t(
|
||||
"Upload an image to create a custom emoji. The name should be unique and contain only lowercase letters, numbers, and underscores."
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Input
|
||||
label={t("Emoji name")}
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
placeholder="my_custom_emoji"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
|
||||
<DropZone {...getRootProps()}>
|
||||
<input {...getInputProps()} />
|
||||
<Flex column align="center" gap={8}>
|
||||
{file ? (
|
||||
<>
|
||||
<PreviewImage src={URL.createObjectURL(file)} alt="Preview" />
|
||||
<Text>{file.name}</Text>
|
||||
<Text type="secondary">{t("Click or drag to replace")}</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text>
|
||||
{isDragActive
|
||||
? t("Drop the image here")
|
||||
: t("Click or drag an image here")}
|
||||
</Text>
|
||||
<Text type="secondary">
|
||||
{t("PNG, JPG, GIF, or WebP up to 1MB")}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</DropZone>
|
||||
|
||||
{name && (
|
||||
<Text type="secondary">
|
||||
{t("This emoji will be available as")} <code>:{name}:</code>
|
||||
</Text>
|
||||
)}
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
const DropZone = styled.div`
|
||||
border: 2px dashed ${s("divider")};
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: ${s("accent")};
|
||||
}
|
||||
`;
|
||||
|
||||
const PreviewImage = styled.img`
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
`;
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
UserIcon,
|
||||
CrossIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import styled, { css } from "styled-components";
|
||||
@@ -48,10 +48,12 @@ export type DocumentEvent = {
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type Event = { id: string; actorId: string; createdAt: string } & (
|
||||
| RevisionEvent
|
||||
| DocumentEvent
|
||||
);
|
||||
export type Event = {
|
||||
id: string;
|
||||
actorId: string;
|
||||
createdAt: string;
|
||||
deletedAt?: string;
|
||||
} & (RevisionEvent | DocumentEvent);
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
@@ -65,7 +67,7 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
|
||||
const user = "userId" in event ? users.get(event.userId) : undefined;
|
||||
const location = useLocation();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const revisionLoadedRef = React.useRef(false);
|
||||
const revisionLoadedRef = useRef(false);
|
||||
const opts = {
|
||||
userName: actor?.name,
|
||||
};
|
||||
@@ -74,7 +76,7 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
|
||||
event.id === RevisionHelper.latestId(document.id);
|
||||
let meta, icon, to: LocationDescriptor | undefined;
|
||||
|
||||
const ref = React.useRef<HTMLAnchorElement>(null);
|
||||
const ref = useRef<HTMLAnchorElement>(null);
|
||||
// the time component tends to steal focus when clicked
|
||||
// ...so forward the focus back to the parent item
|
||||
const handleTimeClick = () => {
|
||||
@@ -85,6 +87,7 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
|
||||
if (
|
||||
!document.isDeleted &&
|
||||
event.name === "revisions.create" &&
|
||||
!event.deletedAt &&
|
||||
!isDerivedFromDocument &&
|
||||
!revisionLoadedRef.current
|
||||
) {
|
||||
@@ -95,24 +98,31 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
|
||||
|
||||
switch (event.name) {
|
||||
case "revisions.create":
|
||||
icon = <EditIcon size={16} />;
|
||||
meta = event.latest ? (
|
||||
<>
|
||||
{t("Current version")} · {actor?.name}
|
||||
</>
|
||||
) : (
|
||||
t("{{userName}} edited", opts)
|
||||
);
|
||||
to = {
|
||||
pathname: documentHistoryPath(
|
||||
document,
|
||||
isDerivedFromDocument ? "latest" : event.id
|
||||
),
|
||||
state: {
|
||||
sidebarContext,
|
||||
retainScrollPosition: true,
|
||||
},
|
||||
};
|
||||
{
|
||||
if (event.deletedAt) {
|
||||
icon = <TrashIcon />;
|
||||
meta = t("Revision deleted");
|
||||
} else {
|
||||
icon = <EditIcon size={16} />;
|
||||
meta = event.latest ? (
|
||||
<>
|
||||
{t("Current version")} · {actor?.name}
|
||||
</>
|
||||
) : (
|
||||
t("{{userName}} edited", opts)
|
||||
);
|
||||
to = {
|
||||
pathname: documentHistoryPath(
|
||||
document,
|
||||
isDerivedFromDocument ? "latest" : event.id
|
||||
),
|
||||
state: {
|
||||
sidebarContext,
|
||||
retainScrollPosition: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "documents.archive":
|
||||
@@ -181,7 +191,7 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
|
||||
to = undefined;
|
||||
}
|
||||
|
||||
return event.name === "revisions.create" ? (
|
||||
return event.name === "revisions.create" && !event.deletedAt ? (
|
||||
<RevisionItem
|
||||
small
|
||||
exact
|
||||
@@ -218,7 +228,12 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
|
||||
</IconWrapper>
|
||||
<Text size="xsmall" type="secondary">
|
||||
{meta} ·{" "}
|
||||
<Time dateTime={event.createdAt} relative shorten addSuffix />
|
||||
<Time
|
||||
dateTime={event.deletedAt ?? event.createdAt}
|
||||
relative
|
||||
shorten
|
||||
addSuffix
|
||||
/>
|
||||
</Text>
|
||||
</EventItem>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import { useState } from "react";
|
||||
import styled from "styled-components";
|
||||
import { fadeIn } from "~/styles/animations";
|
||||
|
||||
@@ -21,7 +21,7 @@ type Props = {
|
||||
* Wraps children in a <Fade> if loading is true on mount.
|
||||
*/
|
||||
export const ConditionalFade = ({ animate, children }: Props) => {
|
||||
const [isAnimated] = React.useState(animate);
|
||||
const [isAnimated] = useState(animate);
|
||||
return isAnimated ? <Fade>{children}</Fade> : <>{children}</>;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Empty from "~/components/Empty";
|
||||
import Fade from "~/components/Fade";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BackIcon } from "outline-icons";
|
||||
import React from "react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { breakpoints, s, hover } from "@shared/styles";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import concat from "lodash/concat";
|
||||
import React from "react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { EmojiCategory, EmojiSkinTone, IconType } from "@shared/types";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import chunk from "lodash/chunk";
|
||||
import compact from "lodash/compact";
|
||||
import React from "react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { IconType } from "@shared/types";
|
||||
import { IconLibrary } from "@shared/utils/IconLibrary";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { IconType } from "@shared/types";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import { useMemo, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Menu, MenuButton, MenuItem, useMenuState } from "reakit";
|
||||
import styled from "styled-components";
|
||||
@@ -19,16 +19,13 @@ const SkinTonePicker = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handEmojiVariants = React.useMemo(
|
||||
() => getEmojiVariants({ id: "hand" }),
|
||||
[]
|
||||
);
|
||||
const handEmojiVariants = useMemo(() => getEmojiVariants({ id: "hand" }), []);
|
||||
|
||||
const menu = useMenuState({
|
||||
placement: "bottom-end",
|
||||
});
|
||||
|
||||
const handleSkinClick = React.useCallback(
|
||||
const handleSkinClick = useCallback(
|
||||
(emojiSkin) => {
|
||||
menu.hide();
|
||||
onChange(emojiSkin);
|
||||
@@ -36,7 +33,7 @@ const SkinTonePicker = ({
|
||||
[menu, onChange]
|
||||
);
|
||||
|
||||
const menuItems = React.useMemo(
|
||||
const menuItems = useMemo(
|
||||
() =>
|
||||
Object.entries(handEmojiVariants).map(([eskin, emoji]) => (
|
||||
<MenuItem {...menu} key={emoji.value}>
|
||||
|
||||
@@ -45,6 +45,7 @@ type Props = {
|
||||
onChange: (icon: string | null, color: string | null) => void;
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const IconPicker = ({
|
||||
@@ -59,6 +60,7 @@ const IconPicker = ({
|
||||
onOpen,
|
||||
onClose,
|
||||
borderOnHover,
|
||||
children,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -174,7 +176,9 @@ const IconPicker = ({
|
||||
onClick={handlePopoverButtonClick}
|
||||
$borderOnHover={borderOnHover}
|
||||
>
|
||||
{iconType && icon ? (
|
||||
{children ? (
|
||||
children
|
||||
) : iconType && icon ? (
|
||||
<Icon value={icon} color={color} size={size} initial={initial} />
|
||||
) : (
|
||||
<StyledSmileyIcon color={theme.placeholder} size={size} />
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
/** The size of the icon, 24px is default to match standard icons */
|
||||
size?: number;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { CollectionIcon, PrivateCollectionIcon } from "outline-icons";
|
||||
import { getLuminance } from "polished";
|
||||
import * as React from "react";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import Collection from "~/models/Collection";
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
/** The size of the icon, 24px is default to match standard icons */
|
||||
size?: number;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import * as React from "react";
|
||||
|
||||
export function LanguageIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
/** The size of the icon, 24px is default to match standard icons */
|
||||
size?: number;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
/** The size of the icon, 24px is default to match standard icons */
|
||||
size?: number;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
|
||||
import { transparentize } from "polished";
|
||||
import React from "react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Text from "~/components/Text";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { m } from "framer-motion";
|
||||
import find from "lodash/find";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { languages, languageOptions } from "@shared/i18n";
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as React from "react";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
export interface LazyComponent<T extends React.ComponentType<any>> {
|
||||
Component: React.LazyExoticComponent<T>;
|
||||
preload: () => Promise<{ default: T }>;
|
||||
}
|
||||
|
||||
interface LazyLoadOptions {
|
||||
retries?: number;
|
||||
interval?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a lazy-loaded component with preloading capability and automatic retries on failure.
|
||||
*
|
||||
* @param factory A function that returns a promise of a component (eg: () => import('./MyComponent'))
|
||||
* @param options Optional configuration for retry behavior
|
||||
* @returns An object containing the lazy Component and a preload function
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const MyComponent = createLazyComponent(() => import('./MyComponent'));
|
||||
*
|
||||
* function App() {
|
||||
* return (
|
||||
* <Suspense fallback={<div>Loading...</div>}>
|
||||
* <MyComponent.Component />
|
||||
* </Suspense>
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* // Preload when needed:
|
||||
* MyComponent.preload();
|
||||
* ```
|
||||
*/
|
||||
export function createLazyComponent<T extends React.ComponentType<any>>(
|
||||
factory: () => Promise<{ default: T }>,
|
||||
options: LazyLoadOptions = {}
|
||||
): LazyComponent<T> {
|
||||
const { retries, interval } = options;
|
||||
|
||||
return {
|
||||
Component: lazyWithRetry(factory, retries, interval),
|
||||
preload: factory,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { DisconnectedIcon, WarningIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import times from "lodash/times";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Fade from "~/components/Fade";
|
||||
import Flex from "~/components/Flex";
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useEffect } from "react";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
function LoadingIndicator() {
|
||||
const { ui } = useStores();
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
ui.enableProgressBar();
|
||||
return () => ui.disableProgressBar();
|
||||
}, [ui]);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from "react";
|
||||
import styled, { keyframes } from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Flex from "./Flex";
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { SubscribeIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
@@ -8,7 +8,7 @@ import { s, hover, truncateMultiline } from "@shared/styles";
|
||||
import Notification from "~/models/Notification";
|
||||
import CommentEditor from "~/scenes/Document/components/CommentEditor";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { Avatar, AvatarSize } from "../Avatar";
|
||||
import { Avatar, AvatarSize, AvatarVariant } from "../Avatar";
|
||||
import Flex from "../Flex";
|
||||
import Text from "../Text";
|
||||
import Time from "../Time";
|
||||
@@ -41,7 +41,7 @@ function NotificationListItem({ notification, onNavigate }: Props) {
|
||||
return (
|
||||
<StyledLink to={notification.path ?? ""} onClick={handleClick}>
|
||||
<Container gap={8} $unread={!notification.viewedAt}>
|
||||
<StyledAvatar model={notification.actor} size={AvatarSize.Large} />
|
||||
<StyledAvatar model={notification.actor} />
|
||||
<Flex column>
|
||||
<Text as="div" size="small">
|
||||
<Text weight="bold">
|
||||
@@ -79,7 +79,10 @@ const StyledCommentEditor = styled(CommentEditor)`
|
||||
${truncateMultiline(3)}
|
||||
`;
|
||||
|
||||
const StyledAvatar = styled(Avatar)`
|
||||
const StyledAvatar = styled(Avatar).attrs({
|
||||
variant: AvatarVariant.Round,
|
||||
size: AvatarSize.Medium,
|
||||
})`
|
||||
margin-top: 4px;
|
||||
`;
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useEffect } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { OAuthClientValidation } from "@shared/validations";
|
||||
import OAuthClient from "~/models/oauth/OAuthClient";
|
||||
import ImageInput from "~/scenes/Settings/components/ImageInput";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import Input, { LabelText } from "~/components/Input";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import Switch from "../Switch";
|
||||
|
||||
export interface FormData {
|
||||
name: string;
|
||||
developerName: string;
|
||||
developerUrl: string;
|
||||
description: string;
|
||||
avatarUrl: string;
|
||||
redirectUris: string[];
|
||||
published: boolean;
|
||||
}
|
||||
|
||||
export const OAuthClientForm = observer(function OAuthClientForm_({
|
||||
handleSubmit,
|
||||
oauthClient,
|
||||
}: {
|
||||
handleSubmit: (data: FormData) => void;
|
||||
oauthClient?: OAuthClient;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit: formHandleSubmit,
|
||||
formState,
|
||||
getValues,
|
||||
setFocus,
|
||||
setError,
|
||||
control,
|
||||
} = useForm<FormData>({
|
||||
mode: "all",
|
||||
defaultValues: {
|
||||
name: oauthClient?.name ?? "",
|
||||
description: oauthClient?.description ?? "",
|
||||
avatarUrl: oauthClient?.avatarUrl ?? "",
|
||||
redirectUris: oauthClient?.redirectUris ?? [],
|
||||
published: oauthClient?.published ?? false,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => setFocus("name", { shouldSelect: true }), 100);
|
||||
}, [setFocus]);
|
||||
|
||||
return (
|
||||
<form onSubmit={formHandleSubmit(handleSubmit)}>
|
||||
<>
|
||||
<label style={{ marginBottom: "1em" }}>
|
||||
<LabelText>{t("Icon")}</LabelText>
|
||||
<Controller
|
||||
control={control}
|
||||
name="avatarUrl"
|
||||
render={({ field }) => (
|
||||
<ImageInput
|
||||
onSuccess={(url) => field.onChange(url)}
|
||||
onError={(err) => setError("avatarUrl", { message: err })}
|
||||
model={{
|
||||
id: oauthClient?.id,
|
||||
avatarUrl: field.value,
|
||||
initial: getValues().name[0],
|
||||
}}
|
||||
borderRadius={0}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
label={t("Name")}
|
||||
placeholder={t("My App")}
|
||||
{...register("name", {
|
||||
required: true,
|
||||
maxLength: OAuthClientValidation.maxNameLength,
|
||||
})}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
flex
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
label={t("Tagline")}
|
||||
placeholder={t("A short description")}
|
||||
{...register("description", {
|
||||
maxLength: OAuthClientValidation.maxDescriptionLength,
|
||||
})}
|
||||
flex
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="redirectUris"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
type="textarea"
|
||||
label={t("Callback URLs")}
|
||||
placeholder="https://example.com/callback"
|
||||
ref={field.ref}
|
||||
value={field.value.join("\n")}
|
||||
rows={Math.max(2, field.value.length + 1)}
|
||||
onChange={(event) => {
|
||||
field.onChange(event.target.value.split("\n"));
|
||||
}}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{isCloudHosted && (
|
||||
<Switch
|
||||
{...register("published")}
|
||||
label={t("Published")}
|
||||
note={t("Allow this app to be installed by other workspaces")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
<Flex justify="flex-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={formState.isSubmitting || !formState.isValid}
|
||||
>
|
||||
{oauthClient
|
||||
? formState.isSubmitting
|
||||
? `${t("Saving")}…`
|
||||
: t("Save")
|
||||
: formState.isSubmitting
|
||||
? `${t("Creating")}…`
|
||||
: t("Create")}
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useCallback } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
import { OAuthClientForm, FormData } from "./OAuthClientForm";
|
||||
|
||||
type Props = {
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
export const OAuthClientNew = observer(function OAuthClientNew_({
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
const { oauthClients } = useStores();
|
||||
const history = useHistory();
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (data: FormData) => {
|
||||
try {
|
||||
const oauthClient = await oauthClients.save(data);
|
||||
onSubmit?.();
|
||||
history.push(settingsPath("applications", oauthClient.id));
|
||||
} catch (error) {
|
||||
toast.error(error.message);
|
||||
}
|
||||
},
|
||||
[oauthClients, history, onSubmit]
|
||||
);
|
||||
|
||||
return <OAuthClientForm handleSubmit={handleSubmit} />;
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as React from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useTheme } from "styled-components";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
@@ -6,7 +6,7 @@ export default function PageTheme() {
|
||||
const { ui } = useStores();
|
||||
const theme = useTheme();
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
// wider page background beyond the React root
|
||||
if (document.body) {
|
||||
document.body.style.background = theme.background;
|
||||
|
||||
@@ -2,7 +2,6 @@ import "../stores";
|
||||
import { render } from "@testing-library/react";
|
||||
import { TFunction } from "i18next";
|
||||
import { Provider } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { getI18n } from "react-i18next";
|
||||
import { Pagination } from "@shared/constants";
|
||||
import PaginatedList from "./PaginatedList";
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import fractionalIndex from "fractional-index";
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Pin from "~/models/Pin";
|
||||
@@ -44,12 +44,12 @@ function PinnedDocuments({
|
||||
...rest
|
||||
}: Props) {
|
||||
const { documents } = useStores();
|
||||
const [items, setItems] = React.useState(pins.map((pin) => pin.documentId));
|
||||
const showPlaceholderRef = React.useRef(true);
|
||||
const [items, setItems] = useState(pins.map((pin) => pin.documentId));
|
||||
const showPlaceholderRef = useRef(true);
|
||||
const showPlaceholder =
|
||||
placeholderCount && !items.length && showPlaceholderRef.current;
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
setItems(pins.map((pin) => pin.documentId));
|
||||
}, [pins]);
|
||||
|
||||
@@ -65,7 +65,7 @@ function PinnedDocuments({
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragEnd = React.useCallback(
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import DelayedMount from "~/components/DelayedMount";
|
||||
import Fade from "~/components/Fade";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Logger from "~/utils/Logger";
|
||||
import { Hook, usePluginValue } from "~/utils/PluginManager";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { transparentize } from "polished";
|
||||
import React from "react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s, hover } from "@shared/styles";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import compact from "lodash/compact";
|
||||
import { observer } from "mobx-react";
|
||||
import React from "react";
|
||||
import * as React from "react";
|
||||
import Comment from "~/models/Comment";
|
||||
import useHover from "~/hooks/useHover";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ReactionIcon } from "outline-icons";
|
||||
import React from "react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PopoverDisclosure, usePopoverState } from "reakit";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import Flex from "~/components/Flex";
|
||||
import { createLazyComponent } from "~/components/LazyLoad";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import PlaceholderText from "~/components/PlaceholderText";
|
||||
import Popover from "~/components/Popover";
|
||||
@@ -12,7 +13,7 @@ import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import useWindowSize from "~/hooks/useWindowSize";
|
||||
import Tooltip from "../Tooltip";
|
||||
|
||||
const EmojiPanel = React.lazy(
|
||||
const EmojiPanel = createLazyComponent(
|
||||
() => import("~/components/IconPicker/components/EmojiPanel")
|
||||
);
|
||||
|
||||
@@ -104,6 +105,7 @@ const ReactionPicker: React.FC<Props> = ({
|
||||
aria-label={t("Reaction picker")}
|
||||
className={className}
|
||||
onClick={handlePopoverButtonClick}
|
||||
onMouseEnter={() => EmojiPanel.preload()}
|
||||
size={size}
|
||||
>
|
||||
<ReactionIcon size={22} />
|
||||
@@ -123,7 +125,7 @@ const ReactionPicker: React.FC<Props> = ({
|
||||
{popover.visible && (
|
||||
<React.Suspense fallback={<Placeholder />}>
|
||||
<EventBoundary>
|
||||
<EmojiPanel
|
||||
<EmojiPanel.Component
|
||||
height={300}
|
||||
panelWidth={panelWidth}
|
||||
query={query}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import compact from "lodash/compact";
|
||||
import { observer } from "mobx-react";
|
||||
import React from "react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tab, TabPanel, useTabState } from "reakit";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import * as React from "react";
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
/**
|
||||
* Context to provide a reference to the scrollable container
|
||||
*/
|
||||
const ScrollContext = React.createContext<
|
||||
const ScrollContext = createContext<
|
||||
React.RefObject<HTMLDivElement> | undefined
|
||||
>(undefined);
|
||||
|
||||
/**
|
||||
* Hook to get the scrollable container reference
|
||||
*/
|
||||
export const useScrollContext = () => React.useContext(ScrollContext);
|
||||
export const useScrollContext = () => useContext(ScrollContext);
|
||||
|
||||
export default ScrollContext;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// based on: https://reacttraining.com/react-router/web/guides/scroll-restoration
|
||||
import * as React from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import { useScrollContext } from "./ScrollContext";
|
||||
@@ -13,7 +13,7 @@ export default function ScrollToTop({ children }: Props) {
|
||||
const previousLocationPathname = usePrevious(location.pathname);
|
||||
const scrollContainerRef = useScrollContext();
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (
|
||||
location.pathname === previousLocationPathname ||
|
||||
location.state?.retainScrollPosition
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useKBar } from "kbar";
|
||||
import * as React from "react";
|
||||
import { useEffect } from "react";
|
||||
import { searchDocumentsForQuery } from "~/actions/definitions/documents";
|
||||
import { navigateToRecentSearchQuery } from "~/actions/definitions/navigation";
|
||||
|
||||
@@ -9,7 +9,7 @@ import useStores from "~/hooks/useStores";
|
||||
export default function SearchActions() {
|
||||
const { searches } = useStores();
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (!searches.isLoaded && !searches.isFetching) {
|
||||
void searches.fetchPage({
|
||||
source: "app",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useCallback, Fragment } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
@@ -31,7 +31,7 @@ const DocumentMemberListItem = ({
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
const handleChange = useCallback(
|
||||
(permission: DocumentPermission | typeof EmptySelectValue) => {
|
||||
if (permission === EmptySelectValue) {
|
||||
onRemove?.();
|
||||
@@ -68,7 +68,7 @@ const DocumentMemberListItem = ({
|
||||
if (!currentPermission) {
|
||||
return null;
|
||||
}
|
||||
const MaybeLink = membership?.source ? StyledLink : React.Fragment;
|
||||
const MaybeLink = membership?.source ? StyledLink : Fragment;
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LinkIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useRef, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import CopyToClipboard from "~/components/CopyToClipboard";
|
||||
@@ -14,9 +14,9 @@ export function CopyLinkButton({
|
||||
onCopy: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const timeout = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
const timeout = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const handleCopied = React.useCallback(() => {
|
||||
const handleCopied = useCallback(() => {
|
||||
onCopy();
|
||||
|
||||
timeout.current = setTimeout(() => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Flex from "@shared/components/Flex";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import times from "lodash/times";
|
||||
import * as React from "react";
|
||||
import { AvatarSize } from "~/components/Avatar";
|
||||
import Fade from "~/components/Fade";
|
||||
import PlaceholderText from "~/components/PlaceholderText";
|
||||
|
||||
@@ -93,11 +93,13 @@ export const Suggestions = observer(
|
||||
const suggestions = React.useMemo(() => {
|
||||
const filtered: Suggestion[] = (
|
||||
document
|
||||
? users.notInDocument(document.id, query)
|
||||
? users
|
||||
.notInDocument(document.id, query)
|
||||
.filter((u) => u.id !== user.id)
|
||||
: collection
|
||||
? users.notInCollection(collection.id, query)
|
||||
: users.activeOrInvited
|
||||
).filter((u) => !u.isSuspended && u.id !== user.id);
|
||||
).filter((u) => !u.isSuspended);
|
||||
|
||||
if (isEmail(query)) {
|
||||
filtered.push(getSuggestionForEmail(query));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { SearchIcon, HomeIcon, SidebarIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useEffect, useState, useCallback, useMemo } from "react";
|
||||
import { DndProvider } from "react-dnd";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -12,7 +12,7 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import OrganizationMenu from "~/menus/OrganizationMenu";
|
||||
import TeamMenu from "~/menus/TeamMenu";
|
||||
import { homePath, searchPath } from "~/utils/routeHelpers";
|
||||
import TeamLogo from "../TeamLogo";
|
||||
import Tooltip from "../Tooltip";
|
||||
@@ -38,7 +38,7 @@ function AppSidebar() {
|
||||
const user = useCurrentUser();
|
||||
const can = usePolicy(team);
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
void collections.fetchAll();
|
||||
|
||||
if (!user.isViewer) {
|
||||
@@ -46,9 +46,9 @@ function AppSidebar() {
|
||||
}
|
||||
}, [documents, collections, user.isViewer]);
|
||||
|
||||
const [dndArea, setDndArea] = React.useState();
|
||||
const handleSidebarRef = React.useCallback((node) => setDndArea(node), []);
|
||||
const html5Options = React.useMemo(
|
||||
const [dndArea, setDndArea] = useState();
|
||||
const handleSidebarRef = useCallback((node) => setDndArea(node), []);
|
||||
const html5Options = useMemo(
|
||||
() => ({
|
||||
rootElement: dndArea,
|
||||
}),
|
||||
@@ -62,7 +62,7 @@ function AppSidebar() {
|
||||
<DndProvider backend={HTML5Backend} options={html5Options}>
|
||||
<DragPlaceholder />
|
||||
|
||||
<OrganizationMenu>
|
||||
<TeamMenu>
|
||||
{(props: SidebarButtonProps) => (
|
||||
<SidebarButton
|
||||
{...props}
|
||||
@@ -91,7 +91,7 @@ function AppSidebar() {
|
||||
</Tooltip>
|
||||
</SidebarButton>
|
||||
)}
|
||||
</OrganizationMenu>
|
||||
</TeamMenu>
|
||||
<Overflow>
|
||||
<Section>
|
||||
<SidebarLink
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import groupBy from "lodash/groupBy";
|
||||
import { observer } from "mobx-react";
|
||||
import { BackIcon, SidebarIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
@@ -23,14 +23,22 @@ import ToggleButton from "./components/ToggleButton";
|
||||
import Version from "./components/Version";
|
||||
|
||||
function SettingsSidebar() {
|
||||
const { ui } = useStores();
|
||||
const { ui, integrations } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const configs = useSettingsConfig();
|
||||
const groupedConfig = groupBy(configs, "group");
|
||||
|
||||
const returnToApp = React.useCallback(() => {
|
||||
const groupedConfig = groupBy(
|
||||
configs.filter((item) =>
|
||||
item.group === "Integrations" && item.pluginId
|
||||
? integrations.findByService(item.pluginId)
|
||||
: true
|
||||
),
|
||||
"group"
|
||||
);
|
||||
|
||||
const returnToApp = useCallback(() => {
|
||||
history.push("/home");
|
||||
}, [history]);
|
||||
|
||||
@@ -63,8 +71,9 @@ function SettingsSidebar() {
|
||||
<SidebarLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClickIntent={item.preload}
|
||||
active={
|
||||
item.path !== settingsPath()
|
||||
item.path.startsWith(settingsPath("templates"))
|
||||
? location.pathname.startsWith(item.path)
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { SidebarIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { hover } from "@shared/styles";
|
||||
|
||||
@@ -321,6 +321,7 @@ const Container = styled(Flex)<ContainerProps>`
|
||||
z-index: ${depths.mobileSidebar};
|
||||
max-width: 80%;
|
||||
min-width: 280px;
|
||||
padding-left: var(--sal);
|
||||
${fadeOnDesktopBackgrounded()}
|
||||
|
||||
@media print {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import isUndefined from "lodash/isUndefined";
|
||||
import { observer } from "mobx-react";
|
||||
import { ArchiveIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Flex from "@shared/components/Flex";
|
||||
import Collection from "~/models/Collection";
|
||||
@@ -21,43 +21,43 @@ function ArchiveLink() {
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [disclosure, setDisclosure] = React.useState<boolean>(false);
|
||||
const [expanded, setExpanded] = React.useState<boolean | undefined>();
|
||||
const [disclosure, setDisclosure] = useState<boolean>(false);
|
||||
const [expanded, setExpanded] = useState<boolean | undefined>();
|
||||
|
||||
const { request, data, loading, error } = useRequest(
|
||||
collections.fetchArchived,
|
||||
true
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (!isUndefined(data) && !loading && isUndefined(error)) {
|
||||
setDisclosure(data.length > 0);
|
||||
}
|
||||
}, [data, loading, error]);
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
setDisclosure(collections.archived.length > 0);
|
||||
}, [collections.archived]);
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (disclosure && isUndefined(expanded)) {
|
||||
setExpanded(false);
|
||||
}
|
||||
}, [disclosure]);
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (expanded) {
|
||||
void request();
|
||||
}
|
||||
}, [expanded, request]);
|
||||
|
||||
const handleDisclosureClick = React.useCallback((ev) => {
|
||||
const handleDisclosureClick = useCallback((ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setExpanded((e) => !e);
|
||||
}, []);
|
||||
|
||||
const handleClick = React.useCallback(() => {
|
||||
const handleClick = useCallback(() => {
|
||||
setExpanded(true);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as React from "react";
|
||||
import { useState, useCallback } from "react";
|
||||
import Collection from "~/models/Collection";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import CollectionLink from "./CollectionLink";
|
||||
@@ -13,15 +13,15 @@ type Props = {
|
||||
export function ArchivedCollectionLink({ collection, depth }: Props) {
|
||||
const { documents } = useStores();
|
||||
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const handleDisclosureClick = React.useCallback((ev) => {
|
||||
const handleDisclosureClick = useCallback((ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setExpanded((e) => !e);
|
||||
}, []);
|
||||
|
||||
const handleClick = React.useCallback(() => {
|
||||
const handleClick = useCallback(() => {
|
||||
setExpanded(true);
|
||||
}, []);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user