mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe2c0d75ef | |||
| 2e9c451979 | |||
| 3a97361654 | |||
| a8bdc14ca2 | |||
| 563b41e34a | |||
| 0621706a95 | |||
| ec1cb61807 | |||
| 5850181c29 | |||
| d29bc22676 | |||
| a9dd771598 | |||
| 35d185a971 | |||
| d6c85f0aac | |||
| b721287d19 | |||
| 57296e3139 | |||
| 4210f877b4 | |||
| 4db8682423 | |||
| 74229813d2 | |||
| 75e457feee | |||
| 36682574c6 | |||
| c636c4e5df | |||
| c23b5d1ef4 | |||
| 9f8298d012 |
@@ -1,11 +1,6 @@
|
||||
{
|
||||
"presets": [
|
||||
[
|
||||
"@babel/preset-react",
|
||||
{
|
||||
"runtime": "automatic"
|
||||
}
|
||||
],
|
||||
"@babel/preset-react",
|
||||
"@babel/preset-env",
|
||||
"@babel/preset-typescript"
|
||||
],
|
||||
@@ -21,7 +16,10 @@
|
||||
[
|
||||
"transform-inline-environment-variables",
|
||||
{
|
||||
"include": ["SOURCE_COMMIT", "SOURCE_VERSION"]
|
||||
"include": [
|
||||
"SOURCE_COMMIT",
|
||||
"SOURCE_VERSION"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tsconfig-paths-module-resolver"
|
||||
@@ -36,10 +34,16 @@
|
||||
}
|
||||
]
|
||||
],
|
||||
"ignore": ["**/__mocks__", "**/*.test.ts"]
|
||||
"ignore": [
|
||||
"**/__mocks__",
|
||||
"**/*.test.ts"
|
||||
]
|
||||
},
|
||||
"development": {
|
||||
"ignore": ["**/__mocks__", "**/*.test.ts"]
|
||||
"ignore": [
|
||||
"**/__mocks__",
|
||||
"**/*.test.ts"
|
||||
]
|
||||
},
|
||||
"test": {
|
||||
"presets": [
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
version: 2.1
|
||||
|
||||
defaults: &defaults
|
||||
working_directory: ~/outline
|
||||
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
|
||||
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
|
||||
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:
|
||||
version: 20.10.6
|
||||
- run:
|
||||
name: Install Docker buildx
|
||||
command: |
|
||||
mkdir -p ~/.docker/cli-plugins
|
||||
url="https://github.com/docker/buildx/releases/download/v0.8.0/buildx-v0.8.0.linux-amd64"
|
||||
curl -sSL -o ~/.docker/cli-plugins/docker-buildx $url
|
||||
chmod a+x ~/.docker/cli-plugins/docker-buildx
|
||||
- run:
|
||||
name: Enable Docker buildx
|
||||
command: export DOCKER_CLI_EXPERIMENTAL=enabled
|
||||
- run:
|
||||
name: Initialize Docker buildx
|
||||
command: |
|
||||
docker buildx install
|
||||
docker context create docker-multiarch
|
||||
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
|
||||
docker buildx create --name docker-multiarch --platform linux/amd64,linux/arm/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: /.*/
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
curl --user ${CIRCLE_TOKEN}: \
|
||||
--request POST \
|
||||
--form revision=<ENTER COMMIT SHA HERE>\
|
||||
--form config=@config.yml \
|
||||
--form notify=false \
|
||||
https://circleci.com/api/v1.1/project/github/outline/outline/tree/master
|
||||
+1
-10
@@ -1,3 +1,4 @@
|
||||
__mocks__
|
||||
.git
|
||||
.vscode
|
||||
.github
|
||||
@@ -5,21 +6,11 @@
|
||||
.DS_Store
|
||||
.env*
|
||||
.eslint*
|
||||
.oxlintrc*
|
||||
.log
|
||||
*.md
|
||||
Makefile
|
||||
Procfile
|
||||
app.json
|
||||
crowdin.yml
|
||||
lint-staged.config.mjs
|
||||
build
|
||||
docker-compose.yml
|
||||
node_modules
|
||||
.yarn
|
||||
**/*.test.ts
|
||||
**/*.test.tsx
|
||||
**/*.test.js
|
||||
**/*.test.jsx
|
||||
**/__tests__
|
||||
**/__mocks__
|
||||
|
||||
@@ -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
|
||||
|
||||
+137
-189
@@ -1,85 +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
|
||||
|
||||
# To enable horizontal scaling of the collaboration service you must provide a Redis URL, it may
|
||||
# be the same as above, or a different server.
|
||||
# DOCS: https://docs.getoutline.com/s/hosting/doc/horizontal-scaling-hkfU5Stao7
|
||||
REDIS_COLLABORATION_URL=
|
||||
|
||||
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
# ––––––––––– 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.
|
||||
@@ -93,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
|
||||
@@ -104,59 +67,38 @@ AWS_S3_UPLOAD_BUCKET_NAME=bucket_name_here
|
||||
AWS_S3_FORCE_PATH_STYLE=true
|
||||
AWS_S3_ACL=private
|
||||
|
||||
# –––––––––––––– AUTHENTICATION ––––––––––––––
|
||||
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
# –––––––––––––––– SSL –––––––––––––––
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
# Third party signin credentials, at least ONE OF EITHER Google, Slack,
|
||||
# or Microsoft is required for a working installation or you'll have no sign-in
|
||||
# options.
|
||||
|
||||
# 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
|
||||
|
||||
# When behind a reverse proxy, the header to use for the client IP.
|
||||
# The default value is "X-Forwarded-For", common values are "X-Real-IP"
|
||||
# and "X-Client-IP".
|
||||
# PROXY_IP_HEADER=
|
||||
|
||||
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
# –––––––––– AUTHENTICATION ––––––––––
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
|
||||
# Third party signin credentials, at least ONE OF these 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=
|
||||
@@ -174,101 +116,107 @@ 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 –––––––––––
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
|
||||
# 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_WEBHOOK_SECRET=
|
||||
GITHUB_APP_NAME=
|
||||
GITHUB_APP_ID=
|
||||
GITHUB_APP_PRIVATE_KEY=
|
||||
|
||||
# The GitLab integration allows previewing issue and merge request links
|
||||
# DOCS:
|
||||
GITLAB_CLIENT_ID=
|
||||
GITLAB_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=
|
||||
|
||||
# Linear integration allows previewing issue links as rich mentions
|
||||
LINEAR_CLIENT_ID=
|
||||
LINEAR_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=
|
||||
|
||||
# For a complete Slack integration with search and posting to channels the
|
||||
# following configs are also needed in addition to Slack authentication:
|
||||
# DOCS: https://docs.getoutline.com/s/hosting/doc/slack-G2mc8DOJHk
|
||||
SLACK_VERIFICATION_TOKEN=your_token
|
||||
SLACK_APP_ID=A0XXXXXXX
|
||||
SLACK_MESSAGE_ACTIONS=true
|
||||
# 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=
|
||||
|
||||
# Figma integration allows previewing design files as rich mentions
|
||||
FIGMA_CLIENT_ID=
|
||||
FIGMA_CLIENT_SECRET=
|
||||
# –––––––––––––––– OPTIONAL ––––––––––––––––
|
||||
|
||||
# For Dropbox integration, follow these instructions to get the key https://www.dropbox.com/developers/embedder#setup
|
||||
# and do not forget to whitelist your domain name in the app settings
|
||||
DROPBOX_APP_KEY=
|
||||
# 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=
|
||||
|
||||
# Optionally enable Sentry (sentry.io) to track errors and performance,
|
||||
# DOCS: https://docs.getoutline.com/s/hosting/doc/sentry-jxcFttcDl5
|
||||
SENTRY_DSN=
|
||||
SENTRY_TUNNEL=
|
||||
# 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=
|
||||
|
||||
# Enable importing pages from a Notion workspace
|
||||
# DOCS: https://docs.getoutline.com/s/hosting/doc/notion-2v6g7WY3l3
|
||||
NOTION_CLIENT_ID=
|
||||
NOTION_CLIENT_SECRET=
|
||||
|
||||
# The Iframely integration allows previews of third-party content within Outline.
|
||||
# For example, hovering over an external link will show a preview.
|
||||
# DOCS: https://docs.getoutline.com/s/hosting/doc/iframely-HwLF1EZ9mo
|
||||
IFRAMELY_URL=
|
||||
IFRAMELY_API_KEY=
|
||||
|
||||
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
# ––––––––––––– DEBUGGING ––––––––––––
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
# 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
|
||||
|
||||
# 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
|
||||
# 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, or silly
|
||||
# error, warn, info, http, verbose, debug and silly
|
||||
LOG_LEVEL=info
|
||||
|
||||
# For a complete Slack integration with search and posting to channels the
|
||||
# following configs are also needed, some more details
|
||||
# => https://wiki.generaloutline.com/share/be25efd1-b3ef-4450-b8e5-c4a4fc11e02a
|
||||
#
|
||||
SLACK_VERIFICATION_TOKEN=your_token
|
||||
SLACK_APP_ID=A0XXXXXXX
|
||||
SLACK_MESSAGE_ACTIONS=true
|
||||
|
||||
# Optionally enable Sentry (sentry.io) to track errors and performance,
|
||||
# and optionally add a Sentry proxy tunnel for bypassing ad blockers in the UI:
|
||||
# https://docs.sentry.io/platforms/javascript/troubleshooting/#using-the-tunnel-option)
|
||||
SENTRY_DSN=
|
||||
SENTRY_TUNNEL=
|
||||
|
||||
# To support sending outgoing transactional emails such as "document updated" or
|
||||
# "you've been invited" you'll need to provide authentication for an SMTP server
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM_EMAIL=
|
||||
SMTP_REPLY_EMAIL=
|
||||
SMTP_TLS_CIPHERS=
|
||||
SMTP_SECURE=true
|
||||
|
||||
# The default interface language. See translate.getoutline.com for a list of
|
||||
# available language codes and their rough percentage translated.
|
||||
DEFAULT_LANGUAGE=en_US
|
||||
|
||||
# Optionally enable rate limiter at application web server
|
||||
RATE_LIMITER_ENABLED=true
|
||||
|
||||
# Configure default throttling parameters for rate limiter
|
||||
RATE_LIMITER_REQUESTS=1000
|
||||
RATE_LIMITER_DURATION_WINDOW=60
|
||||
|
||||
# Iframely API config
|
||||
IFRAMELY_URL=
|
||||
IFRAMELY_API_KEY=
|
||||
|
||||
@@ -12,15 +12,11 @@ GOOGLE_CLIENT_SECRET=123
|
||||
|
||||
SLACK_CLIENT_ID=123
|
||||
SLACK_CLIENT_SECRET=123
|
||||
SLACK_VERIFICATION_TOKEN=test-token-123
|
||||
|
||||
GITHUB_CLIENT_ID=123;
|
||||
GITHUB_CLIENT_SECRET=123;
|
||||
GITHUB_APP_NAME=outline-test;
|
||||
|
||||
GITLAB_CLIENT_ID=123
|
||||
GITLAB_CLIENT_SECRET=123
|
||||
|
||||
OIDC_CLIENT_ID=client-id
|
||||
OIDC_CLIENT_SECRET=client-secret
|
||||
OIDC_AUTH_URI=http://localhost/authorize
|
||||
@@ -33,11 +29,3 @@ RATE_LIMITER_ENABLED=false
|
||||
|
||||
FILE_STORAGE=local
|
||||
FILE_STORAGE_LOCAL_ROOT_DIR=/tmp
|
||||
|
||||
URL=http://localhost:3000
|
||||
COLLABORATION_URL=
|
||||
REDIS_URL=redis://localhost:6379
|
||||
UTILS_SECRET=test-utils-secret
|
||||
|
||||
DEBUG=
|
||||
LOG_LEVEL=error
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
server/migrations/*.js
|
||||
@@ -0,0 +1,143 @@
|
||||
{
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"sourceType": "module",
|
||||
"extraFileExtensions": [".json"],
|
||||
"project": "./tsconfig.json",
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
}
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:import/recommended",
|
||||
"plugin:import/typescript",
|
||||
"plugin:prettier/recommended"
|
||||
],
|
||||
"plugins": [
|
||||
"es",
|
||||
"@typescript-eslint",
|
||||
"eslint-plugin-import",
|
||||
"eslint-plugin-node",
|
||||
"eslint-plugin-react",
|
||||
"eslint-plugin-lodash"
|
||||
],
|
||||
"rules": {
|
||||
"eqeqeq": 2,
|
||||
"curly": 2,
|
||||
"no-console": "error",
|
||||
"arrow-body-style": ["error", "as-needed"],
|
||||
"spaced-comment": "error",
|
||||
"object-shorthand": "error",
|
||||
"no-mixed-operators": "off",
|
||||
"no-useless-escape": "off",
|
||||
"no-shadow": "off",
|
||||
"es/no-regexp-lookbehind-assertions": "error",
|
||||
"react/self-closing-comp": ["error", {
|
||||
"component": true,
|
||||
"html": true
|
||||
}],
|
||||
"@typescript-eslint/no-shadow": [
|
||||
"warn",
|
||||
{
|
||||
"allow": ["transaction"],
|
||||
"hoist": "all",
|
||||
"ignoreTypeValueShadow": true
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/no-floating-promises": "error",
|
||||
"@typescript-eslint/await-thenable": "error",
|
||||
"@typescript-eslint/no-misused-promises": [
|
||||
"error",
|
||||
{
|
||||
"checksVoidReturn": false
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
"argsIgnorePattern": "^_",
|
||||
"args": "after-used",
|
||||
"ignoreRestSiblings": true
|
||||
}
|
||||
],
|
||||
"padding-line-between-statements": ["error", { "blankLine": "always", "prev": "*", "next": "export" }],
|
||||
"lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }],
|
||||
"lodash/import-scope": ["warn", "method"],
|
||||
"import/no-named-as-default": "off",
|
||||
"import/no-named-as-default-member": "off",
|
||||
"import/newline-after-import": 2,
|
||||
"import/order": [
|
||||
"error",
|
||||
{
|
||||
"alphabetize": {
|
||||
"order": "asc"
|
||||
},
|
||||
"pathGroups": [
|
||||
{
|
||||
"pattern": "@shared/**",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
},
|
||||
{
|
||||
"pattern": "@server/**",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
},
|
||||
{
|
||||
"pattern": "~/stores",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
},
|
||||
{
|
||||
"pattern": "~/stores/**",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
},
|
||||
{
|
||||
"pattern": "~/models/**",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
},
|
||||
{
|
||||
"pattern": "~/scenes/**",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
},
|
||||
{
|
||||
"pattern": "~/components/**",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
},
|
||||
{
|
||||
"pattern": "~/**",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"prettier/prettier": [
|
||||
"error",
|
||||
{
|
||||
"printWidth": 80,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
]
|
||||
},
|
||||
"settings": {
|
||||
"react": {
|
||||
"createClass": "createReactClass",
|
||||
"pragma": "React",
|
||||
"version": "detect"
|
||||
},
|
||||
"import/parsers": {
|
||||
"@typescript-eslint/parser": [".ts", ".tsx"]
|
||||
},
|
||||
"import/resolver": {
|
||||
"typescript": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots or videos to help explain your problem.
|
||||
|
||||
**Outline (please complete the following information):**
|
||||
- Install: [getoutline.com or self hosted]
|
||||
- Version: [commit sha if self hosted]
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Mobile (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
@@ -1,63 +0,0 @@
|
||||
name: Bug report
|
||||
description: File a bug to help us improve
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Is there an existing issue for this?
|
||||
description: Please search to see if an issue already exists for the bug you encountered.
|
||||
options:
|
||||
- label: I have searched the existing issues
|
||||
required: true
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: This is not related to configuring Outline
|
||||
description: I understand that questions related to configuring self-hosted Outline should be asked in the [community forum](https://github.com/outline/outline/discussions/categories/self-hosting).
|
||||
options:
|
||||
- label: The issue is not related to self-hosting config
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Current Behavior
|
||||
description: A concise description of what you're experiencing.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: A concise description of what you expected to happen.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Steps To Reproduce
|
||||
description: Steps to reproduce the behavior.
|
||||
placeholder: |
|
||||
1. In this environment...
|
||||
1. With this config...
|
||||
1. Run '...'
|
||||
1. See error...
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Environment
|
||||
description: |
|
||||
examples:
|
||||
- **Outline**: Outline 0.80.0
|
||||
- **Browser**: Safari
|
||||
value: |
|
||||
- Outline:
|
||||
- Browser:
|
||||
render: markdown
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Anything else?
|
||||
description: |
|
||||
Links? References? Anything that will give us more context about the issue you are encountering!
|
||||
|
||||
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
|
||||
validations:
|
||||
required: false
|
||||
@@ -2,9 +2,9 @@
|
||||
addReviewers: true
|
||||
|
||||
# A list of reviewers to be added to pull requests (GitHub user name)
|
||||
reviewers:
|
||||
reviewers:
|
||||
- tommoor
|
||||
|
||||
# A list of keywords to be skipped the process that add reviewers if pull requests include it
|
||||
# A list of keywords to be skipped the process that add reviewers if pull requests include it
|
||||
skipKeywords:
|
||||
- wip
|
||||
|
||||
@@ -15,8 +15,6 @@ requestInfoDefaultTitles:
|
||||
|
||||
requestInfoLabelToAdd: more information needed
|
||||
|
||||
requestInfoUserstoExclude:
|
||||
- tommoor
|
||||
|
||||
# Configuration for new-pr-welcome - https://github.com/behaviorbot/new-pr-welcome
|
||||
|
||||
|
||||
@@ -13,19 +13,3 @@ updates:
|
||||
update-types: ["version-update:semver-major"]
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
groups:
|
||||
babel:
|
||||
patterns:
|
||||
- "@babel/*"
|
||||
sentry:
|
||||
patterns:
|
||||
- "@sentry/*"
|
||||
fortawesome:
|
||||
patterns:
|
||||
- "@fortawesome/*"
|
||||
aws:
|
||||
patterns:
|
||||
- "@aws-sdk/*"
|
||||
radix-ui:
|
||||
patterns:
|
||||
- "@radix-ui/*"
|
||||
|
||||
@@ -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@v8
|
||||
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.'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
github.event.pull_request.head.repo.full_name == github.repository)
|
||||
steps:
|
||||
- name: Checkout Branch
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v2
|
||||
- name: Compress Images
|
||||
id: calibre
|
||||
uses: calibreapp/image-actions@main
|
||||
@@ -48,7 +48,6 @@ jobs:
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
# For non-Pull Requests, run in compressOnly mode and we'll PR after.
|
||||
compressOnly: ${{ github.event_name != 'pull_request' }}
|
||||
minPctChange: "10"
|
||||
- name: Create Pull Request
|
||||
# If it's not a Pull Request then commit any changes as a new PR.
|
||||
if: |
|
||||
|
||||
@@ -1,193 +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:
|
||||
setup:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- name: Use Node.js 22.x
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
- name: Cache node_modules
|
||||
id: cache-node-modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
|
||||
- name: Install dependencies
|
||||
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
||||
run: yarn install --immutable
|
||||
|
||||
lint:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
- name: Restore node_modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
|
||||
- run: yarn lint --quiet
|
||||
|
||||
types:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
- name: Restore node_modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
|
||||
- run: yarn tsc
|
||||
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
config: ${{ steps.filter.outputs.config }}
|
||||
server: ${{ steps.filter.outputs.server }}
|
||||
app: ${{ steps.filter.outputs.app }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dorny/paths-filter@v2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
config:
|
||||
- '.github/**'
|
||||
- 'vite.config.ts'
|
||||
server:
|
||||
- 'server/**'
|
||||
- 'shared/**'
|
||||
- 'package.json'
|
||||
- 'yarn.lock'
|
||||
app:
|
||||
- 'app/**'
|
||||
- 'shared/**'
|
||||
- 'package.json'
|
||||
- 'yarn.lock'
|
||||
|
||||
test:
|
||||
needs: [setup, changes]
|
||||
if: ${{ needs.changes.outputs.app == 'true' || needs.changes.outputs.config == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
test-group: [app, shared]
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
- name: Restore node_modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
|
||||
- run: yarn test:${{ matrix.test-group }}
|
||||
|
||||
test-server:
|
||||
needs: [setup, changes]
|
||||
if: ${{ needs.changes.outputs.server == 'true' || needs.changes.outputs.config == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:14.2
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_DB: outline_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
- name: Restore node_modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
|
||||
- run: yarn sequelize db:migrate
|
||||
- name: Run server tests
|
||||
run: |
|
||||
TESTFILES=$(find . -name "*.test.ts" -path "*/server/*" | sort | awk "NR % 4 == (${{ matrix.shard }} - 1)")
|
||||
yarn test --maxWorkers=2 $TESTFILES
|
||||
|
||||
bundle-size:
|
||||
needs: [setup, types, changes]
|
||||
if: ${{ (needs.changes.outputs.app == 'true' || needs.changes.outputs.config == 'true') && github.repository == 'outline/outline' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
- name: Restore node_modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
|
||||
- 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
|
||||
@@ -13,12 +13,12 @@ name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [main]
|
||||
branches: [ main ]
|
||||
schedule:
|
||||
- cron: "28 15 * * 2"
|
||||
- cron: '28 15 * * 2'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
@@ -32,39 +32,39 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: ["javascript"]
|
||||
language: [ 'javascript' ]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||
# Learn more about CodeQL language support at https://git.io/codeql-language-support
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
|
||||
@@ -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@v5
|
||||
|
||||
- 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@v5
|
||||
|
||||
- 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 }}
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v10
|
||||
- uses: actions/stale@v5
|
||||
with:
|
||||
stale-pr-message: "This PR is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 5 days"
|
||||
stale-issue-message: "This issue is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 5 days"
|
||||
@@ -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.*, ',') }}
|
||||
|
||||
@@ -14,11 +14,3 @@ data/*
|
||||
*.pem
|
||||
*.key
|
||||
*.cert
|
||||
|
||||
# Yarn Berry
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
.yarn/releases
|
||||
!.yarn/sdks
|
||||
|
||||
+12
-9
@@ -1,20 +1,18 @@
|
||||
{
|
||||
"workerIdleMemoryLimit": "0.75",
|
||||
"maxWorkers": "50%",
|
||||
"transformIgnorePatterns": ["node_modules/(?!(franc|trigram-utils)/)"],
|
||||
"projects": [
|
||||
{
|
||||
"displayName": "server",
|
||||
"roots": ["<rootDir>/server", "<rootDir>/plugins"],
|
||||
"moduleNameMapper": {
|
||||
"^@server/(.*)$": "<rootDir>/server/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1"
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
|
||||
},
|
||||
"setupFiles": [
|
||||
"<rootDir>/__mocks__/console.js",
|
||||
"<rootDir>/server/test/setupMocks.js"
|
||||
],
|
||||
"setupFiles": ["<rootDir>/__mocks__/console.js"],
|
||||
"setupFilesAfterEnv": ["<rootDir>/server/test/setup.ts"],
|
||||
"globalSetup": "<rootDir>/server/test/globalSetup.js",
|
||||
"globalTeardown": "<rootDir>/server/test/globalTeardown.js",
|
||||
"testEnvironment": "node"
|
||||
},
|
||||
@@ -24,7 +22,9 @@
|
||||
"moduleNameMapper": {
|
||||
"^~/(.*)$": "<rootDir>/app/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
|
||||
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js",
|
||||
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
|
||||
},
|
||||
"modulePaths": ["<rootDir>/app"],
|
||||
"setupFiles": ["<rootDir>/__mocks__/window.js"],
|
||||
@@ -39,7 +39,8 @@
|
||||
"roots": ["<rootDir>/shared"],
|
||||
"moduleNameMapper": {
|
||||
"^@server/(.*)$": "<rootDir>/server/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1"
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
|
||||
},
|
||||
"setupFiles": ["<rootDir>/__mocks__/console.js"],
|
||||
"setupFilesAfterEnv": ["<rootDir>/shared/test/setup.ts"],
|
||||
@@ -51,7 +52,9 @@
|
||||
"moduleNameMapper": {
|
||||
"^~/(.*)$": "<rootDir>/app/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
|
||||
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js",
|
||||
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
|
||||
},
|
||||
"setupFiles": ["<rootDir>/__mocks__/window.js"],
|
||||
"testEnvironment": "jsdom",
|
||||
|
||||
-104
@@ -1,104 +0,0 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"ignorePatterns": [
|
||||
"build/**",
|
||||
"node_modules/**",
|
||||
"public/**",
|
||||
"server/migrations/**",
|
||||
"server/scripts/**",
|
||||
"patches/**",
|
||||
"*.d.ts"
|
||||
],
|
||||
"rules": {
|
||||
"for-direction": "error",
|
||||
"no-async-promise-executor": "error",
|
||||
"no-case-declarations": "error",
|
||||
"no-class-assign": "error",
|
||||
"no-compare-neg-zero": "error",
|
||||
"no-cond-assign": "error",
|
||||
"no-const-assign": "error",
|
||||
"no-constant-binary-expression": "error",
|
||||
"no-constant-condition": "error",
|
||||
"no-control-regex": "error",
|
||||
"no-debugger": "error",
|
||||
"no-delete-var": "error",
|
||||
"no-dupe-class-members": "error",
|
||||
"no-dupe-else-if": "error",
|
||||
"no-dupe-keys": "error",
|
||||
"no-duplicate-case": "error",
|
||||
"no-empty": "error",
|
||||
"no-empty-character-class": "error",
|
||||
"no-empty-pattern": "error",
|
||||
"no-empty-static-block": "error",
|
||||
"no-ex-assign": "error",
|
||||
"no-explicit-any": "warn",
|
||||
"no-extra-boolean-cast": "error",
|
||||
"no-fallthrough": "error",
|
||||
"no-func-assign": "error",
|
||||
"no-global-assign": "error",
|
||||
"no-import-assign": "error",
|
||||
"no-invalid-regexp": "error",
|
||||
"no-irregular-whitespace": "error",
|
||||
"no-loss-of-precision": "error",
|
||||
"no-new-native-nonconstructor": "error",
|
||||
"no-nonoctal-decimal-escape": "error",
|
||||
"no-obj-calls": "error",
|
||||
"no-prototype-builtins": "error",
|
||||
"no-redeclare": "error",
|
||||
"no-regex-spaces": "error",
|
||||
"no-self-assign": "error",
|
||||
"no-setter-return": "error",
|
||||
"no-shadow-restricted-names": "error",
|
||||
"no-sparse-arrays": "error",
|
||||
"no-this-before-super": "error",
|
||||
"no-unexpected-multiline": "error",
|
||||
"no-unsafe-finally": "error",
|
||||
"no-unsafe-negation": "error",
|
||||
"no-unsafe-optional-chaining": "error",
|
||||
"no-unused-labels": "error",
|
||||
"no-unused-private-class-members": "error",
|
||||
"no-unused-vars": "error",
|
||||
"no-useless-backreference": "error",
|
||||
"no-useless-catch": "error",
|
||||
"no-useless-escape": "error",
|
||||
"no-with": "error",
|
||||
"require-yield": "error",
|
||||
"use-isnan": "error",
|
||||
"valid-typeof": "error"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["**/*.{js,jsx,ts,tsx}"],
|
||||
"rules": {
|
||||
"eqeqeq": "error",
|
||||
"curly": "error",
|
||||
"no-console": "error",
|
||||
"arrow-body-style": ["error", "as-needed"],
|
||||
"no-useless-escape": "off",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/self-closing-comp": [
|
||||
"error",
|
||||
{
|
||||
"component": true,
|
||||
"html": true
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
"import/no-named-as-default": "off",
|
||||
"import/no-named-as-default-member": "off",
|
||||
"typescript/consistent-type-imports": "error",
|
||||
"no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
"argsIgnorePattern": "^_",
|
||||
"caughtErrorsIgnorePattern": "^_",
|
||||
"args": "after-used",
|
||||
"ignoreRestSiblings": true
|
||||
}
|
||||
],
|
||||
"react/rules-of-hooks": "error"
|
||||
},
|
||||
"plugins": ["eslint", "oxc", "react", "typescript", "import"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"printWidth": 80,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
require("@dotenvx/dotenvx").config({
|
||||
require("dotenv").config({
|
||||
path: process.env.NODE_ENV === "test" ? ".env.test" : ".env",
|
||||
});
|
||||
|
||||
var path = require('path');
|
||||
|
||||
module.exports = {
|
||||
'config': path.resolve('server/config', 'database.js'),
|
||||
'config': path.resolve('server/config', 'database.json'),
|
||||
'migrations-path': path.resolve('server', 'migrations'),
|
||||
'models-path': path.resolve('server', 'models'),
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
nodeLinker: node-modules
|
||||
|
||||
npmMinimalAgeGate: 86400
|
||||
|
||||
npmPreapprovedPackages:
|
||||
- outline-icons
|
||||
@@ -1,195 +0,0 @@
|
||||
Outline is a fast, collaborative knowledge base built for teams. It's built with React and TypeScript in both frontend and backend, uses a real-time collaboration engine, and is designed for excellent performance and user experience. The backend is a Koa server with an RPC API and uses PostgreSQL and Redis. The application can be self-hosted or used as a cloud service.
|
||||
|
||||
There is a web client which is fully responsive and works on mobile devices.
|
||||
|
||||
**Monorepo Structure:**
|
||||
|
||||
- **`app/`** - React web application with MobX state management
|
||||
- **`server/`** - Koa API server with Sequelize ORM and background workers
|
||||
- **`shared/`** - Shared TypeScript types, utilities, and editor components
|
||||
- **`plugins/`** - Plugin system for extending functionality
|
||||
- **`public/`** - Static assets served directly
|
||||
- **Various config files** - TypeScript, Vite, Jest, Prettier, Oxlint configurations
|
||||
|
||||
Refer to /docs/ARCHITECTURE.md for detailed architecture documentation.
|
||||
|
||||
## Instructions
|
||||
|
||||
You're an expert in the following areas:
|
||||
|
||||
- TypeScript
|
||||
- React and React Router
|
||||
- MobX and MobX-React
|
||||
- Node.js and Koa
|
||||
- Sequelize ORM
|
||||
- PostgreSQL
|
||||
- Redis
|
||||
- HTML, CSS and Styled Components
|
||||
- Prosemirror (rich text editor)
|
||||
- WebSockets and real-time collaboration
|
||||
|
||||
## General Guidelines
|
||||
|
||||
- Critical – Do not create new markdown (.md) files.
|
||||
- Use early returns for readability.
|
||||
- Emphasize type safety and static analysis.
|
||||
- Follow consistent Prettier formatting.
|
||||
- Do not replace smart quotes ("") or ('') with simple quotes ("").
|
||||
- Do not add translation strings manually; they will be extracted automatically from the codebase.
|
||||
|
||||
## Dependencies and Upgrading
|
||||
|
||||
- Use yarn for all dependency management.
|
||||
- After updating dependency versions, install to update lockfiles:
|
||||
|
||||
```bash
|
||||
yarn install
|
||||
```
|
||||
|
||||
## TypeScript Usage
|
||||
|
||||
- Use strict mode.
|
||||
- Avoid "unknown" unless absolutely necessary.
|
||||
- Never use "any".
|
||||
- Prefer type definitions; avoid type assertions (as, !).
|
||||
- Always use curly braces for if statements.
|
||||
- Avoid # for private properties.
|
||||
- Prefer interface over type for object shapes.
|
||||
|
||||
## Classes & Code Organization
|
||||
|
||||
### Class Member Order
|
||||
|
||||
1. Public static variables
|
||||
2. Public static methods
|
||||
3. Public variables
|
||||
4. Public methods
|
||||
5. Protected variables & methods
|
||||
6. Private variables & methods
|
||||
|
||||
### Exports
|
||||
|
||||
- Exported members must appear at the top of the file.
|
||||
- Always use named exports for new components & classes.
|
||||
- Document ALL public/exported functions with JSDoc.
|
||||
|
||||
## React Usage
|
||||
|
||||
- Use functional components with hooks.
|
||||
- Event handlers should be prefixed with "handle", like "handleClick" for onClick.
|
||||
- Avoid unnecessary re-renders by using React.memo, useMemo, and useCallback appropriately.
|
||||
- Use descriptive prop types with TypeScript interfaces.
|
||||
- Do not import React unless it is used directly.
|
||||
- Use styled-components for component styling.
|
||||
- Ensure high accessibility (a11y) standards using ARIA roles and semantic HTML.
|
||||
|
||||
## MobX State Management
|
||||
|
||||
- Use MobX stores for global state management.
|
||||
- Keep stores in `app/stores/`.
|
||||
- Use `observable`, `action`, and `computed` decorators appropriately.
|
||||
- Prefer computed values over manual calculations in render.
|
||||
- Keep business logic in stores, not components.
|
||||
|
||||
## Database & ORM
|
||||
|
||||
- Use Sequelize models in `server/models/`.
|
||||
- Generate migrations with Sequelize CLI:
|
||||
|
||||
```bash
|
||||
yarn sequelize migration:create --name=add-field-to-table
|
||||
```
|
||||
|
||||
- Run migrations with `yarn db:migrate`.
|
||||
- Use transactions for multi-table operations.
|
||||
- Add appropriate indexes for query performance.
|
||||
- Always handle database errors gracefully.
|
||||
|
||||
## API Design
|
||||
|
||||
- RESTful endpoints under `/api/`.
|
||||
- Authentication endpoints under `/auth/`.
|
||||
- Use consistent error responses.
|
||||
- Validate request data using the validation middleware and schemas
|
||||
- Use presenters to format API responses.
|
||||
- Keep API routes thin, use model methods for business logic, or commands if logic spans multiple models.
|
||||
|
||||
## Authentication & Authorization
|
||||
|
||||
- JWT tokens for authentication.
|
||||
- Policies in `server/policies/` for authorization.
|
||||
- Use cancan-style ability checks.
|
||||
- Use authenticated middleware for protected routes.
|
||||
- Always verify user permissions before data access.
|
||||
|
||||
## Real-time Collaboration
|
||||
|
||||
- WebSocket connections for real-time updates.
|
||||
- Use Y.js for collaborative editing.
|
||||
- Handle connection state changes gracefully.
|
||||
|
||||
## Documentation
|
||||
|
||||
- All public/exported functions & classes must have JSDoc.
|
||||
- Include:
|
||||
- Description
|
||||
- @param and @return (start lowercase, end with period)
|
||||
- @throws if applicable
|
||||
- Add a newline between the description and the @ block.
|
||||
- Use correct punctuation.
|
||||
|
||||
## Testing
|
||||
|
||||
- Run tests with Jest:
|
||||
|
||||
```bash
|
||||
# Run a specific test file (preferred)
|
||||
yarn test path/to/test.spec.ts
|
||||
|
||||
# Run every test (avoid)
|
||||
yarn test
|
||||
|
||||
# Run test suites (avoid)
|
||||
yarn test:app # All frontend tests
|
||||
yarn test:server # All backend tests
|
||||
yarn test:shared # All shared code tests
|
||||
```
|
||||
|
||||
- Write unit tests for utilities and business logic in a collocated .test.ts file.
|
||||
- Do not create new test directories
|
||||
- Mock external dependencies appropriately in **mocks** folder.
|
||||
- Aim for high code coverage but focus on critical paths.
|
||||
|
||||
## Code Quality
|
||||
|
||||
- Use Oxlint for linting: `yarn lint`
|
||||
- Format code with Prettier: `yarn format`
|
||||
- Check types with TypeScript: `yarn tsc`
|
||||
- Pre-commit hooks run automatically via Husky.
|
||||
- Fix linting issues before committing.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Use custom error classes in `server/errors.ts`.
|
||||
- Always catch and handle errors appropriately.
|
||||
- Log errors with appropriate context.
|
||||
- Return user-friendly error messages.
|
||||
- Never expose sensitive information in errors.
|
||||
|
||||
## Performance
|
||||
|
||||
- Use React.memo for expensive components.
|
||||
- Implement pagination for large lists.
|
||||
- Use database indexes effectively.
|
||||
- Cache expensive computations.
|
||||
- Monitor performance with appropriate tools.
|
||||
- Lazy load routes and components where appropriate.
|
||||
|
||||
## Security
|
||||
|
||||
- Sanitize all user input.
|
||||
- Use CSRF protection.
|
||||
- Use rateLimiter middleware for sensitive endpoints.
|
||||
- Follow OWASP guidelines.
|
||||
- Never store sensitive data in plain text.
|
||||
- Use environment variables for secrets.
|
||||
+16
-23
@@ -1,12 +1,11 @@
|
||||
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
|
||||
|
||||
# ---
|
||||
FROM node:22.21.0-slim AS runner
|
||||
FROM node:20-slim AS runner
|
||||
|
||||
LABEL org.opencontainers.image.source="https://github.com/outline/outline"
|
||||
|
||||
@@ -14,34 +13,28 @@ ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=base $APP_PATH/build ./build
|
||||
COPY --from=base $APP_PATH/server ./server
|
||||
COPY --from=base $APP_PATH/public ./public
|
||||
COPY --from=base $APP_PATH/.sequelizerc ./.sequelizerc
|
||||
COPY --from=base $APP_PATH/node_modules ./node_modules
|
||||
COPY --from=base $APP_PATH/package.json ./package.json
|
||||
|
||||
# Create a non-root user compatible with Debian and BusyBox based images
|
||||
RUN addgroup --gid 1001 nodejs && \
|
||||
adduser --uid 1001 --ingroup nodejs nodejs && \
|
||||
mkdir -p /var/lib/outline && \
|
||||
chown -R nodejs:nodejs /var/lib/outline && \
|
||||
chown -R nodejs:nodejs $APP_PATH
|
||||
|
||||
COPY --from=base --chown=nodejs:nodejs $APP_PATH/build ./build
|
||||
COPY --from=base --chown=nodejs:nodejs $APP_PATH/server ./server
|
||||
COPY --from=base --chown=nodejs:nodejs $APP_PATH/public ./public
|
||||
COPY --from=base --chown=nodejs:nodejs $APP_PATH/.sequelizerc ./.sequelizerc
|
||||
COPY --from=base --chown=nodejs:nodejs $APP_PATH/node_modules ./node_modules
|
||||
COPY --from=base --chown=nodejs:nodejs $APP_PATH/package.json ./package.json
|
||||
# Install wget to healthcheck the server
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
adduser --uid 1001 --ingroup nodejs nodejs && \
|
||||
chown -R nodejs:nodejs $APP_PATH/build && \
|
||||
mkdir -p /var/lib/outline && \
|
||||
chown -R nodejs:nodejs /var/lib/outline
|
||||
|
||||
ENV FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
|
||||
RUN mkdir -p "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
|
||||
chown -R nodejs:nodejs "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
|
||||
chmod 1777 "$FILE_STORAGE_LOCAL_ROOT_DIR"
|
||||
chown -R nodejs:nodejs "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
|
||||
chmod 1777 "$FILE_STORAGE_LOCAL_ROOT_DIR"
|
||||
|
||||
VOLUME /var/lib/outline/data
|
||||
|
||||
USER nodejs
|
||||
|
||||
HEALTHCHECK --interval=1m CMD wget -qO- "http://localhost:${PORT:-3000}/_health" | grep -q "OK" || exit 1
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["node", "build/server/index.js"]
|
||||
CMD ["yarn", "start"]
|
||||
|
||||
+10
-7
@@ -1,23 +1,26 @@
|
||||
ARG APP_PATH=/opt/outline
|
||||
FROM node:22.21.0 AS deps
|
||||
FROM node:20-slim AS deps
|
||||
|
||||
ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
COPY ./package.json ./yarn.lock ./.yarnrc.yml ./
|
||||
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 apt-get update \
|
||||
&& apt-get install -y wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN corepack enable
|
||||
RUN yarn install --immutable --network-timeout 1000000 && \
|
||||
RUN yarn install --no-optional --frozen-lockfile --network-timeout 1000000 && \
|
||||
yarn cache clean
|
||||
|
||||
COPY . .
|
||||
ARG CDN_URL
|
||||
RUN yarn build
|
||||
|
||||
RUN yarn workspaces focus --production && \
|
||||
RUN rm -rf node_modules
|
||||
|
||||
RUN yarn install --production=true --frozen-lockfile --network-timeout 1000000 && \
|
||||
yarn cache clean
|
||||
|
||||
ENV PORT=3000
|
||||
HEALTHCHECK CMD wget -qO- http://localhost:${PORT}/_health | grep -q "OK" || exit 1
|
||||
|
||||
@@ -3,8 +3,8 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 1.6.1
|
||||
The Licensed Work is (c) 2026 General Outline, Inc.
|
||||
Licensed Work: Outline 0.71.0
|
||||
The Licensed Work is (c) 2020 General Outline, Inc.
|
||||
Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
you may not use the Licensed Work for a Document
|
||||
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: 2030-03-18
|
||||
Change Date: 2027-08-18
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
up:
|
||||
docker compose up -d redis postgres
|
||||
yarn install-local-ssl
|
||||
yarn install --immutable
|
||||
yarn install --pure-lockfile
|
||||
yarn dev:watch
|
||||
|
||||
build:
|
||||
docker compose build --pull outline
|
||||
|
||||
test:
|
||||
docker compose up -d postgres
|
||||
docker compose up -d redis postgres
|
||||
NODE_ENV=test yarn sequelize db:drop
|
||||
NODE_ENV=test yarn sequelize db:create
|
||||
NODE_ENV=test yarn sequelize db:migrate
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./public/logos/outline-logo-dark.png" height="29">
|
||||
<source media="(prefers-color-scheme: light)" srcset="./public/logos/outline-logo-light.png" height="29">
|
||||
<img src="./public/logos/outline-logo-light.png" height="29" alt="Outline" />
|
||||
</picture>
|
||||
<img src="https://user-images.githubusercontent.com/31465/34380645-bd67f474-eb0b-11e7-8d03-0151c1730654.png" height="29" />
|
||||
</p>
|
||||
<p align="center">
|
||||
<i>A fast, collaborative, knowledge base for your team built using React and Node.js.<br/>Try out Outline using our hosted version at <a href="https://www.getoutline.com">www.getoutline.com</a>.</i>
|
||||
@@ -11,13 +7,14 @@
|
||||
<img width="1640" alt="screenshot" src="https://user-images.githubusercontent.com/380914/110356468-26374600-7fef-11eb-9f6a-f2cc2c8c6590.png">
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://circleci.com/gh/outline/outline" rel="nofollow"><img src="https://circleci.com/gh/outline/outline.svg?style=shield"></a>
|
||||
<a href="http://www.typescriptlang.org" rel="nofollow"><img src="https://img.shields.io/badge/%3C%2F%3E-TypeScript-%230074c1.svg" alt="TypeScript"></a>
|
||||
<a href="https://github.com/prettier/prettier"><img src="https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat" alt="Prettier"></a>
|
||||
<a href="https://github.com/styled-components/styled-components"><img src="https://img.shields.io/badge/style-%F0%9F%92%85%20styled--components-orange.svg" alt="Styled Components"></a>
|
||||
<a href="https://translate.getoutline.com/project/outline" alt="Localized"><img src="https://badges.crowdin.net/outline/localized.svg"></a>
|
||||
</p>
|
||||
|
||||
This is the source code that runs [**Outline**](https://www.getoutline.com) and all the associated services. If you want to use Outline then you don't need to run this code, A hosted version of the app is offered at [getoutline.com](https://www.getoutline.com). You can also find documentation on using Outline in [our guide](https://docs.getoutline.com/s/guide).
|
||||
This is the source code that runs [**Outline**](https://www.getoutline.com) and all the associated services. If you want to use Outline then you don't need to run this code, we offer a hosted version of the app at [getoutline.com](https://www.getoutline.com). You can also find documentation on using Outline in [our guide](https://docs.getoutline.com/s/guide).
|
||||
|
||||
If you'd like to run your own copy of Outline or contribute to development then this is the place for you.
|
||||
|
||||
@@ -33,9 +30,9 @@ There is a short guide for [setting up a development environment](https://docs.g
|
||||
|
||||
## Contributing
|
||||
|
||||
Outline is built and maintained by a small team – your help finding and fixing bugs is appreciated, though AI assisted PR's from new contributors are discouraged and unlikely to be merged.
|
||||
Outline is built and maintained by a small team – we'd love your help to fix bugs and add features!
|
||||
|
||||
Before submitting a pull request _you must_ discuss with the core team by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues) – we'd also love to hear from you in the [discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written and that you have read these instructions. This will result in a much higher likelihood of your code being accepted.
|
||||
Before submitting a pull request _please_ discuss with the core team by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues) – we'd also love to hear from you in the [discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written. This will result in a much higher likelihood of your code being accepted.
|
||||
|
||||
If you’re looking for ways to get started, here's a list of ways to help us improve Outline:
|
||||
|
||||
@@ -54,14 +51,13 @@ please refer to the [architecture document](docs/ARCHITECTURE.md) first for a hi
|
||||
|
||||
In development Outline outputs simple logging to the console, prefixed by categories. In production it outputs JSON logs, these can be easily parsed by your preferred log ingestion pipeline.
|
||||
|
||||
HTTP logging is disabled by default, but can be enabled by setting the `DEBUG=http` environment variable. logging
|
||||
can be enabled for all categories by setting `DEBUG=*` or for specific categories such as `DEBUG=database` and `LOG_LEVEL=debug`, or `LOG_LEVEL=silly` for very verbose logging.
|
||||
HTTP logging is disabled by default, but can be enabled by setting the `DEBUG=http` environment variable.
|
||||
|
||||
## Tests
|
||||
|
||||
We aim to have sufficient test coverage for critical parts of the application and aren't aiming for 100% unit test coverage. All API endpoints and anything authentication related should be thoroughly tested.
|
||||
|
||||
To add new tests, write your tests with [Jest](https://facebook.github.io/jest/) and add a file with `.test.ts` extension next to the tested code.
|
||||
To add new tests, write your tests with [Jest](https://facebook.github.io/jest/) and add a file with `.test.js` extension next to the tested code.
|
||||
|
||||
```shell
|
||||
# To run all tests
|
||||
@@ -72,14 +68,14 @@ make watch
|
||||
```
|
||||
|
||||
Once the test database is created with `make test` you may individually run
|
||||
frontend and backend tests directly with jest:
|
||||
frontend and backend tests directly.
|
||||
|
||||
```shell
|
||||
# To run backend tests
|
||||
yarn test:server
|
||||
|
||||
# To run a specific backend test in watch mode
|
||||
yarn test path/to/file.test.ts --watch
|
||||
# To run a specific backend test
|
||||
yarn test:server myTestFile
|
||||
|
||||
# To run frontend tests
|
||||
yarn test:app
|
||||
@@ -90,15 +86,14 @@ yarn test:app
|
||||
Sequelize is used to create and run migrations, for example:
|
||||
|
||||
```shell
|
||||
yarn db:create-migration --name my-migration
|
||||
yarn db:migrate
|
||||
yarn db:rollback
|
||||
yarn sequelize migration:generate --name my-migration
|
||||
yarn sequelize db:migrate
|
||||
```
|
||||
|
||||
Or, to run migrations on test database:
|
||||
Or to run migrations on test database:
|
||||
|
||||
```shell
|
||||
yarn db:migrate --env test
|
||||
yarn sequelize db:migrate --env test
|
||||
```
|
||||
|
||||
# Activity
|
||||
|
||||
@@ -1 +1 @@
|
||||
export default "";
|
||||
export default '';
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
const storage = {};
|
||||
|
||||
export default {
|
||||
setItem: function (key, value) {
|
||||
storage[key] = value || "";
|
||||
setItem: function(key, value) {
|
||||
storage[key] = value || '';
|
||||
},
|
||||
getItem: function (key) {
|
||||
getItem: function(key) {
|
||||
return key in storage ? storage[key] : null;
|
||||
},
|
||||
removeItem: function (key) {
|
||||
removeItem: function(key) {
|
||||
delete storage[key];
|
||||
},
|
||||
get length() {
|
||||
return Object.keys(storage).length;
|
||||
},
|
||||
key: function (i) {
|
||||
key: function(i) {
|
||||
var keys = Object.keys(storage);
|
||||
return keys[i] || null;
|
||||
},
|
||||
|
||||
@@ -21,23 +21,7 @@
|
||||
}
|
||||
],
|
||||
"scripts": {
|
||||
"postdeploy": "yarn sequelize db:migrate",
|
||||
"pr-predeploy": "yarn sequelize db:migrate"
|
||||
},
|
||||
"environments": {
|
||||
"review": {
|
||||
"scripts": {
|
||||
"postdeploy": "yarn sequelize db:migrate"
|
||||
},
|
||||
"addons": [
|
||||
{
|
||||
"plan": "heroku-redis:mini"
|
||||
},
|
||||
{
|
||||
"plan": "heroku-postgresql:essential-0"
|
||||
}
|
||||
]
|
||||
}
|
||||
"postdeploy": "yarn sequelize db:migrate"
|
||||
},
|
||||
"env": {
|
||||
"NODE_ENV": {
|
||||
@@ -59,12 +43,8 @@
|
||||
"required": true
|
||||
},
|
||||
"URL": {
|
||||
"description": "https://{your app name}.herokuapp.com, or the domain you are binding to. For review apps, this is auto-generated.",
|
||||
"required": false
|
||||
},
|
||||
"HEROKU_APP_NAME": {
|
||||
"description": "Automatically set by Heroku for review apps",
|
||||
"required": false
|
||||
"description": "https://{your app name}.herokuapp.com, or the domain you are binding to",
|
||||
"required": true
|
||||
},
|
||||
"GOOGLE_CLIENT_ID": {
|
||||
"description": "See https://developers.google.com/identity/protocols/OAuth2 to create a new Google OAuth client. You must configure at least one of Slack or Google to control login.",
|
||||
@@ -185,10 +165,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
|
||||
@@ -214,11 +190,6 @@
|
||||
"description": "Use a secure SMTP connection (optional)",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_DISABLE_STARTTLS": {
|
||||
"value": "false",
|
||||
"description": "Disable STARTTLS even if the server supports it (optional)",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_TLS_CIPHERS": {
|
||||
"description": "Override SMTP cipher configuration (optional)",
|
||||
"required": false
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": [
|
||||
"../.eslintrc",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react-hooks/recommended"
|
||||
],
|
||||
"plugins": [
|
||||
"eslint-plugin-react-hooks"
|
||||
],
|
||||
"env": {
|
||||
"jest": true,
|
||||
"browser": true
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"extends": ["../.oxlintrc.json"],
|
||||
"plugins": ["oxc", "eslint", "typescript", "react"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["**/*.{jsx,tsx}"],
|
||||
"rules": {
|
||||
"no-restricted-globals": [
|
||||
"error",
|
||||
{
|
||||
"name": "crypto",
|
||||
"message": "Do not use, does not work in environments without SSL."
|
||||
}
|
||||
],
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": [
|
||||
{
|
||||
"group": ["mime-types"],
|
||||
"message": "Do not use the mime-types package in the browser."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"plugins": ["import"]
|
||||
}
|
||||
],
|
||||
"env": {
|
||||
"jest": true,
|
||||
"browser": true
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { PlusIcon, TrashIcon } from "outline-icons";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import stores from "~/stores";
|
||||
import type ApiKey from "~/models/ApiKey";
|
||||
import ApiKeyNew from "~/scenes/ApiKeyNew";
|
||||
import ApiKeyRevokeDialog from "~/scenes/Settings/components/ApiKeyRevokeDialog";
|
||||
import { createAction } from "..";
|
||||
import { SettingsSection } from "../sections";
|
||||
|
||||
@@ -24,37 +23,3 @@ export const createApiKey = createAction({
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const revokeApiKeyFactory = ({ apiKey }: { apiKey: ApiKey }) =>
|
||||
createAction({
|
||||
name: ({ t, isMenu }) =>
|
||||
isMenu
|
||||
? apiKey.isExpired
|
||||
? t("Delete")
|
||||
: `${t("Revoke")}…`
|
||||
: t("Revoke API key"),
|
||||
analyticsName: "Revoke API key",
|
||||
section: SettingsSection,
|
||||
icon: <TrashIcon />,
|
||||
keywords: "revoke delete remove",
|
||||
dangerous: true,
|
||||
perform: async ({ t, event }) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
if (apiKey.isExpired) {
|
||||
await apiKey.delete();
|
||||
return;
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Revoke token"),
|
||||
content: (
|
||||
<ApiKeyRevokeDialog
|
||||
onSubmit={stores.dialogs.closeAllModals}
|
||||
apiKey={apiKey}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,57 +1,34 @@
|
||||
import {
|
||||
SortAlphabeticalReverseIcon,
|
||||
SortAlphabeticalIcon,
|
||||
ArchiveIcon,
|
||||
CollectionIcon,
|
||||
EditIcon,
|
||||
ExportIcon,
|
||||
ImportIcon,
|
||||
SortManualIcon,
|
||||
NewDocumentIcon,
|
||||
PadlockIcon,
|
||||
PlusIcon,
|
||||
RestoreIcon,
|
||||
SearchIcon,
|
||||
ShapesIcon,
|
||||
StarredIcon,
|
||||
SubscribeIcon,
|
||||
TrashIcon,
|
||||
UnstarredIcon,
|
||||
UnsubscribeIcon,
|
||||
} from "outline-icons";
|
||||
import { toast } from "sonner";
|
||||
import * as React from "react";
|
||||
import stores from "~/stores";
|
||||
import Collection from "~/models/Collection";
|
||||
import { CollectionEdit } from "~/components/Collection/CollectionEdit";
|
||||
import { CollectionNew } from "~/components/Collection/CollectionNew";
|
||||
import CollectionDeleteDialog from "~/components/CollectionDeleteDialog";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import SharePopover from "~/components/Sharing/Collection/SharePopover";
|
||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||
import {
|
||||
createAction,
|
||||
createInternalLinkAction,
|
||||
createActionWithChildren,
|
||||
} from "~/actions";
|
||||
import { ActiveCollectionSection, CollectionSection } from "~/actions/sections";
|
||||
import { createAction } from "~/actions";
|
||||
import { CollectionSection } from "~/actions/sections";
|
||||
import { setPersistedState } from "~/hooks/usePersistedState";
|
||||
import {
|
||||
newDocumentPath,
|
||||
newTemplatePath,
|
||||
searchPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
import ExportDialog from "~/components/ExportDialog";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import history from "~/utils/history";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import { newTemplatePath, searchPath } from "~/utils/routeHelpers";
|
||||
|
||||
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => (
|
||||
<DynamicCollectionIcon collection={collection} />
|
||||
);
|
||||
const SharePopover = lazyWithRetry(
|
||||
() => import("~/components/Sharing/Collection/SharePopover")
|
||||
);
|
||||
|
||||
export const openCollection = createActionWithChildren({
|
||||
export const openCollection = createAction({
|
||||
name: ({ t }) => t("Open collection"),
|
||||
analyticsName: "Open collection",
|
||||
section: CollectionSection,
|
||||
@@ -59,17 +36,15 @@ export const openCollection = createActionWithChildren({
|
||||
icon: <CollectionIcon />,
|
||||
children: ({ stores }) => {
|
||||
const collections = stores.collections.orderedData;
|
||||
return collections.map((collection) =>
|
||||
createInternalLinkAction({
|
||||
// Note: using url which includes the slug rather than id here to bust
|
||||
// cache if the collection is renamed
|
||||
id: collection.path,
|
||||
name: collection.name,
|
||||
icon: <ColorCollectionIcon collection={collection} />,
|
||||
section: CollectionSection,
|
||||
to: collection.path,
|
||||
})
|
||||
);
|
||||
return collections.map((collection) => ({
|
||||
// Note: using url which includes the slug rather than id here to bust
|
||||
// cache if the collection is renamed
|
||||
id: collection.path,
|
||||
name: collection.name,
|
||||
icon: <ColorCollectionIcon collection={collection} />,
|
||||
section: CollectionSection,
|
||||
perform: () => history.push(collection.path),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -81,7 +56,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({
|
||||
@@ -92,15 +67,16 @@ export const createCollection = createAction({
|
||||
});
|
||||
|
||||
export const editCollection = createAction({
|
||||
name: ({ t, isMenu }) => (isMenu ? `${t("Edit")}…` : t("Edit collection")),
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? `${t("Edit")}…` : t("Edit collection"),
|
||||
analyticsName: "Edit collection",
|
||||
section: ActiveCollectionSection,
|
||||
section: CollectionSection,
|
||||
icon: <EditIcon />,
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some((policy) => policy.abilities.update),
|
||||
perform: ({ t, getActiveModel, stores }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
visible: ({ stores, activeCollectionId }) =>
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).update,
|
||||
perform: ({ t, activeCollectionId }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -109,7 +85,7 @@ export const editCollection = createAction({
|
||||
content: (
|
||||
<CollectionEdit
|
||||
onSubmit={stores.dialogs.closeAllModals}
|
||||
collectionId={collection.id}
|
||||
collectionId={activeCollectionId}
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -117,21 +93,26 @@ export const editCollection = createAction({
|
||||
});
|
||||
|
||||
export const editCollectionPermissions = createAction({
|
||||
name: ({ t, isMenu }) =>
|
||||
isMenu ? `${t("Permissions")}…` : t("Collection permissions"),
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? `${t("Permissions")}…` : t("Collection permissions"),
|
||||
analyticsName: "Collection permissions",
|
||||
section: ActiveCollectionSection,
|
||||
section: CollectionSection,
|
||||
icon: <PadlockIcon />,
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some((policy) => policy.abilities.update),
|
||||
perform: ({ t, getActiveModel, stores }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
visible: ({ stores, activeCollectionId }) =>
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).update,
|
||||
perform: ({ t, stores, activeCollectionId }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Share this collection"),
|
||||
style: { marginBottom: -12 },
|
||||
content: (
|
||||
<SharePopover
|
||||
collection={collection}
|
||||
@@ -143,177 +124,42 @@ export const editCollectionPermissions = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const importDocument = createAction({
|
||||
name: ({ t }) => t("Import document"),
|
||||
analyticsName: "Import document",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ImportIcon />,
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some(
|
||||
(policy) => policy.abilities.createDocument
|
||||
),
|
||||
perform: ({ t, getActiveModel, stores }) => {
|
||||
const { documents } = stores;
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = documents.importFileTypesString;
|
||||
|
||||
input.onchange = async (ev) => {
|
||||
const files = getEventFiles(ev);
|
||||
const file = files[0];
|
||||
const toastId = toast.loading(`${t("Uploading")}…`);
|
||||
|
||||
try {
|
||||
const document = await documents.import(file, null, collection.id, {
|
||||
publish: true,
|
||||
});
|
||||
history.push(document.path);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
} finally {
|
||||
toast.dismiss(toastId);
|
||||
}
|
||||
};
|
||||
|
||||
input.click();
|
||||
},
|
||||
});
|
||||
|
||||
export const sortCollection = createActionWithChildren({
|
||||
name: ({ t }) => t("Sort in sidebar"),
|
||||
section: ActiveCollectionSection,
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some((policy) => policy.abilities.update),
|
||||
icon: ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
const sortAlphabetical = collection?.sort.field === "title";
|
||||
const sortDir = collection?.sort.direction;
|
||||
|
||||
return sortAlphabetical ? (
|
||||
sortDir === "asc" ? (
|
||||
<SortAlphabeticalIcon />
|
||||
) : (
|
||||
<SortAlphabeticalReverseIcon />
|
||||
)
|
||||
) : (
|
||||
<SortManualIcon />
|
||||
);
|
||||
},
|
||||
children: [
|
||||
createAction({
|
||||
name: ({ t }) => t("A-Z sort"),
|
||||
section: ActiveCollectionSection,
|
||||
selected: ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
return (
|
||||
collection?.sort.field === "title" &&
|
||||
collection?.sort.direction === "asc"
|
||||
);
|
||||
},
|
||||
perform: ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
return collection?.save({
|
||||
sort: {
|
||||
field: "title",
|
||||
direction: "asc",
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
createAction({
|
||||
name: ({ t }) => t("Z-A sort"),
|
||||
section: ActiveCollectionSection,
|
||||
selected: ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
return (
|
||||
collection?.sort.field === "title" &&
|
||||
collection?.sort.direction === "desc"
|
||||
);
|
||||
},
|
||||
perform: ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
return collection?.save({
|
||||
sort: {
|
||||
field: "title",
|
||||
direction: "desc",
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
createAction({
|
||||
name: ({ t }) => t("Manual sort"),
|
||||
section: ActiveCollectionSection,
|
||||
selected: ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
return collection?.sort.field !== "title";
|
||||
},
|
||||
perform: ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
return collection?.save({
|
||||
sort: {
|
||||
field: "index",
|
||||
direction: "asc",
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
export const searchInCollection = createInternalLinkAction({
|
||||
export const searchInCollection = createAction({
|
||||
name: ({ t }) => t("Search in collection"),
|
||||
analyticsName: "Search collection",
|
||||
section: ActiveCollectionSection,
|
||||
section: CollectionSection,
|
||||
icon: <SearchIcon />,
|
||||
visible: ({ getActiveModel, stores }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection?.isActive) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return stores.policies.abilities(collection.id).readDocument;
|
||||
},
|
||||
to: ({ getActiveModel, sidebarContext }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
|
||||
const [pathname, search] = searchPath({
|
||||
collectionId: collection?.id,
|
||||
}).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
visible: ({ activeCollectionId }) =>
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).readDocument,
|
||||
perform: ({ activeCollectionId }) => {
|
||||
history.push(searchPath(undefined, { collectionId: activeCollectionId }));
|
||||
},
|
||||
});
|
||||
|
||||
export const starCollection = createAction({
|
||||
name: ({ t }) => t("Star"),
|
||||
analyticsName: "Star collection",
|
||||
section: ActiveCollectionSection,
|
||||
section: CollectionSection,
|
||||
icon: <StarredIcon />,
|
||||
keywords: "favorite bookmark",
|
||||
visible: ({ getActiveModel, stores }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return (
|
||||
!collection.isStarred && stores.policies.abilities(collection.id).star
|
||||
!collection?.isStarred &&
|
||||
stores.policies.abilities(activeCollectionId).star
|
||||
);
|
||||
},
|
||||
perform: async ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
perform: async ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
await collection.star();
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
await collection?.star();
|
||||
setPersistedState(getHeaderExpandedKey("starred"), true);
|
||||
},
|
||||
});
|
||||
@@ -321,142 +167,47 @@ export const starCollection = createAction({
|
||||
export const unstarCollection = createAction({
|
||||
name: ({ t }) => t("Unstar"),
|
||||
analyticsName: "Unstar collection",
|
||||
section: ActiveCollectionSection,
|
||||
section: CollectionSection,
|
||||
icon: <UnstarredIcon />,
|
||||
keywords: "unfavorite unbookmark",
|
||||
visible: ({ getActiveModel, stores }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return (
|
||||
!!collection.isStarred && stores.policies.abilities(collection.id).unstar
|
||||
!!collection?.isStarred &&
|
||||
stores.policies.abilities(activeCollectionId).unstar
|
||||
);
|
||||
},
|
||||
perform: async ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
perform: async ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
await collection?.unstar();
|
||||
},
|
||||
});
|
||||
|
||||
export const subscribeCollection = createAction({
|
||||
name: ({ t }) => t("Subscribe"),
|
||||
analyticsName: "Subscribe to collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <SubscribeIcon />,
|
||||
visible: ({ getActiveModel, stores }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
!!collection.isActive &&
|
||||
!collection.isSubscribed &&
|
||||
stores.policies.abilities(collection.id).subscribe
|
||||
);
|
||||
},
|
||||
perform: async ({ getActiveModel, t }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
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: ({ getActiveModel, stores }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
!!collection.isActive &&
|
||||
!!collection.isSubscribed &&
|
||||
stores.policies.abilities(collection.id).unsubscribe
|
||||
);
|
||||
},
|
||||
perform: async ({ getActiveModel, t }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
await collection.unsubscribe();
|
||||
toast.success(t("Unsubscribed from document notifications"));
|
||||
},
|
||||
});
|
||||
|
||||
export const archiveCollection = createAction({
|
||||
name: ({ t }) => `${t("Archive")}…`,
|
||||
analyticsName: "Archive collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ArchiveIcon />,
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some((policy) => policy.abilities.archive),
|
||||
perform: async ({ getActiveModel, stores, t }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Archive collection"),
|
||||
content: (
|
||||
<ConfirmationDialog
|
||||
onSubmit={async () => {
|
||||
await collection.archive();
|
||||
toast.success(t("Collection archived"));
|
||||
}}
|
||||
submitText={t("Archive")}
|
||||
savingText={`${t("Archiving")}…`}
|
||||
>
|
||||
{t(
|
||||
"Archiving this collection will also archive all documents within it. Documents from the collection will no longer be visible in search results."
|
||||
)}
|
||||
</ConfirmationDialog>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const restoreCollection = createAction({
|
||||
name: ({ t }) => t("Restore"),
|
||||
analyticsName: "Restore collection",
|
||||
section: CollectionSection,
|
||||
icon: <RestoreIcon />,
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some((policy) => policy.abilities.restore),
|
||||
perform: async ({ getActiveModel, t }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
await collection.restore();
|
||||
toast.success(t("Collection restored"));
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteCollection = createAction({
|
||||
name: ({ t }) => `${t("Delete")}…`,
|
||||
analyticsName: "Delete collection",
|
||||
section: ActiveCollectionSection,
|
||||
section: CollectionSection,
|
||||
dangerous: true,
|
||||
icon: <TrashIcon />,
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some((policy) => policy.abilities.delete),
|
||||
perform: ({ getActiveModel, t, stores }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
return stores.policies.abilities(activeCollectionId).delete;
|
||||
},
|
||||
perform: ({ activeCollectionId, stores, t }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
@@ -473,66 +224,24 @@ export const deleteCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const exportCollection = createAction({
|
||||
name: ({ t }) => `${t("Export")}…`,
|
||||
analyticsName: "Export collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ExportIcon />,
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some((policy) => policy.abilities.export),
|
||||
perform: async ({ getActiveModel, stores, t }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Export collection"),
|
||||
content: (
|
||||
<ExportDialog
|
||||
collection={collection}
|
||||
onSubmit={stores.dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const createDocument = createInternalLinkAction({
|
||||
name: ({ t }) => t("New document"),
|
||||
analyticsName: "New document",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "new create document",
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some(
|
||||
(policy) => policy.abilities.createDocument
|
||||
),
|
||||
to: ({ getActiveModel, sidebarContext }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
const [pathname, search] = newDocumentPath(collection?.id).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const createTemplate = createInternalLinkAction({
|
||||
export const createTemplate = createAction({
|
||||
name: ({ t }) => t("New template"),
|
||||
analyticsName: "New template",
|
||||
section: ActiveCollectionSection,
|
||||
section: CollectionSection,
|
||||
icon: <ShapesIcon />,
|
||||
keywords: "new create template",
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some(
|
||||
(policy) => policy.abilities.createTemplate
|
||||
visible: ({ activeCollectionId, stores }) =>
|
||||
!!(
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).createDocument
|
||||
),
|
||||
to: ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
return newTemplatePath(collection?.id);
|
||||
perform: ({ activeCollectionId, event }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
history.push(newTemplatePath(activeCollectionId));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -541,7 +250,5 @@ export const rootCollectionActions = [
|
||||
createCollection,
|
||||
starCollection,
|
||||
unstarCollection,
|
||||
subscribeCollection,
|
||||
unsubscribeCollection,
|
||||
deleteCollection,
|
||||
];
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { DoneIcon, SmileyIcon, TrashIcon } from "outline-icons";
|
||||
import { DoneIcon, TrashIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import type Comment from "~/models/Comment";
|
||||
import stores from "~/stores";
|
||||
import Comment from "~/models/Comment";
|
||||
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
|
||||
import ViewReactionsDialog from "~/components/Reactions/ViewReactionsDialog";
|
||||
import history from "~/utils/history";
|
||||
import { createAction } from "..";
|
||||
import { ActiveDocumentSection } from "../sections";
|
||||
import { DocumentSection } from "../sections";
|
||||
|
||||
export const deleteCommentFactory = ({
|
||||
comment,
|
||||
@@ -16,12 +18,12 @@ export const deleteCommentFactory = ({
|
||||
createAction({
|
||||
name: ({ t }) => `${t("Delete")}…`,
|
||||
analyticsName: "Delete comment",
|
||||
section: ActiveDocumentSection,
|
||||
section: DocumentSection,
|
||||
icon: <TrashIcon />,
|
||||
keywords: "trash",
|
||||
dangerous: true,
|
||||
visible: ({ stores }) => stores.policies.abilities(comment.id).delete,
|
||||
perform: ({ t, stores, event }) => {
|
||||
visible: () => stores.policies.abilities(comment.id).delete,
|
||||
perform: ({ t, event }) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
@@ -42,13 +44,19 @@ export const resolveCommentFactory = ({
|
||||
createAction({
|
||||
name: ({ t }) => t("Mark as resolved"),
|
||||
analyticsName: "Resolve thread",
|
||||
section: ActiveDocumentSection,
|
||||
section: DocumentSection,
|
||||
icon: <DoneIcon outline />,
|
||||
visible: ({ stores }) =>
|
||||
visible: () =>
|
||||
stores.policies.abilities(comment.id).resolve &&
|
||||
stores.policies.abilities(comment.documentId).update,
|
||||
perform: async ({ t }) => {
|
||||
await comment.resolve();
|
||||
|
||||
history.replace({
|
||||
...history.location,
|
||||
state: null,
|
||||
});
|
||||
|
||||
onResolve();
|
||||
toast.success(t("Thread resolved"));
|
||||
},
|
||||
@@ -64,37 +72,19 @@ export const unresolveCommentFactory = ({
|
||||
createAction({
|
||||
name: ({ t }) => t("Mark as unresolved"),
|
||||
analyticsName: "Unresolve thread",
|
||||
section: ActiveDocumentSection,
|
||||
section: DocumentSection,
|
||||
icon: <DoneIcon outline />,
|
||||
visible: ({ stores }) =>
|
||||
visible: () =>
|
||||
stores.policies.abilities(comment.id).unresolve &&
|
||||
stores.policies.abilities(comment.documentId).update,
|
||||
perform: async () => {
|
||||
await comment.unresolve();
|
||||
|
||||
history.replace({
|
||||
...history.location,
|
||||
state: null,
|
||||
});
|
||||
|
||||
onUnresolve();
|
||||
},
|
||||
});
|
||||
|
||||
export const viewCommentReactionsFactory = ({
|
||||
comment,
|
||||
}: {
|
||||
comment: Comment;
|
||||
}) =>
|
||||
createAction({
|
||||
name: ({ t }) => `${t("View reactions")}`,
|
||||
analyticsName: "View comment reactions",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <SmileyIcon />,
|
||||
visible: ({ stores }) =>
|
||||
stores.policies.abilities(comment.id).read &&
|
||||
comment.reactions.length > 0,
|
||||
perform: ({ t, stores, event }) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Reactions"),
|
||||
content: <ViewReactionsDialog model={comment} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
+8
-7
@@ -1,24 +1,25 @@
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import stores from "~/stores";
|
||||
import { OAuthClientNew } from "~/components/OAuthClient/OAuthClientNew";
|
||||
import { DataAttributeNew } from "~/components/DataAttribute/DataAttributeNew";
|
||||
import { createAction } from "..";
|
||||
import { SettingsSection } from "../sections";
|
||||
|
||||
export const createOAuthClient = createAction({
|
||||
name: ({ t }) => t("New App"),
|
||||
analyticsName: "New App",
|
||||
export const createDataAttribute = createAction({
|
||||
name: ({ t }) => t("New attribute"),
|
||||
analyticsName: "New attribute",
|
||||
section: SettingsSection,
|
||||
icon: <PlusIcon />,
|
||||
keywords: "create",
|
||||
visible: () =>
|
||||
stores.policies.abilities(stores.auth.team?.id || "").createOAuthClient,
|
||||
stores.policies.abilities(stores.auth.team?.id || "").createDataAttribute,
|
||||
perform: ({ t, event }) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("New Application"),
|
||||
content: <OAuthClientNew onSubmit={stores.dialogs.closeAllModals} />,
|
||||
title: t("New attribute"),
|
||||
content: <DataAttributeNew onSubmit={stores.dialogs.closeAllModals} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,15 +1,14 @@
|
||||
import Storage from "@shared/utils/Storage";
|
||||
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, createActionWithChildren } from "~/actions";
|
||||
import { createAction } from "~/actions";
|
||||
import { DeveloperSection } from "~/actions/sections";
|
||||
import env from "~/env";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
@@ -17,19 +16,9 @@ import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
|
||||
import Logger from "~/utils/Logger";
|
||||
import { deleteAllDatabases } from "~/utils/developer";
|
||||
import history from "~/utils/history";
|
||||
import { homePath, debugPath } from "~/utils/routeHelpers";
|
||||
import { homePath } from "~/utils/routeHelpers";
|
||||
|
||||
export const goToDebug = createAction({
|
||||
name: "Go to debug screen",
|
||||
icon: <BeakerIcon />,
|
||||
section: DeveloperSection,
|
||||
visible: () => env.ENVIRONMENT === "development",
|
||||
perform: () => {
|
||||
history.push(debugPath());
|
||||
},
|
||||
});
|
||||
|
||||
export const copyId = createActionWithChildren({
|
||||
export const copyId = createAction({
|
||||
name: ({ t }) => t("Copy ID"),
|
||||
icon: <CopyIcon />,
|
||||
keywords: "uuid",
|
||||
@@ -94,38 +83,6 @@ export const copyId = createActionWithChildren({
|
||||
},
|
||||
});
|
||||
|
||||
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 />,
|
||||
@@ -138,17 +95,6 @@ export const clearIndexedDB = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const clearStorage = createAction({
|
||||
name: ({ t }) => t("Clear local storage"),
|
||||
icon: <TrashIcon />,
|
||||
keywords: "cache clear localstorage",
|
||||
section: DeveloperSection,
|
||||
perform: ({ t }) => {
|
||||
Storage.clear();
|
||||
toast.success(t("Local storage cleared"));
|
||||
},
|
||||
});
|
||||
|
||||
export const createTestUsers = createAction({
|
||||
name: "Create 10 test users",
|
||||
icon: <UserIcon />,
|
||||
@@ -186,22 +132,7 @@ export const toggleDebugLogging = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const toggleDebugSafeArea = createAction({
|
||||
name: () => "Toggle menu safe area debugging",
|
||||
icon: <ToolsIcon />,
|
||||
section: DeveloperSection,
|
||||
visible: () => env.ENVIRONMENT === "development",
|
||||
perform: ({ stores }) => {
|
||||
stores.ui.toggleDebugSafeArea();
|
||||
toast.message(
|
||||
stores.ui.debugSafeArea
|
||||
? "Menu safe area debugging enabled"
|
||||
: "Menu safe area debugging disabled"
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const toggleFeatureFlag = createActionWithChildren({
|
||||
export const toggleFeatureFlag = createAction({
|
||||
name: "Toggle feature flag",
|
||||
icon: <BeakerIcon />,
|
||||
section: DeveloperSection,
|
||||
@@ -225,23 +156,19 @@ export const toggleFeatureFlag = createActionWithChildren({
|
||||
),
|
||||
});
|
||||
|
||||
export const developer = createActionWithChildren({
|
||||
export const developer = createAction({
|
||||
name: ({ t }) => t("Development"),
|
||||
keywords: "debug",
|
||||
icon: <ToolsIcon />,
|
||||
iconInContextMenu: false,
|
||||
section: DeveloperSection,
|
||||
children: [
|
||||
goToDebug,
|
||||
copyId,
|
||||
toggleDebugLogging,
|
||||
toggleDebugSafeArea,
|
||||
toggleFeatureFlag,
|
||||
createToast,
|
||||
createTestUsers,
|
||||
clearIndexedDB,
|
||||
clearStorage,
|
||||
startTyping,
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,21 +0,0 @@
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import { createAction } from "~/actions";
|
||||
import { TeamSection } from "../sections";
|
||||
import stores from "~/stores";
|
||||
import { EmojiCreateDialog } from "~/components/EmojiCreateDialog";
|
||||
|
||||
export const createEmoji = createAction({
|
||||
name: ({ t }) => `${t("New emoji")}…`,
|
||||
analyticsName: "Create emoji",
|
||||
icon: <PlusIcon />,
|
||||
keywords: "emoji custom upload image",
|
||||
section: TeamSection,
|
||||
visible: () =>
|
||||
stores.policies.abilities(stores.auth.team?.id || "").createEmoji,
|
||||
perform: ({ t }) => {
|
||||
stores.dialogs.openModal({
|
||||
title: t("Upload emoji"),
|
||||
content: <EmojiCreateDialog onSubmit={stores.dialogs.closeAllModals} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import { TrashIcon } from "outline-icons";
|
||||
import stores from "~/stores";
|
||||
import { createAction } from "..";
|
||||
import { SettingsSection } from "../sections";
|
||||
import type Integration from "~/models/Integration";
|
||||
import { DisconnectAnalyticsDialog } from "~/scenes/Settings/components/DisconnectAnalyticsDialog";
|
||||
import type { IntegrationType } from "@shared/types";
|
||||
import { settingsPath } from "@shared/utils/routeHelpers";
|
||||
import history from "~/utils/history";
|
||||
|
||||
export const disconnectIntegrationFactory = (integration?: Integration) =>
|
||||
createAction({
|
||||
name: ({ t }) => t("Disconnect"),
|
||||
analyticsName: "Disconnect integration",
|
||||
section: SettingsSection,
|
||||
icon: <TrashIcon />,
|
||||
keywords: "disconnect",
|
||||
visible: () => !!integration,
|
||||
perform: async ({ event }) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
await integration?.delete();
|
||||
history.push(settingsPath("integrations"));
|
||||
},
|
||||
});
|
||||
|
||||
export const disconnectAnalyticsIntegrationFactory = (
|
||||
integration?: Integration<IntegrationType.Analytics>
|
||||
) =>
|
||||
createAction({
|
||||
name: ({ t }) => t("Disconnect analytics"),
|
||||
analyticsName: "Disconnect analytics",
|
||||
section: SettingsSection,
|
||||
icon: <TrashIcon />,
|
||||
keywords: "disconnect",
|
||||
visible: () => !!integration,
|
||||
perform: ({ t, event }) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Disconnect analytics"),
|
||||
content: <DisconnectAnalyticsDialog integration={integration!} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -12,20 +12,18 @@ import {
|
||||
BrowserIcon,
|
||||
ShapesIcon,
|
||||
DraftsIcon,
|
||||
BugIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { UrlHelper } from "@shared/utils/UrlHelper";
|
||||
import { isMac } from "@shared/utils/browser";
|
||||
import stores from "~/stores";
|
||||
import type SearchQuery from "~/models/SearchQuery";
|
||||
import SearchQuery from "~/models/SearchQuery";
|
||||
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
|
||||
import {
|
||||
createAction,
|
||||
createExternalLinkAction,
|
||||
createInternalLinkAction,
|
||||
} from "~/actions";
|
||||
import { createAction } from "~/actions";
|
||||
import { NavigationSection, RecentSearchesSection } from "~/actions/sections";
|
||||
import env from "~/env";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import history from "~/utils/history";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import {
|
||||
homePath,
|
||||
@@ -36,136 +34,115 @@ import {
|
||||
settingsPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
|
||||
export const navigateToHome = createInternalLinkAction({
|
||||
export const navigateToHome = createAction({
|
||||
name: ({ t }) => t("Home"),
|
||||
analyticsName: "Navigate to home",
|
||||
section: NavigationSection,
|
||||
shortcut: ["d"],
|
||||
icon: <HomeIcon />,
|
||||
to: homePath(),
|
||||
perform: () => history.push(homePath()),
|
||||
visible: ({ location }) => location.pathname !== homePath(),
|
||||
});
|
||||
|
||||
export const navigateToRecentSearchQuery = (searchQuery: SearchQuery) =>
|
||||
createInternalLinkAction({
|
||||
createAction({
|
||||
section: RecentSearchesSection,
|
||||
name: searchQuery.query,
|
||||
analyticsName: "Navigate to recent search query",
|
||||
icon: <SearchIcon />,
|
||||
to: searchPath({ query: searchQuery.query }),
|
||||
perform: () => history.push(searchPath(searchQuery.query)),
|
||||
});
|
||||
|
||||
export const navigateToDrafts = createInternalLinkAction({
|
||||
export const navigateToDrafts = createAction({
|
||||
name: ({ t }) => t("Drafts"),
|
||||
analyticsName: "Navigate to drafts",
|
||||
section: NavigationSection,
|
||||
icon: <DraftsIcon />,
|
||||
to: draftsPath(),
|
||||
perform: () => history.push(draftsPath()),
|
||||
visible: ({ location }) => location.pathname !== draftsPath(),
|
||||
});
|
||||
|
||||
export const navigateToSearch = createInternalLinkAction({
|
||||
name: ({ t }) => t("Search"),
|
||||
analyticsName: "Navigate to search",
|
||||
section: NavigationSection,
|
||||
icon: <SearchIcon />,
|
||||
to: searchPath(),
|
||||
visible: ({ location }) => location.pathname !== searchPath(),
|
||||
});
|
||||
|
||||
export const navigateToArchive = createInternalLinkAction({
|
||||
export const navigateToArchive = createAction({
|
||||
name: ({ t }) => t("Archive"),
|
||||
analyticsName: "Navigate to archive",
|
||||
section: NavigationSection,
|
||||
shortcut: ["g", "a"],
|
||||
icon: <ArchiveIcon />,
|
||||
to: archivePath(),
|
||||
perform: () => history.push(archivePath()),
|
||||
visible: ({ location }) => location.pathname !== archivePath(),
|
||||
});
|
||||
|
||||
export const navigateToTrash = createInternalLinkAction({
|
||||
export const navigateToTrash = createAction({
|
||||
name: ({ t }) => t("Trash"),
|
||||
analyticsName: "Navigate to trash",
|
||||
section: NavigationSection,
|
||||
icon: <TrashIcon />,
|
||||
to: trashPath(),
|
||||
perform: () => history.push(trashPath()),
|
||||
visible: ({ location }) => location.pathname !== trashPath(),
|
||||
});
|
||||
|
||||
export const navigateToSettings = createInternalLinkAction({
|
||||
export const navigateToSettings = createAction({
|
||||
name: ({ t }) => t("Settings"),
|
||||
analyticsName: "Navigate to settings",
|
||||
section: NavigationSection,
|
||||
shortcut: ["g", "s"],
|
||||
icon: <SettingsIcon />,
|
||||
visible: () => stores.policies.abilities(stores.auth.team?.id || "").update,
|
||||
to: settingsPath(),
|
||||
perform: () => history.push(settingsPath()),
|
||||
});
|
||||
|
||||
export const navigateToWorkspaceSettings = createInternalLinkAction({
|
||||
name: ({ t }) => t("Settings"),
|
||||
analyticsName: "Navigate to workspace settings",
|
||||
section: NavigationSection,
|
||||
icon: <SettingsIcon />,
|
||||
visible: () => stores.policies.abilities(stores.auth.team?.id || "").update,
|
||||
to: settingsPath("details"),
|
||||
});
|
||||
|
||||
export const navigateToProfileSettings = createInternalLinkAction({
|
||||
export const navigateToProfileSettings = createAction({
|
||||
name: ({ t }) => t("Profile"),
|
||||
analyticsName: "Navigate to profile settings",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <ProfileIcon />,
|
||||
to: settingsPath(),
|
||||
perform: () => history.push(settingsPath()),
|
||||
});
|
||||
|
||||
export const navigateToTemplateSettings = createInternalLinkAction({
|
||||
export const navigateToTemplateSettings = createAction({
|
||||
name: ({ t }) => t("Templates"),
|
||||
analyticsName: "Navigate to template settings",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <ShapesIcon />,
|
||||
to: settingsPath("templates"),
|
||||
perform: () => history.push(settingsPath("templates")),
|
||||
});
|
||||
|
||||
export const navigateToNotificationSettings = createInternalLinkAction({
|
||||
name: ({ t, isMenu }) =>
|
||||
isMenu ? t("Notification settings") : t("Notifications"),
|
||||
export const navigateToNotificationSettings = createAction({
|
||||
name: ({ t }) => t("Notifications"),
|
||||
analyticsName: "Navigate to notification settings",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <EmailIcon />,
|
||||
to: settingsPath("notifications"),
|
||||
perform: () => history.push(settingsPath("notifications")),
|
||||
});
|
||||
|
||||
export const navigateToAccountPreferences = createInternalLinkAction({
|
||||
export const navigateToAccountPreferences = createAction({
|
||||
name: ({ t }) => t("Preferences"),
|
||||
analyticsName: "Navigate to account preferences",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <SettingsIcon />,
|
||||
to: settingsPath("preferences"),
|
||||
perform: () => history.push(settingsPath("preferences")),
|
||||
});
|
||||
|
||||
export const openDocumentation = createExternalLinkAction({
|
||||
export const openDocumentation = createAction({
|
||||
name: ({ t }) => t("Documentation"),
|
||||
analyticsName: "Open documentation",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <OpenIcon />,
|
||||
url: UrlHelper.guide,
|
||||
target: "_blank",
|
||||
perform: () => window.open(UrlHelper.guide),
|
||||
});
|
||||
|
||||
export const openAPIDocumentation = createExternalLinkAction({
|
||||
export const openAPIDocumentation = createAction({
|
||||
name: ({ t }) => t("API documentation"),
|
||||
analyticsName: "Open API documentation",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <OpenIcon />,
|
||||
url: UrlHelper.developers,
|
||||
target: "_blank",
|
||||
perform: () => window.open(UrlHelper.developers),
|
||||
});
|
||||
|
||||
export const toggleSidebar = createAction({
|
||||
@@ -176,34 +153,29 @@ export const toggleSidebar = createAction({
|
||||
perform: () => stores.ui.toggleCollapsedSidebar(),
|
||||
});
|
||||
|
||||
export const openFeedbackUrl = createExternalLinkAction({
|
||||
export const openFeedbackUrl = createAction({
|
||||
name: ({ t }) => t("Send us feedback"),
|
||||
analyticsName: "Open feedback",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <EmailIcon />,
|
||||
url: UrlHelper.contact,
|
||||
target: "_blank",
|
||||
perform: () => window.open(UrlHelper.contact),
|
||||
});
|
||||
|
||||
export const openBugReportUrl = createExternalLinkAction({
|
||||
export const openBugReportUrl = createAction({
|
||||
name: ({ t }) => t("Report a bug"),
|
||||
analyticsName: "Open bug report",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <BugIcon />,
|
||||
url: UrlHelper.github,
|
||||
target: "_blank",
|
||||
perform: () => window.open(UrlHelper.github),
|
||||
});
|
||||
|
||||
export const openChangelog = createExternalLinkAction({
|
||||
export const openChangelog = createAction({
|
||||
name: ({ t }) => t("Changelog"),
|
||||
analyticsName: "Open changelog",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <OpenIcon />,
|
||||
url: UrlHelper.changelog,
|
||||
target: "_blank",
|
||||
perform: () => window.open(UrlHelper.changelog),
|
||||
});
|
||||
|
||||
export const openKeyboardShortcuts = createAction({
|
||||
@@ -221,18 +193,19 @@ export const openKeyboardShortcuts = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadApp = createExternalLinkAction({
|
||||
export const downloadApp = createAction({
|
||||
name: ({ t }) =>
|
||||
t("Download {{ platform }} app", {
|
||||
platform: isMac ? "macOS" : "Windows",
|
||||
platform: isMac() ? "macOS" : "Windows",
|
||||
}),
|
||||
analyticsName: "Download app",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <BrowserIcon />,
|
||||
visible: () => !Desktop.isElectron() && isMac && isCloudHosted,
|
||||
url: "https://desktop.getoutline.com",
|
||||
target: "_blank",
|
||||
visible: () => !Desktop.isElectron() && isMac() && isCloudHosted,
|
||||
perform: () => {
|
||||
window.open("https://desktop.getoutline.com");
|
||||
},
|
||||
});
|
||||
|
||||
export const logout = createAction({
|
||||
@@ -241,7 +214,10 @@ export const logout = createAction({
|
||||
section: NavigationSection,
|
||||
icon: <LogoutIcon />,
|
||||
perform: async () => {
|
||||
await stores.auth.logout({ userInitiated: true });
|
||||
await stores.auth.logout();
|
||||
if (env.OIDC_LOGOUT_URI) {
|
||||
window.location.replace(env.OIDC_LOGOUT_URI);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ArchiveIcon, CheckmarkIcon, MarkAsReadIcon } from "outline-icons";
|
||||
import { ArchiveIcon, MarkAsReadIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { createAction } from "..";
|
||||
import { NotificationSection } from "../sections";
|
||||
import type Notification from "~/models/Notification";
|
||||
|
||||
export const markNotificationsAsRead = createAction({
|
||||
name: ({ t }) => t("Mark notifications as read"),
|
||||
@@ -23,36 +23,6 @@ export const markNotificationsAsArchived = createAction({
|
||||
visible: ({ stores }) => stores.notifications.orderedData.length > 0,
|
||||
});
|
||||
|
||||
export const notificationMarkRead = (notification: Notification) =>
|
||||
createAction({
|
||||
name: ({ t }) => t("Mark as read"),
|
||||
analyticsName: "Mark notification read",
|
||||
section: NotificationSection,
|
||||
icon: <CheckmarkIcon />,
|
||||
perform: () => notification.toggleRead(),
|
||||
visible: () => !notification.viewedAt,
|
||||
});
|
||||
|
||||
export const notificationMarkUnread = (notification: Notification) =>
|
||||
createAction({
|
||||
name: ({ t }) => t("Mark as unread"),
|
||||
analyticsName: "Mark notification unread",
|
||||
section: NotificationSection,
|
||||
icon: <CheckmarkIcon />,
|
||||
perform: () => notification.toggleRead(),
|
||||
visible: () => !!notification.viewedAt,
|
||||
});
|
||||
|
||||
export const notificationArchive = (notification: Notification) =>
|
||||
createAction({
|
||||
name: ({ t }) => t("Archive"),
|
||||
analyticsName: "Mark notification as archived",
|
||||
section: NotificationSection,
|
||||
icon: <ArchiveIcon />,
|
||||
perform: () => notification.archive(),
|
||||
visible: () => !notification.archivedAt,
|
||||
});
|
||||
|
||||
export const rootNotificationActions = [
|
||||
markNotificationsAsRead,
|
||||
markNotificationsAsArchived,
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import { LinkIcon, RestoreIcon, TrashIcon, DownloadIcon } 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 { ExportContentType } from "@shared/types";
|
||||
import stores from "~/stores";
|
||||
import { createAction, createActionWithChildren } from "~/actions";
|
||||
import { createAction } from "~/actions";
|
||||
import { RevisionSection } from "~/actions/sections";
|
||||
import env from "~/env";
|
||||
import history from "~/utils/history";
|
||||
import {
|
||||
documentHistoryPath,
|
||||
matchDocumentHistory,
|
||||
urlify,
|
||||
} from "~/utils/routeHelpers";
|
||||
|
||||
export const restoreRevision = createAction({
|
||||
name: ({ t }) => t("Restore"),
|
||||
name: ({ t }) => t("Restore revision"),
|
||||
analyticsName: "Restore revision",
|
||||
icon: <RestoreIcon />,
|
||||
section: RevisionSection,
|
||||
visible: ({ activeDocumentId }) =>
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).update,
|
||||
perform: async ({ event, location, activeDocumentId }) => {
|
||||
event?.preventDefault();
|
||||
@@ -44,137 +42,37 @@ export const restoreRevision = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteRevision = createAction({
|
||||
name: ({ t }) => t("Delete"),
|
||||
analyticsName: "Delete revision",
|
||||
icon: <TrashIcon />,
|
||||
export const copyLinkToRevision = createAction({
|
||||
name: ({ t }) => t("Copy link"),
|
||||
analyticsName: "Copy link to revision",
|
||||
icon: <LinkIcon />,
|
||||
section: RevisionSection,
|
||||
dangerous: true,
|
||||
visible: ({ activeDocumentId }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).update,
|
||||
perform: async ({ t, event, location, activeDocumentId }) => {
|
||||
event?.preventDefault();
|
||||
perform: async ({ activeDocumentId, stores, t }) => {
|
||||
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));
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = `${window.location.origin}${documentHistoryPath(
|
||||
document,
|
||||
revisionId
|
||||
)}`;
|
||||
|
||||
copy(url, {
|
||||
format: "text/plain",
|
||||
onCopy: () => {
|
||||
toast.message(t("Link copied"));
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const copyLinkToRevision = (revisionId: string) =>
|
||||
createAction({
|
||||
name: ({ t }) => t("Copy link"),
|
||||
analyticsName: "Copy link to revision",
|
||||
icon: <LinkIcon />,
|
||||
section: RevisionSection,
|
||||
perform: async ({ activeDocumentId, t }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = urlify(documentHistoryPath(document, revisionId));
|
||||
|
||||
copy(url, {
|
||||
format: "text/plain",
|
||||
onCopy: () => {
|
||||
toast.message(t("Link copied"));
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadRevisionAsHTML = (revisionId: string) =>
|
||||
createAction({
|
||||
name: ({ t }) => t("HTML"),
|
||||
analyticsName: "Download revision as HTML",
|
||||
section: RevisionSection,
|
||||
keywords: "html export",
|
||||
icon: <DownloadIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId }) =>
|
||||
!!activeDocumentId &&
|
||||
stores.policies.abilities(activeDocumentId).download,
|
||||
perform: async () => {
|
||||
const revision = stores.revisions.get(revisionId);
|
||||
await revision?.download(ExportContentType.Html);
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadRevisionAsPDF = (revisionId: string) =>
|
||||
createAction({
|
||||
name: ({ t }) => t("PDF"),
|
||||
analyticsName: "Download revision as PDF",
|
||||
section: RevisionSection,
|
||||
keywords: "export",
|
||||
icon: <DownloadIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId }) =>
|
||||
!!(
|
||||
activeDocumentId &&
|
||||
stores.policies.abilities(activeDocumentId).download &&
|
||||
env.PDF_EXPORT_ENABLED
|
||||
),
|
||||
perform: ({ t }) => {
|
||||
const id = toast.loading(`${t("Exporting")}…`);
|
||||
const revision = stores.revisions.get(revisionId);
|
||||
return revision
|
||||
?.download(ExportContentType.Pdf)
|
||||
.finally(() => id && toast.dismiss(id));
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadRevisionAsMarkdown = (revisionId: string) =>
|
||||
createAction({
|
||||
name: ({ t }) => t("Markdown"),
|
||||
analyticsName: "Download revision as Markdown",
|
||||
section: RevisionSection,
|
||||
keywords: "md markdown export",
|
||||
icon: <DownloadIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId }) =>
|
||||
!!activeDocumentId &&
|
||||
stores.policies.abilities(activeDocumentId).download,
|
||||
perform: async () => {
|
||||
const revision = stores.revisions.get(revisionId);
|
||||
await revision?.download(ExportContentType.Markdown);
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadRevision = (revisionId: string) =>
|
||||
createActionWithChildren({
|
||||
name: ({ t, isMenu }) => (isMenu ? t("Download") : t("Download revision")),
|
||||
analyticsName: "Download revision",
|
||||
section: RevisionSection,
|
||||
icon: <DownloadIcon />,
|
||||
keywords: "export",
|
||||
visible: ({ activeDocumentId }) =>
|
||||
!!activeDocumentId &&
|
||||
stores.policies.abilities(activeDocumentId).download,
|
||||
children: [
|
||||
downloadRevisionAsHTML(revisionId),
|
||||
downloadRevisionAsPDF(revisionId),
|
||||
downloadRevisionAsMarkdown(revisionId),
|
||||
],
|
||||
});
|
||||
|
||||
export const rootRevisionActions = [];
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { SunIcon, MoonIcon, BrowserIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import stores from "~/stores";
|
||||
import { Theme } from "~/stores/UiStore";
|
||||
import { createAction, createActionWithChildren } from "~/actions";
|
||||
import { createAction } from "~/actions";
|
||||
import { SettingsSection } from "~/actions/sections";
|
||||
|
||||
export const changeToDarkTheme = createAction({
|
||||
@@ -10,8 +12,8 @@ export const changeToDarkTheme = createAction({
|
||||
iconInContextMenu: false,
|
||||
keywords: "theme dark night",
|
||||
section: SettingsSection,
|
||||
selected: ({ stores }) => stores.ui.theme === "dark",
|
||||
perform: ({ stores }) => stores.ui.setTheme(Theme.Dark),
|
||||
selected: () => stores.ui.theme === "dark",
|
||||
perform: () => stores.ui.setTheme(Theme.Dark),
|
||||
});
|
||||
|
||||
export const changeToLightTheme = createAction({
|
||||
@@ -21,23 +23,8 @@ export const changeToLightTheme = createAction({
|
||||
iconInContextMenu: false,
|
||||
keywords: "theme light day",
|
||||
section: SettingsSection,
|
||||
selected: ({ stores }) => stores.ui.theme === "light",
|
||||
perform: ({ stores }) => stores.ui.setTheme(Theme.Light),
|
||||
});
|
||||
|
||||
export const toggleTheme = createAction({
|
||||
name: ({ t }) => t("Toggle theme"),
|
||||
analyticsName: "Change theme",
|
||||
iconInContextMenu: false,
|
||||
icon: ({ stores }) =>
|
||||
stores.ui.resolvedTheme === "light" ? <MoonIcon /> : <SunIcon />,
|
||||
keywords: "theme light day",
|
||||
section: SettingsSection,
|
||||
shortcut: ["Meta+Shift+l"],
|
||||
perform: ({ stores }) =>
|
||||
stores.ui.setTheme(
|
||||
stores.ui.resolvedTheme === "light" ? Theme.Dark : Theme.Light
|
||||
),
|
||||
selected: () => stores.ui.theme === "light",
|
||||
perform: () => stores.ui.setTheme(Theme.Light),
|
||||
});
|
||||
|
||||
export const changeToSystemTheme = createAction({
|
||||
@@ -47,19 +34,21 @@ export const changeToSystemTheme = createAction({
|
||||
iconInContextMenu: false,
|
||||
keywords: "theme system default",
|
||||
section: SettingsSection,
|
||||
selected: ({ stores }) => stores.ui.theme === "system",
|
||||
perform: ({ stores }) => stores.ui.setTheme(Theme.System),
|
||||
selected: () => stores.ui.theme === "system",
|
||||
perform: () => stores.ui.setTheme(Theme.System),
|
||||
});
|
||||
|
||||
export const changeTheme = createActionWithChildren({
|
||||
name: ({ t, isMenu }) => (isMenu ? t("Appearance") : t("Change theme")),
|
||||
export const changeTheme = createAction({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Appearance") : t("Change theme"),
|
||||
analyticsName: "Change theme",
|
||||
placeholder: ({ t }) => t("Change theme to"),
|
||||
icon: ({ stores }) =>
|
||||
stores.ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />,
|
||||
icon: function _Icon() {
|
||||
return stores.ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />;
|
||||
},
|
||||
keywords: "appearance display",
|
||||
section: SettingsSection,
|
||||
children: [changeToLightTheme, changeToDarkTheme, changeToSystemTheme],
|
||||
});
|
||||
|
||||
export const rootSettingsActions = [changeTheme, toggleTheme];
|
||||
export const rootSettingsActions = [changeTheme];
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import type Share from "~/models/Share";
|
||||
import { createAction, createInternalLinkAction } from "..";
|
||||
import { ArrowIcon, CopyIcon, TrashIcon } from "outline-icons";
|
||||
import { ShareSection } from "../sections";
|
||||
import env from "~/env";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const copyShareUrlFactory = ({ share }: { share: Share }) =>
|
||||
createAction({
|
||||
name: ({ t }) => t("Copy link"),
|
||||
analyticsName: "Copy share link",
|
||||
section: ShareSection,
|
||||
icon: <CopyIcon />,
|
||||
perform: ({ t }) => {
|
||||
copy(share.url, {
|
||||
debug: env.ENVIRONMENT !== "production",
|
||||
format: "text/plain",
|
||||
});
|
||||
toast.success(t("Share link copied"));
|
||||
},
|
||||
});
|
||||
|
||||
export const goToShareSourceFactory = ({ share }: { share: Share }) =>
|
||||
createInternalLinkAction({
|
||||
name: ({ t }) =>
|
||||
share.collectionId ? t("Go to collection") : t("Go to document"),
|
||||
analyticsName: "Go to share source",
|
||||
section: ShareSection,
|
||||
icon: <ArrowIcon />,
|
||||
to: {
|
||||
pathname: share.sourcePathWithFallback,
|
||||
state: { sidebarContext: "collections" }, // optimistic preference of "collections"
|
||||
},
|
||||
});
|
||||
|
||||
export const revokeShareFactory = ({
|
||||
share,
|
||||
can,
|
||||
}: {
|
||||
share: Share;
|
||||
can: Record<string, boolean>;
|
||||
}) =>
|
||||
createAction({
|
||||
name: ({ t }) => t("Revoke link"),
|
||||
analyticsName: "Revoke share",
|
||||
section: ShareSection,
|
||||
icon: <TrashIcon />,
|
||||
dangerous: true,
|
||||
visible: !!can.revoke,
|
||||
perform: async ({ t, stores }) => {
|
||||
try {
|
||||
await stores.shares.revoke(share);
|
||||
toast.message(t("Share link revoked"));
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,28 +1,25 @@
|
||||
import { ArrowIcon, PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { stringToColor } from "@shared/utils/color";
|
||||
import type RootStore from "~/stores/RootStore";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import { LoginDialog } from "~/scenes/Login/components/LoginDialog";
|
||||
import TeamNew from "~/scenes/TeamNew";
|
||||
import TeamLogo from "~/components/TeamLogo";
|
||||
import {
|
||||
createAction,
|
||||
createActionWithChildren,
|
||||
createExternalLinkAction,
|
||||
} from "~/actions";
|
||||
import type { ActionContext, ExternalLinkAction } from "~/types";
|
||||
import { createAction } from "~/actions";
|
||||
import { ActionContext } from "~/types";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import { TeamSection } from "../sections";
|
||||
|
||||
export const switchTeamsList = ({ stores }: { stores: RootStore }) =>
|
||||
stores.auth.availableTeams?.map<ExternalLinkAction>((session) =>
|
||||
createExternalLinkAction({
|
||||
id: `switch-${session.id}`,
|
||||
name: session.name,
|
||||
analyticsName: "Switch workspace",
|
||||
section: TeamSection,
|
||||
keywords: "change switch workspace organization team",
|
||||
icon: (
|
||||
export const createTeamsList = ({ stores }: { stores: RootStore }) =>
|
||||
stores.auth.availableTeams?.map((session) => ({
|
||||
id: `switch-${session.id}`,
|
||||
name: session.name,
|
||||
analyticsName: "Switch workspace",
|
||||
section: TeamSection,
|
||||
keywords: "change switch workspace organization team",
|
||||
icon: function _Icon() {
|
||||
return (
|
||||
<StyledTeamLogo
|
||||
alt={session.name}
|
||||
model={{
|
||||
@@ -33,15 +30,13 @@ export const switchTeamsList = ({ stores }: { stores: RootStore }) =>
|
||||
}}
|
||||
size={24}
|
||||
/>
|
||||
),
|
||||
visible: ({ currentTeamId }: ActionContext) =>
|
||||
currentTeamId !== session.id,
|
||||
url: session.url,
|
||||
target: "_self",
|
||||
})
|
||||
) ?? [];
|
||||
);
|
||||
},
|
||||
visible: ({ currentTeamId }: ActionContext) => currentTeamId !== session.id,
|
||||
perform: () => (window.location.href = session.url),
|
||||
})) ?? [];
|
||||
|
||||
export const switchTeam = createActionWithChildren({
|
||||
export const switchTeam = createAction({
|
||||
name: ({ t }) => t("Switch workspace"),
|
||||
placeholder: ({ t }) => t("Select a workspace"),
|
||||
analyticsName: "Switch workspace",
|
||||
@@ -49,7 +44,7 @@ export const switchTeam = createActionWithChildren({
|
||||
section: TeamSection,
|
||||
visible: ({ stores }) =>
|
||||
!!stores.auth.availableTeams && stores.auth.availableTeams?.length > 1,
|
||||
children: switchTeamsList,
|
||||
children: createTeamsList,
|
||||
});
|
||||
|
||||
export const createTeam = createAction({
|
||||
@@ -63,14 +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,231 +0,0 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import {
|
||||
CaseSensitiveIcon,
|
||||
CollectionIcon,
|
||||
CopyIcon,
|
||||
MoveIcon,
|
||||
NewDocumentIcon,
|
||||
PlusIcon,
|
||||
PrintIcon,
|
||||
TrashIcon,
|
||||
} from "outline-icons";
|
||||
import { Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import TemplateMove from "~/components/DocumentExplorer/TemplateMove";
|
||||
import {
|
||||
createAction,
|
||||
createActionWithChildren,
|
||||
createInternalLinkAction,
|
||||
} from "~/actions";
|
||||
import history from "~/utils/history";
|
||||
import {
|
||||
newDocumentPath,
|
||||
newTemplatePath,
|
||||
settingsPath,
|
||||
urlify,
|
||||
} from "~/utils/routeHelpers";
|
||||
import { ActiveTemplateSection, TemplateSection } from "../sections";
|
||||
import Template from "~/models/Template";
|
||||
import { AvatarSize } from "~/components/Avatar";
|
||||
import TeamLogo from "~/components/TeamLogo";
|
||||
|
||||
export const createTemplate = createInternalLinkAction({
|
||||
name: ({ t }) => t("New template"),
|
||||
analyticsName: "New template",
|
||||
section: TemplateSection,
|
||||
icon: <PlusIcon />,
|
||||
keywords: "new create template",
|
||||
visible: ({ currentTeamId, stores }) =>
|
||||
!!stores.policies.abilities(currentTeamId!).createTemplate,
|
||||
to: newTemplatePath(),
|
||||
});
|
||||
|
||||
export const deleteTemplate = createAction({
|
||||
name: ({ t }) => `${t("Delete")}…`,
|
||||
analyticsName: "Delete template",
|
||||
section: ActiveTemplateSection,
|
||||
icon: <TrashIcon />,
|
||||
dangerous: true,
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Template).some((policy) => policy.abilities.delete),
|
||||
perform: ({ getActiveModel, stores, t }) => {
|
||||
const template = getActiveModel(Template);
|
||||
if (!template) {
|
||||
return;
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Delete {{ documentName }}", {
|
||||
documentName: t("template"),
|
||||
}),
|
||||
content: (
|
||||
<ConfirmationDialog
|
||||
onSubmit={async () => {
|
||||
await template.delete();
|
||||
history.push(settingsPath("templates"));
|
||||
toast.success(t("Template deleted"));
|
||||
}}
|
||||
savingText={`${t("Deleting")}…`}
|
||||
danger
|
||||
>
|
||||
<Trans
|
||||
defaults="Are you sure about that? Deleting the <em>{{ templateName }}</em> template is permanent."
|
||||
values={{
|
||||
templateName: template.titleWithDefault,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</ConfirmationDialog>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const moveTemplateToWorkspace = createAction({
|
||||
name: ({ t }) => t("Move to workspace"),
|
||||
analyticsName: "Move template to workspace",
|
||||
section: ActiveTemplateSection,
|
||||
icon: ({ stores }) => {
|
||||
const { team } = stores.auth;
|
||||
return <TeamLogo model={team} size={AvatarSize.Small} />;
|
||||
},
|
||||
visible: ({ getActiveModel }) => {
|
||||
const template = getActiveModel(Template);
|
||||
return !!template?.collectionId;
|
||||
},
|
||||
perform: async ({ getActiveModel, stores, t }) => {
|
||||
const template = getActiveModel(Template);
|
||||
if (!template) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await template.save({ collectionId: null });
|
||||
toast.success(t("Template moved"));
|
||||
stores.dialogs.closeAllModals();
|
||||
} catch (_err) {
|
||||
toast.error(t("Couldn't move the template, try again?"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const moveTemplateToCollection = createAction({
|
||||
name: ({ t }) => t("Move to collection"),
|
||||
analyticsName: "Move template to collection",
|
||||
section: ActiveTemplateSection,
|
||||
icon: <CollectionIcon />,
|
||||
perform: ({ getActiveModel, stores, t }) => {
|
||||
const template = getActiveModel(Template);
|
||||
if (!template) {
|
||||
return;
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Move template"),
|
||||
content: <TemplateMove template={template} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const moveTemplate = createActionWithChildren({
|
||||
name: ({ t }) => t("Move"),
|
||||
analyticsName: "Move template",
|
||||
section: ActiveTemplateSection,
|
||||
icon: <MoveIcon />,
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Template).some((policy) => policy.abilities.move),
|
||||
children: [moveTemplateToWorkspace, moveTemplateToCollection],
|
||||
});
|
||||
|
||||
export const createDocumentFromTemplate = createInternalLinkAction({
|
||||
name: ({ t }) => t("New document"),
|
||||
analyticsName: "New document from template",
|
||||
section: ActiveTemplateSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "create",
|
||||
visible: ({ currentTeamId, getActiveModel, stores }) => {
|
||||
const template = getActiveModel(Template);
|
||||
if (!template || !currentTeamId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (template.collectionId) {
|
||||
return !!stores.policies.abilities(template.collectionId).createDocument;
|
||||
}
|
||||
return !!stores.policies.abilities(currentTeamId).createDocument;
|
||||
},
|
||||
to: ({ getActiveModel, activeCollectionId, sidebarContext }) => {
|
||||
const template = getActiveModel(Template);
|
||||
if (!template) {
|
||||
return "";
|
||||
}
|
||||
const collectionId = template?.collectionId ?? activeCollectionId;
|
||||
|
||||
const [pathname, search] = newDocumentPath(collectionId, {
|
||||
templateId: template.id,
|
||||
}).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const copyTemplateLink = createAction({
|
||||
name: ({ t }) => t("Copy link"),
|
||||
analyticsName: "Copy template link",
|
||||
section: ActiveTemplateSection,
|
||||
icon: <CopyIcon />,
|
||||
iconInContextMenu: false,
|
||||
perform: ({ getActiveModel, t }) => {
|
||||
const template = getActiveModel(Template);
|
||||
if (template) {
|
||||
copy(urlify(template.path));
|
||||
toast.success(t("Link copied to clipboard"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const copyTemplateAsPlainText = createAction({
|
||||
name: ({ t }) => t("Copy as text"),
|
||||
analyticsName: "Copy template as text",
|
||||
section: ActiveTemplateSection,
|
||||
icon: <CaseSensitiveIcon />,
|
||||
iconInContextMenu: false,
|
||||
perform: async ({ getActiveModel, t }) => {
|
||||
const template = getActiveModel(Template);
|
||||
if (template) {
|
||||
const { ProsemirrorHelper } =
|
||||
await import("~/models/helpers/ProsemirrorHelper");
|
||||
copy(ProsemirrorHelper.toPlainText(template));
|
||||
toast.success(t("Text copied to clipboard"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const copyTemplate = createActionWithChildren({
|
||||
name: ({ t }) => t("Copy"),
|
||||
analyticsName: "Copy template",
|
||||
section: ActiveTemplateSection,
|
||||
icon: <CopyIcon />,
|
||||
keywords: "clipboard",
|
||||
children: [copyTemplateLink, copyTemplateAsPlainText],
|
||||
});
|
||||
|
||||
export const printTemplate = createAction({
|
||||
name: ({ t, isMenu }) => (isMenu ? t("Print") : t("Print template")),
|
||||
analyticsName: "Print template",
|
||||
section: ActiveTemplateSection,
|
||||
icon: <PrintIcon />,
|
||||
visible: ({ getActiveModel }) => !!getActiveModel(Template) && !!window.print,
|
||||
perform: () => {
|
||||
setTimeout(window.print, 0);
|
||||
},
|
||||
});
|
||||
|
||||
export const rootTemplateActions = [moveTemplate, createDocumentFromTemplate];
|
||||
@@ -1,8 +1,9 @@
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import type { UserRole } from "@shared/types";
|
||||
import * as React from "react";
|
||||
import { UserRole } from "@shared/types";
|
||||
import { UserRoleHelper } from "@shared/utils/UserRoleHelper";
|
||||
import stores from "~/stores";
|
||||
import type User from "~/models/User";
|
||||
import User from "~/models/User";
|
||||
import Invite from "~/scenes/Invite";
|
||||
import {
|
||||
UserChangeRoleDialog,
|
||||
@@ -17,12 +18,11 @@ export const inviteUser = createAction({
|
||||
icon: <PlusIcon />,
|
||||
keywords: "team member workspace user",
|
||||
section: UserSection,
|
||||
visible: () =>
|
||||
visible: ({ stores }) =>
|
||||
stores.policies.abilities(stores.auth.team?.id || "").inviteUser,
|
||||
perform: ({ t }) => {
|
||||
stores.dialogs.openModal({
|
||||
title: t("Invite to workspace"),
|
||||
width: "500px",
|
||||
content: <Invite onSubmit={stores.dialogs.closeAllModals} />,
|
||||
});
|
||||
},
|
||||
@@ -40,14 +40,14 @@ export const updateUserRoleActionFactory = (user: User, role: UserRole) =>
|
||||
})}…`,
|
||||
analyticsName: "Update user role",
|
||||
section: UserSection,
|
||||
visible: () => {
|
||||
visible: ({ stores }) => {
|
||||
const can = stores.policies.abilities(user.id);
|
||||
|
||||
return UserRoleHelper.isRoleHigher(role, user.role)
|
||||
? can.promote
|
||||
: UserRoleHelper.isRoleLower(role, user.role)
|
||||
? can.demote
|
||||
: false;
|
||||
? can.demote
|
||||
: false;
|
||||
},
|
||||
perform: ({ t }) => {
|
||||
stores.dialogs.openModal({
|
||||
@@ -70,7 +70,7 @@ export const deleteUserActionFactory = (userId: string) =>
|
||||
keywords: "leave",
|
||||
dangerous: true,
|
||||
section: UserSection,
|
||||
visible: () => stores.policies.abilities(userId).delete,
|
||||
visible: ({ stores }) => stores.policies.abilities(userId).delete,
|
||||
perform: ({ t }) => {
|
||||
const user = stores.users.get(userId);
|
||||
if (!user) {
|
||||
|
||||
+81
-248
@@ -1,285 +1,125 @@
|
||||
import type { LocationDescriptor } from "history";
|
||||
import flattenDeep from "lodash/flattenDeep";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { Optional } from "utility-types";
|
||||
import { Optional } from "utility-types";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type {
|
||||
ActionContext,
|
||||
import {
|
||||
Action,
|
||||
ActionGroup,
|
||||
ActionSeparator as TActionSeparator,
|
||||
ActionVariant,
|
||||
ActionWithChildren,
|
||||
ExternalLinkAction,
|
||||
InternalLinkAction,
|
||||
MenuItem,
|
||||
ActionContext,
|
||||
CommandBarAction,
|
||||
MenuItemButton,
|
||||
MenuItemWithChildren,
|
||||
} from "~/types";
|
||||
import Analytics from "~/utils/Analytics";
|
||||
import history from "~/utils/history";
|
||||
import type { Action as KbarAction } from "kbar";
|
||||
|
||||
export function resolve<T>(value: any, context: ActionContext): T {
|
||||
function resolve<T>(value: any, context: ActionContext): T {
|
||||
return typeof value === "function" ? value(context) : value;
|
||||
}
|
||||
|
||||
export const ActionSeparator: TActionSeparator = {
|
||||
type: "action_separator",
|
||||
};
|
||||
|
||||
export function createAction(
|
||||
definition: Optional<Omit<Action, "type" | "variant">, "id">
|
||||
): Action {
|
||||
export function createAction(definition: Optional<Action, "id">): Action {
|
||||
return {
|
||||
...definition,
|
||||
type: "action",
|
||||
variant: "action",
|
||||
perform: definition.perform
|
||||
? (context) => {
|
||||
// We must use the specific analytics name here as the action name is
|
||||
// We muse use the specific analytics name here as the action name is
|
||||
// translated and potentially contains user strings.
|
||||
if (definition.analyticsName) {
|
||||
Analytics.track("perform_action", definition.analyticsName, {
|
||||
context: context.isButton
|
||||
? "button"
|
||||
: context.isCommandBar
|
||||
? "commandbar"
|
||||
: "contextmenu",
|
||||
? "commandbar"
|
||||
: "contextmenu",
|
||||
});
|
||||
}
|
||||
return definition.perform(context);
|
||||
|
||||
return definition.perform?.(context);
|
||||
}
|
||||
: () => {},
|
||||
: undefined,
|
||||
id: definition.id ?? uuidv4(),
|
||||
};
|
||||
}
|
||||
|
||||
export function createInternalLinkAction(
|
||||
definition: Optional<Omit<InternalLinkAction, "type" | "variant">, "id">
|
||||
): InternalLinkAction {
|
||||
return {
|
||||
...definition,
|
||||
type: "action",
|
||||
variant: "internal_link",
|
||||
id: definition.id ?? uuidv4(),
|
||||
};
|
||||
}
|
||||
|
||||
export function createExternalLinkAction(
|
||||
definition: Optional<Omit<ExternalLinkAction, "type" | "variant">, "id">
|
||||
): ExternalLinkAction {
|
||||
return {
|
||||
...definition,
|
||||
type: "action",
|
||||
variant: "external_link",
|
||||
id: definition.id ?? uuidv4(),
|
||||
};
|
||||
}
|
||||
|
||||
export function createActionWithChildren(
|
||||
definition: Optional<Omit<ActionWithChildren, "type" | "variant">, "id">
|
||||
): ActionWithChildren {
|
||||
return {
|
||||
...definition,
|
||||
type: "action",
|
||||
variant: "action_with_children",
|
||||
id: definition.id ?? uuidv4(),
|
||||
};
|
||||
}
|
||||
|
||||
export function createActionGroup(
|
||||
definition: Omit<ActionGroup, "type">
|
||||
): ActionGroup {
|
||||
return {
|
||||
...definition,
|
||||
type: "action_group",
|
||||
};
|
||||
}
|
||||
|
||||
export function createRootMenuAction(
|
||||
actions: (ActionVariant | ActionGroup | TActionSeparator)[]
|
||||
): ActionWithChildren {
|
||||
return {
|
||||
id: uuidv4(),
|
||||
type: "action",
|
||||
variant: "action_with_children",
|
||||
name: "root_action",
|
||||
section: "Root",
|
||||
children: actions,
|
||||
};
|
||||
}
|
||||
|
||||
export function actionToMenuItem(
|
||||
action: ActionVariant | ActionGroup | TActionSeparator,
|
||||
action: Action,
|
||||
context: ActionContext
|
||||
): MenuItem {
|
||||
switch (action.type) {
|
||||
case "action": {
|
||||
const title = resolve<string>(action.name, context);
|
||||
const visible = resolve<boolean>(action.visible, context) ?? true;
|
||||
const disabled = resolve<boolean>(action.disabled, context);
|
||||
const icon =
|
||||
!!action.icon && action.iconInContextMenu !== false
|
||||
? resolve<React.ReactNode>(action.icon, context)
|
||||
: undefined;
|
||||
): MenuItemButton | MenuItemWithChildren {
|
||||
const resolvedIcon = resolve<React.ReactElement<any>>(action.icon, context);
|
||||
const resolvedChildren = resolve<Action[]>(action.children, context);
|
||||
const visible = action.visible ? action.visible(context) : true;
|
||||
const title = resolve<string>(action.name, context);
|
||||
const icon =
|
||||
resolvedIcon && action.iconInContextMenu !== false
|
||||
? resolvedIcon
|
||||
: undefined;
|
||||
|
||||
switch (action.variant) {
|
||||
case "action":
|
||||
return {
|
||||
type: "button",
|
||||
title,
|
||||
icon,
|
||||
visible,
|
||||
disabled,
|
||||
tooltip: resolve<React.ReactChild>(action.tooltip, context),
|
||||
selected: resolve<boolean>(action.selected, context),
|
||||
dangerous: action.dangerous,
|
||||
onClick: () => performAction(action, context),
|
||||
};
|
||||
if (resolvedChildren) {
|
||||
const items = resolvedChildren
|
||||
.map((a) => actionToMenuItem(a, context))
|
||||
.filter(Boolean)
|
||||
.filter((a) => a.visible);
|
||||
|
||||
case "internal_link": {
|
||||
const to = resolve<LocationDescriptor>(action.to, context);
|
||||
return {
|
||||
type: "route",
|
||||
title,
|
||||
icon,
|
||||
visible,
|
||||
disabled,
|
||||
to,
|
||||
};
|
||||
}
|
||||
|
||||
case "external_link":
|
||||
return {
|
||||
type: "link",
|
||||
title,
|
||||
icon,
|
||||
visible,
|
||||
disabled,
|
||||
href: action.target
|
||||
? { url: action.url, target: action.target }
|
||||
: action.url,
|
||||
};
|
||||
|
||||
case "action_with_children": {
|
||||
const children = resolve<
|
||||
(ActionVariant | ActionGroup | TActionSeparator)[]
|
||||
>(action.children, context);
|
||||
const subMenuItems = children.map((a) =>
|
||||
actionToMenuItem(a, context)
|
||||
);
|
||||
return {
|
||||
type: "submenu",
|
||||
title,
|
||||
icon,
|
||||
items: subMenuItems,
|
||||
disabled,
|
||||
visible: visible && hasVisibleItems(subMenuItems),
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
throw Error("invalid action variant");
|
||||
}
|
||||
}
|
||||
|
||||
case "action_group": {
|
||||
const groupItems = action.actions.map((a) =>
|
||||
actionToMenuItem(a, context)
|
||||
);
|
||||
return {
|
||||
type: "group",
|
||||
title: resolve<string>(action.name, context),
|
||||
visible: hasVisibleItems(groupItems),
|
||||
items: groupItems,
|
||||
};
|
||||
}
|
||||
|
||||
case "action_separator":
|
||||
return { type: "separator" };
|
||||
return {
|
||||
type: "submenu",
|
||||
title,
|
||||
icon,
|
||||
items,
|
||||
visible: visible && items.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "button",
|
||||
title,
|
||||
icon,
|
||||
visible,
|
||||
dangerous: action.dangerous,
|
||||
onClick: () => performAction(action, context),
|
||||
selected: action.selected?.(context),
|
||||
};
|
||||
}
|
||||
|
||||
export function actionToKBar(
|
||||
action: ActionVariant,
|
||||
action: Action,
|
||||
context: ActionContext
|
||||
): KbarAction[] {
|
||||
const visible = resolve<boolean>(action.visible, context);
|
||||
if (visible === false) {
|
||||
): CommandBarAction[] {
|
||||
if (typeof action.visible === "function" && !action.visible(context)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const name = resolve<string>(action.name, context);
|
||||
const icon = resolve<React.ReactElement>(action.icon, context);
|
||||
const section = resolve<string>(action.section, context);
|
||||
const resolvedIcon = resolve<React.ReactElement>(action.icon, context);
|
||||
const resolvedChildren = resolve<Action[]>(action.children, context);
|
||||
const resolvedSection = resolve<string>(action.section, context);
|
||||
const resolvedName = resolve<string>(action.name, context);
|
||||
const resolvedPlaceholder = resolve<string>(action.placeholder, context);
|
||||
const children = resolvedChildren
|
||||
? flattenDeep(resolvedChildren.map((a) => actionToKBar(a, context))).filter(
|
||||
(a) => !!a
|
||||
)
|
||||
: [];
|
||||
|
||||
const sectionPriority =
|
||||
typeof action.section !== "string" && "priority" in action.section
|
||||
? ((action.section.priority as number) ?? 0)
|
||||
: 0;
|
||||
|
||||
const priority = (1 + (action.priority ?? 0)) * (1 + (sectionPriority ?? 0));
|
||||
|
||||
switch (action.variant) {
|
||||
case "action":
|
||||
case "internal_link":
|
||||
case "external_link": {
|
||||
return [
|
||||
{
|
||||
id: action.id,
|
||||
name,
|
||||
section,
|
||||
keywords: action.keywords,
|
||||
shortcut: action.shortcut,
|
||||
icon,
|
||||
priority,
|
||||
perform: () => performAction(action, context),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
case "action_with_children": {
|
||||
const resolvedChildren = resolve<ActionVariant[]>(
|
||||
action.children,
|
||||
context
|
||||
);
|
||||
const children = resolvedChildren
|
||||
.map((a) => actionToKBar(a, context))
|
||||
.flat()
|
||||
.filter(Boolean);
|
||||
|
||||
return [
|
||||
{
|
||||
id: action.id,
|
||||
name,
|
||||
section,
|
||||
keywords: action.keywords,
|
||||
shortcut: action.shortcut,
|
||||
icon,
|
||||
priority,
|
||||
},
|
||||
...children.map((child) => ({
|
||||
...child,
|
||||
parent: child.parent ?? action.id,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
default:
|
||||
throw Error("invalid action variant");
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: action.id,
|
||||
name: resolvedName,
|
||||
analyticsName: action.analyticsName,
|
||||
section: resolvedSection,
|
||||
placeholder: resolvedPlaceholder,
|
||||
keywords: action.keywords ?? "",
|
||||
shortcut: action.shortcut || [],
|
||||
icon: resolvedIcon,
|
||||
perform: action.perform
|
||||
? () => performAction(action, context)
|
||||
: undefined,
|
||||
},
|
||||
].concat(
|
||||
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
|
||||
children.map((child) => ({ ...child, parent: child.parent ?? action.id }))
|
||||
);
|
||||
}
|
||||
|
||||
export async function performAction(
|
||||
action: Exclude<ActionVariant, ActionWithChildren>,
|
||||
context: ActionContext
|
||||
) {
|
||||
const perform =
|
||||
action.variant === "action"
|
||||
? () => action.perform(context)
|
||||
: action.variant === "internal_link"
|
||||
? () => history.push(resolve<LocationDescriptor>(action.to, context))
|
||||
: () => window.open(action.url, action.target);
|
||||
|
||||
const result = perform();
|
||||
export async function performAction(action: Action, context: ActionContext) {
|
||||
const result = action.perform?.(context);
|
||||
|
||||
if (result instanceof Promise) {
|
||||
return result.catch((err: Error) => {
|
||||
@@ -289,10 +129,3 @@ export async function performAction(
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function hasVisibleItems(items: MenuItem[]) {
|
||||
const applicableTypes = ["button", "link", "route", "group", "submenu"];
|
||||
return items.some(
|
||||
(item) => applicableTypes.includes(item.type) && item.visible
|
||||
);
|
||||
}
|
||||
|
||||
+1
-42
@@ -1,42 +1,11 @@
|
||||
import type { ActionContext } from "~/types";
|
||||
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}`;
|
||||
};
|
||||
|
||||
ActiveCollectionSection.priority = 0.8;
|
||||
|
||||
export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
|
||||
|
||||
export const DocumentSection = ({ t }: ActionContext) => t("Document");
|
||||
|
||||
export const DocumentsSection = ({ t }: ActionContext) => t("Documents");
|
||||
|
||||
export const ActiveDocumentSection = ({ t, stores }: ActionContext) => {
|
||||
const activeDocument = stores.documents.active;
|
||||
return `${t("Document")} · ${activeDocument?.titleWithDefault}`;
|
||||
};
|
||||
|
||||
ActiveDocumentSection.priority = 0.9;
|
||||
|
||||
export const TemplateSection = ({ t }: ActionContext) => t("Template");
|
||||
|
||||
export const ActiveTemplateSection = ({ t, stores }: ActionContext) => {
|
||||
const activeTemplate = stores.templates.active;
|
||||
return `${t("Template")} · ${activeTemplate?.titleWithDefault}`;
|
||||
};
|
||||
|
||||
ActiveTemplateSection.priority = 0.9;
|
||||
|
||||
export const RecentSection = ({ t }: ActionContext) => t("Recently viewed");
|
||||
|
||||
RecentSection.priority = 1;
|
||||
|
||||
export const RevisionSection = ({ t }: ActionContext) => t("Revision");
|
||||
|
||||
export const SettingsSection = ({ t }: ActionContext) => t("Settings");
|
||||
@@ -45,21 +14,11 @@ export const NavigationSection = ({ t }: ActionContext) => t("Navigation");
|
||||
|
||||
export const NotificationSection = ({ t }: ActionContext) => t("Notification");
|
||||
|
||||
export const GroupSection = ({ t }: ActionContext) => t("Groups");
|
||||
|
||||
export const EmojiSecion = ({ t }: ActionContext) => t("Emoji");
|
||||
|
||||
export const UserSection = ({ t }: ActionContext) => t("People");
|
||||
|
||||
UserSection.priority = 0.5;
|
||||
|
||||
export const ShareSection = ({ t }: ActionContext) => t("Share");
|
||||
|
||||
export const TeamSection = ({ t }: ActionContext) => t("Workspace");
|
||||
|
||||
export const RecentSearchesSection = ({ t }: ActionContext) =>
|
||||
t("Recent searches");
|
||||
|
||||
RecentSearchesSection.priority = -0.1;
|
||||
|
||||
export const TrashSection = ({ t }: ActionContext) => t("Trash");
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
/* oxlint-disable react/prop-types */
|
||||
/* eslint-disable react/prop-types */
|
||||
import * as React from "react";
|
||||
import type { Props as TooltipProps } from "~/components/Tooltip";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import { performAction, resolve } from "~/actions";
|
||||
import Tooltip, { Props as TooltipProps } from "~/components/Tooltip";
|
||||
import { performAction } from "~/actions";
|
||||
import useIsMounted from "~/hooks/useIsMounted";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import type { ActionVariant, ActionWithChildren } from "~/types";
|
||||
import { Action, ActionContext } from "~/types";
|
||||
|
||||
export type Props = React.HTMLAttributes<HTMLButtonElement> & {
|
||||
/** Show the button in a disabled state */
|
||||
@@ -13,7 +11,9 @@ export type Props = React.HTMLAttributes<HTMLButtonElement> & {
|
||||
/** Hide the button entirely if action is not applicable */
|
||||
hideOnActionDisabled?: boolean;
|
||||
/** Action to use on button */
|
||||
action?: Exclude<ActionVariant, ActionWithChildren>;
|
||||
action?: Action;
|
||||
/** Context of action, must be provided with action */
|
||||
context?: ActionContext;
|
||||
/** If tooltip props are provided the button will be wrapped in a tooltip */
|
||||
tooltip?: Omit<TooltipProps, "children">;
|
||||
};
|
||||
@@ -22,34 +22,35 @@ export type Props = React.HTMLAttributes<HTMLButtonElement> & {
|
||||
* Button that can be used to trigger an action definition.
|
||||
*/
|
||||
const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
|
||||
function ActionButton_(
|
||||
{ action, tooltip, hideOnActionDisabled, ...rest }: Props,
|
||||
function _ActionButton(
|
||||
{ action, context, tooltip, hideOnActionDisabled, ...rest }: Props,
|
||||
ref: React.Ref<HTMLButtonElement>
|
||||
) {
|
||||
const actionContext = useActionContext({
|
||||
isButton: true,
|
||||
});
|
||||
const isMounted = useIsMounted();
|
||||
const [executing, setExecuting] = React.useState(false);
|
||||
const disabled = rest.disabled;
|
||||
|
||||
if (!actionContext || !action) {
|
||||
if (action && !context) {
|
||||
throw new Error("Context must be provided with action");
|
||||
}
|
||||
if (!context || !action) {
|
||||
return <button {...rest} ref={ref} />;
|
||||
}
|
||||
|
||||
const actionIsDisabled =
|
||||
action.visible && !resolve<boolean>(action.visible, actionContext);
|
||||
const actionContext = { ...context, isButton: true };
|
||||
|
||||
if (actionIsDisabled && hideOnActionDisabled) {
|
||||
if (
|
||||
action?.visible &&
|
||||
!action.visible(actionContext) &&
|
||||
hideOnActionDisabled
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const disabled = rest.disabled || actionIsDisabled;
|
||||
|
||||
const label =
|
||||
rest["aria-label"] ??
|
||||
(typeof action.name === "function"
|
||||
typeof action.name === "function"
|
||||
? action.name(actionContext)
|
||||
: action.name);
|
||||
: action.name;
|
||||
|
||||
const button = (
|
||||
<button
|
||||
@@ -58,7 +59,7 @@ const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
|
||||
disabled={disabled || executing}
|
||||
ref={ref}
|
||||
onClick={
|
||||
actionContext
|
||||
action?.perform && actionContext
|
||||
? (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
@@ -6,6 +6,7 @@ import Flex from "~/components/Flex";
|
||||
export const Action = styled(Flex)`
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0 0 0 12px;
|
||||
height: 32px;
|
||||
font-size: 15px;
|
||||
flex-shrink: 0;
|
||||
@@ -17,6 +18,7 @@ export const Action = styled(Flex)`
|
||||
|
||||
export const Separator = styled.div`
|
||||
flex-shrink: 0;
|
||||
margin-left: 12px;
|
||||
width: 1px;
|
||||
height: 28px;
|
||||
background: ${s("divider")};
|
||||
@@ -29,9 +31,9 @@ const Actions = styled(Flex)`
|
||||
left: 0;
|
||||
border-radius: 3px;
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
padding: 12px;
|
||||
backdrop-filter: blur(20px);
|
||||
gap: 12px;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
/* oxlint-disable prefer-rest-params */
|
||||
/* eslint-disable prefer-rest-params */
|
||||
/* global ga */
|
||||
import escape from "lodash/escape";
|
||||
import * as React from "react";
|
||||
import type { PublicEnv } from "@shared/types";
|
||||
import { IntegrationService } from "@shared/types";
|
||||
import { IntegrationService, PublicEnv } from "@shared/types";
|
||||
import env from "~/env";
|
||||
|
||||
type Props = {
|
||||
@@ -107,24 +106,6 @@ const Analytics: React.FC = ({ children }: Props) => {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Umami
|
||||
React.useEffect(() => {
|
||||
(env.analytics as PublicEnv["analytics"]).forEach((integration) => {
|
||||
if (integration.service !== IntegrationService.Umami) {
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.defer = true;
|
||||
script.src = `${integration.settings?.instanceUrl}${integration.settings?.scriptName}`;
|
||||
script.setAttribute(
|
||||
"data-website-id",
|
||||
integration.settings?.measurementId
|
||||
);
|
||||
document.getElementsByTagName("head")[0]?.appendChild(script);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import * as React from "react";
|
||||
|
||||
export default function Arrow() {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -19,7 +19,7 @@ function ArrowKeyNavigation(
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === "Escape" || ev.key === "Backspace") {
|
||||
if (ev.key === "Escape") {
|
||||
ev.preventDefault();
|
||||
onEscape(ev);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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";
|
||||
@@ -19,7 +19,7 @@ const Authenticated = ({ children }: Props) => {
|
||||
|
||||
// Watching for language changes here as this is the earliest point we might have the user
|
||||
// available and means we can start loading translations faster
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
void changeLanguage(language, i18n);
|
||||
}, [i18n, language]);
|
||||
|
||||
@@ -31,12 +31,7 @@ const Authenticated = ({ children }: Props) => {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
void auth.logout({ savePath: true });
|
||||
|
||||
if (auth.logoutRedirectUri) {
|
||||
window.location.href = auth.logoutRedirectUri;
|
||||
return null;
|
||||
}
|
||||
void auth.logout(true);
|
||||
return <Redirect to="/" />;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { observer, useLocalStore } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Switch, Route } from "react-router-dom";
|
||||
import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
|
||||
import { Switch, Route, useLocation, matchPath } from "react-router-dom";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import ErrorSuspended from "~/scenes/ErrorSuspended";
|
||||
import DocumentContext from "~/components/DocumentContext";
|
||||
import type { DocumentContextValue } from "~/components/DocumentContext";
|
||||
import Layout from "~/components/Layout";
|
||||
import RegisterKeyDown from "~/components/RegisterKeyDown";
|
||||
import { RightSidebarProvider } from "~/components/RightSidebarContext";
|
||||
import Sidebar from "~/components/Sidebar";
|
||||
import SidebarRight from "~/components/Sidebar/Right";
|
||||
import SettingsSidebar from "~/components/Sidebar/Settings";
|
||||
import type { Editor as TEditor } from "~/editor";
|
||||
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";
|
||||
@@ -16,16 +21,23 @@ import {
|
||||
searchPath,
|
||||
newDocumentPath,
|
||||
settingsPath,
|
||||
matchDocumentHistory,
|
||||
matchDocumentSlug as slug,
|
||||
matchDocumentInsights,
|
||||
} from "~/utils/routeHelpers";
|
||||
import { DocumentContextProvider } from "./DocumentContext";
|
||||
import Fade from "./Fade";
|
||||
import NotificationBadge from "./NotificationBadge";
|
||||
import { PortalContext } from "./Portal";
|
||||
import CommandBar from "./CommandBar";
|
||||
|
||||
const SettingsSidebar = lazyWithRetry(
|
||||
() => import("~/components/Sidebar/Settings")
|
||||
const DocumentComments = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/Comments")
|
||||
);
|
||||
const DocumentHistory = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/History")
|
||||
);
|
||||
const DocumentInsights = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/Insights")
|
||||
);
|
||||
const CommandBar = lazyWithRetry(() => import("~/components/CommandBar"));
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -33,10 +45,17 @@ type Props = {
|
||||
|
||||
const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
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 documentContext = useLocalStore<DocumentContextValue>(() => ({
|
||||
editor: null,
|
||||
setEditor: (editor: TEditor) => {
|
||||
documentContext.editor = editor;
|
||||
},
|
||||
}));
|
||||
|
||||
const goToSearch = (ev: KeyboardEvent) => {
|
||||
if (!ev.metaKey && !ev.ctrlKey) {
|
||||
@@ -57,13 +76,6 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
history.push(newDocumentPath(activeCollectionId));
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const postLoginPath = spendPostLoginPath();
|
||||
if (postLoginPath) {
|
||||
history.replace(postLoginPath);
|
||||
}
|
||||
}, [spendPostLoginPath]);
|
||||
|
||||
if (auth.isSuspended) {
|
||||
return <ErrorSuspended />;
|
||||
}
|
||||
@@ -77,21 +89,60 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
</Fade>
|
||||
);
|
||||
|
||||
const showHistory =
|
||||
!!matchPath(location.pathname, {
|
||||
path: matchDocumentHistory,
|
||||
}) && can.listRevisions;
|
||||
const showInsights =
|
||||
!!matchPath(location.pathname, {
|
||||
path: matchDocumentInsights,
|
||||
}) && can.listViews;
|
||||
const showComments =
|
||||
!showInsights &&
|
||||
!showHistory &&
|
||||
can.comment &&
|
||||
ui.activeDocumentId &&
|
||||
ui.commentsExpanded.includes(ui.activeDocumentId) &&
|
||||
team.getPreference(TeamPreference.Commenting);
|
||||
|
||||
const sidebarRight = (
|
||||
<AnimatePresence
|
||||
initial={false}
|
||||
key={ui.activeDocumentId ? "active" : "inactive"}
|
||||
>
|
||||
{(showHistory || showInsights || showComments) && (
|
||||
<Route path={`/doc/${slug}`}>
|
||||
<SidebarRight>
|
||||
<React.Suspense fallback={null}>
|
||||
{showHistory && <DocumentHistory />}
|
||||
{showInsights && <DocumentInsights />}
|
||||
{showComments && <DocumentComments />}
|
||||
</React.Suspense>
|
||||
</SidebarRight>
|
||||
</Route>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
||||
return (
|
||||
<DocumentContextProvider>
|
||||
<RightSidebarProvider>
|
||||
<PortalContext.Provider value={layoutRef.current}>
|
||||
<Layout title={team.name} sidebar={sidebar} ref={layoutRef}>
|
||||
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
|
||||
<RegisterKeyDown trigger="t" handler={goToSearch} />
|
||||
<RegisterKeyDown trigger="/" handler={goToSearch} />
|
||||
{children}
|
||||
<DocumentContext.Provider value={documentContext}>
|
||||
<PortalContext.Provider value={layoutRef.current}>
|
||||
<Layout
|
||||
title={team.name}
|
||||
sidebar={sidebar}
|
||||
sidebarRight={sidebarRight}
|
||||
ref={layoutRef}
|
||||
>
|
||||
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
|
||||
<RegisterKeyDown trigger="t" handler={goToSearch} />
|
||||
<RegisterKeyDown trigger="/" handler={goToSearch} />
|
||||
{children}
|
||||
<React.Suspense fallback={null}>
|
||||
<CommandBar />
|
||||
<NotificationBadge />
|
||||
</Layout>
|
||||
</PortalContext.Provider>
|
||||
</RightSidebarProvider>
|
||||
</DocumentContextProvider>
|
||||
</React.Suspense>
|
||||
</Layout>
|
||||
</PortalContext.Provider>
|
||||
</DocumentContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,113 +1,81 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import Initials from "./Initials";
|
||||
import Tooltip from "../Tooltip";
|
||||
|
||||
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 {
|
||||
avatarUrl: string | null;
|
||||
color?: string;
|
||||
initial?: string;
|
||||
name?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
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;
|
||||
/** Whether to show a tooltip */
|
||||
showTooltip?: boolean;
|
||||
};
|
||||
|
||||
function Avatar(props: Props) {
|
||||
const {
|
||||
model,
|
||||
style,
|
||||
variant = AvatarVariant.Round,
|
||||
className,
|
||||
showTooltip,
|
||||
...rest
|
||||
} = props;
|
||||
const { showBorder, model, style, ...rest } = props;
|
||||
const src = props.src || model?.avatarUrl;
|
||||
const [error, handleError] = useBoolean(false);
|
||||
const initial =
|
||||
model?.initial || (model?.name ? model.name[0] : "").toUpperCase();
|
||||
|
||||
const content = (
|
||||
<Relative
|
||||
style={style}
|
||||
$variant={variant}
|
||||
$size={props.size}
|
||||
className={className}
|
||||
>
|
||||
return (
|
||||
<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}>
|
||||
{initial}
|
||||
<Initials color={model.color} $showBorder={showBorder} {...rest}>
|
||||
{model.initial}
|
||||
</Initials>
|
||||
) : (
|
||||
<Initials {...rest} />
|
||||
<Initials $showBorder={showBorder} {...rest} />
|
||||
)}
|
||||
</Relative>
|
||||
);
|
||||
|
||||
return showTooltip ? (
|
||||
<Tooltip content={props.alt || model?.name || ""}>{content}</Tooltip>
|
||||
) : (
|
||||
content
|
||||
);
|
||||
}
|
||||
|
||||
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 observer(Avatar);
|
||||
export default Avatar;
|
||||
|
||||
@@ -3,49 +3,19 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import type User from "~/models/User";
|
||||
import User from "~/models/User";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import Avatar, { AvatarSize } 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 alt text for the avatar image */
|
||||
alt?: string;
|
||||
/** 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,
|
||||
@@ -53,9 +23,6 @@ function AvatarWithPresence({
|
||||
isEditing,
|
||||
isObserving,
|
||||
isCurrentUser,
|
||||
size = AvatarSize.Large,
|
||||
style,
|
||||
alt,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const status = isPresent
|
||||
@@ -80,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} alt={alt} />
|
||||
</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,35 +0,0 @@
|
||||
import { GroupIcon } from "outline-icons";
|
||||
import { useTheme } from "styled-components";
|
||||
import Squircle from "@shared/components/Squircle";
|
||||
import type Group from "~/models/Group";
|
||||
import { AvatarSize } from "../Avatar/Avatar";
|
||||
|
||||
type Props = {
|
||||
/** The group to show an avatar for */
|
||||
group: Group;
|
||||
/** The size of the icon, 24px is default to match standard avatars */
|
||||
size?: number;
|
||||
/** The color of the avatar */
|
||||
color?: string;
|
||||
/** The background color of the avatar */
|
||||
backgroundColor?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function GroupAvatar({
|
||||
color,
|
||||
backgroundColor,
|
||||
size = AvatarSize.Medium,
|
||||
className,
|
||||
}: Props) {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Squircle color={color ?? theme.text} size={size} className={className}>
|
||||
<GroupIcon
|
||||
data-fixed-color
|
||||
color={backgroundColor ?? theme.background}
|
||||
size={size * 0.75}
|
||||
/>
|
||||
</Squircle>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +1,26 @@
|
||||
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: #fff;
|
||||
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;
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { IAvatar } from "./Avatar";
|
||||
import Avatar, { AvatarSize, AvatarVariant } from "./Avatar";
|
||||
import Avatar from "./Avatar";
|
||||
import AvatarWithPresence from "./AvatarWithPresence";
|
||||
import { GroupAvatar } from "./GroupAvatar";
|
||||
|
||||
export { Avatar, GroupAvatar, AvatarSize, AvatarVariant, AvatarWithPresence };
|
||||
export { AvatarWithPresence };
|
||||
|
||||
export type { IAvatar };
|
||||
export default Avatar;
|
||||
|
||||
@@ -2,26 +2,25 @@ import { transparentize } from "polished";
|
||||
import styled from "styled-components";
|
||||
|
||||
const Badge = styled.span<{ yellow?: boolean; primary?: boolean }>`
|
||||
padding: 1.5px 5.5px;
|
||||
margin: 0 2px;
|
||||
margin-left: 10px;
|
||||
padding: 1px 5px 2px;
|
||||
background-color: ${({ yellow, primary, theme }) =>
|
||||
yellow ? theme.yellow : primary ? theme.accent : "transparent"};
|
||||
color: ${({ primary, yellow, theme }) =>
|
||||
primary
|
||||
? theme.accentText
|
||||
: yellow
|
||||
? theme.almostBlack
|
||||
: theme.textTertiary};
|
||||
? theme.almostBlack
|
||||
: theme.textTertiary};
|
||||
border: 1px solid
|
||||
${({ primary, yellow, theme }) =>
|
||||
primary || yellow
|
||||
? "transparent"
|
||||
: transparentize(0.4, theme.textTertiary)};
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
export default Badge;
|
||||
|
||||
+14
-11
@@ -1,5 +1,6 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import env from "~/env";
|
||||
import OutlineIcon from "./Icons/OutlineIcon";
|
||||
@@ -10,7 +11,7 @@ type Props = {
|
||||
|
||||
function Branding({ href = env.URL }: Props) {
|
||||
return (
|
||||
<Link href={href} target="_blank">
|
||||
<Link href={href}>
|
||||
<OutlineIcon size={20} />
|
||||
{env.APP_NAME}
|
||||
</Link>
|
||||
@@ -33,16 +34,18 @@ const Link = styled.a`
|
||||
fill: ${s("text")};
|
||||
}
|
||||
|
||||
z-index: ${depths.sidebar + 1};
|
||||
background: ${s("sidebarBackground")};
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
padding: 16px;
|
||||
${breakpoint("tablet")`
|
||||
z-index: ${depths.sidebar + 1};
|
||||
background: ${s("sidebarBackground")};
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
padding: 16px;
|
||||
|
||||
&:hover {
|
||||
background: ${s("sidebarControlHoverBackground")};
|
||||
}
|
||||
&:hover {
|
||||
background: ${s("sidebarControlHoverBackground")};
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
||||
export default React.memo(Branding);
|
||||
export default Branding;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { GoToIcon } from "outline-icons";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
@@ -7,86 +6,53 @@ import { s, ellipsis } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
import BreadcrumbMenu from "~/menus/BreadcrumbMenu";
|
||||
import { undraggableOnDesktop } from "~/styles";
|
||||
import type { InternalLinkAction, MenuInternalLink } from "~/types";
|
||||
import { actionToMenuItem } from "~/actions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { useComputed } from "~/hooks/useComputed";
|
||||
import { MenuInternalLink } from "~/types";
|
||||
|
||||
type TopLevelAction =
|
||||
| InternalLinkAction
|
||||
| { type: "menu"; actions: InternalLinkAction[] };
|
||||
|
||||
type Props = React.PropsWithChildren<{
|
||||
actions: InternalLinkAction[];
|
||||
type Props = {
|
||||
items: MenuInternalLink[];
|
||||
max?: number;
|
||||
highlightFirstItem?: boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
function Breadcrumb(
|
||||
{ actions, highlightFirstItem, children, max = 2 }: Props,
|
||||
ref: React.RefObject<HTMLDivElement> | null
|
||||
) {
|
||||
const actionContext = useActionContext({ isMenu: true });
|
||||
|
||||
const visibleActions = useComputed(
|
||||
() =>
|
||||
actions.filter((action) =>
|
||||
typeof action.visible === "function"
|
||||
? action.visible(actionContext)
|
||||
: (action.visible ?? true)
|
||||
),
|
||||
[actions, actionContext]
|
||||
);
|
||||
const totalVisibleActions = visibleActions.length;
|
||||
|
||||
const topLevelActions: TopLevelAction[] = [...visibleActions];
|
||||
function Breadcrumb({
|
||||
items,
|
||||
highlightFirstItem,
|
||||
children,
|
||||
max = 2,
|
||||
}: React.PropsWithChildren<Props>) {
|
||||
const totalItems = items.length;
|
||||
const topLevelItems: MenuInternalLink[] = [...items];
|
||||
let overflowItems;
|
||||
|
||||
// chop middle breadcrumbs and present a "..." menu instead
|
||||
if (totalVisibleActions > max) {
|
||||
if (totalItems > max) {
|
||||
const halfMax = Math.floor(max / 2);
|
||||
const menuActions = topLevelActions.splice(
|
||||
halfMax,
|
||||
totalVisibleActions - max
|
||||
) as InternalLinkAction[];
|
||||
overflowItems = topLevelItems.splice(halfMax, totalItems - max);
|
||||
|
||||
topLevelActions.splice(halfMax, 0, {
|
||||
type: "menu",
|
||||
actions: menuActions,
|
||||
topLevelItems.splice(halfMax, 0, {
|
||||
to: "",
|
||||
type: "route",
|
||||
title: <BreadcrumbMenu items={overflowItems as MenuInternalLink[]} />,
|
||||
});
|
||||
}
|
||||
|
||||
const toBreadcrumb = React.useCallback(
|
||||
(action: TopLevelAction, index: number) => {
|
||||
if (action.type === "menu") {
|
||||
return <BreadcrumbMenu key="menu" actions={action.actions} />;
|
||||
}
|
||||
|
||||
const item = actionToMenuItem(action, actionContext) as MenuInternalLink;
|
||||
|
||||
return (
|
||||
<>
|
||||
{item.icon}
|
||||
<Item
|
||||
to={item.to}
|
||||
$withIcon={!!item.icon}
|
||||
$highlight={!!highlightFirstItem && index === 0}
|
||||
>
|
||||
{item.title}
|
||||
</Item>
|
||||
</>
|
||||
);
|
||||
},
|
||||
[actionContext, highlightFirstItem]
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex justify="flex-start" align="center" ref={ref}>
|
||||
{topLevelActions.map((action, index) => (
|
||||
<React.Fragment key={action.type === "menu" ? "menu" : `item-${index}`}>
|
||||
{toBreadcrumb(action, index)}
|
||||
{index !== topLevelActions.length - 1 || !!children ? (
|
||||
<Slash />
|
||||
) : null}
|
||||
<Flex justify="flex-start" align="center">
|
||||
{topLevelItems.map((item, index) => (
|
||||
<React.Fragment key={String(item.to) || index}>
|
||||
{item.icon}
|
||||
{item.to ? (
|
||||
<Item
|
||||
to={item.to}
|
||||
$withIcon={!!item.icon}
|
||||
$highlight={!!highlightFirstItem && index === 0}
|
||||
>
|
||||
{item.title}
|
||||
</Item>
|
||||
) : (
|
||||
item.title
|
||||
)}
|
||||
{index !== topLevelItems.length - 1 || !!children ? <Slash /> : null}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{children}
|
||||
@@ -101,8 +67,6 @@ const Slash = styled(GoToIcon)`
|
||||
|
||||
const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
|
||||
${ellipsis()}
|
||||
${undraggableOnDesktop()}
|
||||
|
||||
display: flex;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
@@ -112,6 +76,7 @@ const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
|
||||
height: 24px;
|
||||
font-weight: ${(props) => (props.$highlight ? "500" : "inherit")};
|
||||
margin-left: ${(props) => (props.$withIcon ? "4px" : "0")};
|
||||
${undraggableOnDesktop()}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
@@ -122,4 +87,4 @@ const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(React.forwardRef<HTMLDivElement, Props>(Breadcrumb));
|
||||
export default Breadcrumb;
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import type { LocationDescriptor } from "history";
|
||||
import { DisclosureIcon } from "outline-icons";
|
||||
import { LocationDescriptor } from "history";
|
||||
import { ExpandedIcon } from "outline-icons";
|
||||
import { darken, lighten, transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import type { HapticInput } from "web-haptics";
|
||||
import { useWebHaptics } from "web-haptics/react";
|
||||
import { s } from "@shared/styles";
|
||||
import type { Props as ActionButtonProps } from "~/components/ActionButton";
|
||||
import ActionButton from "~/components/ActionButton";
|
||||
import ActionButton, {
|
||||
Props as ActionButtonProps,
|
||||
} from "~/components/ActionButton";
|
||||
import { undraggableOnDesktop } from "~/styles";
|
||||
|
||||
type RealProps = {
|
||||
@@ -35,7 +34,6 @@ const RealButton = styled(ActionButton)<RealProps>`
|
||||
cursor: var(--pointer);
|
||||
user-select: none;
|
||||
appearance: none !important;
|
||||
transition: background 200ms ease-out;
|
||||
${undraggableOnDesktop()}
|
||||
|
||||
&::-moz-focus-inner {
|
||||
@@ -46,7 +44,6 @@ const RealButton = styled(ActionButton)<RealProps>`
|
||||
&:hover:not(:disabled),
|
||||
&[aria-expanded="true"] {
|
||||
background: ${(props) => darken(0.05, props.theme.accent)};
|
||||
transition: background 0s;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
@@ -81,11 +78,6 @@ const RealButton = styled(ActionButton)<RealProps>`
|
||||
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, ${
|
||||
props.theme.buttonNeutralBorder
|
||||
} 0 0 0 1px inset;
|
||||
transition: background 0s;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
box-shadow: ${`rgba(0, 0, 0, 0.07) 0px 1px 2px, ${props.theme.inputBorderFocused} 0 0 0 1px inset`};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
@@ -107,14 +99,13 @@ const RealButton = styled(ActionButton)<RealProps>`
|
||||
&:hover:not(:disabled),
|
||||
&[aria-expanded="true"] {
|
||||
background: ${darken(0.05, props.theme.danger)};
|
||||
transition: background 0s;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: ${lighten(0.05, props.theme.danger)};
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
&.focus-visible {
|
||||
outline-color: ${darken(0.2, props.theme.danger)} !important;
|
||||
}
|
||||
`};
|
||||
@@ -154,8 +145,6 @@ export type Props<T> = ActionButtonProps & {
|
||||
fullwidth?: boolean;
|
||||
as?: T;
|
||||
to?: LocationDescriptor;
|
||||
/** Haptic feedback to trigger on click. Pass a preset name or custom pattern. */
|
||||
haptic?: HapticInput;
|
||||
borderOnHover?: boolean;
|
||||
hideIcon?: boolean;
|
||||
href?: string;
|
||||
@@ -180,13 +169,11 @@ const Button = <T extends React.ElementType = "button">(
|
||||
hideIcon,
|
||||
fullwidth,
|
||||
danger,
|
||||
haptic,
|
||||
...rest
|
||||
} = props;
|
||||
const hasText = !!children || value !== undefined;
|
||||
const ic = hideIcon ? undefined : (action?.icon ?? icon);
|
||||
const ic = hideIcon ? undefined : action?.icon ?? icon;
|
||||
const hasIcon = ic !== undefined;
|
||||
const { trigger } = useWebHaptics();
|
||||
|
||||
return (
|
||||
<RealButton
|
||||
@@ -197,20 +184,15 @@ const Button = <T extends React.ElementType = "button">(
|
||||
$danger={danger}
|
||||
$fullwidth={fullwidth}
|
||||
$borderOnHover={borderOnHover}
|
||||
onClickCapture={haptic ? () => void trigger(haptic) : undefined}
|
||||
{...rest}
|
||||
>
|
||||
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
|
||||
{hasIcon && ic}
|
||||
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
|
||||
{disclosure && <StyledDisclosureIcon />}
|
||||
{disclosure && <ExpandedIcon />}
|
||||
</Inner>
|
||||
</RealButton>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledDisclosureIcon = styled(DisclosureIcon)`
|
||||
opacity: 0.8;
|
||||
`;
|
||||
|
||||
export default React.forwardRef(Button);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -23,9 +22,12 @@ const Container = styled.div<Props>`
|
||||
type ContentProps = { $maxWidth?: string };
|
||||
|
||||
const Content = styled.div<ContentProps>`
|
||||
max-width: ${(props: ContentProps) =>
|
||||
props.$maxWidth ?? EditorStyleHelper.documentWidth};
|
||||
max-width: ${(props) => props.$maxWidth ?? "46em"};
|
||||
margin: 0 auto;
|
||||
|
||||
${breakpoint("desktopLarge")`
|
||||
max-width: ${(props: ContentProps) => props.$maxWidth ?? "52em"};
|
||||
`};
|
||||
`;
|
||||
|
||||
const CenteredContent: React.FC<Props> = ({
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
import React from "react";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
|
||||
const cleanPercentage = (percentage: number) => {
|
||||
|
||||
@@ -1,178 +1,119 @@
|
||||
import filter from "lodash/filter";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import orderBy from "lodash/orderBy";
|
||||
import sortBy from "lodash/sortBy";
|
||||
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 type Document from "~/models/Document";
|
||||
import { AvatarSize, AvatarWithPresence } from "~/components/Avatar";
|
||||
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
||||
import Document from "~/models/Document";
|
||||
import AvatarWithPresence from "~/components/Avatar/AvatarWithPresence";
|
||||
import DocumentViews from "~/components/DocumentViews";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "~/components/primitives/Popover";
|
||||
import Popover from "~/components/Popover";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
/** The document to display live collaborators for */
|
||||
document: Document;
|
||||
/** The maximum number of collaborators to display, defaults to 6 */
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Displays a list of live collaborators for a document, including their avatars
|
||||
* and presence status.
|
||||
*/
|
||||
function Collaborators(props: Props) {
|
||||
const { limit = 6 } = props;
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
const currentUserId = user?.id;
|
||||
const [requestedUserIds, setRequestedUserIds] = useState<string[]>([]);
|
||||
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 = useMemo(
|
||||
() => (documentPresence ? Array.from(documentPresence.values()) : []),
|
||||
[documentPresence]
|
||||
);
|
||||
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(
|
||||
sortBy(
|
||||
filter(
|
||||
users.all,
|
||||
(u) =>
|
||||
(presentIds.has(u.id) || collaboratorIdsSet.has(u.id)) &&
|
||||
!u.isSuspended
|
||||
users.orderedData,
|
||||
(user) =>
|
||||
(presentIds.includes(user.id) ||
|
||||
document.collaboratorIds.includes(user.id)) &&
|
||||
!user.isSuspended
|
||||
),
|
||||
[(u) => presentIds.has(u.id), "id"],
|
||||
["asc", "asc"]
|
||||
(user) => presentIds.includes(user.id)
|
||||
),
|
||||
[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
|
||||
key={collaborator.id}
|
||||
{...rest}
|
||||
user={collaborator}
|
||||
isPresent={isPresent}
|
||||
isEditing={isEditing}
|
||||
isObserving={isObserving}
|
||||
isCurrentUser={currentUserId === collaborator.id}
|
||||
alt={t("Avatar of {{ name }}", { name: collaborator.name })}
|
||||
onClick={
|
||||
isObservable
|
||||
? handleAvatarClick(
|
||||
collaborator.id,
|
||||
isPresent,
|
||||
isObserving,
|
||||
isObservable
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[presentIds, editingIds, observingUserId, currentUserId, handleAvatarClick]
|
||||
);
|
||||
|
||||
if (!document.insightsEnabled) {
|
||||
return null;
|
||||
}
|
||||
const popover = usePopoverState({
|
||||
gutter: 0,
|
||||
placement: "bottom-end",
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<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>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent aria-label={t("Viewers")} side="bottom" align="end">
|
||||
<DocumentViews document={document} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<>
|
||||
<PopoverDisclosure {...popover}>
|
||||
{(props) => (
|
||||
<NudeButton width={collaborators.length * 32} height={32} {...props}>
|
||||
<Facepile
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import * as RadixCollapsible from "@radix-ui/react-collapsible";
|
||||
import { ExpandedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
interface CollapsibleProps {
|
||||
/** The label displayed on the trigger button. */
|
||||
label: React.ReactNode;
|
||||
/** The content to show/hide inside the collapsible panel. */
|
||||
children: React.ReactNode;
|
||||
/** Whether the collapsible is open by default. */
|
||||
defaultOpen?: boolean;
|
||||
/** Controlled open state. */
|
||||
open?: boolean;
|
||||
/** Callback fired when the open state changes. */
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
/** Additional class name for the root element. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An accessible collapsible section built on Radix UI Collapsible.
|
||||
* Renders a trigger button with a disclosure chevron and animated content panel.
|
||||
*
|
||||
* @param props - component props.
|
||||
* @returns the collapsible component.
|
||||
*/
|
||||
export function Collapsible({
|
||||
label,
|
||||
children,
|
||||
defaultOpen = false,
|
||||
open,
|
||||
onOpenChange,
|
||||
className,
|
||||
}: CollapsibleProps) {
|
||||
return (
|
||||
<RadixCollapsible.Root
|
||||
defaultOpen={defaultOpen}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
className={className}
|
||||
>
|
||||
<StyledTrigger>
|
||||
<StyledExpandedIcon aria-hidden="true" />
|
||||
{label}
|
||||
</StyledTrigger>
|
||||
<StyledContent>{children}</StyledContent>
|
||||
</RadixCollapsible.Root>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledExpandedIcon = styled(ExpandedIcon)`
|
||||
flex-shrink: 0;
|
||||
transition: transform 150ms ease-out;
|
||||
margin-left: -4px;
|
||||
`;
|
||||
|
||||
const StyledTrigger = styled(RadixCollapsible.Trigger)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0 0 8px 0;
|
||||
cursor: var(--pointer);
|
||||
color: ${s("textTertiary")};
|
||||
font-size: 14pxte
|
||||
|
||||
&:hover {
|
||||
color: ${s("textSecondary")};
|
||||
}
|
||||
|
||||
&[data-state="closed"] {
|
||||
${StyledExpandedIcon} {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledContent = styled(RadixCollapsible.Content)`
|
||||
overflow: hidden;
|
||||
|
||||
&[data-state="open"] {
|
||||
animation: slideDown 200ms ease-out;
|
||||
}
|
||||
|
||||
&[data-state="closed"] {
|
||||
animation: slideUp 200ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-collapsible-content-height);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
height: var(--radix-collapsible-content-height);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -1,9 +1,8 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useCallback } from "react";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import type { FormData } from "./CollectionForm";
|
||||
import { CollectionForm } from "./CollectionForm";
|
||||
import { CollectionForm, FormData } from "./CollectionForm";
|
||||
|
||||
type Props = {
|
||||
collectionId: string;
|
||||
@@ -17,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);
|
||||
|
||||
@@ -1,32 +1,27 @@
|
||||
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 type { Option } from "~/components/InputSelect";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import { IconLibrary } from "@shared/utils/IconLibrary";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import { CollectionValidation } from "@shared/validations";
|
||||
import type Collection from "~/models/Collection";
|
||||
import Collection from "~/models/Collection";
|
||||
import Button from "~/components/Button";
|
||||
import { Collapsible } from "~/components/Collapsible";
|
||||
import Flex from "~/components/Flex";
|
||||
import Icon from "~/components/Icon";
|
||||
import Input from "~/components/Input";
|
||||
import { InputSelect } from "~/components/InputSelect";
|
||||
import { InputSelectPermission } from "~/components/InputSelectPermission";
|
||||
import { createLazyComponent } from "~/components/LazyLoad";
|
||||
import InputSelectPermission from "~/components/InputSelectPermission";
|
||||
import Switch from "~/components/Switch";
|
||||
import Text from "~/components/Text";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { EmptySelectValue } from "~/types";
|
||||
import { HStack } from "../primitives/HStack";
|
||||
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
|
||||
|
||||
const IconPicker = createLazyComponent(() => import("~/components/IconPicker"));
|
||||
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
|
||||
|
||||
export interface FormData {
|
||||
name: string;
|
||||
@@ -34,30 +29,8 @@ export interface FormData {
|
||||
color: string | null;
|
||||
sharing: boolean;
|
||||
permission: CollectionPermission | undefined;
|
||||
commenting?: boolean | null;
|
||||
templateManagement: CollectionPermission;
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -70,30 +43,12 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
|
||||
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
|
||||
|
||||
const templateManagementOptions = useMemo<Option[]>(
|
||||
() => [
|
||||
{
|
||||
type: "item",
|
||||
label: t("Managers"),
|
||||
value: CollectionPermission.Admin,
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
label: t("Members"),
|
||||
value: CollectionPermission.ReadWrite,
|
||||
},
|
||||
],
|
||||
[t]
|
||||
const iconColor = React.useMemo(
|
||||
() => collection?.color ?? randomElement(colorPalette),
|
||||
[collection?.color]
|
||||
);
|
||||
|
||||
const iconColor = useIconColor(collection);
|
||||
const fallbackIcon = (
|
||||
<Icon
|
||||
value="collection"
|
||||
initial={collection?.initial ?? "?"}
|
||||
color={iconColor}
|
||||
/>
|
||||
);
|
||||
const fallbackIcon = <Icon value="collection" color={iconColor} />;
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -110,21 +65,13 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
icon: collection?.icon,
|
||||
sharing: collection?.sharing ?? true,
|
||||
permission: collection?.permission,
|
||||
commenting: collection?.commenting ?? true,
|
||||
templateManagement:
|
||||
collection?.templateManagement ?? CollectionPermission.Admin,
|
||||
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) {
|
||||
@@ -137,12 +84,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");
|
||||
}
|
||||
@@ -153,105 +100,39 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
[setFocus, setValue, values.icon]
|
||||
);
|
||||
|
||||
const initial = values.name.charAt(0).toUpperCase();
|
||||
|
||||
const options = (
|
||||
<>
|
||||
<Controller
|
||||
control={control}
|
||||
name="templateManagement"
|
||||
render={({ field }) => (
|
||||
<>
|
||||
<InputSelect
|
||||
value={field.value}
|
||||
onChange={(value: string) => {
|
||||
field.onChange(value as CollectionPermission);
|
||||
}}
|
||||
options={templateManagementOptions}
|
||||
label={t("Manage templates")}
|
||||
/>
|
||||
<Text
|
||||
type="secondary"
|
||||
size="small"
|
||||
as="p"
|
||||
style={{ paddingTop: 4 }}
|
||||
>
|
||||
{t(
|
||||
"Choose who can create and edit templates in this collection."
|
||||
)}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
{team.sharing && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="sharing"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
id="sharing"
|
||||
label={t("Public document sharing")}
|
||||
note={t(
|
||||
"Allow documents within this collection to be shared publicly on the internet."
|
||||
)}
|
||||
checked={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{team.getPreference(TeamPreference.Commenting) && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="commenting"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
id="commenting"
|
||||
label={t("Commenting")}
|
||||
note={t("Allow commenting on documents within this collection.")}
|
||||
checked={!!field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={formHandleSubmit(handleSubmit)}>
|
||||
<Text as="p">
|
||||
<Trans>
|
||||
Collections are used to group documents and choose permissions
|
||||
</Trans>
|
||||
.
|
||||
</Text>
|
||||
<HStack>
|
||||
<Flex gap={8}>
|
||||
<Input
|
||||
type="text"
|
||||
label={t("Name")}
|
||||
placeholder={t("Name")}
|
||||
{...register("name", {
|
||||
required: true,
|
||||
maxLength: CollectionValidation.maxNameLength,
|
||||
})}
|
||||
prefix={
|
||||
<Suspense fallback={fallbackIcon}>
|
||||
<React.Suspense fallback={fallbackIcon}>
|
||||
<StyledIconPicker
|
||||
icon={values.icon}
|
||||
color={values.color ?? iconColor}
|
||||
initial={initial}
|
||||
initial={values.name[0]}
|
||||
popoverPosition="right"
|
||||
onOpen={setHasOpenedIconPicker}
|
||||
onChange={handleIconChange}
|
||||
/>
|
||||
</Suspense>
|
||||
</React.Suspense>
|
||||
}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
flex
|
||||
/>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* Following controls are available in create flow, but moved elsewhere for edit */}
|
||||
{!collection && (
|
||||
@@ -267,7 +148,7 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
) => {
|
||||
field.onChange(value === EmptySelectValue ? null : value);
|
||||
}}
|
||||
help={t(
|
||||
note={t(
|
||||
"The default access for workspace members, you can share with more users or groups later."
|
||||
)}
|
||||
/>
|
||||
@@ -275,13 +156,20 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
/>
|
||||
)}
|
||||
|
||||
{collection ? (
|
||||
options
|
||||
) : (
|
||||
<Collapsible label={t("Advanced options")}>{options}</Collapsible>
|
||||
)}
|
||||
{team.sharing &&
|
||||
(!collection ||
|
||||
FeatureFlags.isEnabled(Feature.newCollectionSharing)) && (
|
||||
<Switch
|
||||
id="sharing"
|
||||
label={t("Public document sharing")}
|
||||
note={t(
|
||||
"Allow documents within this collection to be shared publicly on the internet."
|
||||
)}
|
||||
{...register("sharing")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<HStack justify="flex-end">
|
||||
<Flex justify="flex-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={formState.isSubmitting || !formState.isValid}
|
||||
@@ -291,15 +179,15 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
? `${t("Saving")}…`
|
||||
: t("Save")
|
||||
: formState.isSubmitting
|
||||
? `${t("Creating")}…`
|
||||
: t("Create")}
|
||||
? `${t("Creating")}…`
|
||||
: t("Create")}
|
||||
</Button>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
|
||||
const StyledIconPicker = styled(IconPicker.Component)`
|
||||
const StyledIconPicker = styled(IconPicker)`
|
||||
margin-left: 4px;
|
||||
margin-right: 4px;
|
||||
`;
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { runInAction } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { useCallback } from "react";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import Collection from "~/models/Collection";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
import type { FormData } from "./CollectionForm";
|
||||
import { CollectionForm } from "./CollectionForm";
|
||||
import { CollectionForm, FormData } from "./CollectionForm";
|
||||
|
||||
type Props = {
|
||||
onSubmit: () => void;
|
||||
@@ -15,14 +14,11 @@ 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);
|
||||
// Avoid flash of loading state for the new collection, we know it's empty.
|
||||
runInAction(() => {
|
||||
collection.documents = [];
|
||||
});
|
||||
const collection = new Collection(data, collections);
|
||||
await collection.save();
|
||||
onSubmit?.();
|
||||
history.push(collection.path);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { ArchiveIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type Collection from "~/models/Collection";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { archivePath, collectionPath } from "~/utils/routeHelpers";
|
||||
import Breadcrumb from "./Breadcrumb";
|
||||
import { createInternalLinkAction } from "~/actions";
|
||||
import { ActiveCollectionSection } from "~/actions/sections";
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
};
|
||||
|
||||
export const CollectionBreadcrumb: React.FC<Props> = ({ collection }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const actions = React.useMemo(
|
||||
() => [
|
||||
createInternalLinkAction({
|
||||
name: t("Archive"),
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ArchiveIcon />,
|
||||
visible: collection.isArchived,
|
||||
to: archivePath(),
|
||||
}),
|
||||
createInternalLinkAction({
|
||||
name: collection.name,
|
||||
section: ActiveCollectionSection,
|
||||
icon: <CollectionIcon collection={collection} expanded />,
|
||||
to: collectionPath(collection),
|
||||
}),
|
||||
],
|
||||
[collection, t]
|
||||
);
|
||||
|
||||
return <Breadcrumb actions={actions} highlightFirstItem />;
|
||||
};
|
||||
@@ -1,8 +1,9 @@
|
||||
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";
|
||||
import type Collection from "~/models/Collection";
|
||||
import Collection from "~/models/Collection";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Text from "~/components/Text";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
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;
|
||||
transition: ${s("backgroundTransition")};
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: calc(25vh - 50px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 50px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
${(props) => transparentize(1, props.theme.background)} 0%,
|
||||
${s("background")} 100%
|
||||
);
|
||||
}
|
||||
|
||||
&[data-editing="true"],
|
||||
&[data-expanded="true"] {
|
||||
&:after {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-editing="true"] {
|
||||
background: ${s("secondaryBackground")};
|
||||
}
|
||||
|
||||
.block-menu-trigger,
|
||||
.heading-anchor {
|
||||
display: none !important;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(CollectionDescription);
|
||||
@@ -1,63 +0,0 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import NudeButton from "./NudeButton";
|
||||
import { hover, s } from "@shared/styles";
|
||||
|
||||
type Props = React.HTMLAttributes<HTMLButtonElement> & {
|
||||
/** The current color value in hex format. If no color is passed a radial gradient will be shown */
|
||||
color?: string;
|
||||
/** Whether the button is currently active/selected */
|
||||
active?: boolean;
|
||||
/** The size of the button in pixels */
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export const ColorButton = React.forwardRef(
|
||||
(
|
||||
{ color, active = false, size = 24, ...rest }: Props,
|
||||
ref: React.Ref<HTMLButtonElement>
|
||||
) => (
|
||||
<ColorButtonInternal
|
||||
$active={active}
|
||||
$size={size}
|
||||
{...rest}
|
||||
style={{ "--color": color, ...rest.style } as React.CSSProperties}
|
||||
ref={ref}
|
||||
>
|
||||
<Selected />
|
||||
</ColorButtonInternal>
|
||||
)
|
||||
);
|
||||
|
||||
const Selected = styled.span`
|
||||
width: 10px;
|
||||
height: 5px;
|
||||
border-left: 2px solid white;
|
||||
border-bottom: 2px solid white;
|
||||
transform: translateY(-25%) rotate(-45deg);
|
||||
`;
|
||||
|
||||
const ColorButtonInternal = styled(NudeButton)<{
|
||||
$active: boolean;
|
||||
$size: number;
|
||||
}>`
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: ${({ $size }) => $size}px;
|
||||
height: ${({ $size }) => $size}px;
|
||||
border-radius: 50%;
|
||||
background: var(
|
||||
--color,
|
||||
linear-gradient(135deg, #ff5858 0%, #fbcc34 50%, #00c6ff 100%)
|
||||
);
|
||||
|
||||
&: ${hover} {
|
||||
outline: 2px solid ${s("menuBackground")} !important;
|
||||
box-shadow: 0px 0px 3px 3px var(--color);
|
||||
}
|
||||
|
||||
& ${Selected} {
|
||||
display: ${({ $active }) => ($active ? "block" : "none")};
|
||||
}
|
||||
`;
|
||||
@@ -6,27 +6,20 @@ import { Portal } from "react-portal";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import CommandBarResults from "~/components/CommandBarResults";
|
||||
import SearchActions from "~/components/SearchActions";
|
||||
import rootActions from "~/actions/root";
|
||||
import useCommandBarActions from "~/hooks/useCommandBarActions";
|
||||
import CommandBarResults from "./CommandBarResults";
|
||||
import useRecentDocumentActions from "./useRecentDocumentActions";
|
||||
import useSettingsAction from "./useSettingsAction";
|
||||
import useTemplatesAction from "./useTemplatesAction";
|
||||
import useSettingsActions from "~/hooks/useSettingsActions";
|
||||
import useTemplateActions from "~/hooks/useTemplateActions";
|
||||
|
||||
function CommandBar() {
|
||||
const { t } = useTranslation();
|
||||
const recentDocumentActions = useRecentDocumentActions();
|
||||
const settingsAction = useSettingsAction();
|
||||
const templatesAction = useTemplatesAction();
|
||||
const settingsActions = useSettingsActions();
|
||||
const templateActions = useTemplateActions();
|
||||
const commandBarActions = React.useMemo(
|
||||
() => [
|
||||
...recentDocumentActions,
|
||||
...rootActions,
|
||||
templatesAction,
|
||||
settingsAction,
|
||||
],
|
||||
[recentDocumentActions, settingsAction, templatesAction]
|
||||
() => [...rootActions, templateActions, settingsActions],
|
||||
[settingsActions, templateActions]
|
||||
);
|
||||
|
||||
useCommandBarActions(commandBarActions);
|
||||
@@ -37,9 +30,7 @@ function CommandBar() {
|
||||
<Positioner>
|
||||
<Animator>
|
||||
<SearchActions />
|
||||
<SearchInput
|
||||
defaultPlaceholder={`${t("Type a command or search")}…`}
|
||||
/>
|
||||
<SearchInput defaultPlaceholder={t("Type a command or search")} />
|
||||
<CommandBarResults />
|
||||
</Animator>
|
||||
</Positioner>
|
||||
@@ -69,19 +60,13 @@ const Positioner = styled(KBarPositioner)`
|
||||
`;
|
||||
|
||||
const SearchInput = styled(KBarSearch)`
|
||||
position: relative;
|
||||
padding: 16px 12px;
|
||||
margin: 0 8px;
|
||||
width: calc(100% - 16px);
|
||||
padding: 16px 20px;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
border: none;
|
||||
background: ${s("menuBackground")};
|
||||
color: ${s("text")};
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid ${s("inputBorder")};
|
||||
}
|
||||
|
||||
&:disabled,
|
||||
&::placeholder {
|
||||
color: ${s("placeholder")};
|
||||
@@ -1,3 +0,0 @@
|
||||
import CommandBar from "./CommandBar";
|
||||
|
||||
export default CommandBar;
|
||||
@@ -1,38 +0,0 @@
|
||||
import { DocumentIcon } from "outline-icons";
|
||||
import { useMemo } from "react";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { createInternalLinkAction } from "~/actions";
|
||||
import { RecentSection } from "~/actions/sections";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
|
||||
const useRecentDocumentActions = (count = 6) => {
|
||||
const { documents, ui } = useStores();
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
documents.recentlyViewed
|
||||
.filter((document) => document.id !== ui.activeDocumentId)
|
||||
.slice(0, count)
|
||||
.map((item) =>
|
||||
createInternalLinkAction({
|
||||
name: item.titleWithDefault,
|
||||
analyticsName: "Recently viewed document",
|
||||
section: RecentSection,
|
||||
icon: item.icon ? (
|
||||
<Icon
|
||||
value={item.icon}
|
||||
initial={item.initial}
|
||||
color={item.color ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<DocumentIcon />
|
||||
),
|
||||
to: documentPath(item),
|
||||
})
|
||||
),
|
||||
[count, ui.activeDocumentId, documents.recentlyViewed]
|
||||
);
|
||||
};
|
||||
|
||||
export default useRecentDocumentActions;
|
||||
@@ -1,97 +0,0 @@
|
||||
import { NewDocumentIcon, ShapesIcon } from "outline-icons";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { createActionWithChildren, createInternalLinkAction } from "~/actions";
|
||||
import {
|
||||
ActiveCollectionSection,
|
||||
DocumentSection,
|
||||
TeamSection,
|
||||
} from "~/actions/sections";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { newDocumentPath } from "~/utils/routeHelpers";
|
||||
|
||||
const useTemplatesAction = () => {
|
||||
const { templates } = useStores();
|
||||
|
||||
useEffect(() => {
|
||||
void templates.fetchAll();
|
||||
}, [templates]);
|
||||
|
||||
const actions = useMemo(
|
||||
() =>
|
||||
templates.alphabetical.map((template) =>
|
||||
createInternalLinkAction({
|
||||
name: template.titleWithDefault,
|
||||
analyticsName: "New document",
|
||||
section: template.isWorkspaceTemplate
|
||||
? TeamSection
|
||||
: ActiveCollectionSection,
|
||||
icon: template.icon ? (
|
||||
<Icon
|
||||
value={template.icon}
|
||||
initial={template.initial}
|
||||
color={template.color ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<NewDocumentIcon />
|
||||
),
|
||||
keywords: "create",
|
||||
visible: ({ currentTeamId, activeCollectionId, stores }) => {
|
||||
if (activeCollectionId) {
|
||||
return (
|
||||
stores.policies.abilities(activeCollectionId).createDocument &&
|
||||
(template.collectionId === activeCollectionId ||
|
||||
template.isWorkspaceTemplate)
|
||||
);
|
||||
}
|
||||
return (
|
||||
!!currentTeamId &&
|
||||
stores.policies.abilities(currentTeamId).createDocument &&
|
||||
template.isWorkspaceTemplate
|
||||
);
|
||||
},
|
||||
to: ({ activeCollectionId, sidebarContext }) => {
|
||||
const [pathname, search] = newDocumentPath(
|
||||
template.collectionId ?? activeCollectionId,
|
||||
{
|
||||
templateId: template.id,
|
||||
}
|
||||
).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
})
|
||||
),
|
||||
[templates.alphabetical]
|
||||
);
|
||||
|
||||
const newFromTemplate = useMemo(
|
||||
() =>
|
||||
createActionWithChildren({
|
||||
id: "templates",
|
||||
name: ({ t }) => t("New from template"),
|
||||
placeholder: ({ t }) => t("Choose a template"),
|
||||
section: DocumentSection,
|
||||
icon: <ShapesIcon />,
|
||||
visible: ({ currentTeamId, activeCollectionId, stores }) => {
|
||||
if (activeCollectionId) {
|
||||
return stores.policies.abilities(activeCollectionId).createDocument;
|
||||
}
|
||||
return (
|
||||
!!currentTeamId &&
|
||||
stores.policies.abilities(currentTeamId).createDocument
|
||||
);
|
||||
},
|
||||
children: actions,
|
||||
}),
|
||||
[actions]
|
||||
);
|
||||
|
||||
return newFromTemplate;
|
||||
};
|
||||
|
||||
export default useTemplatesAction;
|
||||
@@ -1,13 +1,11 @@
|
||||
import type { ActionImpl } from "kbar";
|
||||
import { ActionImpl } from "kbar";
|
||||
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";
|
||||
import { HStack } from "../primitives/HStack";
|
||||
import Text from "./Text";
|
||||
|
||||
type Props = {
|
||||
action: ActionImpl;
|
||||
@@ -36,7 +34,7 @@ function CommandBarItem(
|
||||
|
||||
return (
|
||||
<Item active={active} ref={ref}>
|
||||
<Content>
|
||||
<Content align="center" gap={8}>
|
||||
<Icon>
|
||||
{action.icon ? (
|
||||
// @ts-expect-error no icon on ActionImpl
|
||||
@@ -71,8 +69,8 @@ function CommandBarItem(
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{sc.split("+").map((key) => (
|
||||
<Key key={key}>{normalizeKeyDisplay(key)}</Key>
|
||||
{sc.split("+").map((s) => (
|
||||
<Key key={s}>{s}</Key>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
@@ -101,7 +99,7 @@ const Ancestor = styled.span`
|
||||
color: ${s("textSecondary")};
|
||||
`;
|
||||
|
||||
const Content = styled(HStack)`
|
||||
const Content = styled(Flex)`
|
||||
${ellipsis()}
|
||||
flex-shrink: 1;
|
||||
`;
|
||||
+8
-12
@@ -1,15 +1,12 @@
|
||||
import { useMatches, KBarResults } from "kbar";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Text from "~/components/Text";
|
||||
import CommandBarItem from "./CommandBarItem";
|
||||
import { s } from "@shared/styles";
|
||||
import CommandBarItem from "~/components/CommandBarItem";
|
||||
|
||||
export default function CommandBarResults() {
|
||||
const { results, rootActionId } = useMatches();
|
||||
|
||||
if (results.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<KBarResults
|
||||
@@ -17,9 +14,7 @@ export default function CommandBarResults() {
|
||||
maxHeight={400}
|
||||
onRender={({ item, active }) =>
|
||||
typeof item === "string" ? (
|
||||
<Header type="tertiary" size="xsmall" ellipsis>
|
||||
{item}
|
||||
</Header>
|
||||
<Header>{item}</Header>
|
||||
) : (
|
||||
<CommandBarItem
|
||||
action={item}
|
||||
@@ -40,10 +35,11 @@ const Container = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const Header = styled(Text).attrs({ as: "h3" })`
|
||||
letter-spacing: 0.03em;
|
||||
const Header = styled.h3`
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.04em;
|
||||
margin: 0;
|
||||
padding: 16px 0 4px 20px;
|
||||
color: ${s("textTertiary")};
|
||||
height: 36px;
|
||||
cursor: default;
|
||||
`;
|
||||
@@ -1,7 +1,8 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import type Comment from "~/models/Comment";
|
||||
import Comment from "~/models/Comment";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import type { NavigationNode } from "@shared/types";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import type Collection from "~/models/Collection";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { AuthorizationError } from "~/utils/errors";
|
||||
|
||||
type Props = {
|
||||
/** The navigation node to move, must represent a document. */
|
||||
item: NavigationNode;
|
||||
/** The collection to move the document to. */
|
||||
collection: Collection;
|
||||
/** The parent document to move the document under. */
|
||||
parentDocumentId?: string | null;
|
||||
/** The index to move the document to. */
|
||||
index?: number | null;
|
||||
};
|
||||
|
||||
function ConfirmMoveDialog({ collection, item, ...rest }: Props) {
|
||||
const { documents, dialogs, collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const prevCollection = collections.get(item.collectionId!);
|
||||
const accessMapping: Record<Partial<CollectionPermission> | "null", string> =
|
||||
{
|
||||
[CollectionPermission.Admin]: t("manage access"),
|
||||
[CollectionPermission.ReadWrite]: t("view and edit access"),
|
||||
[CollectionPermission.Read]: t("view only access"),
|
||||
null: t("no access"),
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await documents.move({
|
||||
documentId: item.id,
|
||||
collectionId: collection.id,
|
||||
...rest,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof AuthorizationError) {
|
||||
toast.error(
|
||||
t(
|
||||
"You do not have permission to move {{ documentName }} to the {{ collectionName }} collection",
|
||||
{
|
||||
documentName: item.title,
|
||||
collectionName: collection.name,
|
||||
}
|
||||
)
|
||||
);
|
||||
} else {
|
||||
toast.error(err.message);
|
||||
}
|
||||
} finally {
|
||||
dialogs.closeAllModals();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
submitText={t("Move document")}
|
||||
savingText={`${t("Moving")}…`}
|
||||
>
|
||||
<Trans
|
||||
defaults="Moving the document <em>{{ title }}</em> to the {{ newCollectionName }} collection will change permission for all workspace members from <em>{{ prevPermission }}</em> to <em>{{ newPermission }}</em>."
|
||||
values={{
|
||||
title: item.title,
|
||||
prevCollectionName: prevCollection?.name,
|
||||
newCollectionName: collection.name,
|
||||
prevPermission: accessMapping[prevCollection?.permission || "null"],
|
||||
newPermission: accessMapping[collection.permission || "null"],
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(ConfirmMoveDialog);
|
||||
@@ -8,8 +8,8 @@ import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
/** Callback when the dialog is submitted. Return false to prevent closing. */
|
||||
onSubmit: () => Promise<void | boolean> | void;
|
||||
/** Callback when the dialog is submitted */
|
||||
onSubmit: () => Promise<void> | void;
|
||||
/** Text to display on the submit button */
|
||||
submitText?: string;
|
||||
/** Text to display while the form is saving */
|
||||
@@ -38,10 +38,7 @@ const ConfirmationDialog: React.FC<Props> = ({
|
||||
ev.preventDefault();
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const res = await onSubmit();
|
||||
if (res === false) {
|
||||
return;
|
||||
}
|
||||
await onSubmit();
|
||||
dialogs.closeAllModals();
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
@@ -64,7 +61,7 @@ const ConfirmationDialog: React.FC<Props> = ({
|
||||
danger={danger}
|
||||
autoFocus
|
||||
>
|
||||
{isSaving && savingText ? savingText : (submitText ?? t("Confirm"))}
|
||||
{isSaving && savingText ? savingText : submitText ?? t("Confirm")}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
+18
-32
@@ -1,15 +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 styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import {
|
||||
AuthenticationFailed,
|
||||
AuthorizationFailed,
|
||||
DocumentTooLarge,
|
||||
EditorUpdateError,
|
||||
TooManyConnections,
|
||||
} from "@shared/collaboration/CloseEvents";
|
||||
import Fade from "~/components/Fade";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
@@ -17,35 +11,32 @@ import useStores from "~/hooks/useStores";
|
||||
|
||||
function ConnectionStatus() {
|
||||
const { ui } = useStores();
|
||||
const theme = useTheme();
|
||||
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"),
|
||||
},
|
||||
[EditorUpdateError.code]: {
|
||||
title: t("New version available"),
|
||||
body: t("Please reload the page to update to the latest version"),
|
||||
},
|
||||
};
|
||||
|
||||
const message = ui.multiplayerErrorCode
|
||||
? codeToMessage[ui.multiplayerErrorCode as keyof typeof codeToMessage]
|
||||
? codeToMessage[ui.multiplayerErrorCode]
|
||||
: undefined;
|
||||
|
||||
return ui.multiplayerStatus === "connecting" ||
|
||||
@@ -68,29 +59,24 @@ function ConnectionStatus() {
|
||||
}
|
||||
placement="bottom"
|
||||
>
|
||||
<Fade>
|
||||
<Button width="auto">
|
||||
{message?.title ?? t("Offline")}
|
||||
<DisconnectedIcon />
|
||||
</Button>
|
||||
</Fade>
|
||||
<Button>
|
||||
<Fade>
|
||||
<DisconnectedIcon color={theme.sidebarText} />
|
||||
</Fade>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null;
|
||||
}
|
||||
|
||||
const Button = styled(NudeButton)`
|
||||
display: none;
|
||||
background: ${(props) => props.theme.backgroundTertiary};
|
||||
color: ${(props) => props.theme.textSecondary};
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
margin: 24px;
|
||||
transform: translateX(-32px);
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
display: block;
|
||||
`};
|
||||
|
||||
@media print {
|
||||
@@ -31,7 +31,7 @@ export type RefHandle = {
|
||||
* Defines a content editable component with the same interface as a native
|
||||
* HTMLInputElement (or, as close as we can get).
|
||||
*/
|
||||
const ContentEditable = React.forwardRef(function ContentEditable_(
|
||||
const ContentEditable = React.forwardRef(function _ContentEditable(
|
||||
{
|
||||
disabled,
|
||||
onChange,
|
||||
@@ -128,14 +128,7 @@ const ContentEditable = React.forwardRef(function ContentEditable_(
|
||||
|
||||
React.useEffect(() => {
|
||||
if (contentRef.current && value !== contentRef.current.textContent) {
|
||||
if (document.activeElement === contentRef.current) {
|
||||
// Don't reset content while the user is actively editing. Update
|
||||
// lastValue so that the next input or blur event will push the
|
||||
// current DOM text back to the model via onChange.
|
||||
lastValue.current = value;
|
||||
} else {
|
||||
setInnerValue(value);
|
||||
}
|
||||
setInnerValue(value);
|
||||
}
|
||||
}, [value, contentRef]);
|
||||
|
||||
@@ -150,14 +143,13 @@ const ContentEditable = React.forwardRef(function ContentEditable_(
|
||||
},
|
||||
[]
|
||||
);
|
||||
const contentEditable = !disabled && !readOnly;
|
||||
|
||||
return (
|
||||
<div className={className} dir={dir} onClick={onClick} tabIndex={-1}>
|
||||
{children}
|
||||
<Content
|
||||
ref={contentRef}
|
||||
contentEditable={contentEditable}
|
||||
contentEditable={!disabled && !readOnly}
|
||||
onInput={wrappedEvent(onInput)}
|
||||
onFocus={wrappedEvent(onFocus)}
|
||||
onBlur={wrappedEvent(onBlur)}
|
||||
@@ -165,7 +157,7 @@ const ContentEditable = React.forwardRef(function ContentEditable_(
|
||||
onPaste={handlePaste}
|
||||
data-placeholder={placeholder}
|
||||
suppressContentEditableWarning
|
||||
role={contentEditable ? "textbox" : undefined}
|
||||
role="textbox"
|
||||
{...rest}
|
||||
>
|
||||
{innerValue}
|
||||
@@ -190,6 +182,7 @@ function placeCaret(element: HTMLElement, atStart: boolean) {
|
||||
|
||||
const Content = styled.span`
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
color: ${s("text")};
|
||||
-webkit-text-fill-color: ${s("text")};
|
||||
outline: none;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
const Header = styled.h3`
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: ${s("sidebarText")};
|
||||
letter-spacing: 0.04em;
|
||||
margin: 1em 12px 0.5em;
|
||||
`;
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1,13 @@
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
const MenuIconWrapper = styled.span`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 6px;
|
||||
margin-left: -4px;
|
||||
color: ${s("textSecondary")};
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export default MenuIconWrapper;
|
||||
@@ -0,0 +1,199 @@
|
||||
import { LocationDescriptor } from "history";
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import { ellipsis, transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import { MenuItem as BaseMenuItem } from "reakit/Menu";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Text from "../Text";
|
||||
import MenuIconWrapper from "./MenuIconWrapper";
|
||||
|
||||
type Props = {
|
||||
id?: string;
|
||||
onClick?: (event: React.MouseEvent) => void | Promise<void>;
|
||||
active?: boolean;
|
||||
selected?: boolean;
|
||||
disabled?: boolean;
|
||||
dangerous?: boolean;
|
||||
to?: LocationDescriptor;
|
||||
href?: string;
|
||||
target?: "_blank";
|
||||
as?: string | React.ComponentType<any>;
|
||||
hide?: () => void;
|
||||
level?: number;
|
||||
icon?: React.ReactElement | null;
|
||||
children?: React.ReactNode;
|
||||
ref?: React.LegacyRef<HTMLButtonElement> | undefined;
|
||||
};
|
||||
|
||||
const MenuItem = (
|
||||
{
|
||||
onClick,
|
||||
children,
|
||||
active,
|
||||
selected,
|
||||
disabled,
|
||||
as,
|
||||
hide,
|
||||
icon,
|
||||
...rest
|
||||
}: Props,
|
||||
ref: React.Ref<HTMLAnchorElement>
|
||||
) => {
|
||||
const content = React.useCallback(
|
||||
(props) => {
|
||||
// Preventing default mousedown otherwise menu items do not work in Firefox,
|
||||
// which triggers the hideOnClickOutside handler first via mousedown – hiding
|
||||
// and un-rendering the menu contents.
|
||||
const preventDefault = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
const handleClick = async (ev: React.MouseEvent) => {
|
||||
hide?.();
|
||||
|
||||
if (onClick) {
|
||||
preventDefault(ev);
|
||||
await onClick(ev);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<MenuAnchor
|
||||
{...props}
|
||||
$active={active}
|
||||
as={onClick ? "button" : as}
|
||||
onClick={handleClick}
|
||||
onPointerDown={preventDefault}
|
||||
onMouseDown={preventDefault}
|
||||
ref={mergeRefs([
|
||||
ref,
|
||||
props.ref as React.RefObject<HTMLAnchorElement>,
|
||||
])}
|
||||
>
|
||||
{selected !== undefined && (
|
||||
<MenuIconWrapper aria-hidden>
|
||||
{selected ? <CheckmarkIcon /> : <Spacer />}
|
||||
</MenuIconWrapper>
|
||||
)}
|
||||
{icon && <MenuIconWrapper aria-hidden>{icon}</MenuIconWrapper>}
|
||||
<Title>{children}</Title>
|
||||
</MenuAnchor>
|
||||
);
|
||||
},
|
||||
[active, as, hide, icon, onClick, ref, children, selected]
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseMenuItem
|
||||
onClick={disabled ? undefined : onClick}
|
||||
disabled={disabled}
|
||||
hide={hide}
|
||||
{...rest}
|
||||
>
|
||||
{content}
|
||||
</BaseMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
const Spacer = styled.svg`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const Title = styled.div`
|
||||
${ellipsis()}
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
type MenuAnchorProps = {
|
||||
level?: number;
|
||||
disabled?: boolean;
|
||||
dangerous?: boolean;
|
||||
disclosure?: boolean;
|
||||
$active?: boolean;
|
||||
};
|
||||
|
||||
export const MenuAnchorCSS = css<MenuAnchorProps>`
|
||||
display: flex;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
padding-left: ${(props) => 12 + (props.level || 0) * 10}px;
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
background: none;
|
||||
color: ${(props) =>
|
||||
props.disabled ? props.theme.textTertiary : props.theme.textSecondary};
|
||||
justify-content: left;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
opacity: ${(props) => (props.disabled ? ".5" : 1)};
|
||||
}
|
||||
|
||||
${(props) => props.disabled && "pointer-events: none;"}
|
||||
|
||||
${(props) =>
|
||||
props.$active === undefined &&
|
||||
!props.disabled &&
|
||||
`
|
||||
@media (hover: hover) {
|
||||
&:hover,
|
||||
&:focus,
|
||||
&.focus-visible {
|
||||
color: ${props.theme.accentText};
|
||||
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
|
||||
box-shadow: none;
|
||||
cursor: var(--pointer);
|
||||
|
||||
svg {
|
||||
color: ${props.theme.accentText};
|
||||
fill: ${props.theme.accentText};
|
||||
}
|
||||
|
||||
${Text} {
|
||||
color: ${transparentize(0.5, props.theme.accentText)};
|
||||
}
|
||||
}
|
||||
}
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.$active &&
|
||||
!props.disabled &&
|
||||
`
|
||||
color: ${props.theme.accentText};
|
||||
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
|
||||
box-shadow: none;
|
||||
cursor: var(--pointer);
|
||||
|
||||
svg {
|
||||
fill: ${props.theme.accentText};
|
||||
}
|
||||
`}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: 4px 12px;
|
||||
padding-right: ${(props: MenuAnchorProps) =>
|
||||
props.disclosure ? 32 : 12}px;
|
||||
font-size: 14px;
|
||||
`}
|
||||
`;
|
||||
|
||||
export const MenuAnchor = styled.a`
|
||||
${MenuAnchorCSS}
|
||||
`;
|
||||
|
||||
export default React.forwardRef<HTMLAnchorElement, Props>(MenuItem);
|
||||
@@ -1,8 +1,5 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useMousePosition } from "~/hooks/useMousePosition";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Positions = {
|
||||
/** Sub-menu x */
|
||||
@@ -24,7 +21,7 @@ type Positions = {
|
||||
* allow moving cursor to lower parts of sub-menu without the sub-menu
|
||||
* disappearing.
|
||||
*/
|
||||
export const MouseSafeArea = observer(function MouseSafeArea_(props: {
|
||||
export default function MouseSafeArea(props: {
|
||||
parentRef: React.RefObject<HTMLElement | null>;
|
||||
}) {
|
||||
const {
|
||||
@@ -33,32 +30,15 @@ export const MouseSafeArea = observer(function MouseSafeArea_(props: {
|
||||
height: h = 0,
|
||||
width: w = 0,
|
||||
} = props.parentRef.current?.getBoundingClientRect() || {};
|
||||
const { ui } = useStores();
|
||||
const [mouseX, mouseY] = useMousePosition();
|
||||
const [isVisible, setIsVisible] = React.useState(true);
|
||||
const positions = { x, y, h, w, mouseX, mouseY };
|
||||
const distance = Math.abs(mouseX - x);
|
||||
const prevDistance = usePrevious(distance) ?? distance;
|
||||
|
||||
// Hide the safe area if the mouse is moving _away_ from the menu
|
||||
React.useEffect(() => {
|
||||
if (distance > prevDistance) {
|
||||
setIsVisible(false);
|
||||
} else if (distance < prevDistance) {
|
||||
setIsVisible(true);
|
||||
}
|
||||
}, [distance, prevDistance]);
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
backgroundColor: ui.debugSafeArea ? "rgba(255,0,0,0.2)" : undefined,
|
||||
// backgroundColor: "rgba(255,0,0,0.1)", // Uncomment to debug
|
||||
right: getRight(positions),
|
||||
left: getLeft(positions),
|
||||
height: h,
|
||||
@@ -67,26 +47,24 @@ export const MouseSafeArea = observer(function MouseSafeArea_(props: {
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const buffer = 10;
|
||||
}
|
||||
|
||||
const getLeft = ({ x, mouseX }: Positions) =>
|
||||
mouseX > x ? undefined : -Math.max(x - mouseX + buffer, buffer) + "px";
|
||||
mouseX > x ? undefined : -Math.max(x - mouseX, 10) + "px";
|
||||
|
||||
const getRight = ({ x, w, mouseX }: Positions) =>
|
||||
mouseX > x ? -Math.max(mouseX - (x + w) + buffer, buffer) + "px" : undefined;
|
||||
mouseX > x ? -Math.max(mouseX - (x + w), 10) + "px" : undefined;
|
||||
|
||||
const getWidth = ({ x, w, mouseX }: Positions) =>
|
||||
mouseX > x
|
||||
? Math.max(mouseX - (x + w - buffer), buffer) + "px"
|
||||
: Math.max(x - mouseX + buffer, buffer) + "px";
|
||||
? Math.max(mouseX - (x + w), 10) + "px"
|
||||
: Math.max(x - mouseX, 10) + "px";
|
||||
|
||||
const getClipPath = ({ x, y, h, mouseX, mouseY }: Positions) =>
|
||||
mouseX > x
|
||||
? `polygon(0% 0%, 0% 100%, 100% ${
|
||||
? `polygon(0% 0%, 0% 100%, 100% ${(100 * (mouseY - y)) / h - 10}%, 100% ${
|
||||
(100 * (mouseY - y)) / h + 5
|
||||
}%, 100% ${(100 * (mouseY - y)) / h - buffer}%)`
|
||||
: `polygon(100% 0%, 0% ${(100 * (mouseY - y)) / h - buffer}%, 0% ${
|
||||
}%)`
|
||||
: `polygon(100% 0%, 0% ${(100 * (mouseY - y)) / h - 10}%, 0% ${
|
||||
(100 * (mouseY - y)) / h + 5
|
||||
}%, 100% 100%)`;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user