Compare commits

..

10 Commits

Author SHA1 Message Date
Tom Moor f8cd5f3e4b fix: Address PR feedback 2025-01-28 19:38:46 -05:00
Tom Moor 39852470cc Update API key list UI 2025-01-27 22:13:47 -05:00
Tom Moor 9a03e1c947 Update API key UI 2025-01-27 22:08:53 -05:00
Tom Moor cfaa08403a Store scopes with full url 2025-01-27 21:35:42 -05:00
Tom Moor 99bc586f34 Switch to storing array 2025-01-27 21:22:54 -05:00
Tom Moor 75838bb311 Merge branch 'main' into tom/api-scopes 2025-01-27 20:30:30 -05:00
Tom Moor 8b3115be9a test 2025-01-25 00:30:00 -05:00
Tom Moor 7782292500 Allow creation 2025-01-25 00:24:13 -05:00
Tom Moor a7da968499 Add scope restriction 2025-01-24 23:24:49 -05:00
Tom Moor a95005776f scope storage 2025-01-24 22:45:32 -05:00
1002 changed files with 15025 additions and 51946 deletions
+2 -7
View File
@@ -1,11 +1,6 @@
{
"presets": [
[
"@babel/preset-react",
{
"runtime": "automatic"
}
],
"@babel/preset-react",
"@babel/preset-env",
"@babel/preset-typescript"
],
@@ -65,4 +60,4 @@
]
}
}
}
}
+183
View File
@@ -0,0 +1,183 @@
version: 2.1
defaults: &defaults
working_directory: ~/outline
docker:
- image: cimg/node:20.10
resource_class: large
environment:
NODE_ENV: test
DATABASE_URL: postgres://postgres:password@localhost:5432/circle_test
URL: http://localhost:3000
NODE_OPTIONS: --max-old-space-size=8000
executors:
docker-publisher:
environment:
IMAGE_NAME: outlinewiki/outline
BASE_IMAGE_NAME: outlinewiki/outline-base
docker:
- image: circleci/buildpack-deps:stretch
jobs:
build:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: install-deps
command: yarn install --frozen-lockfile
- save_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
paths:
- ./node_modules
lint:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: lint
command: yarn lint
types:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: typescript
command: yarn tsc
test-app:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: test
command: yarn test:app
test-shared:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: test
command: yarn test:shared
test-server:
<<: *defaults
parallelism: 3
docker:
- image: cimg/node:20.10
- image: cimg/redis:5.0
- image: cimg/postgres:14.2
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: circle_test
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: migrate
command: ./node_modules/.bin/sequelize db:migrate
- run:
name: test
command: |
TESTFILES=$(circleci tests glob "**/server/**/*.test.ts" | circleci tests split)
yarn test --maxWorkers=2 $TESTFILES
bundle-size:
<<: *defaults
environment:
NODE_ENV: production
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: build-vite
command: yarn vite:build
- run:
name: Send bundle stats to RelativeCI
command: npx relative-ci-agent
build-image:
executor: docker-publisher
steps:
- checkout
- setup_remote_docker
- run:
name: Install Docker buildx
command: |
mkdir -p ~/.docker/cli-plugins
url="https://github.com/docker/buildx/releases/download/v0.8.0/buildx-v0.8.0.linux-amd64"
curl -sSL -o ~/.docker/cli-plugins/docker-buildx $url
chmod a+x ~/.docker/cli-plugins/docker-buildx
- run:
name: Enable Docker buildx
command: export DOCKER_CLI_EXPERIMENTAL=enabled
- run:
name: Initialize Docker buildx
command: |
docker buildx install
docker context create docker-multiarch
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
docker buildx create --name docker-multiarch --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x docker-multiarch
docker buildx inspect --builder docker-multiarch --bootstrap
docker buildx use docker-multiarch
- run:
name: Build base image
command: docker build -f Dockerfile.base -t $BASE_IMAGE_NAME:latest --load .
- run:
name: Login to Docker Hub
command: echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
- run:
name: Publish base Docker Image to Docker Hub
command: docker push $BASE_IMAGE_NAME:latest
- run:
name: Build and push Docker image
command: |
if [[ "$CIRCLE_TAG" == *"-"* ]]; then
docker buildx build -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
else
docker buildx build -t $IMAGE_NAME:latest -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
fi
workflows:
version: 2
all:
jobs:
- build
- lint:
requires:
- build
- test-server:
requires:
- build
- test-shared:
requires:
- build
- test-app:
requires:
- build
- types:
requires:
- build
- bundle-size:
requires:
- build
- types
build-docker:
jobs:
- build-image:
filters:
tags:
only: /^v.*/
branches:
ignore: /.*/
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
curl --user ${CIRCLE_TOKEN}: \
--request POST \
--form revision=<ENTER COMMIT SHA HERE>\
--form config=@config.yml \
--form notify=false \
https://circleci.com/api/v1.1/project/github/outline/outline/tree/master
-3
View File
@@ -1,8 +1,5 @@
URL=https://local.outline.dev:3000
DATABASE_URL=postgres://user:pass@127.0.0.1:5432/outline
REDIS_URL=redis://127.0.0.1:6379
SMTP_FROM_EMAIL=hello@example.com
# Enable unsafe-inline in script-src CSP directive
+138 -167
View File
@@ -1,80 +1,48 @@
# –––––––––––––––– REQUIRED ––––––––––––––––
NODE_ENV=production
# This URL should point to the fully qualified, publicly accessible, URL. If using a
# proxy this will be the proxy's URL.
URL=
# 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
# The port to expose the Outline server on, this should match what is configured
# in your docker-compose.yml
# 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@localhost: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://localhost:6379
# or alternatively, if you would like to provide additional connection options,
# use a base64 encoded JSON connection option object. Refer to the ioredis documentation
# for a list of available options.
# Example: Use Redis Sentinel for high availability
# {"sentinels":[{"host":"sentinel-0","port":26379},{"host":"sentinel-1","port":26379}],"name":"mymaster"}
# REDIS_URL=ioredis://eyJzZW50aW5lbHMiOlt7Imhvc3QiOiJzZW50aW5lbC0wIiwicG9ydCI6MjYzNzl9LHsiaG9zdCI6InNlbnRpbmVsLTEiLCJwb3J0IjoyNjM3OX1dLCJuYW1lIjoibXltYXN0ZXIifQ==
# URL should point to the fully qualified, publicly accessible URL. If using a
# proxy the port in URL and PORT may be different.
URL=
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" 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
# For "local", the avatar images and document attachments will be saved on local disk.
FILE_STORAGE=local
# If "local" is configured for FILE_STORAGE above, then this sets the parent directory under
# which all attachments/images are stored. Make sure that the process has permissions to
# create this path and also to write files to it.
# which all attachments/images go. 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.
@@ -88,8 +56,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
@@ -99,55 +67,38 @@ AWS_S3_UPLOAD_BUCKET_NAME=bucket_name_here
AWS_S3_FORCE_PATH_STYLE=true
AWS_S3_ACL=private
# ––––––––––––––––––––––––––––––––––––––
# –––––––––––––––– 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 ––––––––––
# ––––––––––––––––––––––––––––––––––––––
# –––––––––––––– AUTHENTICATION ––––––––––––––
# Third party signin credentials, at least ONE OF EITHER Google, Slack,
# Discord, or Microsoft is required for a working installation or you'll
# have no sign-in options.
# or Microsoft is required for a working installation or you'll have no sign-in
# options.
# Slack sign-in provider
# DOCS: https://docs.getoutline.com/s/hosting/doc/slack-sgMujR8J9J
# 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_CLIENT_ID=get_a_key_from_slack
SLACK_CLIENT_SECRET=get_the_secret_of_above_key
# Google sign-in provider
# DOCS: https://docs.getoutline.com/s/hosting/doc/google-hOuvtCmTqQ
# 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_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Microsoft Entra / Azure AD sign-in provider
# DOCS: https://docs.getoutline.com/s/hosting/doc/microsoft-entra-UVz6jsIOcv
# 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
AZURE_CLIENT_ID=
AZURE_CLIENT_SECRET=
AZURE_RESOURCE_APP_ID=
# 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
# 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
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_AUTH_URI=
@@ -165,54 +116,75 @@ OIDC_DISPLAY_NAME=OpenID Connect
# Space separated auth scopes.
OIDC_SCOPES=openid profile email
# ––––––––––––––––––––––––––––––––––––––
# –––––––––––––– 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
# To configure the GitHub integration, you'll need to create a GitHub App at
# => https://github.com/settings/apps
#
# When configuring the Client ID, add a redirect URL under "Permissions & events":
# https://<URL>/api/github.callback
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GITHUB_APP_NAME=
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
# 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=
# –––––––––––––––– 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 in addition to Slack authentication:
# DOCS: https://docs.getoutline.com/s/hosting/doc/slack-G2mc8DOJHk
# following configs are also needed, some more details
# => https://wiki.generaloutline.com/share/be25efd1-b3ef-4450-b8e5-c4a4fc11e02a
#
SLACK_VERIFICATION_TOKEN=your_token
SLACK_APP_ID=A0XXXXXXX
SLACK_MESSAGE_ACTIONS=true
@@ -222,34 +194,33 @@ SLACK_MESSAGE_ACTIONS=true
DROPBOX_APP_KEY=
# Optionally enable Sentry (sentry.io) to track errors and performance,
# DOCS: https://docs.getoutline.com/s/hosting/doc/sentry-jxcFttcDl5
# and optionally add a Sentry proxy tunnel for bypassing ad blockers in the UI:
# https://docs.sentry.io/platforms/javascript/troubleshooting/#using-the-tunnel-option)
SENTRY_DSN=
SENTRY_TUNNEL=
# Enable importing pages from a Notion workspace
# DOCS: https://docs.getoutline.com/s/hosting/doc/notion-2v6g7WY3l3
NOTION_CLIENT_ID=
NOTION_CLIENT_SECRET=
# To support sending outgoing transactional emails such as "document updated" or
# "you've been invited" you'll need to provide authentication for an SMTP server
SMTP_HOST=
SMTP_PORT=
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM_EMAIL=
SMTP_REPLY_EMAIL=
SMTP_TLS_CIPHERS=
SMTP_SECURE=true
# 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
# The default interface language. See translate.getoutline.com for a list of
# available language codes and their rough percentage translated.
DEFAULT_LANGUAGE=en_US
# Optionally enable rate limiter at application web server
RATE_LIMITER_ENABLED=true
# Configure default throttling parameters for rate limiter
RATE_LIMITER_REQUESTS=1000
RATE_LIMITER_DURATION_WINDOW=60
# Iframely API config
IFRAMELY_URL=
IFRAMELY_API_KEY=
# ––––––––––––––––––––––––––––––––––––––
# ––––––––––––– 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
+11 -58
View File
@@ -2,9 +2,7 @@
"parser": "@typescript-eslint/parser",
"parserOptions": {
"sourceType": "module",
"extraFileExtensions": [
".json"
],
"extraFileExtensions": [".json"],
"project": "./tsconfig.json",
"ecmaFeatures": {
"jsx": true
@@ -19,7 +17,6 @@
],
"plugins": [
"es",
"react",
"@typescript-eslint",
"eslint-plugin-import",
"eslint-plugin-node",
@@ -27,52 +24,28 @@
"eslint-plugin-lodash"
],
"rules": {
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "reakit/Menu",
"importNames": [
"useMenuState"
],
"message": "Do not use useMenuState from reakit/Menu. Use useMenuState instead."
}
]
}
],
"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/react-in-jsx-scope": "off",
"react/self-closing-comp": [
"error",
{
"component": true,
"html": true
}
],
"react/self-closing-comp": ["error", {
"component": true,
"html": true
}],
"@typescript-eslint/no-shadow": [
"warn",
{
"allow": [
"transaction"
],
"allow": ["transaction"],
"hoist": "all",
"ignoreTypeValueShadow": true
}
],
"@typescript-eslint/no-require-imports": "off",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/await-thenable": "error",
@@ -86,30 +59,13 @@
"error",
{
"argsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_",
"args": "after-used",
"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": ["warn", "method"],
"import/no-named-as-default": "off",
"import/no-named-as-default-member": "off",
"import/newline-after-import": 2,
@@ -178,10 +134,7 @@
"version": "detect"
},
"import/parsers": {
"@typescript-eslint/parser": [
".ts",
".tsx"
]
"@typescript-eslint/parser": [".ts", ".tsx"]
},
"import/resolver": {
"typescript": {}
-2
View File
@@ -15,8 +15,6 @@ requestInfoDefaultTitles:
requestInfoLabelToAdd: more information needed
requestInfoUserstoExclude:
- tommoor
# Configuration for new-pr-welcome - https://github.com/behaviorbot/new-pr-welcome
-59
View File
@@ -1,59 +0,0 @@
name: Auto Close Unsigned PRs
on:
schedule:
- cron: '0 0 * * *' # Run daily at midnight UTC
jobs:
close-unsigned-prs:
runs-on: ubuntu-latest
permissions:
pull-requests: write
issues: write
steps:
- name: Close unsigned PRs
uses: actions/github-script@v6
with:
script: |
const now = new Date();
const TWO_WEEKS = 14 * 24 * 60 * 60 * 1000; // 14 days in milliseconds
const prs = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open'
});
for (const pr of prs.data) {
const prCreatedAt = new Date(pr.created_at);
const prAge = now - prCreatedAt;
if (prAge < TWO_WEEKS) continue;
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number
});
const hasNotSignedComment = comments.data.some(comment =>
comment.body.toLowerCase().includes('https://cla-assistant.io/pull/badge/not_signed')
);
if (hasNotSignedComment) {
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
state: 'closed'
});
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: 'This PR has been automatically closed because it has been open for more than 14 days and has not accepted the CLA.'
});
}
}
-165
View File
@@ -1,165 +0,0 @@
name: CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
env:
NODE_ENV: test
DATABASE_URL: postgres://postgres:password@localhost:5432/outline_test
REDIS_URL: redis://127.0.0.1:6379
URL: http://localhost:3000
NODE_OPTIONS: --max-old-space-size=8192
SECRET_KEY: F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
UTILS_SECRET: 123456
SLACK_VERIFICATION_TOKEN: 123456
SMTP_USERNAME: localhost
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x]
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'yarn'
- name: Install dependencies
run: yarn install --frozen-lockfile
lint:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- run: yarn lint
types:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- run: yarn tsc
changes:
runs-on: ubuntu-latest
outputs:
server: ${{ steps.filter.outputs.server }}
app: ${{ steps.filter.outputs.app }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v2
id: filter
with:
filters: |
server:
- 'server/**'
- 'shared/**'
- 'package.json'
- 'yarn.lock'
app:
- 'app/**'
- 'shared/**'
- 'package.json'
- 'yarn.lock'
test:
needs: [build, changes]
if: ${{ needs.changes.outputs.app == 'true' }}
runs-on: ubuntu-latest
strategy:
matrix:
test-group: [app, shared]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- run: yarn test:${{ matrix.test-group }}
test-server:
needs: [build, changes]
if: ${{ needs.changes.outputs.server == 'true' }}
runs-on: ubuntu-latest
services:
postgres:
image: postgres:14.2
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: outline_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:5.0
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
strategy:
matrix:
shard: [1, 2, 3]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- run: yarn sequelize db:migrate
- name: Run server tests
run: |
TESTFILES=$(find . -name "*.test.ts" -path "*/server/*" | sort | split -n -d -l $(($(find . -name "*.test.ts" -path "*/server/*" | wc -l)/${{ matrix.shard }})) - | sed -n "${{ matrix.shard }}p")
yarn test --maxWorkers=2 $TESTFILES
bundle-size:
needs: [build, types, changes]
if: ${{ needs.changes.outputs.app == 'true' && github.repository == 'outline/outline' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- name: Set environment to production
run: echo "NODE_ENV=production" >> $GITHUB_ENV
- run: yarn vite:build
- name: Send bundle stats to RelativeCI
uses: relative-ci/agent-action@v2
with:
key: ${{ secrets.RELATIVE_CI_KEY }}
token: ${{ secrets.GITHUB_TOKEN }}
webpackStatsFile: ./build/app/webpack-stats.json
-212
View File
@@ -1,212 +0,0 @@
name: Docker
on:
push:
tags:
- "v*"
env:
IMAGE_NAME: outlinewiki/outline
BASE_IMAGE_NAME: outlinewiki/outline-base
jobs:
build-arm:
runs-on: ubicloud-standard-8-arm
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker base meta
id: base_meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.BASE_IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push base image
id: base_build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.base
labels: ${{ steps.base_meta.outputs.labels }}
tags: ${{ env.BASE_IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ env.IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
build-args: |
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}@${{ steps.base_build.outputs.digest }}
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-linux-arm64
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
build-amd:
runs-on: ubicloud-standard-8
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker base meta
id: base_meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.BASE_IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push base image
id: base_build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.base
labels: ${{ steps.base_meta.outputs.labels }}
tags: ${{ env.BASE_IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/amd64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ env.IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/amd64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
build-args: |
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}@${{ steps.base_build.outputs.digest }}
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-linux-amd64
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubicloud-standard-8
needs:
- build-amd
- build-arm
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
-30
View File
@@ -1,30 +0,0 @@
name: Lint
on:
pull_request:
branches: [ main ]
jobs:
run-linters:
if: startsWith(github.actor, 'codegen-sh')
name: Run linters
runs-on: ubuntu-latest
permissions:
# Give the default GITHUB_TOKEN write permission to commit and push the
# added or changed files to the repository.
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- run: yarn lint --fix
- name: Commit changes
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: 'Applied automatic fixes'
+1 -1
View File
@@ -24,6 +24,6 @@ jobs:
operations-per-run: 60
stale-issue-label: stale
stale-pr-label: stale
exempt-issue-labels: "security,pinned,A1"
exempt-issue-labels: "security,pinned"
- name: Print outputs
run: echo ${{ join(steps.stale.outputs.*, ',') }}
+2 -3
View File
@@ -1,6 +1,5 @@
ARG APP_PATH=/opt/outline
ARG BASE_IMAGE=outlinewiki/outline-base
FROM ${BASE_IMAGE} AS base
FROM outlinewiki/outline-base AS base
ARG APP_PATH
WORKDIR $APP_PATH
@@ -31,7 +30,7 @@ RUN addgroup --gid 1001 nodejs && \
adduser --uid 1001 --ingroup nodejs nodejs && \
chown -R nodejs:nodejs $APP_PATH/build && \
mkdir -p /var/lib/outline && \
chown -R nodejs:nodejs /var/lib/outline
chown -R nodejs:nodejs /var/lib/outline
ENV FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
RUN mkdir -p "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
+1 -4
View File
@@ -1,14 +1,11 @@
ARG APP_PATH=/opt/outline
FROM node:20 AS deps
FROM node:20-slim AS deps
ARG APP_PATH
WORKDIR $APP_PATH
COPY ./package.json ./yarn.lock ./
COPY ./patches ./patches
RUN apt-get update && apt-get install -y cmake
ENV NODE_OPTIONS="--max-old-space-size=24000"
RUN yarn install --no-optional --frozen-lockfile --network-timeout 1000000 && \
yarn cache clean
+3 -3
View File
@@ -3,8 +3,8 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.84.0
The Licensed Work is (c) 2025 General Outline, Inc.
Licensed Work: Outline 0.81.0
The Licensed Work is (c) 2024 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
Service.
@@ -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-05-11
Change Date: 2028-11-11
Change License: Apache License, Version 2.0
-4
View File
@@ -171,10 +171,6 @@
"description": "smtp.example.com (optional)",
"required": false
},
"SMTP_SERVICE": {
"description": "Well-known SMTP service name for nodemailer (optional, e.g. 'gmail', 'SES')",
"required": false
},
"SMTP_PORT": {
"description": "1234 (optional)",
"required": false
-3
View File
@@ -7,9 +7,6 @@
"plugins": [
"eslint-plugin-react-hooks"
],
"rules": {
"react/react-in-jsx-scope": "off"
},
"env": {
"jest": true,
"browser": true
+1
View File
@@ -1,4 +1,5 @@
import { PlusIcon } from "outline-icons";
import * as React from "react";
import stores from "~/stores";
import ApiKeyNew from "~/scenes/ApiKeyNew";
import { createAction } from "..";
+16 -78
View File
@@ -8,12 +8,12 @@ import {
SearchIcon,
ShapesIcon,
StarredIcon,
SubscribeIcon,
TrashIcon,
UnstarredIcon,
UnsubscribeIcon,
} from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
import stores from "~/stores";
import Collection from "~/models/Collection";
import { CollectionEdit } from "~/components/Collection/CollectionEdit";
import { CollectionNew } from "~/components/Collection/CollectionNew";
@@ -60,7 +60,7 @@ export const createCollection = createAction({
keywords: "create",
visible: ({ stores }) =>
stores.policies.abilities(stores.auth.team?.id || "").createCollection,
perform: ({ t, event, stores }) => {
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
@@ -76,10 +76,10 @@ export const editCollection = createAction({
analyticsName: "Edit collection",
section: ActiveCollectionSection,
icon: <EditIcon />,
visible: ({ activeCollectionId, stores }) =>
visible: ({ activeCollectionId }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId, stores }) => {
perform: ({ t, activeCollectionId }) => {
if (!activeCollectionId) {
return;
}
@@ -102,10 +102,10 @@ export const editCollectionPermissions = createAction({
analyticsName: "Collection permissions",
section: ActiveCollectionSection,
icon: <PadlockIcon />,
visible: ({ activeCollectionId, stores }) =>
visible: ({ activeCollectionId }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId, stores }) => {
perform: ({ t, activeCollectionId }) => {
if (!activeCollectionId) {
return;
}
@@ -133,7 +133,7 @@ export const searchInCollection = createAction({
analyticsName: "Search collection",
section: ActiveCollectionSection,
icon: <SearchIcon />,
visible: ({ activeCollectionId, stores }) => {
visible: ({ activeCollectionId }) => {
if (!activeCollectionId) {
return false;
}
@@ -148,7 +148,7 @@ export const searchInCollection = createAction({
},
perform: ({ activeCollectionId }) => {
history.push(searchPath({ collectionId: activeCollectionId }));
history.push(searchPath(undefined, { collectionId: activeCollectionId }));
},
});
@@ -158,7 +158,7 @@ export const starCollection = createAction({
section: ActiveCollectionSection,
icon: <StarredIcon />,
keywords: "favorite bookmark",
visible: ({ activeCollectionId, stores }) => {
visible: ({ activeCollectionId }) => {
if (!activeCollectionId) {
return false;
}
@@ -168,7 +168,7 @@ export const starCollection = createAction({
stores.policies.abilities(activeCollectionId).star
);
},
perform: async ({ activeCollectionId, stores }) => {
perform: async ({ activeCollectionId }) => {
if (!activeCollectionId) {
return;
}
@@ -185,7 +185,7 @@ export const unstarCollection = createAction({
section: ActiveCollectionSection,
icon: <UnstarredIcon />,
keywords: "unfavorite unbookmark",
visible: ({ activeCollectionId, stores }) => {
visible: ({ activeCollectionId }) => {
if (!activeCollectionId) {
return false;
}
@@ -195,7 +195,7 @@ export const unstarCollection = createAction({
stores.policies.abilities(activeCollectionId).unstar
);
},
perform: async ({ activeCollectionId, stores }) => {
perform: async ({ activeCollectionId }) => {
if (!activeCollectionId) {
return;
}
@@ -205,66 +205,6 @@ export const unstarCollection = createAction({
},
});
export const subscribeCollection = createAction({
name: ({ t }) => t("Subscribe"),
analyticsName: "Subscribe to collection",
section: ActiveCollectionSection,
icon: <SubscribeIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!collection?.isSubscribed &&
stores.policies.abilities(activeCollectionId).subscribe
);
},
perform: async ({ activeCollectionId, stores, t }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
await collection?.subscribe();
toast.success(t("Subscribed to document notifications"));
},
});
export const unsubscribeCollection = createAction({
name: ({ t }) => t("Unsubscribe"),
analyticsName: "Unsubscribe from collection",
section: ActiveCollectionSection,
icon: <UnsubscribeIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!!collection?.isSubscribed &&
stores.policies.abilities(activeCollectionId).unsubscribe
);
},
perform: async ({ activeCollectionId, currentUserId, stores, t }) => {
if (!activeCollectionId || !currentUserId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
await collection?.unsubscribe();
toast.success(t("Unsubscribed from document notifications"));
},
});
export const archiveCollection = createAction({
name: ({ t }) => `${t("Archive")}`,
analyticsName: "Archive collection",
@@ -337,13 +277,13 @@ export const deleteCollection = createAction({
section: ActiveCollectionSection,
dangerous: true,
icon: <TrashIcon />,
visible: ({ activeCollectionId, stores }) => {
visible: ({ activeCollectionId }) => {
if (!activeCollectionId) {
return false;
}
return stores.policies.abilities(activeCollectionId).delete;
},
perform: ({ activeCollectionId, t, stores }) => {
perform: ({ activeCollectionId, t }) => {
if (!activeCollectionId) {
return;
}
@@ -371,7 +311,7 @@ export const createTemplate = createAction({
section: ActiveCollectionSection,
icon: <ShapesIcon />,
keywords: "new create template",
visible: ({ activeCollectionId, stores }) =>
visible: ({ activeCollectionId }) =>
!!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).createDocument
@@ -391,7 +331,5 @@ export const rootCollectionActions = [
createCollection,
starCollection,
unstarCollection,
subscribeCollection,
unsubscribeCollection,
deleteCollection,
];
+1
View File
@@ -1,4 +1,5 @@
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";
+1 -34
View File
@@ -2,11 +2,11 @@ import copy from "copy-to-clipboard";
import {
BeakerIcon,
CopyIcon,
EditIcon,
ToolsIcon,
TrashIcon,
UserIcon,
} from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
import { createAction } from "~/actions";
import { DeveloperSection } from "~/actions/sections";
@@ -83,38 +83,6 @@ export const copyId = createAction({
},
});
function generateRandomText() {
const characters =
"abcdefghijklmno pqrstuvwxyzABCDEFGHIJKL MNOPQRSTUVWXYZ 0123456789\n";
let text = "";
for (let i = 0; i < Math.floor(Math.random() * 10) + 1; i++) {
text += characters.charAt(Math.floor(Math.random() * characters.length));
}
return text;
}
export const startTyping = createAction({
name: "Start automatic typing",
icon: <EditIcon />,
section: DeveloperSection,
visible: ({ activeDocumentId }) =>
!!activeDocumentId && env.ENVIRONMENT === "development",
perform: () => {
const intervalId = setInterval(() => {
const text = generateRandomText();
document.execCommand("insertText", false, text);
}, 250);
window.addEventListener("keydown", (event) => {
if (event.key === "Escape" && intervalId) {
clearInterval(intervalId);
}
});
toast.info("Automatic typing started, press Escape to stop");
},
});
export const clearIndexedDB = createAction({
name: ({ t }) => t("Clear IndexedDB cache"),
icon: <TrashIcon />,
@@ -201,7 +169,6 @@ export const developer = createAction({
createToast,
createTestUsers,
clearIndexedDB,
startTyping,
],
});
+13 -63
View File
@@ -29,8 +29,8 @@ import {
PadlockIcon,
GlobeIcon,
LogoutIcon,
CaseSensitiveIcon,
} from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
import Icon from "@shared/components/Icon";
import {
@@ -125,20 +125,6 @@ export const createDocument = createAction({
}),
});
export const createDraftDocument = createAction({
name: ({ t }) => t("New draft"),
analyticsName: "New document",
section: DocumentSection,
icon: <NewDocumentIcon />,
keywords: "create document",
visible: ({ currentTeamId, stores }) =>
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument,
perform: ({ sidebarContext }) =>
history.push(newDocumentPath(), {
sidebarContext,
}),
});
export const createDocumentFromTemplate = createAction({
name: ({ t }) => t("New from template"),
analyticsName: "New document",
@@ -333,7 +319,6 @@ export const subscribeDocument = createAction({
const document = stores.documents.get(activeDocumentId);
return (
!document?.collection?.isSubscribed &&
!document?.isSubscribed &&
stores.policies.abilities(activeDocumentId).subscribe
);
@@ -362,9 +347,8 @@ export const unsubscribeDocument = createAction({
const document = stores.documents.get(activeDocumentId);
return (
!!document?.collection?.isSubscribed ||
(!!document?.isSubscribed &&
stores.policies.abilities(activeDocumentId).unsubscribe)
!!document?.isSubscribed &&
stores.policies.abilities(activeDocumentId).unsubscribe
);
},
perform: async ({ activeDocumentId, stores, currentUserId, t }) => {
@@ -374,7 +358,7 @@ export const unsubscribeDocument = createAction({
const document = stores.documents.get(activeDocumentId);
await document?.unsubscribe();
await document?.unsubscribe(currentUserId);
toast.success(t("Unsubscribed from document notifications"));
},
@@ -510,25 +494,6 @@ export const copyDocumentAsMarkdown = createAction({
},
});
export const copyDocumentAsPlainText = createAction({
name: ({ t }) => t("Copy as text"),
section: ActiveDocumentSection,
keywords: "clipboard",
icon: <CaseSensitiveIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: ({ stores, activeDocumentId, t }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (document) {
copy(document.toPlainText());
toast.success(t("Text copied to clipboard"));
}
},
});
export const copyDocumentShareLink = createAction({
name: ({ t }) => t("Copy public link"),
section: ActiveDocumentSection,
@@ -574,12 +539,7 @@ export const copyDocument = createAction({
section: ActiveDocumentSection,
icon: <CopyIcon />,
keywords: "clipboard",
children: [
copyDocumentLink,
copyDocumentShareLink,
copyDocumentAsMarkdown,
copyDocumentAsPlainText,
],
children: [copyDocumentLink, copyDocumentShareLink, copyDocumentAsMarkdown],
});
export const duplicateDocument = createAction({
@@ -707,7 +667,6 @@ export const searchInDocument = createAction({
name: ({ t }) => t("Search in document"),
analyticsName: "Search document",
section: ActiveDocumentSection,
shortcut: [`Meta+/`],
icon: <SearchIcon />,
visible: ({ stores, activeDocumentId }) => {
if (!activeDocumentId) {
@@ -717,7 +676,7 @@ export const searchInDocument = createAction({
return !!document?.isActive;
},
perform: ({ activeDocumentId }) => {
history.push(searchPath({ documentId: activeDocumentId }));
history.push(searchPath(undefined, { documentId: activeDocumentId }));
},
});
@@ -750,7 +709,7 @@ export const importDocument = createAction({
return false;
},
perform: ({ activeDocumentId, activeCollectionId, stores }) => {
perform: ({ activeCollectionId, activeDocumentId, stores }) => {
const { documents } = stores;
const input = document.createElement("input");
input.type = "file";
@@ -830,15 +789,15 @@ export const openRandomDocument = createAction({
},
});
export const searchDocumentsForQuery = (query: string) =>
export const searchDocumentsForQuery = (searchQuery: string) =>
createAction({
id: "search",
name: ({ t }) =>
t(`Search documents for "{{searchQuery}}"`, { searchQuery: query }),
t(`Search documents for "{{searchQuery}}"`, { searchQuery }),
analyticsName: "Search documents",
section: DocumentSection,
icon: <SearchIcon />,
perform: () => history.push(searchPath({ query })),
perform: () => history.push(searchPath(searchQuery)),
visible: ({ location }) => location.pathname !== searchPath(),
});
@@ -1081,17 +1040,12 @@ export const openDocumentComments = createAction({
analyticsName: "Open comments",
section: ActiveDocumentSection,
icon: <CommentIcon />,
visible: ({ activeCollectionId, activeDocumentId, stores }) => {
visible: ({ activeDocumentId, stores }) => {
const can = stores.policies.abilities(activeDocumentId ?? "");
const collection = activeCollectionId
? stores.collections.get(activeCollectionId)
: undefined;
return (
!!activeDocumentId &&
can.comment &&
(collection?.canCreateComment ??
!!stores.auth.team?.getPreference(TeamPreference.Commenting))
!!stores.auth.team?.getPreference(TeamPreference.Commenting)
);
},
perform: ({ activeDocumentId, stores }) => {
@@ -1215,7 +1169,7 @@ export const leaveDocument = createAction({
} as UserMembership);
toast.success(t("You have left the shared document"));
} catch (_err) {
} catch (err) {
toast.error(t("Could not leave document"));
}
},
@@ -1225,8 +1179,6 @@ export const rootDocumentActions = [
openDocument,
archiveDocument,
createDocument,
createDraftDocument,
createNestedDocument,
createTemplateFromDocument,
deleteDocument,
importDocument,
@@ -1234,14 +1186,12 @@ export const rootDocumentActions = [
copyDocumentLink,
copyDocumentShareLink,
copyDocumentAsMarkdown,
copyDocumentAsPlainText,
starDocument,
unstarDocument,
publishDocument,
unpublishDocument,
subscribeDocument,
unsubscribeDocument,
searchInDocument,
duplicateDocument,
leaveDocument,
moveTemplateToWorkspace,
+2 -10
View File
@@ -13,6 +13,7 @@ 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";
@@ -49,7 +50,7 @@ export const navigateToRecentSearchQuery = (searchQuery: SearchQuery) =>
name: searchQuery.query,
analyticsName: "Navigate to recent search query",
icon: <SearchIcon />,
perform: () => history.push(searchPath({ query: searchQuery.query })),
perform: () => history.push(searchPath(searchQuery.query)),
});
export const navigateToDrafts = createAction({
@@ -61,15 +62,6 @@ export const navigateToDrafts = createAction({
visible: ({ location }) => location.pathname !== draftsPath(),
});
export const navigateToSearch = createAction({
name: ({ t }) => t("Search"),
analyticsName: "Navigate to search",
section: NavigationSection,
icon: <SearchIcon />,
perform: () => history.push(searchPath()),
visible: ({ location }) => location.pathname !== searchPath(),
});
export const navigateToArchive = createAction({
name: ({ t }) => t("Archive"),
analyticsName: "Navigate to archive",
@@ -1,4 +1,5 @@
import { ArchiveIcon, MarkAsReadIcon } from "outline-icons";
import * as React from "react";
import { createAction } from "..";
import { NotificationSection } from "../sections";
-24
View File
@@ -1,24 +0,0 @@
import { PlusIcon } from "outline-icons";
import stores from "~/stores";
import { OAuthClientNew } from "~/components/OAuthClient/OAuthClientNew";
import { createAction } from "..";
import { SettingsSection } from "../sections";
export const createOAuthClient = createAction({
name: ({ t }) => t("New App"),
analyticsName: "New App",
section: SettingsSection,
icon: <PlusIcon />,
keywords: "create",
visible: () =>
stores.policies.abilities(stores.auth.team?.id || "").createOAuthClient,
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("New Application"),
content: <OAuthClientNew onSubmit={stores.dialogs.closeAllModals} />,
});
},
});
+3 -34
View File
@@ -1,5 +1,6 @@
import copy from "copy-to-clipboard";
import { LinkIcon, RestoreIcon, TrashIcon } from "outline-icons";
import { LinkIcon, RestoreIcon } from "outline-icons";
import * as React from "react";
import { matchPath } from "react-router-dom";
import { toast } from "sonner";
import stores from "~/stores";
@@ -12,7 +13,7 @@ import {
} from "~/utils/routeHelpers";
export const restoreRevision = createAction({
name: ({ t }) => t("Restore"),
name: ({ t }) => t("Restore revision"),
analyticsName: "Restore revision",
icon: <RestoreIcon />,
section: RevisionSection,
@@ -41,38 +42,6 @@ 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
View File
@@ -1,4 +1,5 @@
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";
+4 -5
View File
@@ -1,4 +1,5 @@
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";
@@ -10,7 +11,7 @@ import { ActionContext } from "~/types";
import Desktop from "~/utils/Desktop";
import { TeamSection } from "../sections";
export const switchTeamsList = ({ stores }: { stores: RootStore }) =>
export const createTeamsList = ({ stores }: { stores: RootStore }) =>
stores.auth.availableTeams?.map((session) => ({
id: `switch-${session.id}`,
name: session.name,
@@ -43,7 +44,7 @@ export const switchTeam = createAction({
section: TeamSection,
visible: ({ stores }) =>
!!stores.auth.availableTeams && stores.auth.availableTeams?.length > 1,
children: switchTeamsList,
children: createTeamsList,
});
export const createTeam = createAction({
@@ -57,15 +58,13 @@ export const createTeam = createAction({
perform: ({ t, event, stores }) => {
event?.preventDefault();
event?.stopPropagation();
const { user } = stores.auth;
if (user) {
user &&
stores.dialogs.openModal({
title: t("Create a workspace"),
fullscreen: true,
content: <TeamNew user={user} />,
});
}
},
});
+1
View File
@@ -1,4 +1,5 @@
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
View File
@@ -1,4 +1,5 @@
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";
-2
View File
@@ -2,8 +2,6 @@ import { ActionContext } from "~/types";
export const CollectionSection = ({ t }: ActionContext) => t("Collection");
export const CollectionsSection = ({ t }: ActionContext) => t("Collections");
export const ActiveCollectionSection = ({ t, stores }: ActionContext) => {
const activeCollection = stores.collections.active;
return `${t("Collection")} · ${activeCollection?.name}`;
+2
View File
@@ -1,3 +1,5 @@
import * as React from "react";
export default function Arrow() {
return (
<svg
+4 -3
View File
@@ -1,10 +1,11 @@
import { observer } from "mobx-react";
import { useEffect } from "react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Redirect } from "react-router-dom";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { changeLanguage } from "~/utils/language";
import { logoutPath } from "~/utils/routeHelpers";
import LoadingIndicator from "./LoadingIndicator";
type Props = {
@@ -19,7 +20,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
useEffect(() => {
React.useEffect(() => {
void changeLanguage(language, i18n);
}, [i18n, language]);
@@ -32,7 +33,7 @@ const Authenticated = ({ children }: Props) => {
}
void auth.logout(true);
return <Redirect to="/" />;
return <Redirect to={logoutPath()} />;
};
export default observer(Authenticated);
+4 -19
View File
@@ -1,22 +1,15 @@
import { AnimatePresence } from "framer-motion";
import { observer } from "mobx-react";
import * as React from "react";
import {
Switch,
Route,
useLocation,
matchPath,
Redirect,
} from "react-router-dom";
import { Switch, Route, useLocation, matchPath } from "react-router-dom";
import { TeamPreference } from "@shared/types";
import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
import ErrorSuspended from "~/scenes/ErrorSuspended";
import Layout from "~/components/Layout";
import RegisterKeyDown from "~/components/RegisterKeyDown";
import Sidebar from "~/components/Sidebar";
import SidebarRight from "~/components/Sidebar/Right";
import SettingsSidebar from "~/components/Sidebar/Settings";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
@@ -49,13 +42,12 @@ type Props = {
};
const AuthenticatedLayout: React.FC = ({ children }: Props) => {
const { ui, auth, collections } = useStores();
const { ui, auth } = useStores();
const location = useLocation();
const layoutRef = React.useRef<HTMLDivElement>(null);
const can = usePolicy(ui.activeDocumentId);
const canCollection = usePolicy(ui.activeCollectionId);
const team = useCurrentTeam();
const [spendPostLoginPath] = usePostLoginPath();
const goToSearch = (ev: KeyboardEvent) => {
if (!ev.metaKey && !ev.ctrlKey) {
@@ -80,11 +72,6 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
return <ErrorSuspended />;
}
const postLoginPath = spendPostLoginPath();
if (postLoginPath) {
return <Redirect to={postLoginPath} />;
}
const sidebar = (
<Fade>
<Switch>
@@ -108,9 +95,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
can.comment &&
ui.activeDocumentId &&
ui.commentsExpanded &&
(ui.activeCollectionId
? collections.get(ui.activeCollectionId)?.canCreateComment
: !!team.getPreference(TeamPreference.Commenting));
team.getPreference(TeamPreference.Commenting);
const sidebarRight = (
<AnimatePresence
+23 -41
View File
@@ -7,15 +7,9 @@ export enum AvatarSize {
Small = 16,
Toast = 18,
Medium = 24,
Large = 28,
XLarge = 32,
XXLarge = 48,
Upload = 64,
}
export enum AvatarVariant {
Round = "round",
Square = "square",
Large = 32,
XLarge = 48,
XXLarge = 64,
}
export interface IAvatar {
@@ -26,50 +20,36 @@ 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. */
model?: IAvatar;
/** The alt text for the image */
alt?: string;
/** Optional click handler */
showBorder?: boolean;
onClick?: React.MouseEventHandler<HTMLImageElement>;
/** Optional class name */
className?: string;
/** Optional style */
style?: React.CSSProperties;
};
function Avatar(props: Props) {
const {
model,
style,
variant = AvatarVariant.Round,
className,
...rest
} = props;
const { showBorder, model, style, ...rest } = props;
const src = props.src || model?.avatarUrl;
const [error, handleError] = useBoolean(false);
return (
<Relative
style={style}
$variant={variant}
$size={props.size}
className={className}
>
<Relative style={style}>
{src && !error ? (
<Image onError={handleError} src={src} {...rest} />
<CircleImg
onError={handleError}
src={src}
$showBorder={showBorder}
{...rest}
/>
) : model ? (
<Initials color={model.color} {...rest}>
<Initials color={model.color} $showBorder={showBorder} {...rest}>
{model.initial}
</Initials>
) : (
<Initials {...rest} />
<Initials $showBorder={showBorder} {...rest} />
)}
</Relative>
);
@@ -79,21 +59,23 @@ Avatar.defaultProps = {
size: AvatarSize.Medium,
};
const Relative = styled.div<{ $variant: AvatarVariant; $size: AvatarSize }>`
const Relative = styled.div`
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 Image = styled.img<{ size: number }>`
const CircleImg = styled.img<{ size: number; $showBorder?: boolean }>`
display: block;
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
border-radius: 50%;
border: ${(props) =>
props.$showBorder === false
? "none"
: `2px solid ${props.theme.background}`};
flex-shrink: 0;
overflow: hidden;
`;
export default Avatar;
+5 -53
View File
@@ -5,45 +5,17 @@ import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import User from "~/models/User";
import Tooltip from "~/components/Tooltip";
import Avatar, { AvatarSize } from "./Avatar";
import Avatar from "./Avatar";
/**
* Props for the AvatarWithPresence component
*/
type Props = {
/** The user to display the avatar for */
user: User;
/** Whether the user is currently present in the document */
isPresent: boolean;
/** Whether the user is currently editing the document */
isEditing: boolean;
/** Whether the user is currently observing the document */
isObserving: boolean;
/** Whether this avatar represents the current user */
isCurrentUser: boolean;
/** Optional click handler for the avatar */
onClick?: React.MouseEventHandler<HTMLImageElement>;
/** Size of the avatar, defaults to AvatarSize.Large */
size?: AvatarSize;
/** Optional inline styles to apply to the avatar wrapper */
style?: React.CSSProperties;
};
/**
* AvatarWithPresence component displays a user's avatar with visual indicators
* for their current status (present, editing, observing).
*
* The component shows different visual states:
* - Present users have full opacity
* - Non-present users have reduced opacity
* - Observing users have a colored border matching their user color
* - Hovering shows a colored border
*
* A tooltip displays the user's name and current status.
*
* @param props - Component properties
* @returns React component
*/
function AvatarWithPresence({
onClick,
user,
@@ -51,8 +23,6 @@ function AvatarWithPresence({
isEditing,
isObserving,
isCurrentUser,
size = AvatarSize.Large,
style,
}: Props) {
const { t } = useTranslation();
const status = isPresent
@@ -77,47 +47,29 @@ function AvatarWithPresence({
}
placement="bottom"
>
<AvatarPresence
<AvatarWrapper
$isPresent={isPresent}
$isObserving={isObserving}
$color={user.color}
style={style}
>
<Avatar model={user} onClick={onClick} size={size} />
</AvatarPresence>
<Avatar model={user} onClick={onClick} size={32} />
</AvatarWrapper>
</Tooltip>
</>
);
}
/**
* Centered container for tooltip content
*/
const Centered = styled.div`
text-align: center;
`;
/**
* Props for the AvatarPresence styled component
*/
type AvatarWrapperProps = {
/** Whether the user is currently present */
$isPresent: boolean;
/** Whether the user is currently observing */
$isObserving: boolean;
/** The user's color for border highlighting */
$color: string;
};
/**
* Styled component that wraps the Avatar and provides visual indicators
* for the user's presence status.
*
* - Adjusts opacity based on presence
* - Adds colored borders for observing users
* - Handles hover effects
*/
const AvatarPresence = styled.div<AvatarWrapperProps>`
const AvatarWrapper = styled.div<AvatarWrapperProps>`
opacity: ${(props) => (props.$isPresent ? 1 : 0.5)};
transition: opacity 250ms ease-in-out;
border-radius: 50%;
+1
View File
@@ -1,4 +1,5 @@
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";
+9 -13
View File
@@ -1,31 +1,27 @@
import { getLuminance } from "polished";
import styled from "styled-components";
import { s } from "@shared/styles";
import Flex from "~/components/Flex";
const Initials = styled(Flex)<{
/** The color of the background, defaults to textTertiary. */
color?: string;
/** Content is only used to calculate font size, use children to render. */
content?: string;
/** The size of the avatar */
size: number;
$showBorder?: boolean;
}>`
align-items: center;
justify-content: center;
border-radius: 50%;
width: 100%;
height: 100%;
color: ${(props) =>
getLuminance(props.color ?? props.theme.textTertiary) > 0.5
? s("black50")
: s("white75")};
background-color: ${(props) => props.color ?? props.theme.textTertiary};
color: ${s("white75")};
background-color: ${(props) => props.color};
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
border-radius: 50%;
border: 2px solid
${(props) =>
props.$showBorder === false ? "transparent" : props.theme.background};
flex-shrink: 0;
// adjust font size down for each additional character
font-size: ${(props) => props.size / 2 - (props.content?.length ?? 0)}px;
font-size: ${(props) => props.size / 2}px;
font-weight: 500;
`;
+2 -2
View File
@@ -1,7 +1,7 @@
import Avatar, { IAvatar, AvatarSize, AvatarVariant } from "./Avatar";
import Avatar, { IAvatar, AvatarSize } from "./Avatar";
import AvatarWithPresence from "./AvatarWithPresence";
import { GroupAvatar } from "./GroupAvatar";
export { Avatar, GroupAvatar, AvatarSize, AvatarVariant, AvatarWithPresence };
export { Avatar, GroupAvatar, AvatarSize, AvatarWithPresence };
export type { IAvatar };
+1
View File
@@ -1,3 +1,4 @@
import * as React from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths, s } from "@shared/styles";
-4
View File
@@ -80,10 +80,6 @@ const RealButton = styled(ActionButton)<RealProps>`
} 0 0 0 1px inset;
}
&:focus-visible {
box-shadow: ${`rgba(0, 0, 0, 0.07) 0px 1px 2px, ${props.theme.inputBorderFocused} 0 0 0 1px inset`};
}
&:disabled {
color: ${props.theme.textTertiary};
background: none;
+2 -2
View File
@@ -1,4 +1,4 @@
import { useEffect } from "react";
import * as React 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();
useEffect(() => {
React.useEffect(() => {
void changeLanguage(locale, i18n);
}, [locale, i18n]);
+1
View File
@@ -1,3 +1,4 @@
import React from "react";
import styled, { useTheme } from "styled-components";
const cleanPercentage = (percentage: number) => {
+77 -138
View File
@@ -1,21 +1,19 @@
import * as Popover from "@radix-ui/react-popover";
import filter from "lodash/filter";
import isEqual from "lodash/isEqual";
import orderBy from "lodash/orderBy";
import uniq from "lodash/uniq";
import { observer } from "mobx-react";
import { useState, useMemo, useEffect, useCallback } from "react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { depths, s } from "@shared/styles";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import Document from "~/models/Document";
import { AvatarSize, AvatarWithPresence } from "~/components/Avatar";
import { AvatarWithPresence } from "~/components/Avatar";
import DocumentViews from "~/components/DocumentViews";
import Facepile from "~/components/Facepile";
import NudeButton from "~/components/NudeButton";
import Popover from "~/components/Popover";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { fadeAndScaleIn } from "~/styles/animations";
type Props = {
/** The document to display live collaborators for */
@@ -24,21 +22,6 @@ type Props = {
limit?: number;
};
// Styled components to match the original Popover styling
const StyledPopoverContent = styled(Popover.Content)`
animation: ${fadeAndScaleIn} 200ms ease;
transform-origin: 75% 0;
background: ${s("menuBackground")};
border-radius: 6px;
padding: 12px 24px;
max-height: 75vh;
box-shadow: ${s("menuShadow")};
z-index: ${depths.modal};
overflow-x: hidden;
overflow-y: auto;
outline: none;
`;
/**
* Displays a list of live collaborators for a document, including their avatars
* and presence status.
@@ -48,148 +31,104 @@ function Collaborators(props: Props) {
const { t } = useTranslation();
const user = useCurrentUser();
const currentUserId = user?.id;
const [requestedUserIds, setRequestedUserIds] = useState<string[]>([]);
const [popoverOpen, setPopoverOpen] = useState(false);
const [requestedUserIds, setRequestedUserIds] = React.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())
: [];
// 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]
);
const presentIds = documentPresenceArray.map((p) => p.userId);
const editingIds = documentPresenceArray
.filter((p) => p.isEditing)
.map((p) => p.userId);
// ensure currently present via websocket are always ordered first
// Memoize collaboratorIds as a Set for efficient lookup
const collaboratorIdsSet = useMemo(
() => new Set(document.collaboratorIds),
[document.collaboratorIds]
);
const collaborators = useMemo(
const collaborators = React.useMemo(
() =>
orderBy(
filter(
users.all,
users.orderedData,
(u) =>
(presentIds.has(u.id) || collaboratorIdsSet.has(u.id)) &&
(presentIds.includes(u.id) ||
document.collaboratorIds.includes(u.id)) &&
!u.isSuspended
),
[(u) => presentIds.has(u.id), "id"],
[(u) => presentIds.includes(u.id), "id"],
["asc", "asc"]
),
[collaboratorIdsSet, users.all, presentIds]
[document.collaboratorIds, users.orderedData, presentIds]
);
// load any users we don't yet have in memory
// 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]
);
React.useEffect(() => {
const ids = uniq([...document.collaboratorIds, ...presentIds])
.filter((userId) => !users.get(userId))
.sort();
useEffect(() => {
if (
!isEqual(requestedUserIds, missingUserIds) &&
missingUserIds.length > 0
) {
setRequestedUserIds(missingUserIds);
void users.fetchPage({ ids: missingUserIds, limit: 100 });
if (!isEqual(requestedUserIds, ids) && ids.length > 0) {
setRequestedUserIds(ids);
void users.fetchPage({ ids, limit: 100 });
}
}, [missingUserIds, requestedUserIds, users]);
}, [document, users, presentIds, document.collaboratorIds, requestedUserIds]);
// 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.has(collaborator.id);
const isEditing = editingIds.has(collaborator.id);
const isObserving = observingUserId === collaborator.id;
const isObservable = collaborator.id !== currentUserId;
return (
<AvatarWithPresence
{...rest}
key={collaborator.id}
user={collaborator}
isPresent={isPresent}
isEditing={isEditing}
isObserving={isObserving}
isCurrentUser={currentUserId === collaborator.id}
onClick={
isObservable
? handleAvatarClick(
collaborator.id,
isPresent,
isObserving,
isObservable
)
: undefined
}
/>
);
},
[presentIds, editingIds, observingUserId, currentUserId, handleAvatarClick]
);
const popover = usePopoverState({
gutter: 0,
placement: "bottom-end",
});
return (
<Popover.Root open={popoverOpen} onOpenChange={setPopoverOpen}>
<Popover.Trigger asChild>
<NudeButton
width={Math.min(collaborators.length, limit) * AvatarSize.Large}
height={AvatarSize.Large}
>
<Facepile
size={AvatarSize.Large}
limit={limit}
overflow={Math.max(0, collaborators.length - limit)}
users={collaborators}
renderAvatar={renderAvatar}
/>
</NudeButton>
</Popover.Trigger>
<Popover.Portal>
<StyledPopoverContent
side="bottom"
align="end"
sideOffset={0}
aria-label={t("Viewers")}
style={{ width: 300 }}
>
<DocumentViews document={document} />
</StyledPopoverContent>
</Popover.Portal>
</Popover.Root>
<>
<PopoverDisclosure {...popover}>
{(popoverProps) => (
<NudeButton
width={Math.min(collaborators.length, limit) * 32}
height={32}
{...popoverProps}
>
<Facepile
limit={limit}
overflow={collaborators.length - limit}
users={collaborators}
renderAvatar={(collaborator) => {
const isPresent = presentIds.includes(collaborator.id);
const isEditing = editingIds.includes(collaborator.id);
const isObserving = ui.observingUserId === collaborator.id;
const isObservable = collaborator.id !== user.id;
return (
<AvatarWithPresence
key={collaborator.id}
user={collaborator}
isPresent={isPresent}
isEditing={isEditing}
isObserving={isObserving}
isCurrentUser={currentUserId === collaborator.id}
onClick={
isObservable
? (ev) => {
if (isPresent) {
ev.preventDefault();
ev.stopPropagation();
ui.setObservingUser(
isObserving ? undefined : collaborator.id
);
}
}
: undefined
}
/>
);
}}
/>
</NudeButton>
)}
</PopoverDisclosure>
<Popover {...popover} width={300} aria-label={t("Viewers")} tabIndex={0}>
<DocumentViews document={document} isOpen={popover.visible} />
</Popover>
</>
);
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import { useCallback } from "react";
import * as React 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 = useCallback(
const handleSubmit = React.useCallback(
async (data: FormData) => {
try {
await collection?.save(data);
+17 -52
View File
@@ -1,12 +1,11 @@
import uniq from "lodash/uniq";
import { observer } from "mobx-react";
import { useMemo, useEffect, useCallback, Suspense } from "react";
import * as React from "react";
import { Controller, useForm } from "react-hook-form";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import { randomElement } from "@shared/random";
import { CollectionPermission, TeamPreference } from "@shared/types";
import { CollectionPermission } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
import { colorPalette } from "@shared/utils/collections";
import { CollectionValidation } from "@shared/validations";
@@ -15,16 +14,13 @@ 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";
import { createSwitchRegister } from "~/utils/forms";
const IconPicker = createLazyComponent(() => import("~/components/IconPicker"));
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
export interface FormData {
name: string;
@@ -32,29 +28,8 @@ export interface FormData {
color: string | null;
sharing: boolean;
permission: CollectionPermission | undefined;
commenting?: boolean | null;
}
const useIconColor = (collection?: Collection) => {
const { collections } = useStores();
const hasMultipleCollections = collections.orderedData.length > 1;
const collectionColors = uniq(
collections.orderedData.map((c) => c.color).filter(Boolean)
) as string[];
const iconColor = useMemo(
() =>
collection?.color ??
// If all the existing collections have the same color, use that color,
// otherwise pick a random color from the palette
(hasMultipleCollections && collectionColors.length === 1
? collectionColors[0]
: randomElement(colorPalette)),
[collection?.color]
);
return iconColor;
};
export const CollectionForm = observer(function CollectionForm_({
handleSubmit,
collection,
@@ -67,7 +42,11 @@ export const CollectionForm = observer(function CollectionForm_({
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
const iconColor = useIconColor(collection);
const iconColor = React.useMemo(
() => collection?.color ?? randomElement(colorPalette),
[collection?.color]
);
const fallbackIcon = <Icon value="collection" color={iconColor} />;
const {
@@ -85,19 +64,13 @@ export const CollectionForm = observer(function CollectionForm_({
icon: collection?.icon,
sharing: collection?.sharing ?? true,
permission: collection?.permission,
commenting: collection?.commenting ?? true,
color: iconColor,
},
});
const values = watch();
// Preload the IconPicker component on mount
useEffect(() => {
void IconPicker.preload();
}, []);
useEffect(() => {
React.useEffect(() => {
// If the user hasn't picked an icon yet, go ahead and suggest one based on
// the name of the collection. It's the little things sometimes.
if (!hasOpenedIconPicker && !collection) {
@@ -110,12 +83,12 @@ export const CollectionForm = observer(function CollectionForm_({
}
}, [collection, hasOpenedIconPicker, setValue, values.name, values.icon]);
useEffect(() => {
React.useEffect(() => {
setTimeout(() => setFocus("name", { shouldSelect: true }), 100);
}, [setFocus]);
const handleIconChange = useCallback(
(icon: string, color: string) => {
const handleIconChange = React.useCallback(
(icon: string, color: string | null) => {
if (icon !== values.icon) {
setFocus("name");
}
@@ -132,6 +105,7 @@ export const CollectionForm = observer(function CollectionForm_({
<Trans>
Collections are used to group documents and choose permissions
</Trans>
.
</Text>
<Flex gap={8}>
<Input
@@ -142,7 +116,7 @@ export const CollectionForm = observer(function CollectionForm_({
maxLength: CollectionValidation.maxNameLength,
})}
prefix={
<Suspense fallback={fallbackIcon}>
<React.Suspense fallback={fallbackIcon}>
<StyledIconPicker
icon={values.icon}
color={values.color ?? iconColor}
@@ -151,7 +125,7 @@ export const CollectionForm = observer(function CollectionForm_({
onOpen={setHasOpenedIconPicker}
onChange={handleIconChange}
/>
</Suspense>
</React.Suspense>
}
autoComplete="off"
autoFocus
@@ -188,16 +162,7 @@ export const CollectionForm = observer(function CollectionForm_({
note={t(
"Allow documents within this collection to be shared publicly on the internet."
)}
{...createSwitchRegister(register, "sharing")}
/>
)}
{team.getPreference(TeamPreference.Commenting) && (
<Switch
id="commenting"
label={t("Commenting")}
note={t("Allow commenting on documents within this collection.")}
{...createSwitchRegister(register, "commenting")}
{...register("sharing")}
/>
)}
@@ -219,7 +184,7 @@ export const CollectionForm = observer(function CollectionForm_({
);
});
const StyledIconPicker = styled(IconPicker.Component)`
const StyledIconPicker = styled(IconPicker)`
margin-left: 4px;
margin-right: 4px;
`;
+2 -2
View File
@@ -1,6 +1,6 @@
import { runInAction } from "mobx";
import { observer } from "mobx-react";
import { useCallback } from "react";
import * as React 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 = useCallback(
const handleSubmit = React.useCallback(
async (data: FormData) => {
try {
const collection = await collections.save(data);
@@ -1,4 +1,5 @@
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";
+237
View File
@@ -0,0 +1,237 @@
import debounce from "lodash/debounce";
import { observer } from "mobx-react";
import { transparentize } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { richExtensions } from "@shared/editor/nodes";
import { s } from "@shared/styles";
import Collection from "~/models/Collection";
import Arrow from "~/components/Arrow";
import ButtonLink from "~/components/ButtonLink";
import Editor from "~/components/Editor";
import LoadingIndicator from "~/components/LoadingIndicator";
import NudeButton from "~/components/NudeButton";
import BlockMenuExtension from "~/editor/extensions/BlockMenu";
import EmojiMenuExtension from "~/editor/extensions/EmojiMenu";
import HoverPreviewsExtension from "~/editor/extensions/HoverPreviews";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
const extensions = [
...richExtensions,
BlockMenuExtension,
EmojiMenuExtension,
HoverPreviewsExtension,
];
type Props = {
collection: Collection;
};
function CollectionDescription({ collection }: Props) {
const { collections } = useStores();
const { t } = useTranslation();
const [isExpanded, setExpanded] = React.useState(false);
const [isEditing, setEditing] = React.useState(false);
const [isDirty, setDirty] = React.useState(false);
const can = usePolicy(collection);
const handleStartEditing = React.useCallback(() => {
setEditing(true);
}, []);
const handleStopEditing = React.useCallback(() => {
setEditing(false);
}, []);
const handleClickDisclosure = React.useCallback(
(event) => {
event.preventDefault();
if (isExpanded && document.activeElement) {
// @ts-expect-error ts-migrate(2339) FIXME: Property 'blur' does not exist on type 'Element'.
document.activeElement.blur();
}
setExpanded(!isExpanded);
},
[isExpanded]
);
const handleSave = React.useMemo(
() =>
debounce(async (getValue) => {
try {
await collection.save({
data: getValue(false),
});
setDirty(false);
} catch (err) {
toast.error(t("Sorry, an error occurred saving the collection"));
throw err;
}
}, 1000),
[collection, t]
);
const handleChange = React.useCallback(
async (getValue) => {
setDirty(true);
await handleSave(getValue);
},
[handleSave]
);
React.useEffect(() => {
setEditing(false);
}, [collection.id]);
const placeholder = `${t("Add a description")}`;
const key = isEditing || isDirty ? "draft" : collection.updatedAt;
return (
<MaxHeight data-editing={isEditing} data-expanded={isExpanded}>
<Input data-editing={isEditing} data-expanded={isExpanded}>
<span onClick={can.update ? handleStartEditing : undefined}>
{collections.isSaving && <LoadingIndicator />}
{collection.hasDescription || isEditing || isDirty ? (
<React.Suspense
fallback={
<Placeholder
onClick={() => {
//
}}
>
Loading
</Placeholder>
}
>
<Editor
key={key}
defaultValue={collection.data}
onChange={handleChange}
placeholder={placeholder}
readOnly={!isEditing}
autoFocus={isEditing}
onBlur={handleStopEditing}
extensions={extensions}
maxLength={1000}
embedsDisabled
canUpdate
/>
</React.Suspense>
) : (
can.update && (
<Placeholder
onClick={() => {
//
}}
>
{placeholder}
</Placeholder>
)
)}
</span>
</Input>
{!isEditing && (
<Disclosure
onClick={handleClickDisclosure}
aria-label={isExpanded ? t("Collapse") : t("Expand")}
size={30}
>
<Arrow />
</Disclosure>
)}
</MaxHeight>
);
}
const Disclosure = styled(NudeButton)`
opacity: 0;
color: ${s("divider")};
position: absolute;
top: calc(25vh - 50px);
left: 50%;
z-index: 1;
transform: rotate(-90deg) translateX(-50%);
transition: opacity 100ms ease-in-out;
&:focus,
&:hover {
opacity: 1;
}
&:active {
color: ${s("sidebarText")};
}
`;
const Placeholder = styled(ButtonLink)`
color: ${s("placeholder")};
cursor: text;
min-height: 27px;
`;
const MaxHeight = styled.div`
position: relative;
max-height: 25vh;
overflow: hidden;
margin: 8px -8px -8px;
padding: 8px;
&[data-editing="true"],
&[data-expanded="true"] {
max-height: initial;
overflow: initial;
${Disclosure} {
top: initial;
bottom: 0;
transform: rotate(90deg) translateX(-50%);
}
}
&:hover ${Disclosure} {
opacity: 1;
}
`;
const Input = styled.div`
margin: -8px;
padding: 8px;
border-radius: 8px;
&:after {
content: "";
position: absolute;
top: calc(25vh - 50px);
left: 0;
right: 0;
height: 50px;
pointer-events: none;
background: linear-gradient(
180deg,
${(props) => transparentize(1, props.theme.background)} 0%,
${s("background")} 100%
);
}
&[data-editing="true"],
&[data-expanded="true"] {
&:after {
background: transparent;
}
}
&[data-editing="true"] {
background: ${s("backgroundSecondary")};
}
.block-menu-trigger,
.heading-anchor {
display: none !important;
}
`;
export default observer(CollectionDescription);
+1 -2
View File
@@ -3,7 +3,6 @@ import { ArrowIcon, BackIcon } from "outline-icons";
import * as React from "react";
import styled, { css, useTheme } from "styled-components";
import { s, ellipsis } from "@shared/styles";
import { normalizeKeyDisplay } from "@shared/utils/keyboard";
import Flex from "~/components/Flex";
import Key from "~/components/Key";
import Text from "~/components/Text";
@@ -71,7 +70,7 @@ function CommandBarItem(
""
)}
{sc.split("+").map((key) => (
<Key key={key}>{normalizeKeyDisplay(key)}</Key>
<Key key={key}>{key}</Key>
))}
</React.Fragment>
))}
@@ -1,4 +1,5 @@
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 { useMemo } from "react";
import * as React 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 useMemo(
return React.useMemo(
() =>
documents.recentlyViewed
.filter((document) => document.id !== ui.activeDocumentId)
@@ -1,5 +1,5 @@
import { SettingsIcon } from "outline-icons";
import { useMemo } from "react";
import * as React 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 = useMemo(
const actions = React.useMemo(
() =>
config.map((item) => {
const Icon = item.icon;
@@ -22,7 +22,7 @@ const useSettingsAction = () => {
[config]
);
const navigateToSettings = useMemo(
const navigateToSettings = React.useMemo(
() =>
createAction({
id: "settings",
@@ -1,5 +1,5 @@
import { NewDocumentIcon, ShapesIcon } from "outline-icons";
import { useEffect, useMemo } from "react";
import * as React 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();
useEffect(() => {
React.useEffect(() => {
void documents.fetchAllTemplates();
}, [documents]);
const actions = useMemo(
const actions = React.useMemo(
() =>
documents.templatesAlphabetical.map((template) =>
createAction({
@@ -61,7 +61,7 @@ const useTemplatesAction = () => {
[documents.templatesAlphabetical]
);
const newFromTemplate = useMemo(
const newFromTemplate = React.useMemo(
() =>
createAction({
id: "templates",
+1
View File
@@ -1,4 +1,5 @@
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";
+7 -25
View File
@@ -1,11 +1,10 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import { CollectionPermission, NavigationNode } from "@shared/types";
import type Collection from "~/models/Collection";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import useStores from "~/hooks/useStores";
import { AuthorizationError } from "~/utils/errors";
type Props = {
/** The navigation node to move, must represent a document. */
@@ -31,29 +30,12 @@ function ConfirmMoveDialog({ collection, item, ...rest }: Props) {
};
const handleSubmit = async () => {
try {
await documents.move({
documentId: item.id,
collectionId: collection.id,
...rest,
});
} catch (err) {
if (err instanceof AuthorizationError) {
toast.error(
t(
"You do not have permission to move {{ documentName }} to the {{ collectionName }} collection",
{
documentName: item.title,
collectionName: collection.name,
}
)
);
} else {
toast.error(err.message);
}
} finally {
dialogs.closeAllModals();
}
await documents.move({
documentId: item.id,
collectionId: collection.id,
...rest,
});
dialogs.closeAllModals();
};
return (
@@ -1,14 +1,9 @@
import { observer } from "mobx-react";
import { DisconnectedIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import {
AuthenticationFailed,
AuthorizationFailed,
DocumentTooLarge,
TooManyConnections,
} from "@shared/collaboration/CloseEvents";
import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
@@ -19,21 +14,21 @@ function ConnectionStatus() {
const { t } = useTranslation();
const codeToMessage = {
[DocumentTooLarge.code]: {
1009: {
title: t("Document is too large"),
body: t(
"This document has reached the maximum size and can no longer be edited"
),
},
[AuthenticationFailed.code]: {
4401: {
title: t("Authentication failed"),
body: t("Please try logging out and back in again"),
},
[AuthorizationFailed.code]: {
4403: {
title: t("Authorization failed"),
body: t("You may have lost access to this document, try reloading"),
},
[TooManyConnections.code]: {
4503: {
title: t("Too many users connected to document"),
body: t("Your edits will sync once other users leave the document"),
},
@@ -74,6 +69,10 @@ function ConnectionStatus() {
const Button = styled(NudeButton)`
display: none;
position: fixed;
bottom: 0;
margin: 20px;
transform: translateX(-32px);
${breakpoint("tablet")`
display: block;
-6
View File
@@ -13,7 +13,6 @@ import MenuIconWrapper from "./MenuIconWrapper";
type Props = {
id?: string;
onClick?: (event: React.MouseEvent) => void | Promise<void>;
onPointerMove?: (event: React.MouseEvent) => void | Promise<void>;
active?: boolean;
selected?: boolean;
disabled?: boolean;
@@ -32,7 +31,6 @@ type Props = {
const MenuItem = (
{
onClick,
onPointerMove,
children,
active,
selected,
@@ -92,7 +90,6 @@ const MenuItem = (
return (
<BaseMenuItem
onClick={disabled ? undefined : onClick}
onPointerMove={disabled ? undefined : onPointerMove}
disabled={disabled}
hide={hide}
{...rest}
@@ -161,9 +158,6 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
&:focus-visible {
color: ${props.theme.accentText};
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
outline-color: ${
props.dangerous ? props.theme.danger : props.theme.accent
};
box-shadow: none;
cursor: var(--pointer);
+11 -31
View File
@@ -3,6 +3,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import {
useMenuState,
MenuButton,
MenuItem as BaseMenuItem,
MenuStateReturn,
@@ -12,7 +13,6 @@ import MenuIconWrapper from "~/components/ContextMenu/MenuIconWrapper";
import Flex from "~/components/Flex";
import { actionToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import { useMenuState } from "~/hooks/useMenuState";
import {
Action,
ActionContext,
@@ -20,7 +20,6 @@ import {
MenuHeading,
MenuItem as TMenuItem,
} from "~/types";
import Tooltip from "../Tooltip";
import Header from "./Header";
import MenuItem, { MenuAnchor } from "./MenuItem";
import MouseSafeArea from "./MouseSafeArea";
@@ -52,9 +51,7 @@ const SubMenu = React.forwardRef(function _Template(
) {
const { t } = useTranslation();
const theme = useTheme();
const menu = useMenuState({
parentId: parentMenuState.baseId,
});
const menu = useMenuState();
return (
<>
@@ -140,7 +137,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
as={Link}
id={`${item.title}-${index}`}
to={item.to}
key={`${item.type}-${item.title}-${index}`}
key={index}
disabled={item.disabled}
selected={item.selected}
icon={showIcons !== false ? item.icon : undefined}
@@ -156,7 +153,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
<MenuItem
id={`${item.title}-${index}`}
href={item.href}
key={`${item.type}-${item.title}-${index}`}
key={index}
disabled={item.disabled}
selected={item.selected}
level={item.level}
@@ -170,7 +167,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
}
if (item.type === "button") {
const menuItem = (
return (
<MenuItem
as="button"
id={`${item.title}-${index}`}
@@ -178,34 +175,19 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
disabled={item.disabled}
selected={item.selected}
dangerous={item.dangerous}
key={`${item.type}-${item.title}-${index}`}
key={index}
icon={showIcons !== false ? item.icon : undefined}
{...menu}
>
{item.title}
</MenuItem>
);
return item.tooltip ? (
<Tooltip
content={item.tooltip}
placement={"bottom"}
key={`tooltip-${item.title}-${index}`}
>
<div>{menuItem}</div>
</Tooltip>
) : (
<React.Fragment key={`${item.type}-${item.title}-${index}`}>
{menuItem}
</React.Fragment>
);
}
if (item.type === "submenu") {
// Skip rendering empty submenus
return item.items.length > 0 ? (
return (
<BaseMenuItem
key={`${item.type}-${item.title}-${index}`}
key={index}
as={SubMenu}
id={`${item.title}-${index}`}
templateItems={item.items}
@@ -218,17 +200,15 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
}
{...menu}
/>
) : null;
);
}
if (item.type === "separator") {
return <Separator key={`separator-${index}`} />;
return <Separator key={index} />;
}
if (item.type === "heading") {
return (
<Header key={`heading-${item.title}-${index}`}>{item.title}</Header>
);
return <Header key={index}>{item.title}</Header>;
}
const _exhaustiveCheck: never = item;
+1 -3
View File
@@ -171,9 +171,7 @@ const InnerContextMenu = (props: InnerContextMenuProps) => {
});
}
return () => {
if (scrollElement && !props.isSubMenu) {
enableBodyScroll(scrollElement);
}
scrollElement && !props.isSubMenu && enableBodyScroll(scrollElement);
};
}, [props.isSubMenu, props.visible]);
+3 -7
View File
@@ -15,7 +15,7 @@ function CopyToClipboard(props: Props, ref: React.Ref<HTMLElement>) {
const onClick = React.useCallback(
(ev: React.MouseEvent<HTMLElement>) => {
const childElem = React.Children.only(children);
const elem = React.Children.only(children);
copy(text, {
debug: env.ENVIRONMENT !== "production",
@@ -24,12 +24,8 @@ function CopyToClipboard(props: Props, ref: React.Ref<HTMLElement>) {
onCopy?.();
if (
childElem &&
childElem.props &&
typeof childElem.props.onClick === "function"
) {
childElem.props.onClick(ev);
if (elem && elem.props && typeof elem.props.onClick === "function") {
elem.props.onClick(ev);
} else {
ev.preventDefault();
ev.stopPropagation();
+29 -14
View File
@@ -2,11 +2,16 @@ import { HomeIcon } from "outline-icons";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Optional } from "utility-types";
import Flex from "~/components/Flex";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { InputSelectNew, Option } from "~/components/InputSelectNew";
import InputSelect from "~/components/InputSelect";
import { IconWrapper } from "~/components/Sidebar/components/SidebarLink";
import useStores from "~/hooks/useStores";
type DefaultCollectionInputSelectProps = {
type DefaultCollectionInputSelectProps = Optional<
React.ComponentProps<typeof InputSelect>
> & {
onSelectCollection: (collection: string) => void;
defaultCollectionId: string | null;
};
@@ -14,6 +19,7 @@ type DefaultCollectionInputSelectProps = {
const DefaultCollectionInputSelect = ({
onSelectCollection,
defaultCollectionId,
...rest
}: DefaultCollectionInputSelectProps) => {
const { t } = useTranslation();
const { collections } = useStores();
@@ -41,26 +47,36 @@ const DefaultCollectionInputSelect = ({
void fetchData();
}, [fetchError, t, fetching, collections]);
const options: Option[] = React.useMemo(
const options = React.useMemo(
() =>
collections.nonPrivate.reduce(
(acc, collection) => [
...acc,
{
type: "item",
label: collection.name,
label: (
<Flex align="center">
<IconWrapper>
<CollectionIcon collection={collection} />
</IconWrapper>
{collection.name}
</Flex>
),
value: collection.id,
icon: <CollectionIcon collection={collection} />,
},
],
[
{
type: "item",
label: t("Home"),
label: (
<Flex align="center">
<IconWrapper>
<HomeIcon />
</IconWrapper>
{t("Home")}
</Flex>
),
value: "home",
icon: <HomeIcon />,
},
] satisfies Option[]
]
),
[collections.nonPrivate, t]
);
@@ -70,14 +86,13 @@ const DefaultCollectionInputSelect = ({
}
return (
<InputSelectNew
options={options}
<InputSelect
value={defaultCollectionId ?? "home"}
options={options}
onChange={onSelectCollection}
ariaLabel={t("Default collection")}
label={t("Start view")}
hideLabel
short
{...rest}
/>
);
};
+3 -3
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import * as React from "react";
type Props = {
delay?: number;
@@ -6,9 +6,9 @@ type Props = {
};
export default function DelayedMount({ delay = 250, children }: Props) {
const [isShowing, setShowing] = useState(false);
const [isShowing, setShowing] = React.useState(false);
useEffect(() => {
React.useEffect(() => {
const timeout = setTimeout(() => setShowing(true), delay);
return () => {
clearTimeout(timeout);
+3 -3
View File
@@ -1,4 +1,4 @@
import { useRef, useEffect } from "react";
import * as React 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 = useRef(false);
const hasDisabledUpdateMessage = React.useRef(false);
useEffect(() => {
React.useEffect(() => {
Desktop.bridge?.redirect((path: string, replace = false) => {
if (replace) {
history.replace(path);
+2 -4
View File
@@ -1,4 +1,5 @@
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";
@@ -22,10 +23,7 @@ function Dialogs() {
key={id}
isOpen={modal.isOpen}
fullscreen={modal.fullscreen ?? false}
onRequestClose={() => {
modal.onClose?.();
dialogs.closeModal(id);
}}
onRequestClose={() => dialogs.closeModal(id)}
title={modal.title}
style={modal.style}
>
+14 -48
View File
@@ -18,13 +18,6 @@ type Props = {
children?: React.ReactNode;
document: Document;
onlyText?: boolean;
reverse?: boolean;
/**
* Maximum number of items to show in the breadcrumb.
* If value is less than or equals to 0, no items will be shown.
* If value is undefined, all items will be shown.
*/
maxDepth?: number;
};
function useCategory(document: Document): MenuInternalLink | null {
@@ -61,7 +54,7 @@ function useCategory(document: Document): MenuInternalLink | null {
}
function DocumentBreadcrumb(
{ document, children, onlyText, reverse = false, maxDepth }: Props,
{ document, children, onlyText }: Props,
ref: React.RefObject<HTMLDivElement> | null
) {
const { collections } = useStores();
@@ -72,7 +65,6 @@ function DocumentBreadcrumb(
? collections.get(document.collectionId)
: undefined;
const can = usePolicy(collection);
const depth = maxDepth === undefined ? undefined : Math.max(0, maxDepth);
React.useEffect(() => {
void document.loadRelations({ withoutPolicies: true });
@@ -99,32 +91,28 @@ function DocumentBreadcrumb(
};
}
const path = document.pathTo.slice(0, -1);
const path = document.pathTo;
const items = React.useMemo(() => {
const output: MenuInternalLink[] = [];
if (depth === 0) {
return output;
}
const output = [];
if (category) {
output.push(category);
}
if (collectionNode) {
output.push(collectionNode);
}
path.forEach((node: NavigationNode) => {
const title = node.title || t("Untitled");
path.slice(0, -1).forEach((node: NavigationNode) => {
output.push({
type: "route",
title: node.icon ? (
<>
<StyledIcon value={node.icon} color={node.color} /> {title}
<StyledIcon value={node.icon} color={node.color} /> {node.title}
</>
) : (
title
node.title
),
to: {
pathname: node.url,
@@ -132,43 +120,21 @@ function DocumentBreadcrumb(
},
});
});
return reverse
? depth !== undefined
? output.slice(-depth)
: output
: depth !== undefined
? output.slice(0, depth)
: output;
}, [t, path, category, sidebarContext, collectionNode, reverse, depth]);
return output;
}, [path, category, sidebarContext, collectionNode]);
if (!collections.isLoaded) {
return null;
}
if (onlyText) {
if (depth === 0) {
return <></>;
}
const slicedPath = reverse
? path.slice(depth && -depth)
: path.slice(0, depth);
const showCollection =
collection &&
(!reverse || depth === undefined || slicedPath.length < depth);
if (onlyText === true) {
return (
<>
{showCollection && collection.name}
{slicedPath.map((node: NavigationNode, index: number) => (
{collection?.name}
{path.slice(0, -1).map((node: NavigationNode) => (
<React.Fragment key={node.id}>
{showCollection && <SmallSlash />}
{node.title || t("Untitled")}
{!showCollection && index !== slicedPath.length - 1 && (
<SmallSlash />
)}
<SmallSlash />
{node.title}
</React.Fragment>
))}
</>
+4 -4
View File
@@ -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 { useRef, useCallback, useMemo } from "react";
import * as React 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 = useRef(!pin?.collectionId).current;
const pinnedToHome = React.useRef(!pin?.collectionId).current;
const collection = document.collectionId
? collections.get(document.collectionId)
: undefined;
@@ -63,7 +63,7 @@ function DocumentCard(props: Props) {
transition,
};
const handleUnpin = useCallback(
const handleUnpin = React.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 = useMemo(() => document.toMarkdown(), [document]);
const markdown = React.useMemo(() => document.toMarkdown(), [document]);
const stats = useTextStats(markdown);
return (
+4 -4
View File
@@ -1,5 +1,5 @@
import { action, computed, observable } from "mobx";
import { createContext, useContext, useMemo, PropsWithChildren } from "react";
import React, { 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 = createContext<DocumentContext | null>(null);
const Context = React.createContext<DocumentContext | null>(null);
export const useDocumentContext = () => {
const ctx = useContext(Context);
const ctx = React.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 = useMemo(() => new DocumentContext(), []);
const context = React.useMemo(() => new DocumentContext(), []);
return <Context.Provider value={context}>{children}</Context.Provider>;
};
+17 -3
View File
@@ -46,6 +46,20 @@ function DocumentCopy({ document, onSubmit }: Props) {
return nodes;
}, [policies, collectionTrees, document.isTemplate]);
const handlePublishChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setPublish(ev.target.checked);
},
[]
);
const handleRecursiveChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setRecursive(ev.target.checked);
},
[]
);
const copy = async () => {
if (!selectedPath) {
toast.message(t("Select a location to copy"));
@@ -65,7 +79,7 @@ function DocumentCopy({ document, onSubmit }: Props) {
toast.success(t("Document copied"));
onSubmit(result);
} catch (_err) {
} catch (err) {
toast.error(t("Couldnt copy the document, try again?"));
}
};
@@ -88,7 +102,7 @@ function DocumentCopy({ document, onSubmit }: Props) {
label={t("Publish")}
labelPosition="right"
checked={publish}
onChange={setPublish}
onChange={handlePublishChange}
/>
</Text>
)}
@@ -99,7 +113,7 @@ function DocumentCopy({ document, onSubmit }: Props) {
label={t("Include nested documents")}
labelPosition="right"
checked={recursive}
onChange={setRecursive}
onChange={handleRecursiveChange}
/>
</Text>
)}
+7 -18
View File
@@ -15,7 +15,7 @@ import scrollIntoView from "scroll-into-view-if-needed";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Icon from "@shared/components/Icon";
import { NavigationNode, NavigationNodeType } from "@shared/types";
import { NavigationNode } from "@shared/types";
import { isModKey } from "@shared/utils/keyboard";
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
@@ -60,7 +60,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
if (defaultValue) {
const node = items.find((item) => item.id === defaultValue);
if (node) {
return ancestors(node).map((ancestorNode) => ancestorNode.id);
return ancestors(node).map((node) => node.id);
}
}
return [];
@@ -78,10 +78,6 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
const VERTICAL_PADDING = 6;
const HORIZONTAL_PADDING = 24;
const recentlyViewedItemIds = documents.recentlyViewed
.slice(0, 5)
.map((item) => item.id);
const searchIndex = React.useMemo(
() =>
new FuzzySearch(items, ["title"], {
@@ -99,10 +95,10 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
}, [searchTerm]);
React.useEffect(() => {
setItemRefs((existingItemRefs) =>
setItemRefs((itemRefs) =>
map(
fill(Array(items.length), 0),
(_, i) => existingItemRefs[i] || React.createRef()
(_, i) => itemRefs[i] || React.createRef()
)
);
}, [items.length]);
@@ -130,18 +126,11 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
return searchTerm
? searchIndex.search(searchTerm)
: items
.filter((item) => recentlyViewedItemIds.includes(item.id))
.concat(
items.filter((item) => item.type === NavigationNodeType.Collection)
)
.filter((item) => item.type === "collection")
.flatMap(includeDescendants);
}
const nodes = getNodes();
const baseDepth = nodes.reduce(
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
Infinity
);
const scrollNodeIntoView = React.useCallback(
(node: number) => {
@@ -180,7 +169,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
);
// remove children
const newNodes = filter(nodes, (n) => !includes(descendantIds, n.id));
const newNodes = filter(nodes, (node) => !includes(descendantIds, node.id));
const scrollOffset = calculateInitialScrollOffset(newNodes.length);
setInitialScrollOffset(scrollOffset);
};
@@ -315,7 +304,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
expanded={isExpanded(index)}
icon={renderedIcon}
title={title}
depth={(node.depth ?? 0) - baseDepth}
depth={node.depth as number}
hasChildren={hasChildren(index)}
ref={itemRefs[index]}
/>
+2 -2
View File
@@ -41,9 +41,9 @@ function DocumentExplorerNode(
) {
const { t } = useTranslation();
const OFFSET = 12;
const DISCLOSURE = 20;
const ICON_SIZE = 24;
const width = depth ? depth * DISCLOSURE + OFFSET : DISCLOSURE;
const width = depth ? depth * ICON_SIZE + OFFSET : ICON_SIZE;
return (
<Node
+1
View File
@@ -1,6 +1,7 @@
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";
+50 -64
View File
@@ -1,12 +1,12 @@
import compact from "lodash/compact";
import sortBy from "lodash/sortBy";
import { observer } from "mobx-react";
import { useMemo, useCallback } from "react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { dateLocale, dateToRelative } from "@shared/utils/date";
import Document from "~/models/Document";
import User from "~/models/User";
import { Avatar, AvatarSize } from "~/components/Avatar";
import { Avatar } from "~/components/Avatar";
import ListItem from "~/components/List/Item";
import PaginatedList from "~/components/PaginatedList";
import useCurrentUser from "~/hooks/useCurrentUser";
@@ -14,86 +14,72 @@ import useStores from "~/hooks/useStores";
type Props = {
document: Document;
isOpen?: boolean;
};
function DocumentViews({ document }: Props) {
function DocumentViews({ document, isOpen }: 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())
: [];
// 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]
);
const presentIds = documentPresenceArray.map((p) => p.userId);
const editingIds = documentPresenceArray
.filter((p) => p.isEditing)
.map((p) => p.userId);
// ensure currently present via websocket are always ordered first
const documentViews = useMemo(
() => views.inDocument(document.id),
[views, document.id]
const documentViews = views.inDocument(document.id);
const sortedViews = sortBy(
documentViews,
(view) => !presentIds.includes(view.userId)
);
const sortedViews = useMemo(
() => sortBy(documentViews, (view) => !presentIds.has(view.userId)),
[documentViews, presentIds]
);
const users = useMemo(
const users = React.useMemo(
() => compact(sortedViews.map((v) => v.user)),
[sortedViews]
);
// 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}
/>
<>
{isOpen && (
<PaginatedList
aria-label={t("Viewers")}
items={users}
renderItem={(model: User) => {
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={32} />}
border={false}
small
/>
);
}}
/>
)}
</>
);
}
+26 -55
View File
@@ -6,9 +6,7 @@ import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import { Optional } from "utility-types";
import insertFiles from "@shared/editor/commands/insertFiles";
import EditorContainer from "@shared/editor/components/Styles";
import { AttachmentPreset } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { getDataTransferFiles } from "@shared/utils/files";
import { AttachmentValidation } from "@shared/validations";
import ClickablePadding from "~/components/ClickablePadding";
@@ -19,7 +17,7 @@ import useDictionary from "~/hooks/useDictionary";
import useEditorClickHandlers from "~/hooks/useEditorClickHandlers";
import useEmbeds from "~/hooks/useEmbeds";
import useStores from "~/hooks/useStores";
import { uploadFile, uploadFileFromUrl } from "~/utils/files";
import { uploadFile } from "~/utils/files";
import lazyWithRetry from "~/utils/lazyWithRetry";
const LazyLoadedEditor = lazyWithRetry(() => import("~/editor"));
@@ -51,15 +49,11 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const previousCommentIds = React.useRef<string[]>();
const handleUploadFile = React.useCallback(
async (file: File | string) => {
const options = {
async (file: File) => {
const result = await uploadFile(file, {
documentId: id,
preset: AttachmentPreset.DocumentAttachment,
};
const result =
file instanceof File
? await uploadFile(file, options)
: await uploadFileFromUrl(file, options);
});
return result.url;
},
[id]
@@ -185,54 +179,31 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
[updateComments]
);
const paragraphs = React.useMemo(() => {
if (props.readOnly && typeof props.value === "object") {
return ProsemirrorHelper.getPlainParagraphs(props.value);
}
return undefined;
}, [props.readOnly, props.value]);
return (
<ErrorBoundary component="div" reloadOnChunkMissing>
<>
{paragraphs ? (
<EditorContainer
rtl={props.dir === "rtl"}
grow={props.grow}
style={props.style}
editorStyle={props.editorStyle}
>
<div className="ProseMirror">
{paragraphs.map((paragraph, index) => (
<p key={index} dir="auto">
{paragraph.content?.map((content) => content.text)}
</p>
))}
</div>
</EditorContainer>
) : (
<LazyLoadedEditor
key={props.extensions?.length || 0}
ref={mergeRefs([ref, localRef, handleRefChanged])}
uploadFile={handleUploadFile}
embeds={embeds}
userPreferences={preferences}
dictionary={dictionary}
{...props}
onClickLink={handleClickLink}
onChange={handleChange}
placeholder={props.placeholder || ""}
defaultValue={props.defaultValue || ""}
/>
)}
{props.editorStyle?.paddingBottom && !props.readOnly && (
<ClickablePadding
onClick={props.readOnly ? undefined : focusAtEnd}
onDrop={props.readOnly ? undefined : handleDrop}
onDragOver={props.readOnly ? undefined : handleDragOver}
minHeight={props.editorStyle.paddingBottom}
/>
)}
<LazyLoadedEditor
key={props.extensions?.length || 0}
ref={mergeRefs([ref, localRef, handleRefChanged])}
uploadFile={handleUploadFile}
embeds={embeds}
userPreferences={preferences}
dictionary={dictionary}
{...props}
onClickLink={handleClickLink}
onChange={handleChange}
placeholder={props.placeholder || ""}
defaultValue={props.defaultValue || ""}
/>
{props.editorStyle?.paddingBottom &&
(!props.readOnly || props.shareId) && (
<ClickablePadding
onClick={props.readOnly ? undefined : focusAtEnd}
onDrop={props.readOnly ? undefined : handleDrop}
onDragOver={props.readOnly ? undefined : handleDragOver}
minHeight={props.editorStyle.paddingBottom}
/>
)}
</>
</ErrorBoundary>
);
+5 -5
View File
@@ -1,9 +1,9 @@
import styled from "styled-components";
import Text from "~/components/Text";
import { s } from "@shared/styles";
const Empty = styled(Text).attrs({
type: "tertiary",
selectable: false,
})``;
const Empty = styled.p`
color: ${s("textTertiary")};
user-select: none;
`;
export default Empty;
+4 -5
View File
@@ -7,7 +7,6 @@ import { s } from "@shared/styles";
import { UrlHelper } from "@shared/utils/UrlHelper";
import Button from "~/components/Button";
import CenteredContent from "~/components/CenteredContent";
import Heading from "~/components/Heading";
import PageTitle from "~/components/PageTitle";
import Text from "~/components/Text";
import env from "~/env";
@@ -78,9 +77,9 @@ class ErrorBoundary extends React.Component<Props> {
{showTitle && (
<>
<PageTitle title={t("Module failed to load")} />
<Heading>
<h1>
<Trans>Loading Failed</Trans>
</Heading>
</h1>
</>
)}
<Text as="p" type="secondary">
@@ -102,9 +101,9 @@ class ErrorBoundary extends React.Component<Props> {
{showTitle && (
<>
<PageTitle title={t("Something Unexpected Happened")} />
<Heading>
<h1>
<Trans>Something Unexpected Happened</Trans>
</Heading>
</h1>
</>
)}
<Text as="p" type="secondary">
+92 -180
View File
@@ -7,76 +7,42 @@ import {
PublishIcon,
MoveIcon,
UnpublishIcon,
RestoreIcon,
UserIcon,
CrossIcon,
} from "outline-icons";
import { useRef } from "react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import styled, { css } from "styled-components";
import EventBoundary from "@shared/components/EventBoundary";
import { s, hover } from "@shared/styles";
import { RevisionHelper } from "@shared/utils/RevisionHelper";
import Document from "~/models/Document";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Item, { Actions } from "~/components/List/Item";
import Event from "~/models/Event";
import { Avatar } from "~/components/Avatar";
import Item, { Actions, Props as ItemProps } from "~/components/List/Item";
import Time from "~/components/Time";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useStores from "~/hooks/useStores";
import RevisionMenu from "~/menus/RevisionMenu";
import Logger from "~/utils/Logger";
import { documentHistoryPath } from "~/utils/routeHelpers";
import Text from "./Text";
export type RevisionEvent = {
name: "revisions.create";
latest: boolean;
};
export type DocumentEvent = {
name:
| "documents.publish"
| "documents.unpublish"
| "documents.archive"
| "documents.unarchive"
| "documents.delete"
| "documents.restore"
| "documents.add_user"
| "documents.remove_user"
| "documents.move";
userId: string;
};
export type Event = {
id: string;
actorId: string;
createdAt: string;
deletedAt?: string;
} & (RevisionEvent | DocumentEvent);
type Props = {
document: Document;
event: Event;
event: Event<Document>;
latest?: boolean;
};
const EventListItem = ({ event, document, ...rest }: Props) => {
const EventListItem = ({ event, latest, document, ...rest }: Props) => {
const { t } = useTranslation();
const { revisions, users } = useStores();
const actor = "actorId" in event ? users.get(event.actorId) : undefined;
const user = "userId" in event ? users.get(event.userId) : undefined;
const { revisions } = useStores();
const location = useLocation();
const sidebarContext = useLocationSidebarContext();
const revisionLoadedRef = useRef(false);
const opts = {
userName: actor?.name,
userName: event.actor.name,
};
const isRevision = event.name === "revisions.create";
const isDerivedFromDocument =
event.id === RevisionHelper.latestId(document.id);
let meta, icon, to: LocationDescriptor | undefined;
const ref = useRef<HTMLAnchorElement>(null);
const ref = React.useRef<HTMLAnchorElement>(null);
// the time component tends to steal focus when clicked
// ...so forward the focus back to the parent item
const handleTimeClick = () => {
@@ -84,93 +50,72 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
};
const prefetchRevision = async () => {
if (
!document.isDeleted &&
event.name === "revisions.create" &&
!event.deletedAt &&
!isDerivedFromDocument &&
!revisionLoadedRef.current
) {
await revisions.fetch(event.id, { force: true });
revisionLoadedRef.current = true;
if (event.name === "revisions.create" && event.modelId) {
await revisions.fetch(event.modelId);
}
};
switch (event.name) {
case "revisions.create":
{
if (event.deletedAt) {
icon = <TrashIcon />;
meta = t("Revision deleted");
} else {
icon = <EditIcon size={16} />;
meta = event.latest ? (
<>
{t("Current version")} &middot; {actor?.name}
</>
) : (
t("{{userName}} edited", opts)
);
to = {
pathname: documentHistoryPath(
document,
isDerivedFromDocument ? "latest" : event.id
),
state: {
sidebarContext,
retainScrollPosition: true,
},
};
}
}
icon = <EditIcon size={16} />;
meta = latest ? (
<>
{t("Current version")} &middot; {event.actor.name}
</>
) : (
t("{{userName}} edited", opts)
);
to = {
pathname: documentHistoryPath(document, event.modelId || "latest"),
state: {
sidebarContext,
retainScrollPosition: true,
},
};
break;
case "documents.archive":
icon = <ArchiveIcon />;
icon = <ArchiveIcon size={16} />;
meta = t("{{userName}} archived", opts);
break;
case "documents.unarchive":
icon = <RestoreIcon />;
meta = t("{{userName}} restored", opts);
break;
case "documents.delete":
icon = <TrashIcon />;
icon = <TrashIcon size={16} />;
meta = t("{{userName}} deleted", opts);
break;
case "documents.add_user":
icon = <UserIcon />;
meta = t("{{userName}} added {{addedUserName}}", {
...opts,
addedUserName: user?.name ?? t("a user"),
addedUserName: event.user?.name ?? t("a user"),
});
break;
case "documents.remove_user":
icon = <CrossIcon />;
meta = t("{{userName}} removed {{removedUserName}}", {
...opts,
removedUserName: user?.name ?? t("a user"),
removedUserName: event.user?.name ?? t("a user"),
});
break;
case "documents.restore":
icon = <RestoreIcon />;
meta = t("{{userName}} moved from trash", opts);
break;
case "documents.publish":
icon = <PublishIcon />;
icon = <PublishIcon size={16} />;
meta = t("{{userName}} published", opts);
break;
case "documents.unpublish":
icon = <UnpublishIcon />;
icon = <UnpublishIcon size={16} />;
meta = t("{{userName}} unpublished", opts);
break;
case "documents.move":
icon = <MoveIcon />;
icon = <MoveIcon size={16} />;
meta = t("{{userName}} moved", opts);
break;
@@ -191,8 +136,8 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
to = undefined;
}
return event.name === "revisions.create" && !event.deletedAt ? (
<RevisionItem
return (
<BaseItem
small
exact
to={to}
@@ -208,12 +153,17 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
onClick={handleTimeClick}
/>
}
image={<Avatar model={actor} size={AvatarSize.Large} />}
subtitle={meta}
image={<Avatar model={event.actor} size={32} />}
subtitle={
<Subtitle>
{icon}
{meta}
</Subtitle>
}
actions={
isRevision && isActive && !event.latest ? (
isRevision && isActive && event.modelId && !latest ? (
<StyledEventBoundary>
<RevisionMenu document={document} revisionId={event.id} />
<RevisionMenu document={document} revisionId={event.modelId} />
</StyledEventBoundary>
) : undefined
}
@@ -221,105 +171,63 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
ref={ref}
{...rest}
/>
) : (
<EventItem>
<IconWrapper size="xsmall" type="secondary">
{icon}
</IconWrapper>
<Text size="xsmall" type="secondary">
{meta} &middot;{" "}
<Time
dateTime={event.deletedAt ?? event.createdAt}
relative
shorten
addSuffix
/>
</Text>
</EventItem>
);
};
const lineStyle = css`
&::before {
content: "";
display: block;
position: absolute;
top: -8px;
left: 22px;
width: 1px;
height: calc(50% - 14px + 8px);
background: ${s("divider")};
mix-blend-mode: ${(props) => (props.theme.isDark ? "lighten" : "multiply")};
z-index: 1;
}
&:first-child::before {
display: none;
}
&:nth-child(2)::before {
display: none;
}
&::after {
content: "";
display: block;
position: absolute;
top: calc(50% + 14px);
left: 22px;
width: 1px;
height: calc(50% - 14px);
background: ${s("divider")};
mix-blend-mode: ${(props) => (props.theme.isDark ? "lighten" : "multiply")};
z-index: 1;
}
&:last-child::after {
display: none;
}
h3 + &::before {
display: none;
}
`;
const IconWrapper = styled(Text)`
height: 24px;
`;
const EventItem = styled.li`
display: flex;
align-items: center;
gap: 8px;
list-style: none;
margin: 8px 0;
padding: 4px 10px;
white-space: nowrap;
position: relative;
time {
white-space: nowrap;
}
svg {
flex-shrink: 0;
}
${lineStyle}
`;
const BaseItem = React.forwardRef(function _BaseItem(
{ to, ...rest }: ItemProps,
ref?: React.Ref<HTMLAnchorElement>
) {
return <ListItem to={to} ref={ref} {...rest} />;
});
const StyledEventBoundary = styled(EventBoundary)`
height: 24px;
`;
const RevisionItem = styled(Item)`
const Subtitle = styled.span`
svg {
margin: -3px;
margin-right: 2px;
}
`;
const ItemStyle = css`
border: 0;
position: relative;
margin: 8px 0;
padding: 8px;
border-radius: 8px;
${lineStyle}
img {
border-color: transparent;
}
&::before {
content: "";
display: block;
position: absolute;
top: -4px;
left: 23px;
width: 2px;
height: calc(100% + 8px);
background: ${s("textSecondary")};
opacity: 0.25;
}
&:nth-child(2)::before {
height: 50%;
top: auto;
bottom: -4px;
}
&:last-child::before {
height: 50%;
}
&:first-child:last-child::before {
display: none;
}
${Actions} {
opacity: 0.5;
@@ -330,4 +238,8 @@ const RevisionItem = styled(Item)`
}
`;
const ListItem = styled(Item)`
${ItemStyle}
`;
export default observer(EventListItem);
+38 -62
View File
@@ -1,26 +1,17 @@
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
import User from "~/models/User";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import Initials from "./Avatar/Initials";
type Props = {
/** The users to display */
users: User[];
/** The size of the avatars, defaults to AvatarSize.Large */
size?: number;
/** A number to show as the number of additional users */
overflow?: number;
/** The maximum number of users to display, defaults to 8 */
limit?: number;
/** A component to render the avatar, defaults to Avatar. */
renderAvatar?: React.ComponentType<
React.ComponentProps<typeof Avatar> & {
model: User;
}
>;
renderAvatar?: (user: User) => React.ReactNode;
};
function Facepile({
@@ -28,70 +19,55 @@ function Facepile({
overflow = 0,
size = AvatarSize.Large,
limit = 8,
renderAvatar = Avatar,
renderAvatar = DefaultAvatar,
...rest
}: Props) {
const filtered = users.filter(Boolean).slice(-limit);
const Component = renderAvatar;
return (
<Avatars {...rest}>
{overflow > 0 && (
<Initials size={size} content={String(overflow)}>
{users.length ? "+" : ""}
{overflow}
</Initials>
<More size={size}>
<span>
{users.length ? "+" : ""}
{overflow}
</span>
</More>
)}
{filtered.map((model, index) => {
const lastChild = index === 0 && overflow <= 0;
return (
<Component
key={model.id}
{...{
model,
size,
style: {
marginRight: lastChild ? 0 : -4,
...(lastChild || filtered.length === 1
? {}
: { clipPath: `url(#${clipPathId(size)})` }),
},
}}
/>
);
})}
<FacepileClip size={size} />
{users
.filter(Boolean)
.slice(0, limit)
.map((user) => (
<AvatarWrapper key={user.id}>{renderAvatar(user)}</AvatarWrapper>
))}
</Avatars>
);
}
function FacepileClip({ size }: { size: number }) {
return (
<SVG
width="25"
height="28"
viewBox="0 0 25 28"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<clipPath id={clipPathId(size)}>
<path
transform={size !== 28 ? `scale(${size / 28})` : ""}
d="M14.0633 0.5C18.1978 0.5 21.8994 2.34071 24.3876 5.24462C22.8709 7.81315 22.0012 10.8061 22.0012 14C22.0012 17.1939 22.8709 20.1868 24.3876 22.7554C21.8994 25.6593 18.1978 27.5 14.0633 27.5C6.57035 27.5 0.5 21.4537 0.5 14C0.5 6.54628 6.57035 0.5 14.0633 0.5Z"
/>
</clipPath>
</SVG>
);
function DefaultAvatar(user: User) {
return <Avatar model={user} size={AvatarSize.Large} />;
}
function clipPathId(size: number) {
return `facepile-${size}`;
}
const AvatarWrapper = styled.div`
margin-right: -8px;
const SVG = styled.svg`
position: absolute;
top: 0;
left: 0;
&:first-child {
margin-right: 0;
}
`;
const More = styled.div<{ size: number }>`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
border-radius: 100%;
background: ${(props) => props.theme.textTertiary};
color: ${s("white")};
border: 2px solid ${s("background")};
text-align: center;
font-size: 12px;
font-weight: 600;
`;
const Avatars = styled(Flex)`
+8
View File
@@ -0,0 +1,8 @@
import styled from "styled-components";
import { fadeIn } from "~/styles/animations";
const Fade = styled.span<{ timing?: number | string }>`
animation: ${fadeIn} ${(props) => props.timing || "250ms"} ease-in-out;
`;
export default Fade;
-28
View File
@@ -1,28 +0,0 @@
import { useState } from "react";
import styled from "styled-components";
import { fadeIn } from "~/styles/animations";
/**
* Fade in animation for a component.
*
* @param timing - The duration of the fade in animation, default is 250ms.
*/
const Fade = styled.span<{ timing?: number | string }>`
animation: ${fadeIn} ${(props) => props.timing || "250ms"} ease-in-out;
`;
type Props = {
children?: JSX.Element | null;
/** If true, children will be animated. */
animate: boolean;
};
/**
* Wraps children in a <Fade> if loading is true on mount.
*/
export const ConditionalFade = ({ animate, children }: Props) => {
const [isAnimated] = useState(animate);
return isAnimated ? <Fade>{children}</Fade> : <>{children}</>;
};
export default Fade;
+19 -17
View File
@@ -1,7 +1,7 @@
import deburr from "lodash/deburr";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { MenuButton } from "reakit/Menu";
import { useMenuState, MenuButton } from "reakit/Menu";
import styled from "styled-components";
import { s } from "@shared/styles";
import type { FetchPageParams } from "~/stores/base/Store";
@@ -9,7 +9,6 @@ import Button, { Inner } from "~/components/Button";
import ContextMenu from "~/components/ContextMenu";
import MenuItem from "~/components/ContextMenu/MenuItem";
import Text from "~/components/Text";
import { useMenuState } from "~/hooks/useMenuState";
import Input, { NativeInput, Outline } from "./Input";
import PaginatedList, { PaginatedItem } from "./PaginatedList";
@@ -24,6 +23,7 @@ type Props = {
options: TFilterOption[];
selectedKeys: (string | null | undefined)[];
defaultLabel?: string;
selectedPrefix?: string;
className?: string;
onSelect: (key: string | null | undefined) => void;
showFilter?: boolean;
@@ -35,6 +35,7 @@ const FilterOptions = ({
options,
selectedKeys = [],
defaultLabel = "Filter options",
selectedPrefix = "",
className,
onSelect,
showFilter,
@@ -53,11 +54,13 @@ const FilterOptions = ({
const [query, setQuery] = React.useState("");
const selectedLabel = selectedItems.length
? selectedItems.map((selected) => selected.label).join(", ")
? selectedItems
.map((selected) => `${selectedPrefix} ${selected.label}`)
.join(", ")
: "";
const renderItem = React.useCallback(
(option) => (
(option: TFilterOption) => (
<MenuItem
key={option.key}
onClick={() => {
@@ -67,7 +70,7 @@ const FilterOptions = ({
selected={selectedKeys.includes(option.key)}
{...menu}
>
{option.icon}
{option.icon && <Icon>{option.icon}</Icon>}
{option.note ? (
<LabelWithNote>
{option.label}
@@ -160,22 +163,16 @@ const FilterOptions = ({
const showFilterInput = showFilter || options.length > 10;
return (
<>
<div>
<MenuButton {...menu}>
{(props) => (
<StyledButton
{...props}
className={className}
icon={selectedItems[0]?.key && selectedItems[0]?.icon}
neutral
disclosure
>
<StyledButton {...props} className={className} neutral disclosure>
{selectedItems.length ? selectedLabel : defaultLabel}
</StyledButton>
)}
</MenuButton>
<ContextMenu aria-label={defaultLabel} minHeight={66} {...menu}>
<PaginatedList<TFilterOption>
<PaginatedList
listRef={listRef}
options={{ query, ...fetchQueryOptions }}
items={filteredOptions}
@@ -196,7 +193,7 @@ const FilterOptions = ({
/>
)}
</ContextMenu>
</>
</div>
);
};
@@ -234,7 +231,6 @@ const SearchInput = styled(Input)`
border-radius: 0;
border-bottom: 1px solid ${s("divider")};
background: ${s("menuBackground")};
margin: 0;
}
${NativeInput} {
@@ -271,9 +267,15 @@ export const StyledButton = styled(Button)`
}
${Inner} {
line-height: 28px;
line-height: 24px;
min-height: auto;
}
`;
const Icon = styled.div`
margin-right: 8px;
width: 18px;
height: 18px;
`;
export default FilterOptions;
+1
View File
@@ -1,3 +1,4 @@
import * as React from "react";
import styled from "styled-components";
import Empty from "~/components/Empty";
import Fade from "~/components/Fade";
+5 -5
View File
@@ -36,8 +36,8 @@ const Guide: React.FC<Props> = ({
return (
<DialogBackdrop {...dialog}>
{(backdropProps) => (
<Backdrop {...backdropProps}>
{(props) => (
<Backdrop {...props}>
<Dialog
{...dialog}
aria-label={title}
@@ -45,8 +45,8 @@ const Guide: React.FC<Props> = ({
hideOnEsc
hide={onRequestClose}
>
{(dialogProps) => (
<Scene {...dialogProps} {...rest}>
{(props) => (
<Scene {...props} {...rest}>
<Content>
{title && <Header>{title}</Header>}
{children}
@@ -73,7 +73,7 @@ const Backdrop = styled.div`
right: 0;
bottom: 0;
background-color: ${s("backdrop")} !important;
z-index: ${depths.overlay};
z-index: ${depths.modalOverlay};
transition: opacity 200ms ease-in-out;
opacity: 0;
+1 -1
View File
@@ -6,12 +6,12 @@ import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { useComponentSize } from "@shared/hooks/useComponentSize";
import { depths, s } from "@shared/styles";
import { supportsPassiveListener } from "@shared/utils/browser";
import Button from "~/components/Button";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import useComponentSize from "~/hooks/useComponentSize";
import useEventListener from "~/hooks/useEventListener";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
+7 -18
View File
@@ -2,6 +2,7 @@ import { transparentize } from "polished";
import { Link } from "react-router-dom";
import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import { getTextColor } from "@shared/utils/color";
import Text from "~/components/Text";
export const CARD_MARGIN = 10;
@@ -32,7 +33,7 @@ export const Title = styled(Text).attrs({ as: "h2", size: "large" })`
display: flex;
align-items: flex-start;
justify-content: flex-start;
gap: 6px;
gap: 4px;
`;
export const Info = styled(StyledText).attrs(() => ({
@@ -59,27 +60,15 @@ export const Thumbnail = styled.img`
export const Label = styled(Text).attrs({ size: "xsmall", weight: "bold" })<{
color?: string;
}>`
border: 1px solid ${(props) => props.theme.divider};
background-color: ${(props) =>
props.color ?? props.theme.backgroundSecondary};
color: ${(props) =>
props.color ? getTextColor(props.color) : props.theme.text};
width: fit-content;
border-radius: 2em;
padding: 1px 8px 1px 20px;
padding: 0 8px;
margin-right: 0.5em;
margin-top: 0.5em;
position: relative;
flex-shrink: 0;
&::after {
content: "";
position: absolute;
left: 8px;
top: 50%;
transform: translateY(-50%);
width: 6px;
height: 6px;
border-radius: 50%;
background-color: ${(props) =>
props.color || props.theme.backgroundSecondary};
}
`;
export const CardContent = styled.div`
+141 -147
View File
@@ -1,5 +1,4 @@
import { m } from "framer-motion";
import { observer } from "mobx-react";
import * as React from "react";
import { Portal } from "react-portal";
import styled from "styled-components";
@@ -9,7 +8,6 @@ import useEventListener from "~/hooks/useEventListener";
import useKeyDown from "~/hooks/useKeyDown";
import useMobile from "~/hooks/useMobile";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import useStores from "~/hooks/useStores";
import LoadingIndicator from "../LoadingIndicator";
import { CARD_MARGIN } from "./Components";
import HoverPreviewDocument from "./HoverPreviewDocument";
@@ -25,9 +23,9 @@ const POINTER_WIDTH = 22;
type Props = {
/** The HTML element that is being hovered over, or null if none. */
element: HTMLElement | null;
/** ID of the unfurl that will be shown in the hover preview. */
unfurlId: string | null;
/** Whether the preview data is being loaded. */
/** Data to be previewed */
data: Record<string, any> | null;
/** Whether the preview data is being loaded */
dataLoading: boolean;
/** A callback on close of the hover preview. */
onClose: () => void;
@@ -38,155 +36,151 @@ enum Direction {
DOWN,
}
const HoverPreviewDesktop = observer(
({ element, unfurlId, dataLoading, onClose }: Props) => {
const { unfurls } = useStores();
const [isVisible, setVisible] = React.useState(false);
const timerClose = React.useRef<ReturnType<typeof setTimeout>>();
const cardRef = React.useRef<HTMLDivElement | null>(null);
const { cardLeft, cardTop, pointerLeft, pointerTop, pointerDir } =
useHoverPosition({
cardRef,
element,
isVisible,
});
const data = unfurlId ? unfurls.get(unfurlId)?.data : undefined;
function HoverPreviewDesktop({ element, data, dataLoading, onClose }: Props) {
const [isVisible, setVisible] = React.useState(false);
const timerClose = React.useRef<ReturnType<typeof setTimeout>>();
const cardRef = React.useRef<HTMLDivElement | null>(null);
const { cardLeft, cardTop, pointerLeft, pointerTop, pointerDir } =
useHoverPosition({
cardRef,
element,
isVisible,
});
const closePreview = React.useCallback(() => {
setVisible(false);
onClose();
}, [onClose]);
const closePreview = React.useCallback(() => {
setVisible(false);
onClose();
}, [onClose]);
const stopCloseTimer = React.useCallback(() => {
if (timerClose.current) {
clearTimeout(timerClose.current);
timerClose.current = undefined;
const stopCloseTimer = React.useCallback(() => {
if (timerClose.current) {
clearTimeout(timerClose.current);
timerClose.current = undefined;
}
}, []);
const startCloseTimer = React.useCallback(() => {
timerClose.current = setTimeout(closePreview, DELAY_CLOSE);
}, [closePreview]);
// Open and close the preview when the element changes.
React.useEffect(() => {
if (element && data && !dataLoading) {
setVisible(true);
} else {
startCloseTimer();
}
}, [startCloseTimer, element, data, dataLoading]);
// Close the preview on Escape, scroll, or click outside.
useOnClickOutside(cardRef, closePreview);
useKeyDown("Escape", closePreview);
useEventListener("scroll", closePreview, window, { capture: true });
// Ensure that the preview stays open while the user is hovering over the card.
React.useEffect(() => {
const card = cardRef.current;
if (isVisible) {
if (card) {
card.addEventListener("mouseenter", stopCloseTimer);
card.addEventListener("mouseleave", startCloseTimer);
}
}, []);
const startCloseTimer = React.useCallback(() => {
timerClose.current = setTimeout(closePreview, DELAY_CLOSE);
}, [closePreview]);
// Open and close the preview when the element changes.
React.useEffect(() => {
if (element && data && !dataLoading) {
setVisible(true);
} else {
startCloseTimer();
}
}, [startCloseTimer, element, data, dataLoading]);
// Close the preview on Escape, scroll, or click outside.
useOnClickOutside(cardRef, closePreview);
useKeyDown("Escape", closePreview);
useEventListener("scroll", closePreview, window, { capture: true });
// Ensure that the preview stays open while the user is hovering over the card.
React.useEffect(() => {
const card = cardRef.current;
if (isVisible) {
if (card) {
card.addEventListener("mouseenter", stopCloseTimer);
card.addEventListener("mouseleave", startCloseTimer);
}
}
return () => {
if (card) {
card.removeEventListener("mouseenter", stopCloseTimer);
card.removeEventListener("mouseleave", startCloseTimer);
}
stopCloseTimer();
};
}, [element, startCloseTimer, isVisible, stopCloseTimer]);
if (dataLoading) {
return <LoadingIndicator />;
}
if (!data) {
return null;
}
return () => {
if (card) {
card.removeEventListener("mouseenter", stopCloseTimer);
card.removeEventListener("mouseleave", startCloseTimer);
}
return (
<Portal>
<Position top={cardTop} left={cardLeft} aria-hidden>
{isVisible ? (
<Animate
initial={{ opacity: 0, y: -20, pointerEvents: "none" }}
animate={{
opacity: 1,
y: 0,
transitionEnd: { pointerEvents: "auto" },
}}
>
{data.type === UnfurlResourceType.Mention ? (
<HoverPreviewMention
ref={cardRef}
name={data.name}
avatarUrl={data.avatarUrl}
color={data.color}
lastActive={data.lastActive}
email={data.email}
/>
) : data.type === UnfurlResourceType.Document ? (
<HoverPreviewDocument
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
summary={data.summary}
lastActivityByViewer={data.lastActivityByViewer}
/>
) : data.type === UnfurlResourceType.Issue ? (
<HoverPreviewIssue
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
description={data.description}
author={data.author}
labels={data.labels}
state={data.state}
createdAt={data.createdAt}
/>
) : data.type === UnfurlResourceType.PR ? (
<HoverPreviewPullRequest
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
description={data.description}
author={data.author}
createdAt={data.createdAt}
state={data.state}
/>
) : (
<HoverPreviewLink
ref={cardRef}
url={data.url}
thumbnailUrl={data.thumbnailUrl}
title={data.title}
description={data.description}
/>
)}
<Pointer
top={pointerTop}
left={pointerLeft}
direction={pointerDir}
/>
</Animate>
) : null}
</Position>
</Portal>
);
stopCloseTimer();
};
}, [element, startCloseTimer, isVisible, stopCloseTimer]);
if (dataLoading) {
return <LoadingIndicator />;
}
);
function HoverPreview({ element, unfurlId, dataLoading, ...rest }: Props) {
if (!data) {
return null;
}
return (
<Portal>
<Position top={cardTop} left={cardLeft} aria-hidden>
{isVisible ? (
<Animate
initial={{ opacity: 0, y: -20, pointerEvents: "none" }}
animate={{
opacity: 1,
y: 0,
transitionEnd: { pointerEvents: "auto" },
}}
>
{data.type === UnfurlResourceType.Mention ? (
<HoverPreviewMention
ref={cardRef}
name={data.name}
avatarUrl={data.avatarUrl}
color={data.color}
lastActive={data.lastActive}
email={data.email}
/>
) : data.type === UnfurlResourceType.Document ? (
<HoverPreviewDocument
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
summary={data.summary}
lastActivityByViewer={data.lastActivityByViewer}
/>
) : data.type === UnfurlResourceType.Issue ? (
<HoverPreviewIssue
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
description={data.description}
author={data.author}
labels={data.labels}
state={data.state}
createdAt={data.createdAt}
/>
) : data.type === UnfurlResourceType.PR ? (
<HoverPreviewPullRequest
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
description={data.description}
author={data.author}
createdAt={data.createdAt}
state={data.state}
/>
) : (
<HoverPreviewLink
ref={cardRef}
url={data.url}
thumbnailUrl={data.thumbnailUrl}
title={data.title}
description={data.description}
/>
)}
<Pointer
top={pointerTop}
left={pointerLeft}
direction={pointerDir}
/>
</Animate>
) : null}
</Position>
</Portal>
);
}
function HoverPreview({ element, data, dataLoading, ...rest }: Props) {
const isMobile = useMobile();
if (isMobile) {
return null;
@@ -196,7 +190,7 @@ function HoverPreview({ element, unfurlId, dataLoading, ...rest }: Props) {
<HoverPreviewDesktop
{...rest}
element={element}
unfurlId={unfurlId}
data={data}
dataLoading={dataLoading}
/>
);
@@ -1,15 +1,9 @@
import * as React from "react";
import { Trans } from "react-i18next";
import styled from "styled-components";
import { Backticks } from "@shared/components/Backticks";
import { IssueStatusIcon } from "@shared/components/IssueStatusIcon";
import {
IntegrationService,
UnfurlResourceType,
UnfurlResponse,
} from "@shared/types";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { Avatar } from "~/components/Avatar";
import Flex from "~/components/Flex";
import { IssueStatusIcon } from "../Icons/IssueStatusIcon";
import Text from "../Text";
import Time from "../Time";
import {
@@ -29,11 +23,6 @@ const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
ref: React.Ref<HTMLDivElement>
) {
const authorName = author.name;
const urlObj = new URL(url);
const service =
urlObj.hostname === "github.com"
? IntegrationService.GitHub
: IntegrationService.Linear;
return (
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
@@ -42,18 +31,13 @@ const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
<CardContent>
<Flex gap={2} column>
<Title>
<StyledIssueStatusIcon
service={service}
state={state}
size={18}
/>
<IssueStatusIcon status={state.name} color={state.color} />
<span>
<Backticks content={title} />
&nbsp;<Text type="tertiary">{id}</Text>
{title}&nbsp;<Text type="tertiary">{id}</Text>
</span>
</Title>
<Flex align="center" gap={6}>
<Avatar src={author.avatarUrl} size={18} />
<Flex align="center" gap={4}>
<Avatar src={author.avatarUrl} />
<Info>
<Trans>
{{ authorName }} created{" "}
@@ -78,8 +62,4 @@ const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
);
});
const StyledIssueStatusIcon = styled(IssueStatusIcon)`
margin-top: 2px;
`;
export default HoverPreviewIssue;
@@ -27,7 +27,7 @@ const HoverPreviewLink = React.forwardRef(function _HoverPreviewLink(
return (
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
<Flex column ref={ref}>
{thumbnailUrl ? <Thumbnail src={thumbnailUrl} alt="" /> : null}
{thumbnailUrl ? <Thumbnail src={thumbnailUrl} alt={""} /> : null}
<Card>
<CardContent>
<Flex column>
@@ -1,11 +1,9 @@
import * as React from "react";
import { Trans } from "react-i18next";
import styled from "styled-components";
import { Backticks } from "@shared/components/Backticks";
import { PullRequestIcon } from "@shared/components/PullRequestIcon";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { Avatar } from "~/components/Avatar";
import Flex from "~/components/Flex";
import { PullRequestIcon } from "../Icons/PullRequestIcon";
import Text from "../Text";
import Time from "../Time";
import {
@@ -33,14 +31,13 @@ const HoverPreviewPullRequest = React.forwardRef(
<CardContent>
<Flex gap={2} column>
<Title>
<StyledPullRequestIcon size={18} state={state} />
<PullRequestIcon status={state.name} color={state.color} />
<span>
<Backticks content={title} />
&nbsp;<Text type="tertiary">{id}</Text>
{title}&nbsp;<Text type="tertiary">{id}</Text>
</span>
</Title>
<Flex align="center" gap={6}>
<Avatar src={author.avatarUrl} size={18} />
<Flex align="center" gap={4}>
<Avatar src={author.avatarUrl} />
<Info>
<Trans>
{{ authorName }} opened{" "}
@@ -58,8 +55,4 @@ const HoverPreviewPullRequest = React.forwardRef(
}
);
const StyledPullRequestIcon = styled(PullRequestIcon)`
margin-top: 2px;
`;
export default HoverPreviewPullRequest;
@@ -1,5 +1,5 @@
import { BackIcon } from "outline-icons";
import * as React from "react";
import React from "react";
import styled from "styled-components";
import { breakpoints, s, hover } from "@shared/styles";
import { colorPalette } from "@shared/utils/collections";
@@ -193,7 +193,7 @@ const SwitcherButton = styled(NudeButton)<{ panel: Panel }>`
`;
const LargeMobileBuiltinColors = styled(BuiltinColors)`
max-width: 400px;
max-width: 380px;
padding-right: 8px;
`;
@@ -1,5 +1,5 @@
import concat from "lodash/concat";
import * as React from "react";
import 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 * as React from "react";
import React from "react";
import styled from "styled-components";
import { IconType } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
@@ -1,4 +1,4 @@
import * as React from "react";
import React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { IconType } from "@shared/types";
@@ -1,6 +1,6 @@
import { useMemo, useCallback } from "react";
import React from "react";
import { useTranslation } from "react-i18next";
import { Menu, MenuButton, MenuItem } from "reakit";
import { Menu, MenuButton, MenuItem, useMenuState } from "reakit";
import styled from "styled-components";
import { depths, s, hover } from "@shared/styles";
import { EmojiSkinTone } from "@shared/types";
@@ -8,7 +8,6 @@ import { getEmojiVariants } from "@shared/utils/emoji";
import { Emoji } from "~/components/Emoji";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import { useMenuState } from "~/hooks/useMenuState";
import { IconButton } from "./IconButton";
const SkinTonePicker = ({
@@ -20,13 +19,16 @@ const SkinTonePicker = ({
}) => {
const { t } = useTranslation();
const handEmojiVariants = useMemo(() => getEmojiVariants({ id: "hand" }), []);
const handEmojiVariants = React.useMemo(
() => getEmojiVariants({ id: "hand" }),
[]
);
const menu = useMenuState({
placement: "bottom-end",
});
const handleSkinClick = useCallback(
const handleSkinClick = React.useCallback(
(emojiSkin) => {
menu.hide();
onChange(emojiSkin);
@@ -34,7 +36,7 @@ const SkinTonePicker = ({
[menu, onChange]
);
const menuItems = useMemo(
const menuItems = React.useMemo(
() =>
Object.entries(handEmojiVariants).map(([eskin, emoji]) => (
<MenuItem {...menu} key={emoji.value}>
+139 -201
View File
@@ -1,20 +1,27 @@
import * as Popover from "@radix-ui/react-popover";
import * as Tabs from "@radix-ui/react-tabs";
import { SmileyIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import {
PopoverDisclosure,
Tab,
TabList,
TabPanel,
usePopoverState,
useTabState,
} from "reakit";
import styled, { css } from "styled-components";
import Icon from "@shared/components/Icon";
import { s, hover, depths } from "@shared/styles";
import { s, hover } from "@shared/styles";
import theme from "@shared/styles/theme";
import { IconType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import Popover from "~/components/Popover";
import useMobile from "~/hooks/useMobile";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import usePrevious from "~/hooks/usePrevious";
import useWindowSize from "~/hooks/useWindowSize";
import { fadeAndScaleIn } from "~/styles/animations";
import { Drawer, DrawerContent, DrawerTrigger } from "../primitives/Drawer";
import EmojiPanel from "./components/EmojiPanel";
import IconPanel from "./components/IconPanel";
import { PopoverButton } from "./components/PopoverButton";
@@ -24,8 +31,6 @@ const TAB_NAMES = {
Emoji: "emoji",
} as const;
type TabName = (typeof TAB_NAMES)[keyof typeof TAB_NAMES];
const POPOVER_WIDTH = 408;
type Props = {
@@ -40,7 +45,6 @@ type Props = {
onChange: (icon: string | null, color: string | null) => void;
onOpen?: () => void;
onClose?: () => void;
children?: React.ReactNode;
};
const IconPicker = ({
@@ -55,16 +59,15 @@ const IconPicker = ({
onOpen,
onClose,
borderOnHover,
children,
}: Props) => {
const { t } = useTranslation();
const { width: windowWidth } = useWindowSize();
const isMobile = useMobile();
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const [chosenColor, setChosenColor] = React.useState(color);
const contentRef = React.useRef<HTMLDivElement | null>(null);
const iconType = determineIconType(icon);
const defaultTab = React.useMemo(
@@ -73,40 +76,32 @@ const IconPicker = ({
[iconType]
);
const [activeTab, setActiveTab] = React.useState<TabName>(defaultTab);
const popover = usePopoverState({
placement: popoverPosition,
modal: true,
unstable_offset: [0, 0],
});
const { hide, show, visible } = popover;
const tab = useTabState({ selectedId: defaultTab });
const previouslyVisible = usePrevious(popover.visible);
const popoverWidth = isMobile ? windowWidth : POPOVER_WIDTH;
const handleTabChange = React.useCallback((value: string) => {
setActiveTab(value as TabName);
}, []);
// In mobile, popover is absolutely positioned to leave 8px on both sides.
const panelWidth = isMobile ? windowWidth - 16 : popoverWidth;
const resetDefaultTab = React.useCallback(() => {
setActiveTab(defaultTab);
tab.select(defaultTab);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultTab]);
const handleOpenChange = React.useCallback(
(isOpen: boolean) => {
setOpen(isOpen);
if (isOpen) {
onOpen?.();
} else {
onClose?.();
setQuery("");
resetDefaultTab();
}
},
[onOpen, onClose, resetDefaultTab]
);
const handleIconChange = React.useCallback(
(ic: string) => {
setOpen(false);
hide();
const icType = determineIconType(ic);
const finalColor = icType === IconType.SVG ? chosenColor : null;
onChange(ic, finalColor);
},
[onChange, chosenColor]
[hide, onChange, chosenColor]
);
const handleIconColorChange = React.useCallback(
@@ -114,6 +109,7 @@ const IconPicker = ({
setChosenColor(c);
const icType = determineIconType(icon);
// Outline icon set; propagate color change
if (icType === IconType.SVG) {
onChange(icon, c);
}
@@ -122,168 +118,133 @@ const IconPicker = ({
);
const handleIconRemove = React.useCallback(() => {
setOpen(false);
hide();
onChange(null, null);
}, [setOpen, onChange]);
}, [hide, onChange]);
const PickerContent = (
<Content
open={open}
activeTab={activeTab}
iconColor={chosenColor}
iconInitial={initial ?? ""}
query={query}
panelWidth={popoverWidth}
allowDelete={!!(allowDelete && icon)}
onTabChange={handleTabChange}
onQueryChange={setQuery}
onIconChange={handleIconChange}
onIconColorChange={handleIconColorChange}
onIconRemove={handleIconRemove}
/>
const handlePopoverButtonClick = React.useCallback(
(ev: React.MouseEvent) => {
ev.stopPropagation();
if (visible) {
hide();
} else {
show();
}
},
[hide, show, visible]
);
// Update selected tab when default tab changes
// Popover open effect
React.useEffect(() => {
setActiveTab(defaultTab);
}, [defaultTab]);
if (visible && !previouslyVisible) {
onOpen?.();
} else if (!visible && previouslyVisible) {
onClose?.();
setQuery("");
resetDefaultTab();
}
}, [visible, previouslyVisible, onOpen, onClose, resetDefaultTab]);
if (isMobile) {
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
// Custom click outside handling rather than using `hideOnClickOutside` from reakit so that we can
// prevent event bubbling.
useOnClickOutside(
contentRef,
(event) => {
if (
popover.visible &&
!popover.unstable_disclosureRef.current?.contains(event.target as Node)
) {
event.stopPropagation();
event.preventDefault();
popover.hide();
}
},
{ capture: true }
);
return (
<>
<PopoverDisclosure {...popover}>
{(props) => (
<PopoverButton
{...props}
aria-label={t("Show menu")}
className={className}
size={size}
onClick={handlePopoverButtonClick}
$borderOnHover={borderOnHover}
>
{children ? (
children
) : iconType && icon ? (
{iconType && icon ? (
<Icon value={icon} color={color} size={size} initial={initial} />
) : (
<StyledSmileyIcon color={theme.placeholder} size={size} />
)}
</PopoverButton>
</DrawerTrigger>
<DrawerContent aria-label={t("Icon Picker")}>
{PickerContent}
</DrawerContent>
</Drawer>
);
}
return (
<Popover.Root open={open} onOpenChange={handleOpenChange} modal={true}>
<Popover.Trigger asChild>
<PopoverButton
aria-label={t("Show menu")}
className={className}
size={size}
$borderOnHover={borderOnHover}
>
{children ? (
children
) : iconType && icon ? (
<Icon value={icon} color={color} size={size} initial={initial} />
) : (
<StyledSmileyIcon color={theme.placeholder} size={size} />
)}
</PopoverButton>
</Popover.Trigger>
<Popover.Portal>
<StyledPopoverContent
side={popoverPosition === "right" ? "right" : "bottom"}
align={popoverPosition === "bottom-start" ? "start" : "center"}
sideOffset={0}
width={popoverWidth}
aria-label={t("Icon Picker")}
onClick={(e) => e.stopPropagation()}
>
{PickerContent}
</StyledPopoverContent>
</Popover.Portal>
</Popover.Root>
);
};
type ContentProps = {
open: boolean;
activeTab: TabName;
query: string;
iconColor: string;
iconInitial: string;
panelWidth: number;
allowDelete: boolean;
onTabChange: (tab: string) => void;
onQueryChange: (query: string) => void;
onIconChange: (icon: string) => void;
onIconColorChange: (color: string) => void;
onIconRemove: () => void;
};
const Content = ({
open,
activeTab,
iconColor,
iconInitial,
query,
panelWidth,
allowDelete,
onTabChange,
onQueryChange,
onIconChange,
onIconColorChange,
onIconRemove,
}: ContentProps) => {
const { t } = useTranslation();
return (
<Tabs.Root value={activeTab} onValueChange={onTabChange}>
<TabActionsWrapper justify="space-between" align="center">
<Tabs.List>
<StyledTab
value={TAB_NAMES["Icon"]}
aria-label={t("Icons")}
$active={activeTab === TAB_NAMES["Icon"]}
>
{t("Icons")}
</StyledTab>
<StyledTab
value={TAB_NAMES["Emoji"]}
aria-label={t("Emojis")}
$active={activeTab === TAB_NAMES["Emoji"]}
>
{t("Emojis")}
</StyledTab>
</Tabs.List>
{allowDelete && (
<RemoveButton onClick={onIconRemove}>{t("Remove")}</RemoveButton>
)}
</TabActionsWrapper>
<StyledTabContent value={TAB_NAMES["Icon"]}>
<IconPanel
panelWidth={panelWidth}
initial={iconInitial}
color={iconColor}
query={query}
panelActive={open && activeTab === TAB_NAMES["Icon"]}
onIconChange={onIconChange}
onColorChange={onIconColorChange}
onQueryChange={onQueryChange}
/>
</StyledTabContent>
<StyledTabContent value={TAB_NAMES["Emoji"]}>
<EmojiPanel
panelWidth={panelWidth}
query={query}
panelActive={open && activeTab === TAB_NAMES["Emoji"]}
onEmojiChange={onIconChange}
onQueryChange={onQueryChange}
/>
</StyledTabContent>
</Tabs.Root>
</PopoverDisclosure>
<Popover
{...popover}
ref={contentRef}
width={popoverWidth}
shrink
aria-label={t("Icon Picker")}
onClick={(e) => e.stopPropagation()}
hideOnClickOutside={false}
>
<>
<TabActionsWrapper justify="space-between" align="center">
<TabList {...tab}>
<StyledTab
{...tab}
id={TAB_NAMES["Icon"]}
aria-label={t("Icons")}
$active={tab.selectedId === TAB_NAMES["Icon"]}
>
{t("Icons")}
</StyledTab>
<StyledTab
{...tab}
id={TAB_NAMES["Emoji"]}
aria-label={t("Emojis")}
$active={tab.selectedId === TAB_NAMES["Emoji"]}
>
{t("Emojis")}
</StyledTab>
</TabList>
{allowDelete && icon && (
<RemoveButton onClick={handleIconRemove}>
{t("Remove")}
</RemoveButton>
)}
</TabActionsWrapper>
<StyledTabPanel {...tab}>
<IconPanel
panelWidth={panelWidth}
initial={initial ?? "?"}
color={chosenColor}
query={query}
panelActive={
popover.visible && tab.selectedId === TAB_NAMES["Icon"]
}
onIconChange={handleIconChange}
onColorChange={handleIconColorChange}
onQueryChange={setQuery}
/>
</StyledTabPanel>
<StyledTabPanel {...tab}>
<EmojiPanel
panelWidth={panelWidth}
query={query}
panelActive={
popover.visible && tab.selectedId === TAB_NAMES["Emoji"]
}
onEmojiChange={handleIconChange}
onQueryChange={setQuery}
/>
</StyledTabPanel>
</>
</Popover>
</>
);
};
@@ -312,7 +273,7 @@ const TabActionsWrapper = styled(Flex)`
border-bottom: 1px solid ${s("inputBorder")};
`;
const StyledTab = styled(Tabs.Trigger)<{ $active: boolean }>`
const StyledTab = styled(Tab)<{ $active: boolean }>`
position: relative;
font-weight: 500;
font-size: 14px;
@@ -343,32 +304,9 @@ const StyledTab = styled(Tabs.Trigger)<{ $active: boolean }>`
`}
`;
const StyledTabContent = styled(Tabs.Content)`
const StyledTabPanel = styled(TabPanel)`
height: 410px;
overflow-y: auto;
`;
const StyledPopoverContent = styled(Popover.Content)<{ width: number }>`
animation: ${fadeAndScaleIn} 200ms ease;
transform-origin: var(--radix-popover-content-transform-origin);
background: ${s("menuBackground")};
border-radius: 6px;
padding: 6px 0;
max-height: 75vh;
box-shadow: ${s("menuShadow")};
z-index: ${depths.modal};
width: ${(props) => props.width}px;
overflow: hidden;
outline: none;
@media (max-width: 768px) {
position: fixed;
z-index: ${depths.menu};
top: 50px;
left: 8px;
right: 8px;
width: auto;
}
`;
export default React.memo(IconPicker);
+2
View File
@@ -1,3 +1,5 @@
import * as React from "react";
type Props = {
/** The size of the icon, 24px is default to match standard icons */
size?: number;
+1
View File
@@ -1,6 +1,7 @@
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";
+2
View File
@@ -1,3 +1,5 @@
import * as React from "react";
type Props = {
/** The size of the icon, 24px is default to match standard icons */
size?: number;
+74
View File
@@ -0,0 +1,74 @@
import * as React from "react";
import styled from "styled-components";
type Props = {
status: string;
color: string;
size?: number;
className?: string;
};
/**
* Issue status icon based on GitHub issue status, but can be used for any git-style integration.
*/
export function IssueStatusIcon({ size, ...rest }: Props) {
return (
<Icon size={size}>
<BaseIcon {...rest} />
</Icon>
);
}
const Icon = styled.span<{ size?: number }>`
display: inline-flex;
flex-shrink: 0;
width: ${(props) => props.size ?? 24}px;
height: ${(props) => props.size ?? 24}px;
align-items: center;
justify-content: center;
`;
function BaseIcon(props: Props) {
switch (props.status) {
case "open":
return (
<svg
viewBox="0 0 16 16"
width="16"
height="16"
fill={props.color}
className={props.className}
>
<path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" />
<path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z" />
</svg>
);
case "closed":
return (
<svg
viewBox="0 0 16 16"
width="16"
height="16"
fill={props.color}
className={props.className}
>
<path d="M11.28 6.78a.75.75 0 0 0-1.06-1.06L7.25 8.69 5.78 7.22a.75.75 0 0 0-1.06 1.06l2 2a.75.75 0 0 0 1.06 0l3.5-3.5Z" />
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0Zm-1.5 0a6.5 6.5 0 1 0-13 0 6.5 6.5 0 0 0 13 0Z" />
</svg>
);
case "canceled":
return (
<svg
viewBox="0 0 16 16"
width="16"
height="16"
fill={props.color}
className={props.className}
>
<path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm9.78-2.22-5.5 5.5a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l5.5-5.5a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042Z" />
</svg>
);
default:
return null;
}
}
+2
View File
@@ -1,3 +1,5 @@
import * as React from "react";
export function LanguageIcon({ className }: { className?: string }) {
return (
<svg

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