Compare commits

..

4 Commits

Author SHA1 Message Date
Translate-O-Tron f5e30d90ca fix: New Chinese Simplified translations from Crowdin [ci skip] 2021-02-18 23:48:41 -08:00
Translate-O-Tron 481605d017 New Crowdin updates (#1876)
* fix: New Russian translations from Crowdin [ci skip]

* fix: New Russian translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]
2021-02-18 18:48:53 -08:00
Translate-O-Tron 985ba9be29 fix: New Chinese Simplified translations from Crowdin [ci skip] (#1869) 2021-02-07 10:32:28 -08:00
Translate-O-Tron 8412efcd0c New Crowdin updates (#1864)
* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]
2021-02-04 18:19:29 -08:00
3048 changed files with 104408 additions and 329212 deletions
+16 -46
View File
@@ -1,59 +1,29 @@
{
"presets": [
"@babel/preset-react",
"@babel/preset-flow",
[
"@babel/preset-react",
"@babel/preset-env",
{
"runtime": "automatic"
"corejs": {
"version": "2",
"proposals": true
},
"useBuiltIns": "usage"
}
],
"@babel/preset-env",
"@babel/preset-typescript"
]
],
"plugins": [
"babel-plugin-transform-typescript-metadata",
"lodash",
"styled-components",
[
"@babel/plugin-proposal-decorators",
{
"legacy": true
}
],
"@babel/plugin-transform-class-properties",
[
"transform-inline-environment-variables",
{
"include": ["SOURCE_COMMIT", "SOURCE_VERSION"]
}
],
"tsconfig-paths-module-resolver"
],
"env": {
"production": {
"plugins": [
[
"styled-components",
{
"displayName": false
}
]
],
"ignore": ["**/__mocks__", "**/*.test.ts"]
},
"development": {
"ignore": ["**/__mocks__", "**/*.test.ts"]
},
"test": {
"presets": [
[
"@babel/preset-env",
{
"corejs": {
"version": "3",
"proposals": true
},
"useBuiltIns": "usage"
}
]
]
}
}
}
"@babel/plugin-transform-destructuring",
"@babel/plugin-transform-regenerator",
"transform-class-properties"
]
}
+43
View File
@@ -0,0 +1,43 @@
version: 2
jobs:
build:
working_directory: ~/outline
docker:
- image: circleci/node:14
- image: circleci/redis:latest
- image: circleci/postgres:9.6.5-alpine-ram
environment:
NODE_ENV: test
SECRET_KEY: F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
DATABASE_URL_TEST: postgres://root@localhost:5432/circle_test
DATABASE_URL: postgres://root@localhost:5432/circle_test
URL: http://localhost:3000
SMTP_FROM_EMAIL: hello@example.com
AWS_S3_UPLOAD_BUCKET_URL: https://s3.amazonaws.com
AWS_S3_UPLOAD_BUCKET_NAME: outline-circle
steps:
- checkout
- restore_cache:
key: dependency-cache-{{ checksum "package.json" }}
- run:
name: install-deps
command: yarn install --pure-lockfile
- save_cache:
key: dependency-cache-{{ checksum "package.json" }}
paths:
- ./node_modules
- run:
name: migrate
command: ./node_modules/.bin/sequelize db:migrate --url $DATABASE_URL_TEST
- run:
name: lint
command: yarn lint
- run:
name: flow
command: yarn flow check --max-workers 4
- run:
name: test
command: yarn test
- run:
name: build-webpack
command: yarn build:webpack
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
curl --user ${CIRCLE_TOKEN}: \
--request POST \
--form revision=<ENTER COMMIT SHA HERE>\
--form config=@config.yml \
--form notify=false \
https://circleci.com/api/v1.1/project/github/outline/outline/tree/master
+5 -11
View File
@@ -1,3 +1,4 @@
__mocks__
.git
.vscode
.github
@@ -5,21 +6,14 @@
.DS_Store
.env*
.eslint*
.oxlintrc*
.flowconfig
.log
*.md
Makefile
Procfile
app.json
crowdin.yml
lint-staged.config.mjs
build
docker-compose.yml
fakes3
flow-typed
node_modules
.yarn
**/*.test.ts
**/*.test.tsx
**/*.test.js
**/*.test.jsx
**/__tests__
**/__mocks__
setupJest.js
-13
View File
@@ -1,13 +0,0 @@
URL=https://local.outline.dev:3000
DATABASE_URL=postgres://user:pass@127.0.0.1:5432/outline
REDIS_URL=redis://127.0.0.1:6379
SMTP_FROM_EMAIL=hello@example.com
# Enable unsafe-inline in script-src CSP directive
# Setting it to true allows React dev tools add-on in Firefox to successfully detect the project
DEVELOPMENT_UNSAFE_INLINE_CSP=true
# Increase the log level to debug for development
LOG_LEVEL=debug
+54 -253
View File
@@ -1,275 +1,76 @@
NODE_ENV=production
# Copy this file to .env, remove this comment and change the keys. For development
# with docker this should mostly work out of the box other than setting the Slack
# keys (for auth) and the SECRET_KEY.
#
# Please use `openssl rand -hex 32` to create SECRET_KEY
SECRET_KEY=generate_a_new_key
UTILS_SECRET=generate_a_new_key
# This URL should point to the fully qualified, publicly accessible, URL. If using a
# proxy this will be the proxy's URL.
URL=
DATABASE_URL=postgres://user:pass@localhost:5532/outline
DATABASE_URL_TEST=postgres://user:pass@localhost:5532/outline-test
REDIS_URL=redis://localhost:6479
# The port to expose the Outline server on, this should match what is configured
# in your docker-compose.yml
# Must point to the publicly accessible URL for the installation
URL=http://localhost:3000
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
# Optional. If using a Cloudfront distribution or similar 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
FILE_STORAGE=local
# If "local" is configured for FILE_STORAGE above, then this sets the parent directory under
# which all attachments/images are stored. Make sure that the process has permissions to
# create this path and also to write files to it.
FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
# Maximum allowed size for the uploaded attachment.
FILE_STORAGE_UPLOAD_MAX_SIZE=262144000
# Override the maximum size of document imports, generally this should be lower
# than the document attachment maximum size.
FILE_STORAGE_IMPORT_MAX_SIZE=
# Override the maximum size of workspace imports, these can be especially large
# and the files are temporary being automatically deleted after a period of time.
FILE_STORAGE_WORKSPACE_IMPORT_MAX_SIZE=
# To support uploading of images for avatars and document attachments in a distributed
# architecture, an s3-compatible storage can be configured if FILE_STORAGE=s3 above.
AWS_ACCESS_KEY_ID=get_a_key_from_aws
AWS_SECRET_ACCESS_KEY=get_the_secret_of_above_key
AWS_REGION=xx-xxxx-x
AWS_S3_ACCELERATE_URL=
AWS_S3_UPLOAD_BUCKET_URL=http://s3:4569
AWS_S3_UPLOAD_BUCKET_NAME=bucket_name_here
AWS_S3_FORCE_PATH_STYLE=true
AWS_S3_ACL=private
# ––––––––––––––––––––––––––––––––––––––
# –––––––––––––––– SSL –––––––––––––––
# ––––––––––––––––––––––––––––––––––––––
# Base64 encoded private key and certificate for HTTPS termination. This is one
# of three ways to configure SSL and can be left empty.
# DOCS: https://docs.getoutline.com/s/hosting/doc/ssl-pzk7WO8d1n
SSL_KEY=
SSL_CERT=
# Auto-redirect to https in production. The default is true but you may set to
# false if you can be sure that SSL is terminated at an external loadbalancer.
# enforce (auto redirect to) https in production, (optional) default is true.
# set to false if your SSL is terminated at a loadbalancer, for example
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=
ENABLE_UPDATES=true
DEBUG=cache,presenters,events,emails,mailer,utils,multiplayer,server,services
# Third party signin credentials (at least one is required)
SLACK_KEY=get_a_key_from_slack
SLACK_SECRET=get_the_secret_of_above_key
# ––––––––––––––––––––––––––––––––––––––
# –––––––––– AUTHENTICATION ––––––––––
# ––––––––––––––––––––––––––––––––––––––
# Third party signin credentials, at least ONE OF EITHER Google, Slack,
# Discord, or Microsoft is required for a working installation or you'll
# have no sign-in options.
# Slack sign-in provider
# DOCS: https://docs.getoutline.com/s/hosting/doc/slack-sgMujR8J9J
SLACK_CLIENT_ID=get_a_key_from_slack
SLACK_CLIENT_SECRET=get_the_secret_of_above_key
# 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://<your Outline 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
AZURE_CLIENT_ID=
AZURE_CLIENT_SECRET=
AZURE_RESOURCE_APP_ID=
# Comma separated list of domains to be allowed (optional)
# If not set, all Google apps domains are allowed by default
GOOGLE_ALLOWED_DOMAINS=
# 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=
# Third party credentials (optional)
SLACK_VERIFICATION_TOKEN=PLxk6OlXXXXXVj3YYYY
SLACK_APP_ID=A0XXXXXXX
SLACK_MESSAGE_ACTIONS=true
GOOGLE_ANALYTICS_ID=
SENTRY_DSN=
# Generic OIDC provider
# DOCS: https://docs.getoutline.com/s/hosting/doc/oidc-8CPBm6uC0I
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_AUTH_URI=
OIDC_TOKEN_URI=
OIDC_USERINFO_URI=
OIDC_LOGOUT_URI=
# AWS credentials (optional in development)
AWS_ACCESS_KEY_ID=get_a_key_from_aws
AWS_SECRET_ACCESS_KEY=get_the_secret_of_above_key
AWS_REGION=xx-xxxx-x
AWS_S3_UPLOAD_BUCKET_URL=http://s3:4569
AWS_S3_UPLOAD_BUCKET_NAME=bucket_name_here
AWS_S3_UPLOAD_MAX_SIZE=26214400
AWS_S3_FORCE_PATH_STYLE=true
# uploaded s3 objects permission level, default is private
# set to "public-read" to allow public access
AWS_S3_ACL=private
# Specify which claims to derive user information from
# Supports any valid JSON path with the JWT payload
OIDC_USERNAME_CLAIM=preferred_username
# Display name for OIDC authentication
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=
# Emails configuration (optional)
SMTP_HOST=
SMTP_PORT=
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM_EMAIL=
SMTP_REPLY_EMAIL=
# Custom logo that displays on the authentication screen, scaled to height: 60px
# TEAM_LOGO=https://example.com/images/logo.png
# ––––––––––––––––––––––––––––––––––––––
# –––––––––– 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
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=
# Linear integration allows previewing issue links as rich mentions
LINEAR_CLIENT_ID=
LINEAR_CLIENT_SECRET=
# For a complete Slack integration with search and posting to channels the
# following configs are also needed in addition to Slack authentication:
# DOCS: https://docs.getoutline.com/s/hosting/doc/slack-G2mc8DOJHk
SLACK_VERIFICATION_TOKEN=your_token
SLACK_APP_ID=A0XXXXXXX
SLACK_MESSAGE_ACTIONS=true
# Figma integration allows previewing design files as rich mentions
FIGMA_CLIENT_ID=
FIGMA_CLIENT_SECRET=
# For Dropbox integration, follow these instructions to get the key https://www.dropbox.com/developers/embedder#setup
# and do not forget to whitelist your domain name in the app settings
DROPBOX_APP_KEY=
# Optionally enable Sentry (sentry.io) to track errors and performance,
# DOCS: https://docs.getoutline.com/s/hosting/doc/sentry-jxcFttcDl5
SENTRY_DSN=
SENTRY_TUNNEL=
# Enable importing pages from a Notion workspace
# DOCS: https://docs.getoutline.com/s/hosting/doc/notion-2v6g7WY3l3
NOTION_CLIENT_ID=
NOTION_CLIENT_SECRET=
# The Iframely integration allows previews of third-party content within Outline.
# For example, hovering over an external link will show a preview.
# DOCS: https://docs.getoutline.com/s/hosting/doc/iframely-HwLF1EZ9mo
IFRAMELY_URL=
IFRAMELY_API_KEY=
# ––––––––––––––––––––––––––––––––––––––
# ––––––––––––– DEBUGGING ––––––––––––
# ––––––––––––––––––––––––––––––––––––––
# Have the installation check for updates by sending anonymized statistics to
# the maintainers
ENABLE_UPDATES=true
# Debugging categories to enable you can remove the default "http" value if
# your proxy already logs incoming http requests and this ends up being duplicative
DEBUG=http
# Configure lowest severity level for server logs. Should be one of
# error, warn, info, http, verbose, debug, or silly
LOG_LEVEL=info
# See translate.getoutline.com for a list of available language codes and their
# percentage translated.
DEFAULT_LANGUAGE=en_US
-43
View File
@@ -1,43 +0,0 @@
NODE_ENV=test
DATABASE_URL=postgres://user:pass@127.0.0.1:5432/outline-test
SECRET_KEY=F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
SMTP_HOST=smtp.example.com
SMTP_USERNAME=test
SMTP_FROM_EMAIL=hello@example.com
SMTP_REPLY_EMAIL=hello@example.com
GOOGLE_CLIENT_ID=123
GOOGLE_CLIENT_SECRET=123
SLACK_CLIENT_ID=123
SLACK_CLIENT_SECRET=123
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
OIDC_TOKEN_URI=http://localhost/token
OIDC_USERINFO_URI=http://localhost/userinfo
IFRAMELY_API_KEY=123
RATE_LIMITER_ENABLED=false
FILE_STORAGE=local
FILE_STORAGE_LOCAL_ROOT_DIR=/tmp
URL=http://localhost:3000
COLLABORATION_URL=
REDIS_URL=redis://localhost:6379
UTILS_SECRET=test-utils-secret
DEBUG=
LOG_LEVEL=error
+1
View File
@@ -0,0 +1 @@
server/migrations/*.js
+105
View File
@@ -0,0 +1,105 @@
{
"parser": "babel-eslint",
"extends": [
"react-app",
"plugin:import/errors",
"plugin:import/warnings",
"plugin:flowtype/recommended",
"plugin:react-hooks/recommended"
],
"plugins": [
"prettier",
"flowtype"
],
"rules": {
"eqeqeq": 2,
"no-unused-vars": 2,
"no-mixed-operators": "off",
"import/order": [
"error",
{
"alphabetize": {
"order": "asc"
},
"pathGroups": [
{
"pattern": "shared/**",
"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"
}
]
}
],
"flowtype/require-valid-file-annotation": [
2,
"always",
{
"annotationStyle": "line"
}
],
"flowtype/space-after-type-colon": [
2,
"always"
],
"flowtype/space-before-type-colon": [
2,
"never"
],
"prettier/prettier": [
"error",
{
"printWidth": 80,
"trailingComma": "es5"
}
]
},
"settings": {
"react": {
"createClass": "createReactClass",
"pragma": "React",
"version": "detect"
},
"import/resolver": {
"node": {
"paths": [
"app",
"."
]
}
},
"flowtype": {
"onlyFilesWithFlowAnnotation": false
}
},
"env": {
"jest": true
},
"globals": {
"EDITOR_VERSION": true
}
}
+39
View File
@@ -0,0 +1,39 @@
[include]
.*/app/.*
.*/server/.*
.*/shared/.*
[ignore]
.*/node_modules/tiny-cookie/flow/.*
.*/node_modules/styled-components/.*
.*/node_modules/polished/.*
.*/node_modules/mobx/.*.flow
.*/node_modules/react-side-effect/.*
.*/node_modules/fbjs/.*
.*/node_modules/config-chain/.*
.*/server/scripts/.*
*.test.js
[libs]
[options]
emoji=true
sharedmemory.heap_size=3221225472
module.system.node.resolve_dirname=node_modules
module.system.node.resolve_dirname=app
module.name_mapper='^\(.*\)\.md$' -> 'empty/object'
module.name_mapper='^shared\/\(.*\)$' -> '<PROJECT_ROOT>/shared/\1'
module.file_ext=.js
module.file_ext=.md
module.file_ext=.json
esproposal.decorators=ignore
esproposal.class_static_fields=enable
esproposal.class_instance_fields=enable
esproposal.optional_chaining=enable
suppress_comment=\\(.\\|\n\\)*\\$FlowFixMe
suppress_comment=\\(.\\|\n\\)*\\$FlowIssue
-3
View File
@@ -1,3 +0,0 @@
# These are supported funding model platforms
github: [outline]
+37
View File
@@ -0,0 +1,37 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots or videos to help explain your problem.
**Outline (please complete the following information):**
- Install: [getoutline.com or self hosted]
- Version: [commit sha if self hosted]
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Mobile (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
-63
View File
@@ -1,63 +0,0 @@
name: Bug report
description: File a bug to help us improve
labels: ["bug"]
body:
- type: checkboxes
attributes:
label: Is there an existing issue for this?
description: Please search to see if an issue already exists for the bug you encountered.
options:
- label: I have searched the existing issues
required: true
- type: checkboxes
attributes:
label: This is not related to configuring Outline
description: I understand that questions related to configuring self-hosted Outline should be asked in the [community forum](https://github.com/outline/outline/discussions/categories/self-hosting).
options:
- label: The issue is not related to self-hosting config
required: true
- type: textarea
attributes:
label: Current Behavior
description: A concise description of what you're experiencing.
validations:
required: false
- type: textarea
attributes:
label: Expected Behavior
description: A concise description of what you expected to happen.
validations:
required: false
- type: textarea
attributes:
label: Steps To Reproduce
description: Steps to reproduce the behavior.
placeholder: |
1. In this environment...
1. With this config...
1. Run '...'
1. See error...
validations:
required: false
- type: textarea
attributes:
label: Environment
description: |
examples:
- **Outline**: Outline 0.80.0
- **Browser**: Safari
value: |
- Outline:
- Browser:
render: markdown
validations:
required: false
- type: textarea
attributes:
label: Anything else?
description: |
Links? References? Anything that will give us more context about the issue you are encountering!
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
validations:
required: false
+2 -2
View File
@@ -1,8 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Feature request
url: https://github.com/outline/outline/discussions/new?category=ideas
url: https://github.com/outline/outline/discussions/new
about: Request a feature to be added to the project
- name: Self hosting questions
url: https://github.com/outline/outline/discussions/new?category=self-hosting
url: https://github.com/outline/outline/discussions/new
about: Ask questions and discuss running Outline with community members
+2 -2
View File
@@ -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
-2
View File
@@ -15,8 +15,6 @@ requestInfoDefaultTitles:
requestInfoLabelToAdd: more information needed
requestInfoUserstoExclude:
- tommoor
# Configuration for new-pr-welcome - https://github.com/behaviorbot/new-pr-welcome
-31
View File
@@ -1,31 +0,0 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "npm" # See documentation for possible values
directory: "/" # Location of package manifests
open-pull-requests-limit: 5
ignore:
- dependency-name: "*"
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 -1
View File
@@ -1,7 +1,7 @@
# Configuration for probot-no-response - https://github.com/probot/no-response
# Number of days of inactivity before an Issue is closed for lack of response
daysUntilClose: 7
daysUntilClose: 14
# Label requiring a response
responseRequiredLabel: more information needed
+21
View File
@@ -0,0 +1,21 @@
# Number of days of inactivity before an issue becomes stale
daysUntilStale: 60
# Number of days of inactivity before a stale issue is closed
daysUntilClose: 7
# Issues with these labels will never be considered stale
exemptLabels:
- security
# Label to use when marking an issue as stale
staleLabel: stale
# Comment to post when marking an issue as stale. Set to `false` to disable
markComment: >
Hey! The issue has been automatically marked as stale because it has not had
recent activity. It will be closed soon if no further activity occurs. Please
reply here if you wish for the issue to be kept open.
# Comment to post when closing a stale issue. Set to `false` to disable
closeComment: false
-59
View File
@@ -1,59 +0,0 @@
name: Auto Close Unsigned PRs
on:
schedule:
- cron: "0 0 * * *" # Run daily at midnight UTC
jobs:
close-unsigned-prs:
runs-on: ubuntu-latest
permissions:
pull-requests: write
issues: write
steps:
- name: Close unsigned PRs
uses: actions/github-script@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.'
});
}
}
@@ -1,62 +0,0 @@
# Image Actions will run in the following scenarios:
# - on Pull Requests containing images (not including forks)
# - on pushing of images to `main` (for forks)
# - on demand (https://github.blog/changelog/2020-07-06-github-actions-manual-triggers-with-workflow_dispatch/)
# - at 11 PM every Sunday in anything gets missed with any of the above scenarios
# For Pull Requests, the images are added to the PR.
# For other scenarios, a new PR will be opened if any images are compressed.
name: Compress images
on:
pull_request:
paths:
- "**.jpg"
- "**.jpeg"
- "**.png"
- "**.webp"
push:
branches:
- main
paths:
- "**.jpg"
- "**.jpeg"
- "**.png"
- "**.webp"
workflow_dispatch:
schedule:
- cron: "00 20 * * 0"
permissions: {}
jobs:
build:
permissions:
contents: write
pull-requests: write # to comment on pull request
name: calibreapp/image-actions
runs-on: ubuntu-latest
# Only run on main repo on and PRs that match the main repo.
if: |
github.repository == 'outline/outline' &&
(github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository)
steps:
- name: Checkout Branch
uses: actions/checkout@v5
- name: Compress Images
id: calibre
uses: calibreapp/image-actions@main
with:
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: |
github.event_name != 'pull_request' &&
steps.calibre.outputs.markdown != ''
uses: peter-evans/create-pull-request@v3
with:
title: "chore: Auto Compress Images"
branch-suffix: timestamp
commit-message: "chore: Compressed inefficient images automatically"
body: ${{ steps.calibre.outputs.markdown }}
-193
View File
@@ -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
-70
View File
@@ -1,70 +0,0 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [main]
pull_request:
# The branches below must be a subset of the branches above
branches: [main]
schedule:
- cron: "28 15 * * 2"
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
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
# 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
# ️ 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
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
-212
View File
@@ -1,212 +0,0 @@
name: Docker
on:
push:
tags:
- "v*"
env:
IMAGE_NAME: outlinewiki/outline
BASE_IMAGE_NAME: outlinewiki/outline-base
jobs:
build-arm:
runs-on: ubicloud-standard-8-arm
steps:
- name: Checkout
uses: actions/checkout@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 }}
-29
View File
@@ -1,29 +0,0 @@
name: "Close Stale PRs"
on:
workflow_dispatch:
schedule:
- cron: "30 1 * * *"
permissions:
issues: write
pull-requests: write
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v10
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"
close-pr-message: "Automatically closed due to inactivity"
close-issue-message: "Automatically closed due to inactivity"
days-before-issue-stale: 120
days-before-pr-stale: 60
days-before-close: 5
operations-per-run: 60
stale-issue-label: stale
stale-pr-label: stale
exempt-issue-labels: "security,pinned,A1"
- name: Print outputs
run: echo ${{ join(steps.stale.outputs.*, ',') }}
+2 -15
View File
@@ -1,24 +1,11 @@
dist
build
node_modules/*
server/scripts
.env
.env.local
.env.production
.log
.vscode/*
npm-debug.log
stats.json
.DS_Store
data/*
fakes3/*
.idea
*.pem
*.key
*.cert
# Yarn Berry
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
.yarn/releases
!.yarn/sdks
-4
View File
@@ -1,4 +0,0 @@
#!/usr/bin/env sh
. "$(dirname "$0")/_/husky.sh"
npx lint-staged
-63
View File
@@ -1,63 +0,0 @@
{
"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"
},
"setupFiles": [
"<rootDir>/__mocks__/console.js",
"<rootDir>/server/test/setupMocks.js"
],
"setupFilesAfterEnv": ["<rootDir>/server/test/setup.ts"],
"globalTeardown": "<rootDir>/server/test/globalTeardown.js",
"testEnvironment": "node"
},
{
"displayName": "app",
"roots": ["<rootDir>/app"],
"moduleNameMapper": {
"^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
},
"modulePaths": ["<rootDir>/app"],
"setupFiles": ["<rootDir>/__mocks__/window.js"],
"setupFilesAfterEnv": ["<rootDir>/app/test/setup.ts"],
"testEnvironment": "jsdom",
"testEnvironmentOptions": {
"url": "http://localhost"
}
},
{
"displayName": "shared-node",
"roots": ["<rootDir>/shared"],
"moduleNameMapper": {
"^@server/(.*)$": "<rootDir>/server/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1"
},
"setupFiles": ["<rootDir>/__mocks__/console.js"],
"setupFilesAfterEnv": ["<rootDir>/shared/test/setup.ts"],
"testEnvironment": "node"
},
{
"displayName": "shared-jsdom",
"roots": ["<rootDir>/shared"],
"moduleNameMapper": {
"^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
},
"setupFiles": ["<rootDir>/__mocks__/window.js"],
"testEnvironment": "jsdom",
"testEnvironmentOptions": {
"url": "http://localhost"
}
}
]
}
-1
View File
@@ -1 +0,0 @@
22
-104
View File
@@ -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"]
}
]
}
-4
View File
@@ -1,4 +0,0 @@
{
"printWidth": 80,
"trailingComma": "es5"
}
+3 -4
View File
@@ -1,11 +1,10 @@
require("@dotenvx/dotenvx").config({
path: process.env.NODE_ENV === "test" ? ".env.test" : ".env",
});
require('dotenv').config({ silent: true });
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'),
'seeders-path': path.resolve('server/models', 'fixtures'),
}
+4 -4
View File
@@ -1,7 +1,7 @@
{
"javascript.validate.enable": true,
"javascript.format.enable": true,
"typescript.validate.enable": true,
"typescript.format.enable": true,
"javascript.validate.enable": false,
"javascript.format.enable": false,
"typescript.validate.enable": false,
"typescript.format.enable": false,
"editor.formatOnSave": true,
}
-3
View File
@@ -1,3 +0,0 @@
nodeLinker: node-modules
npmMinimalAgeGate: 86400
-195
View File
@@ -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.
- Prefer named exports for 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.
-1
View File
@@ -1 +0,0 @@
AGENTS.md
+10 -10
View File
@@ -8,19 +8,19 @@ In the interest of fostering an open and welcoming environment, we as contributo
Examples of behavior that contributes to creating a positive environment include:
- Using welcoming and inclusive language
- Being respectful of differing viewpoints and experiences
- Gracefully accepting constructive criticism
- Focusing on what is best for the community
- Showing empathy towards other community members
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
- The use of sexualized language or imagery and unwelcome sexual attention or advances
- Trolling, insulting/derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or electronic address, without explicit permission
- Other conduct which could reasonably be considered inappropriate in a professional setting
* The use of sexualized language or imagery and unwelcome sexual attention or advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting
## Our Responsibilities
+14 -38
View File
@@ -1,47 +1,23 @@
ARG APP_PATH=/opt/outline
ARG BASE_IMAGE=outlinewiki/outline-base
FROM ${BASE_IMAGE} AS base
FROM node:14-alpine
ENV APP_PATH /opt/outline
RUN mkdir -p $APP_PATH
ARG APP_PATH
WORKDIR $APP_PATH
# ---
FROM node:22.21.0-slim AS runner
COPY package.json ./
COPY yarn.lock ./
LABEL org.opencontainers.image.source="https://github.com/outline/outline"
RUN yarn --pure-lockfile
ARG APP_PATH
WORKDIR $APP_PATH
ENV NODE_ENV=production
COPY . .
# 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
RUN yarn build && \
yarn --production --ignore-scripts --prefer-offline && \
rm -rf shared && \
rm -rf app
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/*
ENV FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
RUN mkdir -p "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
chown -R nodejs:nodejs "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
chmod 1777 "$FILE_STORAGE_LOCAL_ROOT_DIR"
VOLUME /var/lib/outline/data
USER nodejs
HEALTHCHECK --interval=1m CMD wget -qO- "http://localhost:${PORT:-3000}/_health" | grep -q "OK" || exit 1
ENV NODE_ENV production
CMD yarn start
EXPOSE 3000
CMD ["node", "build/server/index.js"]
-23
View File
@@ -1,23 +0,0 @@
ARG APP_PATH=/opt/outline
FROM node:22.21.0 AS deps
ARG APP_PATH
WORKDIR $APP_PATH
COPY ./package.json ./yarn.lock ./.yarnrc.yml ./
COPY ./patches ./patches
RUN apt-get update && apt-get install -y cmake
ENV NODE_OPTIONS="--max-old-space-size=24000"
RUN corepack enable
RUN yarn install --immutable --network-timeout 1000000 && \
yarn cache clean
COPY . .
ARG CDN_URL
RUN yarn build
RUN yarn workspaces focus --production && \
yarn cache clean
ENV PORT=3000
+3 -3
View File
@@ -3,8 +3,8 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 1.5.0
The Licensed Work is (c) 2026 General Outline, Inc.
Licensed Work: Outline 0.51.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-02-15
Change Date: 2023-12-13
Change License: Apache License, Version 2.0
+15 -15
View File
@@ -1,28 +1,28 @@
up:
docker compose up -d redis postgres
yarn install-local-ssl
yarn install --immutable
yarn dev:watch
docker-compose up -d redis postgres s3
yarn install --pure-lockfile
yarn sequelize db:migrate
yarn dev
build:
docker compose build --pull outline
docker-compose build --pull outline
test:
docker compose up -d postgres
NODE_ENV=test yarn sequelize db:drop
NODE_ENV=test yarn sequelize db:create
NODE_ENV=test yarn sequelize db:migrate
docker-compose up -d redis postgres s3
yarn sequelize db:drop --env=test
yarn sequelize db:create --env=test
yarn sequelize db:migrate --env=test
yarn test
watch:
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
docker-compose up -d redis postgres s3
yarn sequelize db:drop --env=test
yarn sequelize db:create --env=test
yarn sequelize db:migrate --env=test
yarn test:watch
destroy:
docker compose stop
docker compose rm -f
docker-compose stop
docker-compose rm -f
.PHONY: up build destroy test watch # let's go to reserve rules names
+1 -2
View File
@@ -1,2 +1 @@
web: yarn start --services=web,websockets,collaboration
worker: yarn start --services=worker
web: node ./build/server/index.js
+136 -56
View File
@@ -1,67 +1,156 @@
<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>
<i>An open, extensible, wiki 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>
<br/>
<img width="1640" alt="screenshot" src="https://user-images.githubusercontent.com/380914/110356468-26374600-7fef-11eb-9f6a-f2cc2c8c6590.png">
<img src="https://user-images.githubusercontent.com/380914/78513257-153ae080-775f-11ea-9b49-1e1939451a3e.png" alt="Outline" width="800" />
</p>
<p align="center">
<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>
<a href="https://circleci.com/gh/outline/outline" rel="nofollow"><img src="https://circleci.com/gh/outline/outline.svg?style=shield&amp;circle-token=c0c4c2f39990e277385d5c1ae96169c409eb887a"></a>
<a href="https://github.com/prettier/prettier"><img src="https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat"></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"></a>
<a href="https://translate.getoutline.com/project/outline"><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).
If you'd like to run your own copy of Outline or contribute to development then this is the place for you.
# Installation
## Installation
Please see the [documentation](https://docs.getoutline.com/s/hosting/) for running your own copy of Outline in a production configuration.
Outline requires the following dependencies:
If you have questions or improvements for the docs please create a thread in [GitHub discussions](https://github.com/outline/outline/discussions).
- [Node.js](https://nodejs.org/) >= 12
- [Yarn](https://yarnpkg.com)
- [Postgres](https://www.postgresql.org/download/) >=9.5
- [Redis](https://redis.io/) >= 4
- AWS S3 bucket or compatible API for file storage
- Slack or Google developer application for authentication
# Development
There is a short guide for [setting up a development environment](https://docs.getoutline.com/s/hosting/doc/local-development-5hEhFRXow7) if you wish to contribute changes, fixes, and improvements to Outline.
### Production
## Contributing
For a manual self-hosted production installation these are the suggested steps:
Outline is built and maintained by a small team we'd love your help to fix bugs and add features!
1. Clone this repo and install dependencies with `yarn install`
1. Build the source code with `yarn build`
1. Using the `.env.sample` as a reference, set the required variables in your production environment. The following are required as a minimum:
1. `SECRET_KEY` (follow instructions in the comments at the top of `.env`)
1. `SLACK_KEY` (this is called "Client ID" in Slack admin)
1. `SLACK_SECRET` (this is called "Client Secret" in Slack admin)
1. `DATABASE_URL` (run your own local copy of Postgres, or use a cloud service)
1. `REDIS_URL` (run your own local copy of Redis, or use a cloud service)
1. `URL` (the public facing URL of your installation)
1. `AWS_` (all of the keys beginning with AWS)
1. Migrate database schema with `yarn sequelize:migrate`. Production assumes an SSL connection, if
Postgres is on the same machine and is not SSL you can migrate with `yarn sequelize:migrate --env=production-ssl-disabled`.
1. Start the service with any daemon tools you prefer. Take PM2 for example, `NODE_ENV=production pm2 start ./build/server/index.js --name outline `
1. Visit http://you_server_ip:3000 and you should be able to see Outline page
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.
> Port number can be changed using the `PORT` environment variable
If youre looking for ways to get started, here's a list of ways to help us improve Outline:
1. (Optional) You can add an `nginx` reverse proxy to serve your instance of Outline for a clean URL without the port number, support SSL, etc.
- [Translation](docs/TRANSLATION.md) into other languages
- Issues with [`good first issue`](https://github.com/outline/outline/labels/good%20first%20issue) label
- Performance improvements, both on server and frontend
- Developer happiness and documentation
- Bugs and other issues listed on GitHub
## Architecture
### Development
If you're interested in contributing or learning more about the Outline codebase
please refer to the [architecture document](docs/ARCHITECTURE.md) first for a high level overview of how the application is put together.
In development you can quickly get an environment running using Docker by following these steps:
## Debugging
1. Install these dependencies if you don't already have them
1. [Docker for Desktop](https://www.docker.com)
1. [Node.js](https://nodejs.org/) (v12 LTS preferred)
1. [Yarn](https://yarnpkg.com)
1. Clone this repo
1. Register a Slack app at https://api.slack.com/apps
1. Copy the file `.env.sample` to `.env`
1. Fill out the following fields:
1. `SECRET_KEY` (follow instructions in the comments at the top of `.env`)
1. `SLACK_KEY` (this is called "Client ID" in Slack admin)
1. `SLACK_SECRET` (this is called "Client Secret" in Slack admin)
1. Configure your Slack app's Oauth & Permissions settings
1. Add `http://localhost:3000/auth/slack.callback` as an Oauth redirect URL
1. Ensure that the bot token scope contains at least `users:read`
1. Run `make up`. This will download dependencies, build and launch a development version of Outline
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.
### Upgrade
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.
#### Docker
If you're running Outline with Docker you'll need to run migrations within the docker container after updating the image. The command will be something like:
```
docker run --rm outlinewiki/outline:latest yarn sequelize:migrate
```
#### Yarn
If you're running Outline by cloning this repository, run the following command to upgrade:
```
yarn run upgrade
```
## Development
### Server
Outline uses [debug](https://www.npmjs.com/package/debug). To enable debugging output, the following categories are available:
```
DEBUG=sql,cache,presenters,events,logistics,emails,mailer
```
## Migrations
Sequelize is used to create and run migrations, for example:
```
yarn sequelize migration:generate --name my-migration
yarn sequelize db:migrate
```
Or to run migrations on test database:
```
yarn sequelize db:migrate --env test
```
## Structure
Outline is composed of separate backend and frontend application which are both driven by the same Node process. As both are written in Javascript, they share some code but are mostly separate. We utilize the latest language features, including `async`/`await`, and [Flow](https://flow.org/) typing. Prettier and ESLint are enforced by CI.
### Frontend
Outline's frontend is a React application compiled with [Webpack](https://webpack.js.org/). It uses [Mobx](https://mobx.js.org/) for state management and [Styled Components](https://www.styled-components.com/) for component styles. Unless global, state logic and styles are always co-located with React components together with their subcomponents to make the component tree easier to manage.
The editor itself is built on [Prosemirror](https://github.com/prosemirror) and hosted in a separate repository to encourage reuse: [rich-markdown-editor](https://github.com/outline/rich-markdown-editor)
- `app/` - Frontend React application
- `app/scenes` - Full page views
- `app/components` - Reusable React components
- `app/stores` - Global state stores
- `app/models` - State models
- `app/types` - Flow types for non-models
### Backend
Backend is driven by [Koa](http://koajs.com/) (API, web server), [Sequelize](http://docs.sequelizejs.com/) (database) and React for public pages and emails.
- `server/api` - API endpoints
- `server/commands` - Domain logic, currently being refactored from /models
- `server/emails` - React rendered email templates
- `server/models` - Database models
- `server/policies` - Authorization logic
- `server/presenters` - API responses for database models
- `server/test` - Test helps and support
- `server/utils` - Utility methods
- `shared` - Code shared between frontend and backend applications
## 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
@@ -71,40 +160,31 @@ make test
make watch
```
Once the test database is created with `make test` you may individually run
frontend and backend tests directly with jest:
Once the test database is created with `make test` you may individually run
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 frontend tests
yarn test:app
```
## Migrations
## Contributing
Sequelize is used to create and run migrations, for example:
Outline is built and maintained by a small team we'd love your help to fix bugs and add features!
```shell
yarn db:create-migration --name my-migration
yarn db:migrate
yarn db:rollback
```
However, before working on a pull request please let the core team know by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues), and 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 will hopefully help to get your contributions integrated faster!
Or, to run migrations on test database:
If youre looking for ways to get started, here's a list of ways to help us improve Outline:
```shell
yarn db:migrate --env test
```
* [Translation](TRANSLATION.md) into other languages
* Issues with [`good first issue`](https://github.com/outline/outline/labels/good%20first%20issue) label
* Performance improvements, both on server and frontend
* Developer happiness and documentation
* Bugs and other issues listed on GitHub
# Activity
## License
![Alt](https://repobeats.axiom.co/api/embed/ff2e4e6918afff1acf9deb72d1ba6b071d586178.svg "Repobeats analytics image")
# License
Outline is [BSL 1.1 licensed](LICENSE).
Outline is [BSL 1.1 licensed](https://github.com/outline/outline/blob/master/LICENSE).
+11
View File
@@ -0,0 +1,11 @@
# Security Policy
## Reporting a Vulnerability
The Outline team takes security bugs seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions.
To report a security issue, email [hello@getoutline.com](mailto:hello@getoutline.com) and include the word "SECURITY" in the subject line.
The Outline team will send a response indicating the next steps in handling your report. After the initial reply to your report you will be kept informed of the progress towards a fix and full announcement.
Report security bugs in third-party dependencies to the person or team maintaining the module. You can also report a vulnerability through the [Node Security Project](https://nodesecurity.io/report).
+3 -3
View File
@@ -1,6 +1,6 @@
# Translation
Outline is localized through community contributions. The text in Outline's user interface is in American English by default, we're very thankful for all help that the community provides bringing the app to different languages.
Outline is localized through community contributions. The text in Outline's user interface is in American English by default, we're very thankful for all help that the community provides bringing the app to different languages.
## Externalizing strings
@@ -17,7 +17,7 @@ To manage the translation process we use [CrowdIn](https://translate.getoutline.
You'll need to create a free account to use CrowdIn. Once you have joined, you can provide translations by following these steps:
1. Select the language for which you want to contribute (or vote for) a translation (below the language you can see the progress of the translation)
![CrowdIn UI](https://i.imgur.com/AkbDY60.png)
![CrowdIn UI](https://i.imgur.com/AkbDY60.png)
2. Please choose the translation.json file from your desired language
@@ -31,4 +31,4 @@ If you are interested in becoming a proof reader, please contact one of the proj
## Release
Updated translations are automatically PR'd against the codebase by a bot and will be merged regularly so that new translations appear in the next release of Outline.
Updated translations are automatically PR'd against the codebase by a bot and will be merged regularly so that new translations appear in the next release of Outline.
+18
View File
@@ -0,0 +1,18 @@
/* eslint-disable flowtype/require-valid-file-annotation */
export default class Queue {
name;
constructor(name) {
this.name = name;
}
process = (fn) => {
console.log(`Registered function ${this.name}`);
this.processFn = fn;
};
add = (data) => {
console.log(`Running ${this.name}`);
return this.processFn({ data });
};
}
+1
View File
@@ -1 +1,2 @@
// Mock for node-uuid
global.console.warn = () => {};
+1 -1
View File
@@ -1 +1 @@
export default "";
export default '';
+5 -5
View File
@@ -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;
},
-1
View File
@@ -1 +0,0 @@
export default null;
+1 -2
View File
@@ -1,2 +1 @@
window.matchMedia = (data) => data;
window.env = {};
window.matchMedia = data => data;
+24 -121
View File
@@ -3,7 +3,13 @@
"description": "Open source wiki and knowledge base for growing teams",
"website": "https://www.getoutline.com/",
"repository": "https://github.com/outline/outline",
"keywords": ["wiki", "team", "node", "markdown", "slack"],
"keywords": [
"wiki",
"team",
"node",
"markdown",
"slack"
],
"success_url": "/",
"formation": {
"web": {
@@ -21,36 +27,11 @@
}
],
"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": {
"value": "production",
"required": true
},
"SECRET_KEY": {
"description": "A 32-character secret key, generate with openssl rand -hex 32",
"generator": "secret",
"required": true
},
"UTILS_SECRET": {
"description": "A 32-character secret key, generate with openssl rand -hex 32",
"description": "A secret key",
"generator": "secret",
"required": true
},
@@ -59,12 +40,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",
"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.",
@@ -74,66 +51,15 @@
"description": "",
"required": false
},
"AZURE_CLIENT_ID": {
"description": "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",
"GOOGLE_ALLOWED_DOMAINS": {
"description": "Comma separated list of domains to be allowed (optional). If not set, all Google apps domains are allowed by default",
"required": false
},
"AZURE_CLIENT_SECRET": {
"description": "",
"required": false
},
"AZURE_RESOURCE_APP_ID": {
"description": "",
"required": false
},
"OIDC_CLIENT_ID": {
"description": "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",
"required": false
},
"OIDC_CLIENT_SECRET": {
"description": "",
"required": false
},
"OIDC_AUTH_URI": {
"description": "",
"required": false
},
"OIDC_TOKEN_URI": {
"description": "",
"required": false
},
"OIDC_USERINFO_URI": {
"description": "",
"required": false
},
"OIDC_DISABLE_REDIRECT": {
"description": "Prevent the app from automatically redirecting to the OIDC login page",
"required": false
},
"OIDC_LOGOUT_URI": {
"description": "",
"required": false
},
"OIDC_USERNAME_CLAIM": {
"description": "Specify which claims to derive user information from. Supports any valid JSON path with the JWT payload",
"value": "preferred_username",
"required": false
},
"OIDC_DISPLAY_NAME": {
"description": "Display name for OIDC authentication",
"value": "OpenID Connect",
"required": false
},
"OIDC_SCOPES": {
"description": "Space separated auth scopes.",
"value": "openid profile email",
"required": false
},
"SLACK_CLIENT_ID": {
"SLACK_KEY": {
"description": "See https://api.slack.com/apps to create a new Slack app. You must configure at least one of Slack or Google to control login.",
"required": false
},
"SLACK_CLIENT_SECRET": {
"SLACK_SECRET": {
"description": "Your Slack client secret - d2dc414f9953226bad0a356cXXXXYYYY",
"required": false
},
@@ -161,6 +87,11 @@
"description": "Live web link to your bucket. For CNAMEs, https://yourbucket.example.com",
"required": false
},
"AWS_S3_UPLOAD_MAX_SIZE": {
"description": "Maximum file upload size in bytes",
"value": "26214400",
"required": false
},
"AWS_S3_FORCE_PATH_STYLE": {
"description": "Use path-style URL's for connecting to S3 instead of subdomain. This is useful for S3-compatible storage.",
"value": "true",
@@ -176,19 +107,10 @@
"description": "S3 canned ACL for document attachments",
"required": false
},
"FILE_STORAGE_UPLOAD_MAX_SIZE": {
"description": "Maximum file upload size in bytes",
"value": "26214400",
"required": false
},
"SMTP_HOST": {
"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
@@ -209,36 +131,17 @@
"description": "wikireply@example.com (optional)",
"required": false
},
"SMTP_SECURE": {
"value": "true",
"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
},
"GOOGLE_ANALYTICS_ID": {
"description": "G-xxxx (optional)",
"description": "UA-xxxx (optional)",
"required": false
},
"SENTRY_DSN": {
"description": "An API key for Sentry if you wish to collect error reporting (optional)",
"required": false
},
"SENTRY_TUNNEL": {
"description": "A sentry tunnel URL for bypassing ad blockers in the UI (optional)",
"required": false
},
"DEFAULT_LANGUAGE": {
"value": "en_US",
"description": "The default interface language. See translate.getoutline.com for a list of available language codes and their rough percentage translated.",
"TEAM_LOGO": {
"description": "A logo that will be displayed on the signed out home page",
"required": false
}
}
}
}
-34
View File
@@ -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
}
}
-60
View File
@@ -1,60 +0,0 @@
import { PlusIcon, TrashIcon } from "outline-icons";
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";
export const createApiKey = createAction({
name: ({ t }) => t("New API key"),
analyticsName: "New API key",
section: SettingsSection,
icon: <PlusIcon />,
keywords: "create",
visible: () =>
stores.policies.abilities(stores.auth.team?.id || "").createApiKey,
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("New API key"),
content: <ApiKeyNew onSubmit={stores.dialogs.closeAllModals} />,
});
},
});
export const revokeApiKeyFactory = ({ apiKey }: { apiKey: ApiKey }) =>
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}
/>
),
});
},
});
-547
View File
@@ -1,547 +0,0 @@
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 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 { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
import {
createAction,
createInternalLinkAction,
createActionWithChildren,
} from "~/actions";
import { ActiveCollectionSection, CollectionSection } from "~/actions/sections";
import { setPersistedState } from "~/hooks/usePersistedState";
import {
newDocumentPath,
newTemplatePath,
searchPath,
} from "~/utils/routeHelpers";
import ExportDialog from "~/components/ExportDialog";
import { getEventFiles } from "@shared/utils/files";
import history from "~/utils/history";
import lazyWithRetry from "~/utils/lazyWithRetry";
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => (
<DynamicCollectionIcon collection={collection} />
);
const SharePopover = lazyWithRetry(
() => import("~/components/Sharing/Collection/SharePopover")
);
export const openCollection = createActionWithChildren({
name: ({ t }) => t("Open collection"),
analyticsName: "Open collection",
section: CollectionSection,
shortcut: ["o", "c"],
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,
})
);
},
});
export const createCollection = createAction({
name: ({ t }) => t("New collection"),
analyticsName: "New collection",
section: CollectionSection,
icon: <PlusIcon />,
keywords: "create",
visible: ({ stores }) =>
stores.policies.abilities(stores.auth.team?.id || "").createCollection,
perform: ({ t, event, stores }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("Create a collection"),
content: <CollectionNew onSubmit={stores.dialogs.closeAllModals} />,
});
},
});
export const editCollection = createAction({
name: ({ t, isMenu }) => (isMenu ? `${t("Edit")}` : t("Edit collection")),
analyticsName: "Edit collection",
section: ActiveCollectionSection,
icon: <EditIcon />,
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.update),
perform: ({ t, getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return;
}
stores.dialogs.openModal({
title: t("Edit collection"),
content: (
<CollectionEdit
onSubmit={stores.dialogs.closeAllModals}
collectionId={collection.id}
/>
),
});
},
});
export const editCollectionPermissions = createAction({
name: ({ t, isMenu }) =>
isMenu ? `${t("Permissions")}` : t("Collection permissions"),
analyticsName: "Collection permissions",
section: ActiveCollectionSection,
icon: <PadlockIcon />,
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.update),
perform: ({ t, getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return;
}
stores.dialogs.openModal({
title: t("Share this collection"),
content: (
<SharePopover
collection={collection}
onRequestClose={stores.dialogs.closeAllModals}
visible
/>
),
});
},
});
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({
name: ({ t }) => t("Search in collection"),
analyticsName: "Search collection",
section: ActiveCollectionSection,
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 },
};
},
});
export const starCollection = createAction({
name: ({ t }) => t("Star"),
analyticsName: "Star collection",
section: ActiveCollectionSection,
icon: <StarredIcon />,
keywords: "favorite bookmark",
visible: ({ getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return false;
}
return (
!collection.isStarred && stores.policies.abilities(collection.id).star
);
},
perform: async ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return;
}
await collection.star();
setPersistedState(getHeaderExpandedKey("starred"), true);
},
});
export const unstarCollection = createAction({
name: ({ t }) => t("Unstar"),
analyticsName: "Unstar collection",
section: ActiveCollectionSection,
icon: <UnstarredIcon />,
keywords: "unfavorite unbookmark",
visible: ({ getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return false;
}
return (
!!collection.isStarred && stores.policies.abilities(collection.id).unstar
);
},
perform: async ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
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,
dangerous: true,
icon: <TrashIcon />,
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.delete),
perform: ({ getActiveModel, t, stores }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return;
}
stores.dialogs.openModal({
title: t("Delete collection"),
content: (
<CollectionDeleteDialog
collection={collection}
onSubmit={stores.dialogs.closeAllModals}
/>
),
});
},
});
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({
name: ({ t }) => t("New template"),
analyticsName: "New template",
section: ActiveCollectionSection,
icon: <ShapesIcon />,
keywords: "new create template",
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some(
(policy) => policy.abilities.createTemplate
),
to: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
return newTemplatePath(collection?.id);
},
});
export const rootCollectionActions = [
openCollection,
createCollection,
starCollection,
unstarCollection,
subscribeCollection,
unsubscribeCollection,
deleteCollection,
];
-100
View File
@@ -1,100 +0,0 @@
import { DoneIcon, SmileyIcon, TrashIcon } from "outline-icons";
import { toast } from "sonner";
import type Comment from "~/models/Comment";
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
import ViewReactionsDialog from "~/components/Reactions/ViewReactionsDialog";
import { createAction } from "..";
import { ActiveDocumentSection } from "../sections";
export const deleteCommentFactory = ({
comment,
onDelete,
}: {
comment: Comment;
onDelete: () => void;
}) =>
createAction({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete comment",
section: ActiveDocumentSection,
icon: <TrashIcon />,
keywords: "trash",
dangerous: true,
visible: ({ stores }) => stores.policies.abilities(comment.id).delete,
perform: ({ t, stores, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("Delete comment"),
content: <CommentDeleteDialog comment={comment} onSubmit={onDelete} />,
});
},
});
export const resolveCommentFactory = ({
comment,
onResolve,
}: {
comment: Comment;
onResolve: () => void;
}) =>
createAction({
name: ({ t }) => t("Mark as resolved"),
analyticsName: "Resolve thread",
section: ActiveDocumentSection,
icon: <DoneIcon outline />,
visible: ({ stores }) =>
stores.policies.abilities(comment.id).resolve &&
stores.policies.abilities(comment.documentId).update,
perform: async ({ t }) => {
await comment.resolve();
onResolve();
toast.success(t("Thread resolved"));
},
});
export const unresolveCommentFactory = ({
comment,
onUnresolve,
}: {
comment: Comment;
onUnresolve: () => void;
}) =>
createAction({
name: ({ t }) => t("Mark as unresolved"),
analyticsName: "Unresolve thread",
section: ActiveDocumentSection,
icon: <DoneIcon outline />,
visible: ({ stores }) =>
stores.policies.abilities(comment.id).unresolve &&
stores.policies.abilities(comment.documentId).update,
perform: async () => {
await comment.unresolve();
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} />,
});
},
});
-248
View File
@@ -1,248 +0,0 @@
import Storage from "@shared/utils/Storage";
import copy from "copy-to-clipboard";
import {
BeakerIcon,
CopyIcon,
EditIcon,
ToolsIcon,
TrashIcon,
UserIcon,
} from "outline-icons";
import { toast } from "sonner";
import { createAction, createActionWithChildren } from "~/actions";
import { DeveloperSection } from "~/actions/sections";
import env from "~/env";
import { client } from "~/utils/ApiClient";
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";
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({
name: ({ t }) => t("Copy ID"),
icon: <CopyIcon />,
keywords: "uuid",
section: DeveloperSection,
children: ({
currentTeamId,
currentUserId,
activeCollectionId,
activeDocumentId,
}) => {
function copyAndToast(text: string | null | undefined) {
if (text) {
copy(text);
toast.success("Copied to clipboard");
}
}
return [
createAction({
name: "Copy User ID",
section: DeveloperSection,
icon: <CopyIcon />,
visible: () => !!currentUserId,
perform: () => copyAndToast(currentUserId),
}),
createAction({
name: "Copy Team ID",
section: DeveloperSection,
icon: <CopyIcon />,
visible: () => !!currentTeamId,
perform: () => copyAndToast(currentTeamId),
}),
createAction({
name: "Copy Collection ID",
icon: <CopyIcon />,
section: DeveloperSection,
visible: () => !!activeCollectionId,
perform: () => copyAndToast(activeCollectionId),
}),
createAction({
name: "Copy Document ID",
icon: <CopyIcon />,
section: DeveloperSection,
visible: () => !!activeDocumentId,
perform: () => copyAndToast(activeDocumentId),
}),
createAction({
name: "Copy Team ID",
icon: <CopyIcon />,
section: DeveloperSection,
visible: () => !!currentTeamId,
perform: () => copyAndToast(currentTeamId),
}),
createAction({
name: "Copy Release ID",
icon: <CopyIcon />,
section: DeveloperSection,
visible: () => !!env.VERSION,
perform: () => copyAndToast(env.VERSION),
}),
];
},
});
function generateRandomText() {
const characters =
"abcdefghijklmno pqrstuvwxyzABCDEFGHIJKL MNOPQRSTUVWXYZ 0123456789\n";
let text = "";
for (let i = 0; i < Math.floor(Math.random() * 10) + 1; i++) {
text += characters.charAt(Math.floor(Math.random() * characters.length));
}
return text;
}
export const startTyping = createAction({
name: "Start automatic typing",
icon: <EditIcon />,
section: DeveloperSection,
visible: ({ activeDocumentId }) =>
!!activeDocumentId && env.ENVIRONMENT === "development",
perform: () => {
const intervalId = setInterval(() => {
const text = generateRandomText();
document.execCommand("insertText", false, text);
}, 250);
window.addEventListener("keydown", (event) => {
if (event.key === "Escape" && intervalId) {
clearInterval(intervalId);
}
});
toast.info("Automatic typing started, press Escape to stop");
},
});
export const clearIndexedDB = createAction({
name: ({ t }) => t("Clear IndexedDB cache"),
icon: <TrashIcon />,
keywords: "cache clear database",
section: DeveloperSection,
perform: async ({ t }) => {
history.push(homePath());
await deleteAllDatabases();
toast.success(t("IndexedDB cache cleared"));
},
});
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 />,
section: DeveloperSection,
visible: () => env.ENVIRONMENT === "development",
perform: async () => {
const count = 10;
await client.post("/developer.create_test_users", { count });
toast.message(`${count} test users created`);
},
});
export const createToast = createAction({
name: "Create toast",
section: DeveloperSection,
visible: () => env.ENVIRONMENT === "development",
perform: () => {
toast.message("Hello world", {
duration: 30000,
});
},
});
export const toggleDebugLogging = createAction({
name: ({ t }) => t("Toggle debug logging"),
icon: <ToolsIcon />,
section: DeveloperSection,
perform: ({ t }) => {
Logger.debugLoggingEnabled = !Logger.debugLoggingEnabled;
toast.message(
Logger.debugLoggingEnabled
? t("Debug logging enabled")
: t("Debug logging disabled")
);
},
});
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({
name: "Toggle feature flag",
icon: <BeakerIcon />,
section: DeveloperSection,
visible: () => env.ENVIRONMENT === "development",
children: Object.values(Feature).map((flag) =>
createAction({
id: `flag-${flag}`,
name: flag,
selected: () => FeatureFlags.isEnabled(flag),
section: DeveloperSection,
perform: () => {
if (FeatureFlags.isEnabled(flag)) {
FeatureFlags.disable(flag);
toast.success(`Disabled feature flag: ${flag}`);
} else {
FeatureFlags.enable(flag);
toast.success(`Enabled feature flag: ${flag}`);
}
},
})
),
});
export const developer = createActionWithChildren({
name: ({ t }) => t("Development"),
keywords: "debug",
icon: <ToolsIcon />,
iconInContextMenu: false,
section: DeveloperSection,
children: [
goToDebug,
copyId,
toggleDebugLogging,
toggleDebugSafeArea,
toggleFeatureFlag,
createToast,
createTestUsers,
clearIndexedDB,
clearStorage,
startTyping,
],
});
export const rootDeveloperActions = [developer];
File diff suppressed because it is too large Load Diff
-21
View File
@@ -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} />,
});
},
});
-47
View File
@@ -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!} />,
});
},
});
-262
View File
@@ -1,262 +0,0 @@
import {
HomeIcon,
SearchIcon,
ArchiveIcon,
TrashIcon,
OpenIcon,
SettingsIcon,
KeyboardIcon,
EmailIcon,
LogoutIcon,
ProfileIcon,
BrowserIcon,
ShapesIcon,
DraftsIcon,
BugIcon,
} from "outline-icons";
import { UrlHelper } from "@shared/utils/UrlHelper";
import { isMac } from "@shared/utils/browser";
import stores from "~/stores";
import type SearchQuery from "~/models/SearchQuery";
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
import {
createAction,
createExternalLinkAction,
createInternalLinkAction,
} from "~/actions";
import { NavigationSection, RecentSearchesSection } from "~/actions/sections";
import Desktop from "~/utils/Desktop";
import isCloudHosted from "~/utils/isCloudHosted";
import {
homePath,
searchPath,
draftsPath,
archivePath,
trashPath,
settingsPath,
} from "~/utils/routeHelpers";
export const navigateToHome = createInternalLinkAction({
name: ({ t }) => t("Home"),
analyticsName: "Navigate to home",
section: NavigationSection,
shortcut: ["d"],
icon: <HomeIcon />,
to: homePath(),
visible: ({ location }) => location.pathname !== homePath(),
});
export const navigateToRecentSearchQuery = (searchQuery: SearchQuery) =>
createInternalLinkAction({
section: RecentSearchesSection,
name: searchQuery.query,
analyticsName: "Navigate to recent search query",
icon: <SearchIcon />,
to: searchPath({ query: searchQuery.query }),
});
export const navigateToDrafts = createInternalLinkAction({
name: ({ t }) => t("Drafts"),
analyticsName: "Navigate to drafts",
section: NavigationSection,
icon: <DraftsIcon />,
to: 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({
name: ({ t }) => t("Archive"),
analyticsName: "Navigate to archive",
section: NavigationSection,
shortcut: ["g", "a"],
icon: <ArchiveIcon />,
to: archivePath(),
visible: ({ location }) => location.pathname !== archivePath(),
});
export const navigateToTrash = createInternalLinkAction({
name: ({ t }) => t("Trash"),
analyticsName: "Navigate to trash",
section: NavigationSection,
icon: <TrashIcon />,
to: trashPath(),
visible: ({ location }) => location.pathname !== trashPath(),
});
export const navigateToSettings = createInternalLinkAction({
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(),
});
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({
name: ({ t }) => t("Profile"),
analyticsName: "Navigate to profile settings",
section: NavigationSection,
iconInContextMenu: false,
icon: <ProfileIcon />,
to: settingsPath(),
});
export const navigateToTemplateSettings = createInternalLinkAction({
name: ({ t }) => t("Templates"),
analyticsName: "Navigate to template settings",
section: NavigationSection,
iconInContextMenu: false,
icon: <ShapesIcon />,
to: settingsPath("templates"),
});
export const navigateToNotificationSettings = createInternalLinkAction({
name: ({ t, isMenu }) =>
isMenu ? t("Notification settings") : t("Notifications"),
analyticsName: "Navigate to notification settings",
section: NavigationSection,
iconInContextMenu: false,
icon: <EmailIcon />,
to: settingsPath("notifications"),
});
export const navigateToAccountPreferences = createInternalLinkAction({
name: ({ t }) => t("Preferences"),
analyticsName: "Navigate to account preferences",
section: NavigationSection,
iconInContextMenu: false,
icon: <SettingsIcon />,
to: settingsPath("preferences"),
});
export const openDocumentation = createExternalLinkAction({
name: ({ t }) => t("Documentation"),
analyticsName: "Open documentation",
section: NavigationSection,
iconInContextMenu: false,
icon: <OpenIcon />,
url: UrlHelper.guide,
target: "_blank",
});
export const openAPIDocumentation = createExternalLinkAction({
name: ({ t }) => t("API documentation"),
analyticsName: "Open API documentation",
section: NavigationSection,
iconInContextMenu: false,
icon: <OpenIcon />,
url: UrlHelper.developers,
target: "_blank",
});
export const toggleSidebar = createAction({
name: ({ t }) => t("Toggle sidebar"),
analyticsName: "Toggle sidebar",
keywords: "hide show navigation",
section: NavigationSection,
perform: () => stores.ui.toggleCollapsedSidebar(),
});
export const openFeedbackUrl = createExternalLinkAction({
name: ({ t }) => t("Send us feedback"),
analyticsName: "Open feedback",
section: NavigationSection,
iconInContextMenu: false,
icon: <EmailIcon />,
url: UrlHelper.contact,
target: "_blank",
});
export const openBugReportUrl = createExternalLinkAction({
name: ({ t }) => t("Report a bug"),
analyticsName: "Open bug report",
section: NavigationSection,
iconInContextMenu: false,
icon: <BugIcon />,
url: UrlHelper.github,
target: "_blank",
});
export const openChangelog = createExternalLinkAction({
name: ({ t }) => t("Changelog"),
analyticsName: "Open changelog",
section: NavigationSection,
iconInContextMenu: false,
icon: <OpenIcon />,
url: UrlHelper.changelog,
target: "_blank",
});
export const openKeyboardShortcuts = createAction({
name: ({ t }) => t("Keyboard shortcuts"),
analyticsName: "Open keyboard shortcuts",
section: NavigationSection,
shortcut: ["?"],
iconInContextMenu: false,
icon: <KeyboardIcon />,
perform: ({ t }) => {
stores.dialogs.openGuide({
title: t("Keyboard shortcuts"),
content: <KeyboardShortcuts />,
});
},
});
export const downloadApp = createExternalLinkAction({
name: ({ t }) =>
t("Download {{ platform }} app", {
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",
});
export const logout = createAction({
name: ({ t }) => t("Log out"),
analyticsName: "Log out",
section: NavigationSection,
icon: <LogoutIcon />,
perform: async () => {
await stores.auth.logout({ userInitiated: true });
},
});
export const rootNavigationActions = [
navigateToHome,
navigateToDrafts,
navigateToArchive,
navigateToTrash,
downloadApp,
openDocumentation,
openAPIDocumentation,
openFeedbackUrl,
openBugReportUrl,
openChangelog,
openKeyboardShortcuts,
toggleSidebar,
logout,
];
-59
View File
@@ -1,59 +0,0 @@
import { ArchiveIcon, CheckmarkIcon, MarkAsReadIcon } from "outline-icons";
import { createAction } from "..";
import { NotificationSection } from "../sections";
import type Notification from "~/models/Notification";
export const markNotificationsAsRead = createAction({
name: ({ t }) => t("Mark notifications as read"),
analyticsName: "Mark notifications as read",
section: NotificationSection,
icon: <MarkAsReadIcon />,
shortcut: ["Shift+Escape"],
perform: ({ stores }) => stores.notifications.markAllAsRead(),
visible: ({ stores }) => stores.notifications.approximateUnreadCount > 0,
});
export const markNotificationsAsArchived = createAction({
name: ({ t }) => t("Archive all notifications"),
analyticsName: "Mark notifications as archived",
section: NotificationSection,
icon: <ArchiveIcon />,
iconInContextMenu: false,
perform: ({ stores }) => stores.notifications.markAllAsArchived(),
visible: ({ stores }) => stores.notifications.orderedData.length > 0,
});
export const 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,
];
-24
View File
@@ -1,24 +0,0 @@
import { PlusIcon } from "outline-icons";
import stores from "~/stores";
import { OAuthClientNew } from "~/components/OAuthClient/OAuthClientNew";
import { createAction } from "..";
import { SettingsSection } from "../sections";
export const createOAuthClient = createAction({
name: ({ t }) => t("New App"),
analyticsName: "New App",
section: SettingsSection,
icon: <PlusIcon />,
keywords: "create",
visible: () =>
stores.policies.abilities(stores.auth.team?.id || "").createOAuthClient,
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("New Application"),
content: <OAuthClientNew onSubmit={stores.dialogs.closeAllModals} />,
});
},
});
-180
View File
@@ -1,180 +0,0 @@
import copy from "copy-to-clipboard";
import { LinkIcon, RestoreIcon, TrashIcon, DownloadIcon } from "outline-icons";
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 { 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"),
analyticsName: "Restore revision",
icon: <RestoreIcon />,
section: RevisionSection,
visible: ({ activeDocumentId }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).update,
perform: async ({ event, location, activeDocumentId }) => {
event?.preventDefault();
if (!activeDocumentId) {
return;
}
const match = matchPath<{ revisionId: string }>(location.pathname, {
path: matchDocumentHistory,
});
const revisionId = match?.params.revisionId;
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
history.push(document.url, {
restore: true,
revisionId,
});
},
});
export const deleteRevision = createAction({
name: ({ t }) => t("Delete"),
analyticsName: "Delete revision",
icon: <TrashIcon />,
section: RevisionSection,
dangerous: true,
visible: ({ activeDocumentId }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).update,
perform: async ({ t, event, location, activeDocumentId }) => {
event?.preventDefault();
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
const match = matchPath<{ revisionId: string }>(location.pathname, {
path: matchDocumentHistory,
});
const revisionId = match?.params.revisionId;
if (revisionId) {
const revision = stores.revisions.get(revisionId);
await revision?.delete();
toast.success(t("This version of the document was deleted"));
history.push(documentHistoryPath(document));
}
},
});
export const copyLinkToRevision = (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 = [];
-65
View File
@@ -1,65 +0,0 @@
import { SunIcon, MoonIcon, BrowserIcon } from "outline-icons";
import { Theme } from "~/stores/UiStore";
import { createAction, createActionWithChildren } from "~/actions";
import { SettingsSection } from "~/actions/sections";
export const changeToDarkTheme = createAction({
name: ({ t }) => t("Dark"),
analyticsName: "Change to dark theme",
icon: <MoonIcon />,
iconInContextMenu: false,
keywords: "theme dark night",
section: SettingsSection,
selected: ({ stores }) => stores.ui.theme === "dark",
perform: ({ stores }) => stores.ui.setTheme(Theme.Dark),
});
export const changeToLightTheme = createAction({
name: ({ t }) => t("Light"),
analyticsName: "Change to light theme",
icon: <SunIcon />,
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
),
});
export const changeToSystemTheme = createAction({
name: ({ t }) => t("System"),
analyticsName: "Change to system theme",
icon: <BrowserIcon />,
iconInContextMenu: false,
keywords: "theme system default",
section: SettingsSection,
selected: ({ stores }) => stores.ui.theme === "system",
perform: ({ stores }) => stores.ui.setTheme(Theme.System),
});
export const changeTheme = createActionWithChildren({
name: ({ t, isMenu }) => (isMenu ? t("Appearance") : t("Change theme")),
analyticsName: "Change theme",
placeholder: ({ t }) => t("Change theme to"),
icon: ({ stores }) =>
stores.ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />,
keywords: "appearance display",
section: SettingsSection,
children: [changeToLightTheme, changeToDarkTheme, changeToSystemTheme],
});
export const rootSettingsActions = [changeTheme, toggleTheme];
-59
View File
@@ -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);
}
},
});
-100
View File
@@ -1,100 +0,0 @@
import { ArrowIcon, PlusIcon } from "outline-icons";
import styled from "styled-components";
import { stringToColor } from "@shared/utils/color";
import type 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 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: (
<StyledTeamLogo
alt={session.name}
model={{
initial: session.name[0],
avatarUrl: session.avatarUrl,
id: session.id,
color: stringToColor(session.id),
}}
size={24}
/>
),
visible: ({ currentTeamId }: ActionContext) =>
currentTeamId !== session.id,
url: session.url,
target: "_self",
})
) ?? [];
export const switchTeam = createActionWithChildren({
name: ({ t }) => t("Switch workspace"),
placeholder: ({ t }) => t("Select a workspace"),
analyticsName: "Switch workspace",
keywords: "change switch workspace organization team",
section: TeamSection,
visible: ({ stores }) =>
!!stores.auth.availableTeams && stores.auth.availableTeams?.length > 1,
children: switchTeamsList,
});
export const createTeam = createAction({
name: ({ t }) => `${t("New workspace")}`,
analyticsName: "New workspace",
keywords: "create change switch workspace organization team",
section: TeamSection,
icon: <PlusIcon />,
visible: ({ stores, currentTeamId }) =>
stores.policies.abilities(currentTeamId ?? "").createTeam,
perform: ({ t, event, stores }) => {
event?.preventDefault();
event?.stopPropagation();
const { user } = stores.auth;
if (user) {
stores.dialogs.openModal({
title: t("Create a workspace"),
content: <TeamNew user={user} />,
});
}
},
});
export const desktopLoginTeam = createAction({
name: ({ t }) => t("Login to workspace"),
analyticsName: "Login to workspace",
keywords: "change switch workspace organization team",
section: TeamSection,
icon: <ArrowIcon />,
visible: () => Desktop.isElectron(),
perform: ({ t, event, stores }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("Login to workspace"),
content: <LoginDialog />,
});
},
});
const StyledTeamLogo = styled(TeamLogo)`
border-radius: 2px;
border: 0;
`;
export const rootTeamActions = [switchTeam, createTeam, desktopLoginTeam];
-231
View File
@@ -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];
-92
View File
@@ -1,92 +0,0 @@
import { PlusIcon } from "outline-icons";
import type { UserRole } from "@shared/types";
import { UserRoleHelper } from "@shared/utils/UserRoleHelper";
import stores from "~/stores";
import type User from "~/models/User";
import Invite from "~/scenes/Invite";
import {
UserChangeRoleDialog,
UserDeleteDialog,
} from "~/components/UserDialogs";
import { createAction } from "~/actions";
import { UserSection } from "~/actions/sections";
export const inviteUser = createAction({
name: ({ t }) => `${t("Invite people")}`,
analyticsName: "Invite people",
icon: <PlusIcon />,
keywords: "team member workspace user",
section: UserSection,
visible: () =>
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} />,
});
},
});
export const updateUserRoleActionFactory = (user: User, role: UserRole) =>
createAction({
name: ({ t }) =>
UserRoleHelper.isRoleHigher(role, user!.role)
? `${t("Promote to {{ role }}", {
role: UserRoleHelper.displayName(role, t),
})}…`
: `${t("Demote to {{ role }}", {
role: UserRoleHelper.displayName(role, t),
})}…`,
analyticsName: "Update user role",
section: UserSection,
visible: () => {
const can = stores.policies.abilities(user.id);
return UserRoleHelper.isRoleHigher(role, user.role)
? can.promote
: UserRoleHelper.isRoleLower(role, user.role)
? can.demote
: false;
},
perform: ({ t }) => {
stores.dialogs.openModal({
title: t("Update role"),
content: (
<UserChangeRoleDialog
user={user}
role={role}
onSubmit={stores.dialogs.closeAllModals}
/>
),
});
},
});
export const deleteUserActionFactory = (userId: string) =>
createAction({
name: ({ t }) => `${t("Delete user")}`,
analyticsName: "Delete user",
keywords: "leave",
dangerous: true,
section: UserSection,
visible: () => stores.policies.abilities(userId).delete,
perform: ({ t }) => {
const user = stores.users.get(userId);
if (!user) {
return;
}
stores.dialogs.openModal({
title: t("Delete user"),
content: (
<UserDeleteDialog
user={user}
onSubmit={stores.dialogs.closeAllModals}
/>
),
});
},
});
export const rootUserActions = [inviteUser];
-298
View File
@@ -1,298 +0,0 @@
import type { LocationDescriptor } from "history";
import { toast } from "sonner";
import type { Optional } from "utility-types";
import { v4 as uuidv4 } from "uuid";
import type {
ActionContext,
Action,
ActionGroup,
ActionSeparator as TActionSeparator,
ActionVariant,
ActionWithChildren,
ExternalLinkAction,
InternalLinkAction,
MenuItem,
} 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 {
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 {
return {
...definition,
type: "action",
variant: "action",
perform: definition.perform
? (context) => {
// We must use the specific analytics name here as the action name is
// translated and potentially contains user strings.
if (definition.analyticsName) {
Analytics.track("perform_action", definition.analyticsName, {
context: context.isButton
? "button"
: context.isCommandBar
? "commandbar"
: "contextmenu",
});
}
return definition.perform(context);
}
: () => {},
id: definition.id ?? uuidv4(),
};
}
export function 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,
context: ActionContext
): MenuItem {
switch (action.type) {
case "action": {
const title = resolve<string>(action.name, context);
const visible = resolve<boolean>(action.visible, context) ?? true;
const disabled = resolve<boolean>(action.disabled, context);
const icon =
!!action.icon && action.iconInContextMenu !== false
? resolve<React.ReactNode>(action.icon, context)
: undefined;
switch (action.variant) {
case "action":
return {
type: "button",
title,
icon,
visible,
disabled,
tooltip: resolve<React.ReactChild>(action.tooltip, context),
selected: resolve<boolean>(action.selected, context),
dangerous: action.dangerous,
onClick: () => performAction(action, context),
};
case "internal_link": {
const to = resolve<LocationDescriptor>(action.to, context);
return {
type: "route",
title,
icon,
visible,
disabled,
to,
};
}
case "external_link":
return {
type: "link",
title,
icon,
visible,
disabled,
href: action.target
? { url: action.url, target: action.target }
: action.url,
};
case "action_with_children": {
const children = resolve<
(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" };
}
}
export function actionToKBar(
action: ActionVariant,
context: ActionContext
): KbarAction[] {
const visible = resolve<boolean>(action.visible, context);
if (visible === false) {
return [];
}
const name = resolve<string>(action.name, context);
const icon = resolve<React.ReactElement>(action.icon, context);
const section = resolve<string>(action.section, context);
const sectionPriority =
typeof action.section !== "string" && "priority" in action.section
? ((action.section.priority as number) ?? 0)
: 0;
const priority = (1 + (action.priority ?? 0)) * (1 + (sectionPriority ?? 0));
switch (action.variant) {
case "action":
case "internal_link":
case "external_link": {
return [
{
id: action.id,
name,
section,
keywords: action.keywords,
shortcut: action.shortcut,
icon,
priority,
perform: () => 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");
}
}
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();
if (result instanceof Promise) {
return result.catch((err: Error) => {
toast.error(err.message);
});
}
return result;
}
function hasVisibleItems(items: MenuItem[]) {
const applicableTypes = ["button", "link", "route", "group", "submenu"];
return items.some(
(item) => applicableTypes.includes(item.type) && item.visible
);
}
-21
View File
@@ -1,21 +0,0 @@
import { rootCollectionActions } from "./definitions/collections";
import { rootDeveloperActions } from "./definitions/developer";
import { rootDocumentActions } from "./definitions/documents";
import { rootNavigationActions } from "./definitions/navigation";
import { rootNotificationActions } from "./definitions/notifications";
import { rootRevisionActions } from "./definitions/revisions";
import { rootSettingsActions } from "./definitions/settings";
import { rootTeamActions } from "./definitions/teams";
import { rootUserActions } from "./definitions/users";
export default [
...rootCollectionActions,
...rootDocumentActions,
...rootUserActions,
...rootNavigationActions,
...rootNotificationActions,
...rootRevisionActions,
...rootSettingsActions,
...rootDeveloperActions,
...rootTeamActions,
];
-65
View File
@@ -1,65 +0,0 @@
import type { 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");
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");
-88
View File
@@ -1,88 +0,0 @@
/* oxlint-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 useIsMounted from "~/hooks/useIsMounted";
import useActionContext from "~/hooks/useActionContext";
import type { ActionVariant, ActionWithChildren } from "~/types";
export type Props = React.HTMLAttributes<HTMLButtonElement> & {
/** Show the button in a disabled state */
disabled?: boolean;
/** Hide the button entirely if action is not applicable */
hideOnActionDisabled?: boolean;
/** Action to use on button */
action?: Exclude<ActionVariant, ActionWithChildren>;
/** If tooltip props are provided the button will be wrapped in a tooltip */
tooltip?: Omit<TooltipProps, "children">;
};
/**
* Button that can be used to trigger an action definition.
*/
const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
function ActionButton_(
{ action, tooltip, hideOnActionDisabled, ...rest }: Props,
ref: React.Ref<HTMLButtonElement>
) {
const actionContext = useActionContext({
isButton: true,
});
const isMounted = useIsMounted();
const [executing, setExecuting] = React.useState(false);
if (!actionContext || !action) {
return <button {...rest} ref={ref} />;
}
const actionIsDisabled =
action.visible && !resolve<boolean>(action.visible, actionContext);
if (actionIsDisabled && hideOnActionDisabled) {
return null;
}
const disabled = rest.disabled || actionIsDisabled;
const label =
rest["aria-label"] ??
(typeof action.name === "function"
? action.name(actionContext)
: action.name);
const button = (
<button
{...rest}
aria-label={label}
disabled={disabled || executing}
ref={ref}
onClick={
actionContext
? (ev) => {
ev.preventDefault();
ev.stopPropagation();
const response = performAction(action, actionContext);
if (response?.finally) {
setExecuting(true);
void response.finally(
() => isMounted() && setExecuting(false)
);
}
}
: rest.onClick
}
>
{rest.children ?? label}
</button>
);
if (tooltip) {
return <Tooltip {...tooltip}>{button}</Tooltip>;
}
return button;
}
);
export default ActionButton;
+48
View File
@@ -0,0 +1,48 @@
// @flow
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
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;
&:empty {
display: none;
}
`;
export const Separator = styled.div`
flex-shrink: 0;
margin-left: 12px;
width: 1px;
height: 28px;
background: ${(props) => props.theme.divider};
`;
const Actions = styled(Flex)`
position: fixed;
top: 0;
right: 0;
left: 0;
border-radius: 3px;
background: ${(props) => props.theme.background};
transition: ${(props) => props.theme.backgroundTransition};
padding: 12px;
-webkit-backdrop-filter: blur(20px);
@media print {
display: none;
}
${breakpoint("tablet")`
left: auto;
padding: 24px;
`};
`;
export default Actions;
-46
View File
@@ -1,46 +0,0 @@
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import Flex from "~/components/Flex";
export const Action = styled(Flex)`
justify-content: center;
align-items: center;
height: 32px;
font-size: 15px;
flex-shrink: 0;
&:empty {
display: none;
}
`;
export const Separator = styled.div`
flex-shrink: 0;
width: 1px;
height: 28px;
background: ${s("divider")};
`;
const Actions = styled(Flex)`
position: fixed;
top: 0;
right: 0;
left: 0;
border-radius: 3px;
background: ${s("background")};
padding: 12px;
backdrop-filter: blur(20px);
gap: 12px;
@media print {
display: none;
}
${breakpoint("tablet")`
left: auto;
padding: 24px;
`};
`;
export default Actions;
+40
View File
@@ -0,0 +1,40 @@
// @flow
/* global ga */
import * as React from "react";
import env from "env";
type Props = {
children?: React.Node,
};
export default class Analytics extends React.Component<Props> {
componentDidMount() {
if (!env.GOOGLE_ANALYTICS_ID) return;
// standard Google Analytics script
window.ga =
window.ga ||
function () {
// $FlowIssue
(ga.q = ga.q || []).push(arguments);
};
// $FlowIssue
ga.l = +new Date();
ga("create", env.GOOGLE_ANALYTICS_ID, "auto");
ga("set", { dimension1: "true" });
ga("send", "pageview");
const script = document.createElement("script");
script.src = "https://www.google-analytics.com/analytics.js";
script.async = true;
if (document.body) {
document.body.appendChild(script);
}
}
render() {
return this.props.children || null;
}
}
-131
View File
@@ -1,131 +0,0 @@
/* oxlint-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 env from "~/env";
type Props = {
children?: React.ReactNode;
};
// TODO: Refactor this component to allow injection from plugins
const Analytics: React.FC = ({ children }: Props) => {
// Google Analytics 3
React.useEffect(() => {
if (!env.GOOGLE_ANALYTICS_ID?.startsWith("UA-")) {
return;
}
// standard Google Analytics script
window.ga =
window.ga ||
function (...args) {
(ga.q = ga.q || []).push(args);
};
ga.l = +new Date();
ga("create", env.GOOGLE_ANALYTICS_ID, "auto");
ga("send", "pageview");
const script = document.createElement("script");
script.type = "text/javascript";
script.src = "https://www.google-analytics.com/analytics.js";
script.async = true;
// Track PWA install event
window.addEventListener("appinstalled", () => {
ga("send", "event", "pwa", "install");
});
document.getElementsByTagName("head")[0]?.appendChild(script);
}, []);
// Google Analytics 4
React.useEffect(() => {
const measurementIds = [];
if (env.GOOGLE_ANALYTICS_ID?.startsWith("G-")) {
measurementIds.push(env.GOOGLE_ANALYTICS_ID);
}
(env.analytics as PublicEnv["analytics"]).forEach((integration) => {
if (integration.service === IntegrationService.GoogleAnalytics) {
measurementIds.push(escape(integration.settings?.measurementId));
}
});
if (measurementIds.length === 0) {
return;
}
const params = {
allow_google_signals: false,
restricted_data_processing: true,
};
window.dataLayer = window.dataLayer || [];
window.gtag = function () {
window.dataLayer.push(arguments);
};
window.gtag("js", new Date());
for (const measurementId of measurementIds) {
window.gtag("config", measurementId, params);
}
const script = document.createElement("script");
script.type = "text/javascript";
script.src = `https://www.googletagmanager.com/gtag/js?id=${measurementIds[0]}`;
script.async = true;
document.getElementsByTagName("head")[0]?.appendChild(script);
}, []);
// Matomo
React.useEffect(() => {
(env.analytics as PublicEnv["analytics"]).forEach((integration) => {
if (integration.service !== IntegrationService.Matomo) {
return;
}
// @ts-expect-error - Matomo global variable
const _paq = (window._paq = window._paq || []);
_paq.push(["trackPageView"]);
_paq.push(["enableLinkTracking"]);
(function () {
const u = integration.settings?.instanceUrl;
_paq.push(["setTrackerUrl", u + "matomo.php"]);
_paq.push(["setSiteId", integration.settings?.measurementId]);
const d = document,
g = d.createElement("script"),
s = d.getElementsByTagName("script")[0];
g.type = "text/javascript";
g.async = true;
g.src = u + "matomo.js";
s.parentNode?.insertBefore(g, s);
})();
});
}, []);
// Umami
React.useEffect(() => {
(env.analytics as PublicEnv["analytics"]).forEach((integration) => {
if (integration.service !== IntegrationService.Umami) {
return;
}
const script = document.createElement("script");
script.defer = true;
script.src = `${integration.settings?.instanceUrl}${integration.settings?.scriptName}`;
script.setAttribute(
"data-website-id",
integration.settings?.measurementId
);
document.getElementsByTagName("head")[0]?.appendChild(script);
});
}, []);
return <>{children}</>;
};
export default Analytics;
-14
View File
@@ -1,14 +0,0 @@
export default function Arrow() {
return (
<svg
width="13"
height="30"
viewBox="0 0 13 30"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M7.40242 1.48635C8.23085 0.0650039 10.0656 -0.421985 11.5005 0.39863C12.9354 1.21924 13.427 3.03671 12.5986 4.45806L5.59858 16.4681C4.77015 17.8894 2.93538 18.3764 1.5005 17.5558C0.065623 16.7352 -0.426002 14.9177 0.402425 13.4964L7.40242 1.48635Z" />
<path d="M12.5986 25.5419C13.427 26.9633 12.9354 28.7808 11.5005 29.6014C10.0656 30.422 8.23087 29.935 7.40244 28.5136L0.402438 16.5036C-0.425989 15.0823 0.0656365 13.2648 1.50051 12.4442C2.93539 11.6236 4.77016 12.1106 5.59859 13.5319L12.5986 25.5419Z" />
</svg>
);
}
-51
View File
@@ -1,51 +0,0 @@
import { RovingTabIndexProvider } from "@getoutline/react-roving-tabindex";
import { observer } from "mobx-react";
import * as React from "react";
type Props = React.HTMLAttributes<HTMLDivElement> & {
children: () => React.ReactNode;
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
items: unknown[];
};
function ArrowKeyNavigation(
{ children, onEscape, items, ...rest }: Props,
ref: React.RefObject<HTMLDivElement>
) {
const handleKeyDown = React.useCallback(
(ev: React.KeyboardEvent<HTMLDivElement>) => {
if (onEscape) {
if (ev.nativeEvent.isComposing) {
return;
}
if (ev.key === "Escape" || ev.key === "Backspace") {
ev.preventDefault();
onEscape(ev);
}
if (
ev.key === "ArrowUp" &&
// If the first item is focused and the user presses ArrowUp
ev.currentTarget.firstElementChild === document.activeElement
) {
onEscape(ev);
}
}
},
[onEscape]
);
return (
<RovingTabIndexProvider
options={{ focusOnClick: true, direction: "both" }}
items={items}
>
<div {...rest} onKeyDown={handleKeyDown} ref={ref}>
{children()}
</div>
</RovingTabIndexProvider>
);
}
export default observer(React.forwardRef(ArrowKeyNavigation));
+62
View File
@@ -0,0 +1,62 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Redirect } from "react-router-dom";
import { isCustomSubdomain } from "shared/utils/domains";
import LoadingIndicator from "components/LoadingIndicator";
import useStores from "../hooks/useStores";
import env from "env";
type Props = {
children: React.Node,
};
const Authenticated = ({ children }: Props) => {
const { auth } = useStores();
const { i18n } = useTranslation();
const language = auth.user && auth.user.language;
// Watching for language changes here as this is the earliest point we have
// the user available and means we can start loading translations faster
React.useEffect(() => {
if (language && i18n.language !== language) {
// Languages are stored in en_US format in the database, however the
// frontend translation framework (i18next) expects en-US
i18n.changeLanguage(language.replace("_", "-"));
}
}, [i18n, language]);
if (auth.authenticated) {
const { user, team } = auth;
const { hostname } = window.location;
if (!team || !user) {
return <LoadingIndicator />;
}
// If we're authenticated but viewing a domain that doesn't match the
// current team then kick the user to the teams correct domain.
if (team.domain) {
if (team.domain !== hostname) {
window.location.href = `${team.url}${window.location.pathname}`;
return <LoadingIndicator />;
}
} else if (
env.SUBDOMAINS_ENABLED &&
team.subdomain &&
isCustomSubdomain(hostname) &&
!hostname.startsWith(`${team.subdomain}.`)
) {
window.location.href = `${team.url}${window.location.pathname}`;
return <LoadingIndicator />;
}
return children;
}
auth.logout(true);
return <Redirect to="/" />;
};
export default observer(Authenticated);
-43
View File
@@ -1,43 +0,0 @@
import { observer } from "mobx-react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Redirect } from "react-router-dom";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { changeLanguage } from "~/utils/language";
import LoadingIndicator from "./LoadingIndicator";
type Props = {
children: JSX.Element;
};
const Authenticated = ({ children }: Props) => {
const { auth } = useStores();
const { i18n } = useTranslation();
const user = useCurrentUser({ rejectOnEmpty: false });
const language = user?.language;
// 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(() => {
void changeLanguage(language, i18n);
}, [i18n, language]);
if (auth.authenticated) {
return children;
}
if (auth.isFetching) {
return <LoadingIndicator />;
}
void auth.logout({ savePath: true });
if (auth.logoutRedirectUri) {
window.location.href = auth.logoutRedirectUri;
return null;
}
return <Redirect to="/" />;
};
export default observer(Authenticated);
-96
View File
@@ -1,96 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Switch, Route, Redirect } from "react-router-dom";
import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
import Layout from "~/components/Layout";
import RegisterKeyDown from "~/components/RegisterKeyDown";
import { RightSidebarProvider } from "~/components/RightSidebarContext";
import Sidebar from "~/components/Sidebar";
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";
import lazyWithRetry from "~/utils/lazyWithRetry";
import {
searchPath,
newDocumentPath,
settingsPath,
} 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")
);
type Props = {
children?: React.ReactNode;
};
const AuthenticatedLayout: React.FC = ({ children }: Props) => {
const { ui, auth } = useStores();
const layoutRef = React.useRef<HTMLDivElement>(null);
const canCollection = usePolicy(ui.activeCollectionId);
const team = useCurrentTeam();
const [spendPostLoginPath] = usePostLoginPath();
const goToSearch = (ev: KeyboardEvent) => {
if (!ev.metaKey && !ev.ctrlKey) {
ev.preventDefault();
ev.stopPropagation();
history.push(searchPath());
}
};
const goToNewDocument = (event: KeyboardEvent) => {
if (event.metaKey || event.altKey) {
return;
}
const { activeCollectionId } = ui;
if (!activeCollectionId || !canCollection.createDocument) {
return;
}
history.push(newDocumentPath(activeCollectionId));
};
if (auth.isSuspended) {
return <ErrorSuspended />;
}
const postLoginPath = spendPostLoginPath();
if (postLoginPath) {
return <Redirect to={postLoginPath} />;
}
const sidebar = (
<Fade>
<Switch>
<Route path={settingsPath()} component={SettingsSidebar} />
<Route component={Sidebar} />
</Switch>
</Fade>
);
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}
<CommandBar />
<NotificationBadge />
</Layout>
</PortalContext.Provider>
</RightSidebarProvider>
</DocumentContextProvider>
);
};
export default observer(AuthenticatedLayout);
+71
View File
@@ -0,0 +1,71 @@
// @flow
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import User from "models/User";
import placeholder from "./placeholder.png";
type Props = {|
src: string,
size: number,
icon?: React.Node,
user?: User,
onClick?: () => void,
className?: string,
|};
@observer
class Avatar extends React.Component<Props> {
@observable error: boolean;
static defaultProps = {
size: 24,
};
handleError = () => {
this.error = true;
};
render() {
const { src, icon, ...rest } = this.props;
return (
<AvatarWrapper>
<CircleImg
onError={this.handleError}
src={this.error ? placeholder : src}
{...rest}
/>
{icon && <IconWrapper>{icon}</IconWrapper>}
</AvatarWrapper>
);
}
}
const AvatarWrapper = styled.div`
position: relative;
`;
const IconWrapper = styled.div`
display: flex;
position: absolute;
bottom: -2px;
right: -2px;
background: ${(props) => props.theme.primary};
border: 2px solid ${(props) => props.theme.background};
border-radius: 100%;
width: 20px;
height: 20px;
`;
const CircleImg = styled.img`
display: block;
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
border-radius: 50%;
border: 2px solid ${(props) => props.theme.background};
flex-shrink: 0;
`;
export default Avatar;
-113
View File
@@ -1,113 +0,0 @@
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",
}
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 */
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 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}
>
{src && !error ? (
<Image onError={handleError} src={src} {...rest} />
) : model ? (
<Initials color={model.color} {...rest}>
{initial}
</Initials>
) : (
<Initials {...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 }>`
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 }>`
display: block;
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
`;
export default observer(Avatar);
@@ -0,0 +1,93 @@
// @flow
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
import { observable } from "mobx";
import { observer } from "mobx-react";
import { EditIcon } from "outline-icons";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import styled from "styled-components";
import User from "models/User";
import UserProfile from "scenes/UserProfile";
import Avatar from "components/Avatar";
import Tooltip from "components/Tooltip";
type Props = {
user: User,
isPresent: boolean,
isEditing: boolean,
isCurrentUser: boolean,
lastViewedAt: string,
t: TFunction,
};
@observer
class AvatarWithPresence extends React.Component<Props> {
@observable isOpen: boolean = false;
handleOpenProfile = () => {
this.isOpen = true;
};
handleCloseProfile = () => {
this.isOpen = false;
};
render() {
const {
user,
lastViewedAt,
isPresent,
isEditing,
isCurrentUser,
t,
} = this.props;
const action = isPresent
? isEditing
? t("currently editing")
: t("currently viewing")
: t("viewed {{ timeAgo }} ago", {
timeAgo: distanceInWordsToNow(new Date(lastViewedAt)),
});
return (
<>
<Tooltip
tooltip={
<Centered>
<strong>{user.name}</strong> {isCurrentUser && `(${t("You")})`}
<br />
{action}
</Centered>
}
placement="bottom"
>
<AvatarWrapper isPresent={isPresent}>
<Avatar
src={user.avatarUrl}
onClick={this.handleOpenProfile}
size={32}
icon={isEditing ? <EditIcon size={16} color="#FFF" /> : undefined}
/>
</AvatarWrapper>
</Tooltip>
<UserProfile
user={user}
isOpen={this.isOpen}
onRequestClose={this.handleCloseProfile}
/>
</>
);
}
}
const Centered = styled.div`
text-align: center;
`;
const AvatarWrapper = styled.div`
opacity: ${(props) => (props.isPresent ? 1 : 0.5)};
transition: opacity 250ms ease-in-out;
`;
export default withTranslation()<AvatarWithPresence>(AvatarWithPresence);
@@ -1,166 +0,0 @@
import { observer } from "mobx-react";
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 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,
isPresent,
isEditing,
isObserving,
isCurrentUser,
size = AvatarSize.Large,
style,
alt,
}: Props) {
const { t } = useTranslation();
const status = isPresent
? isEditing
? t("currently editing")
: t("currently viewing")
: t("previously edited");
return (
<>
<Tooltip
content={
<Centered>
<strong>{user.name}</strong> {isCurrentUser && `(${t("You")})`}
{status && (
<>
<br />
{status}
</>
)}
</Centered>
}
placement="bottom"
>
<AvatarPresence
$isPresent={isPresent}
$isObserving={isObserving}
$color={user.color}
style={style}
>
<Avatar model={user} onClick={onClick} size={size} alt={alt} />
</AvatarPresence>
</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>`
opacity: ${(props) => (props.$isPresent ? 1 : 0.5)};
transition: opacity 250ms ease-in-out;
border-radius: 50%;
position: relative;
${(props) =>
props.$isPresent &&
css<AvatarWrapperProps>`
&:after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 50%;
transition: border-color 100ms ease-in-out;
border: 2px solid transparent;
pointer-events: none;
${(props) =>
props.$isObserving &&
css`
border: 2px solid ${props.$color};
box-shadow: inset 0 0 0 2px ${props.theme.background};
&:hover {
top: -1px;
left: -1px;
right: -1px;
bottom: -1px;
}
`}
}
&:hover:after {
border: 2px solid ${(props) => props.$color};
box-shadow: inset 0 0 0 2px ${s("background")};
}
`}
`;
export default observer(AvatarWithPresence);
-35
View File
@@ -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>
);
}
-32
View File
@@ -1,32 +0,0 @@
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;
}>`
align-items: center;
justify-content: center;
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};
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
flex-shrink: 0;
// adjust font size down for each additional character
font-size: ${(props) => props.size / 2 - (props.content?.length ?? 0)}px;
font-weight: 500;
`;
export default Initials;
+6
View File
@@ -0,0 +1,6 @@
// @flow
import Avatar from "./Avatar";
import AvatarWithPresence from "./AvatarWithPresence";
export { AvatarWithPresence };
export default Avatar;
-8
View File
@@ -1,8 +0,0 @@
import type { IAvatar } from "./Avatar";
import Avatar, { AvatarSize, AvatarVariant } from "./Avatar";
import AvatarWithPresence from "./AvatarWithPresence";
import { GroupAvatar } from "./GroupAvatar";
export { Avatar, GroupAvatar, AvatarSize, AvatarVariant, AvatarWithPresence };
export type { IAvatar };
Binary file not shown.

After

Width:  |  Height:  |  Size: 976 B

+18
View File
@@ -0,0 +1,18 @@
// @flow
import styled from "styled-components";
const Badge = styled.span`
margin-left: 10px;
padding: 2px 6px 3px;
background-color: ${({ yellow, primary, theme }) =>
yellow ? theme.yellow : primary ? theme.primary : theme.textTertiary};
color: ${({ primary, yellow, theme }) =>
primary ? theme.white : yellow ? theme.almostBlack : theme.background};
border-radius: 4px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
user-select: none;
`;
export default Badge;
-27
View File
@@ -1,27 +0,0 @@
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;
background-color: ${({ yellow, primary, theme }) =>
yellow ? theme.yellow : primary ? theme.accent : "transparent"};
color: ${({ primary, yellow, theme }) =>
primary
? theme.accentText
: yellow
? theme.almostBlack
: theme.textTertiary};
border: 1px solid
${({ primary, yellow, theme }) =>
primary || yellow
? "transparent"
: transparentize(0.4, theme.textTertiary)};
border-radius: 8px;
font-size: 12px;
font-weight: 500;
user-select: none;
white-space: nowrap;
`;
export default Badge;
+43
View File
@@ -0,0 +1,43 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import OutlineLogo from "./OutlineLogo";
import env from "env";
type Props = {
href?: string,
};
function Branding({ href = env.URL }: Props) {
return (
<Link href={href}>
<OutlineLogo size={16} />
&nbsp;Outline
</Link>
);
}
const Link = styled.a`
position: fixed;
bottom: 0;
left: 0;
font-weight: 600;
font-size: 14px;
text-decoration: none;
border-top-right-radius: 2px;
color: ${(props) => props.theme.text};
display: flex;
align-items: center;
padding: 16px;
svg {
fill: ${(props) => props.theme.text};
}
&:hover {
background: ${(props) => props.theme.sidebarBackground};
}
`;
export default Branding;
-48
View File
@@ -1,48 +0,0 @@
import * as React from "react";
import styled from "styled-components";
import { depths, s } from "@shared/styles";
import env from "~/env";
import OutlineIcon from "./Icons/OutlineIcon";
type Props = {
href?: string;
};
function Branding({ href = env.URL }: Props) {
return (
<Link href={href} target="_blank">
<OutlineIcon size={20} />
&nbsp;{env.APP_NAME}
</Link>
);
}
const Link = styled.a`
justify-content: center;
padding-bottom: 16px;
font-weight: 600;
font-size: 14px;
text-decoration: none;
border-top-right-radius: 2px;
color: ${s("text")};
display: flex;
align-items: center;
svg {
fill: ${s("text")};
}
z-index: ${depths.sidebar + 1};
background: ${s("sidebarBackground")};
position: fixed;
bottom: 0;
right: 0;
padding: 16px;
&:hover {
background: ${s("sidebarControlHoverBackground")};
}
`;
export default React.memo(Branding);
+204
View File
@@ -0,0 +1,204 @@
// @flow
import { observer } from "mobx-react";
import {
ArchiveIcon,
EditIcon,
GoToIcon,
PadlockIcon,
ShapesIcon,
TrashIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Document from "models/Document";
import CollectionIcon from "components/CollectionIcon";
import Flex from "components/Flex";
import useStores from "hooks/useStores";
import BreadcrumbMenu from "menus/BreadcrumbMenu";
import { collectionUrl } from "utils/routeHelpers";
type Props = {
document: Document,
onlyText: boolean,
};
function Icon({ document }) {
const { t } = useTranslation();
if (document.isDeleted) {
return (
<>
<CategoryName to="/trash">
<TrashIcon color="currentColor" />
&nbsp;
<span>{t("Trash")}</span>
</CategoryName>
<Slash />
</>
);
}
if (document.isArchived) {
return (
<>
<CategoryName to="/archive">
<ArchiveIcon color="currentColor" />
&nbsp;
<span>{t("Archive")}</span>
</CategoryName>
<Slash />
</>
);
}
if (document.isDraft) {
return (
<>
<CategoryName to="/drafts">
<EditIcon color="currentColor" />
&nbsp;
<span>{t("Drafts")}</span>
</CategoryName>
<Slash />
</>
);
}
if (document.isTemplate) {
return (
<>
<CategoryName to="/templates">
<ShapesIcon color="currentColor" />
&nbsp;
<span>{t("Templates")}</span>
</CategoryName>
<Slash />
</>
);
}
return null;
}
const Breadcrumb = ({ document, onlyText }: Props) => {
const { collections } = useStores();
const { t } = useTranslation();
let collection = collections.get(document.collectionId);
if (!collection) {
collection = {
id: document.collectionId,
name: t("Deleted Collection"),
color: "currentColor",
};
}
const path = collection.pathToDocument
? collection.pathToDocument(document.id).slice(0, -1)
: [];
if (onlyText === true) {
return (
<>
{collection.private && (
<>
<SmallPadlockIcon color="currentColor" size={16} />{" "}
</>
)}
{collection.name}
{path.map((n) => (
<React.Fragment key={n.id}>
<SmallSlash />
{n.title}
</React.Fragment>
))}
</>
);
}
const isNestedDocument = path.length > 1;
const lastPath = path.length ? path[path.length - 1] : undefined;
const menuPath = isNestedDocument ? path.slice(0, -1) : [];
return (
<Wrapper justify="flex-start" align="center">
<Icon document={document} />
<CollectionName to={collectionUrl(collection.id)}>
<CollectionIcon collection={collection} expanded />
&nbsp;
<span>{collection.name}</span>
</CollectionName>
{isNestedDocument && (
<>
<Slash /> <BreadcrumbMenu path={menuPath} />
</>
)}
{lastPath && (
<>
<Slash />{" "}
<Crumb to={lastPath.url} title={lastPath.title}>
{lastPath.title}
</Crumb>
</>
)}
</Wrapper>
);
};
export const Slash = styled(GoToIcon)`
flex-shrink: 0;
fill: ${(props) => props.theme.divider};
`;
const Wrapper = styled(Flex)`
display: none;
${breakpoint("tablet")`
display: flex;
`};
`;
const SmallPadlockIcon = styled(PadlockIcon)`
display: inline-block;
vertical-align: sub;
`;
const SmallSlash = styled(GoToIcon)`
width: 15px;
height: 10px;
flex-shrink: 0;
opacity: 0.25;
`;
const Crumb = styled(Link)`
color: ${(props) => props.theme.text};
font-size: 15px;
height: 24px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
&:hover {
text-decoration: underline;
}
`;
const CollectionName = styled(Link)`
display: flex;
flex-shrink: 1;
color: ${(props) => props.theme.text};
font-size: 15px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
min-width: 0;
svg {
flex-shrink: 0;
}
`;
const CategoryName = styled(CollectionName)`
flex-shrink: 0;
`;
export default observer(Breadcrumb);
-125
View File
@@ -1,125 +0,0 @@
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";
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";
type TopLevelAction =
| InternalLinkAction
| { type: "menu"; actions: InternalLinkAction[] };
type Props = React.PropsWithChildren<{
actions: InternalLinkAction[];
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];
// chop middle breadcrumbs and present a "..." menu instead
if (totalVisibleActions > max) {
const halfMax = Math.floor(max / 2);
const menuActions = topLevelActions.splice(
halfMax,
totalVisibleActions - max
) as InternalLinkAction[];
topLevelActions.splice(halfMax, 0, {
type: "menu",
actions: menuActions,
});
}
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}
</React.Fragment>
))}
{children}
</Flex>
);
}
const Slash = styled(GoToIcon)`
flex-shrink: 0;
fill: ${s("divider")};
`;
const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
${ellipsis()}
${undraggableOnDesktop()}
display: flex;
flex-shrink: 1;
min-width: 0;
cursor: var(--pointer);
color: ${s("text")};
font-size: 15px;
height: 24px;
font-weight: ${(props) => (props.$highlight ? "500" : "inherit")};
margin-left: ${(props) => (props.$withIcon ? "4px" : "0")};
svg {
flex-shrink: 0;
}
&:hover {
text-decoration: underline;
}
`;
export default observer(React.forwardRef<HTMLDivElement, Props>(Breadcrumb));
+163
View File
@@ -0,0 +1,163 @@
// @flow
import { ExpandedIcon } from "outline-icons";
import { darken } from "polished";
import * as React from "react";
import styled from "styled-components";
const RealButton = styled.button`
display: ${(props) => (props.fullwidth ? "block" : "inline-block")};
width: ${(props) => (props.fullwidth ? "100%" : "auto")};
margin: 0;
padding: 0;
border: 0;
background: ${(props) => props.theme.buttonBackground};
color: ${(props) => props.theme.buttonText};
box-shadow: rgba(0, 0, 0, 0.2) 0px 1px 2px;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
height: 32px;
text-decoration: none;
flex-shrink: 0;
cursor: pointer;
user-select: none;
${(props) =>
!props.borderOnHover &&
`
svg {
fill: ${props.iconColor || props.theme.buttonText};
}
`}
&::-moz-focus-inner {
padding: 0;
border: 0;
}
&:hover {
background: ${(props) => darken(0.05, props.theme.buttonBackground)};
}
&:disabled {
cursor: default;
pointer-events: none;
color: ${(props) => props.theme.white50};
}
${(props) =>
props.$neutral &&
`
background: ${props.theme.buttonNeutralBackground};
color: ${props.theme.buttonNeutralText};
box-shadow: ${
props.borderOnHover
? "none"
: `rgba(0, 0, 0, 0.07) 0px 1px 2px, ${props.theme.buttonNeutralBorder} 0 0 0 1px inset`
};
${
props.borderOnHover
? ""
: `svg {
fill: ${props.iconColor || props.theme.buttonNeutralText};
}`
}
&:hover {
background: ${darken(0.05, props.theme.buttonNeutralBackground)};
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, ${
props.theme.buttonNeutralBorder
} 0 0 0 1px inset;
}
&:disabled {
color: ${props.theme.textTertiary};
}
`} ${(props) =>
props.danger &&
`
background: ${props.theme.danger};
color: ${props.theme.white};
&:hover {
background: ${darken(0.05, props.theme.danger)};
}
`};
`;
const Label = styled.span`
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
${(props) => props.hasIcon && "padding-left: 4px;"};
`;
export const Inner = styled.span`
display: flex;
padding: 0 8px;
padding-right: ${(props) => (props.disclosure ? 2 : 8)}px;
line-height: ${(props) => (props.hasIcon ? 24 : 32)}px;
justify-content: center;
align-items: center;
min-height: 32px;
${(props) => props.hasIcon && props.hasText && "padding-left: 4px;"};
${(props) => props.hasIcon && !props.hasText && "padding: 0 4px;"};
`;
export type Props = {|
type?: "button" | "submit",
value?: string,
icon?: React.Node,
iconColor?: string,
className?: string,
children?: React.Node,
innerRef?: React.ElementRef<any>,
disclosure?: boolean,
neutral?: boolean,
danger?: boolean,
primary?: boolean,
disabled?: boolean,
fullwidth?: boolean,
autoFocus?: boolean,
style?: Object,
as?: React.ComponentType<any>,
to?: string,
onClick?: (event: SyntheticEvent<>) => mixed,
borderOnHover?: boolean,
"data-on"?: string,
"data-event-category"?: string,
"data-event-action"?: string,
|};
function Button({
type = "text",
icon,
children,
value,
disclosure,
innerRef,
neutral,
...rest
}: Props) {
const hasText = children !== undefined || value !== undefined;
const hasIcon = icon !== undefined;
return (
<RealButton type={type} ref={innerRef} $neutral={neutral} {...rest}>
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
{hasIcon && icon}
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
{disclosure && <ExpandedIcon />}
</Inner>
</RealButton>
);
}
export default React.forwardRef<Props, typeof Button>((props, ref) => (
<Button {...props} innerRef={ref} />
));
-209
View File
@@ -1,209 +0,0 @@
import type { LocationDescriptor } from "history";
import { DisclosureIcon } from "outline-icons";
import { darken, lighten, transparentize } from "polished";
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
import type { Props as ActionButtonProps } from "~/components/ActionButton";
import ActionButton from "~/components/ActionButton";
import { undraggableOnDesktop } from "~/styles";
type RealProps = {
$fullwidth?: boolean;
$borderOnHover?: boolean;
$neutral?: boolean;
$danger?: boolean;
};
const RealButton = styled(ActionButton)<RealProps>`
display: ${(props) => (props.$fullwidth ? "block" : "inline-block")};
width: ${(props) => (props.$fullwidth ? "100%" : "auto")};
margin: 0;
padding: 0;
border: 0;
background: ${s("accent")};
color: ${s("accentText")};
box-shadow: rgba(0, 0, 0, 0.2) 0px 1px 2px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
height: 32px;
text-decoration: none;
flex-shrink: 0;
cursor: var(--pointer);
user-select: none;
appearance: none !important;
transition: background 200ms ease-out;
${undraggableOnDesktop()}
&::-moz-focus-inner {
padding: 0;
border: 0;
}
&:hover:not(:disabled),
&[aria-expanded="true"] {
background: ${(props) => darken(0.05, props.theme.accent)};
transition: background 0s;
}
&:disabled {
cursor: default;
pointer-events: none;
color: ${(props) => transparentize(0.3, props.theme.accentText)};
background: ${(props) => transparentize(0.1, props.theme.accent)};
svg {
fill: ${(props) => props.theme.white50};
}
}
${(props) =>
props.$neutral &&
`
background: inherit;
color: ${props.theme.buttonNeutralText};
box-shadow: ${
props.$borderOnHover
? "none"
: `rgba(0, 0, 0, 0.07) 0px 1px 2px, ${props.theme.buttonNeutralBorder} 0 0 0 1px inset`
};
&:hover:not(:disabled),
&[aria-expanded="true"] {
background: ${
props.$borderOnHover
? props.theme.buttonNeutralBackground
: darken(0.05, props.theme.buttonNeutralBackground)
};
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 {
color: ${props.theme.textTertiary};
background: none;
svg {
fill: currentColor;
}
}
`}
${(props) =>
props.$danger &&
`
background: ${props.theme.danger};
color: ${props.theme.white};
&: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 {
outline-color: ${darken(0.2, props.theme.danger)} !important;
}
`};
`;
const Label = styled.span<{ hasIcon?: boolean }>`
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
${(props) => props.hasIcon && "padding-left: 4px;"};
`;
export const Inner = styled.span<{
disclosure?: boolean;
hasIcon?: boolean;
hasText?: boolean;
}>`
display: flex;
padding: 0 8px;
padding-right: ${(props) => (props.disclosure ? 2 : 8)}px;
line-height: ${(props) => (props.hasIcon ? 24 : 32)}px;
justify-content: center;
align-items: center;
min-height: 32px;
${(props) => props.hasIcon && props.hasText && "padding-left: 4px;"};
${(props) => props.hasIcon && !props.hasText && "padding: 0 4px;"};
`;
export type Props<T> = ActionButtonProps & {
icon?: React.ReactNode;
children?: React.ReactNode;
disclosure?: boolean;
neutral?: boolean;
danger?: boolean;
fullwidth?: boolean;
as?: T;
to?: LocationDescriptor;
borderOnHover?: boolean;
hideIcon?: boolean;
href?: string;
"data-on"?: string;
"data-event-category"?: string;
"data-event-action"?: string;
};
const Button = <T extends React.ElementType = "button">(
props: Props<T> & React.ComponentPropsWithoutRef<T>,
ref: React.Ref<HTMLButtonElement>
) => {
const {
type,
children,
value,
disclosure,
neutral,
action,
icon,
borderOnHover,
hideIcon,
fullwidth,
danger,
...rest
} = props;
const hasText = !!children || value !== undefined;
const ic = hideIcon ? undefined : (action?.icon ?? icon);
const hasIcon = ic !== undefined;
return (
<RealButton
type={type || "button"}
ref={ref}
$neutral={neutral}
action={action}
$danger={danger}
$fullwidth={fullwidth}
$borderOnHover={borderOnHover}
{...rest}
>
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
{hasIcon && ic}
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
{disclosure && <StyledDisclosureIcon />}
</Inner>
</RealButton>
);
};
const StyledDisclosureIcon = styled(DisclosureIcon)`
opacity: 0.8;
`;
export default React.forwardRef(Button);
+13
View File
@@ -0,0 +1,13 @@
// @flow
import styled from "styled-components";
import Button, { Inner } from "./Button";
const ButtonLarge = styled(Button)`
height: 40px;
${Inner} {
padding: 4px 16px;
}
`;
export default ButtonLarge;

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