mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf5e365c7a |
@@ -35,14 +35,6 @@
|
||||
"displayName": false
|
||||
}
|
||||
]
|
||||
],
|
||||
"ignore": [
|
||||
"**/*.test.ts"
|
||||
]
|
||||
},
|
||||
"development": {
|
||||
"ignore": [
|
||||
"**/*.test.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+83
-122
@@ -1,102 +1,51 @@
|
||||
version: 2.1
|
||||
|
||||
defaults: &defaults
|
||||
working_directory: ~/outline
|
||||
docker:
|
||||
- image: cimg/node:14.19
|
||||
- image: cimg/redis:5.0
|
||||
- image: cimg/postgres:14.2
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_DB: circle_test
|
||||
resource_class: large
|
||||
environment:
|
||||
NODE_ENV: test
|
||||
SECRET_KEY: F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
|
||||
DATABASE_URL_TEST: postgres://postgres:password@localhost:5432/circle_test
|
||||
DATABASE_URL: postgres://postgres:password@localhost:5432/circle_test
|
||||
URL: http://localhost:3000
|
||||
SMTP_FROM_EMAIL: hello@example.com
|
||||
AWS_S3_UPLOAD_BUCKET_URL: https://s3.amazonaws.com
|
||||
AWS_S3_UPLOAD_BUCKET_NAME: outline-circle
|
||||
|
||||
executors:
|
||||
docker-publisher:
|
||||
environment:
|
||||
IMAGE_NAME: outlinewiki/outline
|
||||
BASE_IMAGE_NAME: outlinewiki/outline-base
|
||||
docker:
|
||||
- image: circleci/buildpack-deps:stretch
|
||||
|
||||
jobs:
|
||||
build:
|
||||
<<: *defaults
|
||||
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 --frozen-lockfile
|
||||
command: yarn install --pure-lockfile
|
||||
- save_cache:
|
||||
key: dependency-cache-{{ checksum "package.json" }}
|
||||
paths:
|
||||
- ./node_modules
|
||||
lint:
|
||||
<<: *defaults
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: dependency-cache-{{ checksum "package.json" }}
|
||||
- run:
|
||||
name: lint
|
||||
command: yarn lint
|
||||
types:
|
||||
<<: *defaults
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: dependency-cache-{{ checksum "package.json" }}
|
||||
- run:
|
||||
name: typescript
|
||||
command: yarn tsc
|
||||
test-app:
|
||||
<<: *defaults
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: dependency-cache-{{ checksum "package.json" }}
|
||||
- run:
|
||||
name: test
|
||||
command: yarn test:app
|
||||
test-shared:
|
||||
<<: *defaults
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: dependency-cache-{{ checksum "package.json" }}
|
||||
- run:
|
||||
name: test
|
||||
command: yarn test:shared
|
||||
test-server:
|
||||
<<: *defaults
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: dependency-cache-{{ checksum "package.json" }}
|
||||
- run:
|
||||
name: migrate
|
||||
command: ./node_modules/.bin/sequelize db:migrate --url $DATABASE_URL_TEST
|
||||
- run:
|
||||
name: lint
|
||||
command: yarn lint
|
||||
- run:
|
||||
name: typescript
|
||||
command: yarn tsc
|
||||
- run:
|
||||
name: test
|
||||
command: yarn test:server --forceExit
|
||||
bundle-size:
|
||||
<<: *defaults
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: dependency-cache-{{ checksum "package.json" }}
|
||||
command: yarn test
|
||||
- run:
|
||||
name: build-webpack
|
||||
command: yarn build:webpack
|
||||
@@ -107,63 +56,59 @@ jobs:
|
||||
- setup_remote_docker:
|
||||
version: 20.10.6
|
||||
- run:
|
||||
name: Install Docker buildx
|
||||
name: Build Docker image
|
||||
command: docker build -t $IMAGE_NAME:latest .
|
||||
- run:
|
||||
name: Archive Docker image
|
||||
command: docker save -o image.tar $IMAGE_NAME
|
||||
- persist_to_workspace:
|
||||
root: .
|
||||
paths:
|
||||
- ./image.tar
|
||||
publish-latest:
|
||||
executor: docker-publisher
|
||||
steps:
|
||||
- attach_workspace:
|
||||
at: /tmp/workspace
|
||||
- setup_remote_docker:
|
||||
version: 20.10.6
|
||||
- run:
|
||||
name: Load archived Docker image
|
||||
command: docker load -i /tmp/workspace/image.tar
|
||||
- run:
|
||||
name: Publish Docker Image to Docker Hub
|
||||
command: |
|
||||
mkdir -p ~/.docker/cli-plugins
|
||||
url="https://github.com/docker/buildx/releases/download/v0.8.0/buildx-v0.8.0.linux-amd64"
|
||||
curl -sSL -o ~/.docker/cli-plugins/docker-buildx $url
|
||||
chmod a+x ~/.docker/cli-plugins/docker-buildx
|
||||
echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
|
||||
IMAGE_TAG=${CIRCLE_TAG/v/''}
|
||||
docker tag $IMAGE_NAME:latest $IMAGE_NAME:$IMAGE_TAG
|
||||
docker push $IMAGE_NAME:latest
|
||||
docker push $IMAGE_NAME:$IMAGE_TAG
|
||||
publish-tag:
|
||||
executor: docker-publisher
|
||||
steps:
|
||||
- attach_workspace:
|
||||
at: /tmp/workspace
|
||||
- setup_remote_docker:
|
||||
version: 20.10.6
|
||||
- run:
|
||||
name: Enable Docker buildx
|
||||
command: export DOCKER_CLI_EXPERIMENTAL=enabled
|
||||
name: Load archived Docker image
|
||||
command: docker load -i /tmp/workspace/image.tar
|
||||
- run:
|
||||
name: Initialize Docker buildx
|
||||
name: Publish Docker Image to Docker Hub
|
||||
command: |
|
||||
docker buildx install
|
||||
docker context create docker-multiarch
|
||||
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
|
||||
docker buildx create --name docker-multiarch --platform linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x docker-multiarch
|
||||
docker buildx inspect --builder docker-multiarch --bootstrap
|
||||
docker buildx use docker-multiarch
|
||||
- run:
|
||||
name: Build base image
|
||||
command: docker build -f Dockerfile.base -t $BASE_IMAGE_NAME:latest --load .
|
||||
- run:
|
||||
name: Login to Docker Hub
|
||||
command: echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
|
||||
- run:
|
||||
name: Publish base Docker Image to Docker Hub
|
||||
command: docker push $BASE_IMAGE_NAME:latest
|
||||
- run:
|
||||
name: Build and push Docker image
|
||||
command: docker buildx build -t $IMAGE_NAME:latest -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
|
||||
echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
|
||||
IMAGE_TAG=${CIRCLE_TAG/v/''}
|
||||
docker tag $IMAGE_NAME:latest $IMAGE_NAME:$IMAGE_TAG
|
||||
docker push $IMAGE_NAME:$IMAGE_TAG
|
||||
|
||||
workflows:
|
||||
version: 2
|
||||
all:
|
||||
build-and-test:
|
||||
jobs:
|
||||
- build
|
||||
- lint:
|
||||
requires:
|
||||
- build
|
||||
- test-server:
|
||||
requires:
|
||||
- build
|
||||
- test-shared:
|
||||
requires:
|
||||
- build
|
||||
- test-app:
|
||||
requires:
|
||||
- build
|
||||
- types:
|
||||
requires:
|
||||
- build
|
||||
- bundle-size:
|
||||
requires:
|
||||
- test-app
|
||||
- test-shared
|
||||
- test-server
|
||||
|
||||
- build:
|
||||
filters:
|
||||
tags:
|
||||
ignore: /^v.*/
|
||||
build-docker:
|
||||
jobs:
|
||||
- build-image:
|
||||
@@ -172,3 +117,19 @@ workflows:
|
||||
only: /^v.*/
|
||||
branches:
|
||||
ignore: /.*/
|
||||
- publish-latest:
|
||||
requires:
|
||||
- build-image
|
||||
filters:
|
||||
tags:
|
||||
only: /^v\d+\.\d+\.\d+$/
|
||||
branches:
|
||||
ignore: /.*/
|
||||
- publish-tag:
|
||||
requires:
|
||||
- build-image
|
||||
filters:
|
||||
tags:
|
||||
only: /^v\d+\.\d+\.\d+-.*$/
|
||||
branches:
|
||||
ignore: /.*/
|
||||
|
||||
+10
-26
@@ -1,7 +1,5 @@
|
||||
# –––––––––––––––– REQUIRED ––––––––––––––––
|
||||
|
||||
NODE_ENV=production
|
||||
|
||||
# Generate a hex-encoded 32-byte random key. You should use `openssl rand -hex 32`
|
||||
# in your terminal to generate a random value.
|
||||
SECRET_KEY=generate_a_new_key
|
||||
@@ -12,21 +10,11 @@ UTILS_SECRET=generate_a_new_key
|
||||
|
||||
# For production point these at your databases, in development the default
|
||||
# should work out of the box.
|
||||
DATABASE_URL=postgres://user:pass@localhost:5432/outline
|
||||
DATABASE_URL_TEST=postgres://user:pass@localhost:5432/outline-test
|
||||
DATABASE_CONNECTION_POOL_MIN=
|
||||
DATABASE_CONNECTION_POOL_MAX=
|
||||
DATABASE_URL=postgres://user:pass@localhost:5532/outline
|
||||
DATABASE_URL_TEST=postgres://user:pass@localhost:5532/outline-test
|
||||
# Uncomment this to disable SSL for connecting to Postgres
|
||||
# PGSSLMODE=disable
|
||||
|
||||
# For redis you can either specify an ioredis compatible url like this
|
||||
REDIS_URL=redis://localhost:6379
|
||||
# or alternatively, if you would like to provide addtional connection options,
|
||||
# use a base64 encoded JSON connection option object. Refer to the ioredis documentation
|
||||
# for a list of available options.
|
||||
# Example: Use Redis Sentinel for high availability
|
||||
# {"sentinels":[{"host":"sentinel-0","port":26379},{"host":"sentinel-1","port":26379}],"name":"mymaster"}
|
||||
# REDIS_URL=ioredis://eyJzZW50aW5lbHMiOlt7Imhvc3QiOiJzZW50aW5lbC0wIiwicG9ydCI6MjYzNzl9LHsiaG9zdCI6InNlbnRpbmVsLTEiLCJwb3J0IjoyNjM3OX1dLCJuYW1lIjoibXltYXN0ZXIifQ==
|
||||
REDIS_URL=redis://localhost:6479
|
||||
|
||||
# URL should point to the fully qualified, publicly accessible URL. If using a
|
||||
# proxy the port in URL and PORT may be different.
|
||||
@@ -48,7 +36,6 @@ COLLABORATION_URL=
|
||||
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_UPLOAD_MAX_SIZE=26214400
|
||||
@@ -67,8 +54,8 @@ AWS_S3_ACL=private
|
||||
#
|
||||
# When configuring the Client ID, add a redirect URL under "OAuth & Permissions":
|
||||
# https://<URL>/auth/slack.callback
|
||||
SLACK_CLIENT_ID=get_a_key_from_slack
|
||||
SLACK_CLIENT_SECRET=get_the_secret_of_above_key
|
||||
SLACK_KEY=get_a_key_from_slack
|
||||
SLACK_SECRET=get_the_secret_of_above_key
|
||||
|
||||
# To configure Google auth, you'll need to create an OAuth Client ID at
|
||||
# => https://console.cloud.google.com/apis/credentials
|
||||
@@ -102,7 +89,7 @@ OIDC_USERNAME_CLAIM=preferred_username
|
||||
OIDC_DISPLAY_NAME=OpenID
|
||||
|
||||
# Space separated auth scopes.
|
||||
OIDC_SCOPES=openid profile email
|
||||
OIDC_SCOPES="openid profile email"
|
||||
|
||||
|
||||
# –––––––––––––––– OPTIONAL ––––––––––––––––
|
||||
@@ -139,6 +126,10 @@ MAXIMUM_IMPORT_SIZE=5120000
|
||||
# requests and this ends up being duplicative
|
||||
DEBUG=http
|
||||
|
||||
# Comma separated list of domains to be allowed to signin to the wiki. If not
|
||||
# set, all domains are allowed by default when using Google OAuth to signin
|
||||
ALLOWED_DOMAINS=
|
||||
|
||||
# For a complete Slack integration with search and posting to channels the
|
||||
# following configs are also needed, some more details
|
||||
# => https://wiki.generaloutline.com/share/be25efd1-b3ef-4450-b8e5-c4a4fc11e02a
|
||||
@@ -170,10 +161,3 @@ SMTP_SECURE=true
|
||||
# The default interface language. See translate.getoutline.com for a list of
|
||||
# available language codes and their rough percentage translated.
|
||||
DEFAULT_LANGUAGE=en_US
|
||||
|
||||
# Optionally enable rate limiter at application web server
|
||||
RATE_LIMITER_ENABLED=true
|
||||
|
||||
# Configure default throttling parameters for rate limiter
|
||||
RATE_LIMITER_REQUESTS=1000
|
||||
RATE_LIMITER_DURATION_WINDOW=60
|
||||
|
||||
@@ -12,23 +12,22 @@
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:import/recommended",
|
||||
"plugin:import/typescript",
|
||||
"plugin:react-hooks/recommended",
|
||||
"plugin:prettier/recommended"
|
||||
],
|
||||
"plugins": [
|
||||
"es",
|
||||
"@typescript-eslint",
|
||||
"eslint-plugin-import",
|
||||
"eslint-plugin-node",
|
||||
"eslint-plugin-react",
|
||||
"eslint-plugin-react-hooks",
|
||||
"import"
|
||||
],
|
||||
"rules": {
|
||||
"eqeqeq": 2,
|
||||
"curly": 2,
|
||||
"object-shorthand": "error",
|
||||
"no-mixed-operators": "off",
|
||||
"no-useless-escape": "off",
|
||||
"es/no-regexp-lookbehind-assertions": "error",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
|
||||
@@ -1,11 +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
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
@@ -0,0 +1,22 @@
|
||||
# Number of days of inactivity before an issue becomes stale
|
||||
daysUntilStale: 120
|
||||
|
||||
# Number of days of inactivity before a stale issue is closed
|
||||
daysUntilClose: 14
|
||||
|
||||
# Issues with these labels will never be considered stale
|
||||
exemptLabels:
|
||||
- security
|
||||
- pinned
|
||||
|
||||
# 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
|
||||
@@ -1,56 +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"
|
||||
jobs:
|
||||
build:
|
||||
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@v2
|
||||
- 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' }}
|
||||
- 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 }}
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
uses: github/codeql-action/init@v1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
# 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
|
||||
uses: github/codeql-action/autobuild@v1
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
@@ -67,4 +67,4 @@ jobs:
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
uses: github/codeql-action/analyze@v1
|
||||
|
||||
@@ -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@v5
|
||||
with:
|
||||
stale-pr-message: "This PR is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 5 days"
|
||||
stale-issue-message: "This issue is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 5 days"
|
||||
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"
|
||||
- name: Print outputs
|
||||
run: echo ${{ join(steps.stale.outputs.*, ',') }}
|
||||
@@ -3,7 +3,6 @@ build
|
||||
node_modules/*
|
||||
.env
|
||||
.log
|
||||
.vscode/*
|
||||
npm-debug.log
|
||||
stats.json
|
||||
.DS_Store
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
npx lint-staged
|
||||
@@ -1,88 +0,0 @@
|
||||
{
|
||||
"projects": [
|
||||
{
|
||||
"displayName": "server",
|
||||
"verbose": false,
|
||||
"roots": [
|
||||
"<rootDir>/server"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"^@server/(.*)$": "<rootDir>/server/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1"
|
||||
},
|
||||
"setupFiles": [
|
||||
"<rootDir>/__mocks__/console.js",
|
||||
"<rootDir>/server/test/env.ts"
|
||||
],
|
||||
"setupFilesAfterEnv": [
|
||||
"<rootDir>/server/test/setup.ts"
|
||||
],
|
||||
"testEnvironment": "node",
|
||||
"runner": "@getoutline/jest-runner-serial"
|
||||
},
|
||||
{
|
||||
"displayName": "app",
|
||||
"verbose": false,
|
||||
"roots": [
|
||||
"<rootDir>/app"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"^~/(.*)$": "<rootDir>/app/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
|
||||
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
|
||||
},
|
||||
"modulePaths": [
|
||||
"<rootDir>/app"
|
||||
],
|
||||
"setupFiles": [
|
||||
"<rootDir>/__mocks__/window.js"
|
||||
],
|
||||
"setupFilesAfterEnv": [
|
||||
"<rootDir>/app/test/setup.ts"
|
||||
],
|
||||
"testEnvironment": "jsdom",
|
||||
"testEnvironmentOptions": {
|
||||
"url": "http://localhost"
|
||||
}
|
||||
},
|
||||
{
|
||||
"displayName": "shared-node",
|
||||
"verbose": false,
|
||||
"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",
|
||||
"verbose": false,
|
||||
"roots": [
|
||||
"<rootDir>/shared"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"^~/(.*)$": "<rootDir>/app/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
|
||||
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
|
||||
},
|
||||
"setupFiles": [
|
||||
"<rootDir>/__mocks__/window.js"
|
||||
],
|
||||
"testEnvironment": "jsdom",
|
||||
"testEnvironmentOptions": {
|
||||
"url": "http://localhost"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+31
-8
@@ -1,22 +1,45 @@
|
||||
# syntax=docker/dockerfile:1.2
|
||||
ARG APP_PATH=/opt/outline
|
||||
FROM outlinewiki/outline-base as base
|
||||
FROM node:16-alpine AS deps-common
|
||||
|
||||
ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
COPY ./package.json ./yarn.lock ./
|
||||
|
||||
# ---
|
||||
FROM deps-common AS deps-dev
|
||||
RUN yarn install --no-optional --frozen-lockfile && \
|
||||
yarn cache clean
|
||||
|
||||
# ---
|
||||
FROM deps-common AS deps-prod
|
||||
RUN yarn install --production=true --frozen-lockfile && \
|
||||
yarn cache clean
|
||||
|
||||
# ---
|
||||
FROM node:16-alpine AS builder
|
||||
|
||||
ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
|
||||
COPY . .
|
||||
COPY --from=deps-dev $APP_PATH/node_modules ./node_modules
|
||||
ARG CDN_URL
|
||||
RUN yarn build
|
||||
|
||||
# ---
|
||||
FROM node:16.14.2-alpine3.15 AS runner
|
||||
FROM node:16-alpine AS runner
|
||||
|
||||
ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
ENV NODE_ENV production
|
||||
|
||||
COPY --from=base $APP_PATH/build ./build
|
||||
COPY --from=base $APP_PATH/server ./server
|
||||
COPY --from=base $APP_PATH/public ./public
|
||||
COPY --from=base $APP_PATH/.sequelizerc ./.sequelizerc
|
||||
COPY --from=base $APP_PATH/node_modules ./node_modules
|
||||
COPY --from=base $APP_PATH/package.json ./package.json
|
||||
COPY --from=builder $APP_PATH/build ./build
|
||||
COPY --from=builder $APP_PATH/server ./server
|
||||
COPY --from=builder $APP_PATH/public ./public
|
||||
COPY --from=builder $APP_PATH/.sequelizerc ./.sequelizerc
|
||||
COPY --from=deps-prod $APP_PATH/node_modules ./node_modules
|
||||
COPY --from=builder $APP_PATH/package.json ./package.json
|
||||
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -S nodejs -u 1001 && \
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
ARG APP_PATH=/opt/outline
|
||||
FROM node:16.14.2-alpine3.15 AS deps
|
||||
|
||||
ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
COPY ./package.json ./yarn.lock ./
|
||||
|
||||
RUN yarn install --no-optional --frozen-lockfile --network-timeout 1000000 && \
|
||||
yarn cache clean
|
||||
|
||||
COPY . .
|
||||
ARG CDN_URL
|
||||
RUN yarn build
|
||||
|
||||
RUN rm -rf node_modules
|
||||
|
||||
RUN yarn install --production=true --frozen-lockfile --network-timeout 1000000 && \
|
||||
yarn cache clean
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.64.0
|
||||
Licensed Work: Outline 0.60.1
|
||||
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
|
||||
@@ -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: 2026-05-23
|
||||
Change Date: 2025-11-11
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
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 +1,2 @@
|
||||
// Mock for node-uuid
|
||||
global.console.warn = () => {};
|
||||
|
||||
+1
-2
@@ -1,2 +1 @@
|
||||
window.matchMedia = (data) => data;
|
||||
window.env = {};
|
||||
window.matchMedia = data => data;
|
||||
|
||||
@@ -43,6 +43,10 @@
|
||||
"value": "true",
|
||||
"required": true
|
||||
},
|
||||
"ALLOWED_DOMAINS": {
|
||||
"description": "Comma separated list of domains to be allowed (optional). If not set, all domains are allowed by default when using Google OAuth to signin. Consider putting {your app name}.herokuapp.com and any domain you are binding on in this list.",
|
||||
"required": false
|
||||
},
|
||||
"URL": {
|
||||
"description": "https://{your app name}.herokuapp.com, or the domain you are binding to",
|
||||
"required": true
|
||||
@@ -102,11 +106,11 @@
|
||||
"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
|
||||
},
|
||||
@@ -205,4 +209,4 @@
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-5
@@ -1,10 +1,6 @@
|
||||
{
|
||||
"extends": [
|
||||
"../.eslintrc",
|
||||
"plugin:react-hooks/recommended",
|
||||
],
|
||||
"plugins": [
|
||||
"eslint-plugin-react-hooks",
|
||||
"../.eslintrc"
|
||||
],
|
||||
"env": {
|
||||
"jest": true,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"testURL": "http://localhost",
|
||||
"verbose": false,
|
||||
"rootDir": "..",
|
||||
"roots": [
|
||||
"<rootDir>/app",
|
||||
"<rootDir>/shared"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"^~/(.*)$": "<rootDir>/app/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
|
||||
},
|
||||
"moduleDirectories": [
|
||||
"node_modules"
|
||||
],
|
||||
"modulePaths": [
|
||||
"<rootDir>/app"
|
||||
],
|
||||
"setupFiles": [
|
||||
"<rootDir>/__mocks__/window.js"
|
||||
],
|
||||
"setupFilesAfterEnv": [
|
||||
"./app/test/setup.ts"
|
||||
],
|
||||
"testEnvironment": "jsdom"
|
||||
}
|
||||
@@ -1,13 +1,6 @@
|
||||
import {
|
||||
CollectionIcon,
|
||||
EditIcon,
|
||||
PlusIcon,
|
||||
StarredIcon,
|
||||
UnstarredIcon,
|
||||
} from "outline-icons";
|
||||
import { CollectionIcon, EditIcon, PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import stores from "~/stores";
|
||||
import Collection from "~/models/Collection";
|
||||
import CollectionEdit from "~/scenes/CollectionEdit";
|
||||
import CollectionNew from "~/scenes/CollectionNew";
|
||||
import DynamicCollectionIcon from "~/components/CollectionIcon";
|
||||
@@ -15,10 +8,6 @@ import { createAction } from "~/actions";
|
||||
import { CollectionSection } from "~/actions/sections";
|
||||
import history from "~/utils/history";
|
||||
|
||||
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => {
|
||||
return <DynamicCollectionIcon collection={collection} />;
|
||||
};
|
||||
|
||||
export const openCollection = createAction({
|
||||
name: ({ t }) => t("Open collection"),
|
||||
section: CollectionSection,
|
||||
@@ -31,7 +20,7 @@ export const openCollection = createAction({
|
||||
// cache if the collection is renamed
|
||||
id: collection.url,
|
||||
name: collection.name,
|
||||
icon: <ColorCollectionIcon collection={collection} />,
|
||||
icon: <DynamicCollectionIcon collection={collection} />,
|
||||
section: CollectionSection,
|
||||
perform: () => history.push(collection.url),
|
||||
}));
|
||||
@@ -79,59 +68,4 @@ export const editCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const starCollection = createAction({
|
||||
name: ({ t }) => t("Star"),
|
||||
section: CollectionSection,
|
||||
icon: <StarredIcon />,
|
||||
keywords: "favorite bookmark",
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return (
|
||||
!collection?.isStarred &&
|
||||
stores.policies.abilities(activeCollectionId).star
|
||||
);
|
||||
},
|
||||
perform: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
collection?.star();
|
||||
},
|
||||
});
|
||||
|
||||
export const unstarCollection = createAction({
|
||||
name: ({ t }) => t("Unstar"),
|
||||
section: CollectionSection,
|
||||
icon: <UnstarredIcon />,
|
||||
keywords: "unfavorite unbookmark",
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return (
|
||||
!!collection?.isStarred &&
|
||||
stores.policies.abilities(activeCollectionId).unstar
|
||||
);
|
||||
},
|
||||
perform: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
collection?.unstar();
|
||||
},
|
||||
});
|
||||
|
||||
export const rootCollectionActions = [
|
||||
openCollection,
|
||||
createCollection,
|
||||
starCollection,
|
||||
unstarCollection,
|
||||
];
|
||||
export const rootCollectionActions = [openCollection, createCollection];
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ToolsIcon, TrashIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import stores from "~/stores";
|
||||
import { createAction } from "~/actions";
|
||||
import { DebugSection } from "~/actions/sections";
|
||||
import env from "~/env";
|
||||
import { deleteAllDatabases } from "~/utils/developer";
|
||||
|
||||
export const clearIndexedDB = createAction({
|
||||
name: ({ t }) => t("Delete IndexedDB cache"),
|
||||
icon: <TrashIcon />,
|
||||
keywords: "cache clear database",
|
||||
section: DebugSection,
|
||||
perform: async ({ t }) => {
|
||||
await deleteAllDatabases();
|
||||
stores.toasts.showToast(t("IndexedDB cache deleted"));
|
||||
},
|
||||
});
|
||||
|
||||
export const development = createAction({
|
||||
name: ({ t }) => t("Development"),
|
||||
keywords: "debug",
|
||||
icon: <ToolsIcon />,
|
||||
iconInContextMenu: false,
|
||||
section: DebugSection,
|
||||
visible: ({ event }) =>
|
||||
env.ENVIRONMENT === "development" ||
|
||||
(event instanceof KeyboardEvent && event.altKey),
|
||||
children: [clearIndexedDB],
|
||||
});
|
||||
|
||||
export const rootDebugActions = [development];
|
||||
@@ -1,50 +0,0 @@
|
||||
import { ToolsIcon, TrashIcon, UserIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import stores from "~/stores";
|
||||
import { createAction } from "~/actions";
|
||||
import { DeveloperSection } from "~/actions/sections";
|
||||
import env from "~/env";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import { deleteAllDatabases } from "~/utils/developer";
|
||||
|
||||
export const clearIndexedDB = createAction({
|
||||
name: ({ t }) => t("Delete IndexedDB cache"),
|
||||
icon: <TrashIcon />,
|
||||
keywords: "cache clear database",
|
||||
section: DeveloperSection,
|
||||
perform: async ({ t }) => {
|
||||
await deleteAllDatabases();
|
||||
stores.toasts.showToast(t("IndexedDB cache deleted"));
|
||||
},
|
||||
});
|
||||
|
||||
export const createTestUsers = createAction({
|
||||
name: "Create test users",
|
||||
icon: <UserIcon />,
|
||||
section: DeveloperSection,
|
||||
visible: () => env.ENVIRONMENT === "development",
|
||||
perform: async () => {
|
||||
const count = 10;
|
||||
|
||||
try {
|
||||
await client.post("/developer.create_test_users", { count });
|
||||
stores.toasts.showToast(`${count} test users created`);
|
||||
} catch (err) {
|
||||
stores.toasts.showToast(err.message, { type: "error" });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const developer = createAction({
|
||||
name: ({ t }) => t("Developer"),
|
||||
keywords: "debug",
|
||||
icon: <ToolsIcon />,
|
||||
iconInContextMenu: false,
|
||||
section: DeveloperSection,
|
||||
visible: ({ event }) =>
|
||||
env.ENVIRONMENT === "development" ||
|
||||
(event instanceof KeyboardEvent && event.altKey),
|
||||
children: [clearIndexedDB, createTestUsers],
|
||||
});
|
||||
|
||||
export const rootDeveloperActions = [developer];
|
||||
@@ -10,24 +10,14 @@ import {
|
||||
ShapesIcon,
|
||||
ImportIcon,
|
||||
PinIcon,
|
||||
SearchIcon,
|
||||
UnsubscribeIcon,
|
||||
SubscribeIcon,
|
||||
MoveIcon,
|
||||
TrashIcon,
|
||||
CrossIcon,
|
||||
ArchiveIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import DocumentDelete from "~/scenes/DocumentDelete";
|
||||
import DocumentMove from "~/scenes/DocumentMove";
|
||||
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
|
||||
import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog";
|
||||
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
|
||||
import DocumentTemplatize from "~/scenes/DocumentTemplatize";
|
||||
import { createAction } from "~/actions";
|
||||
import { DocumentSection } from "~/actions/sections";
|
||||
import history from "~/utils/history";
|
||||
import { homePath, newDocumentPath, searchPath } from "~/utils/routeHelpers";
|
||||
import { homePath, newDocumentPath } from "~/utils/routeHelpers";
|
||||
|
||||
export const openDocument = createAction({
|
||||
name: ({ t }) => t("Open document"),
|
||||
@@ -61,11 +51,8 @@ export const createDocument = createAction({
|
||||
visible: ({ activeCollectionId, stores }) =>
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).update,
|
||||
perform: ({ activeCollectionId, inStarredSection }) =>
|
||||
activeCollectionId &&
|
||||
history.push(newDocumentPath(activeCollectionId), {
|
||||
starred: inStarredSection,
|
||||
}),
|
||||
perform: ({ activeCollectionId }) =>
|
||||
activeCollectionId && history.push(newDocumentPath(activeCollectionId)),
|
||||
});
|
||||
|
||||
export const starDocument = createAction({
|
||||
@@ -117,111 +104,22 @@ export const unstarDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const subscribeDocument = createAction({
|
||||
name: ({ t }) => t("Subscribe"),
|
||||
section: DocumentSection,
|
||||
icon: <SubscribeIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
return (
|
||||
!document?.isSubscribed &&
|
||||
stores.policies.abilities(activeDocumentId).subscribe
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores, t }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
document?.subscribe();
|
||||
|
||||
stores.toasts.showToast(t("Subscribed to document notifications"), {
|
||||
type: "success",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const unsubscribeDocument = createAction({
|
||||
name: ({ t }) => t("Unsubscribe"),
|
||||
section: DocumentSection,
|
||||
icon: <UnsubscribeIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
return (
|
||||
!!document?.isSubscribed &&
|
||||
stores.policies.abilities(activeDocumentId).unsubscribe
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores, currentUserId, t }) => {
|
||||
if (!activeDocumentId || !currentUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
document?.unsubscribe(currentUserId);
|
||||
|
||||
stores.toasts.showToast(t("Unsubscribed from document notifications"), {
|
||||
type: "success",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocumentAsHTML = createAction({
|
||||
name: ({ t }) => t("HTML"),
|
||||
section: DocumentSection,
|
||||
keywords: "html export",
|
||||
icon: <DownloadIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
document?.download("text/html");
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocumentAsMarkdown = createAction({
|
||||
name: ({ t }) => t("Markdown"),
|
||||
section: DocumentSection,
|
||||
keywords: "md markdown export",
|
||||
icon: <DownloadIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
document?.download("text/markdown");
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocument = createAction({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Download") : t("Download document"),
|
||||
section: DocumentSection,
|
||||
icon: <DownloadIcon />,
|
||||
keywords: "export",
|
||||
children: [downloadDocumentAsHTML, downloadDocumentAsMarkdown],
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
document?.download();
|
||||
},
|
||||
});
|
||||
|
||||
export const duplicateDocument = createAction({
|
||||
@@ -252,11 +150,10 @@ export const duplicateDocument = createAction({
|
||||
* Pin a document to a collection. Pinned documents will be displayed at the top
|
||||
* of the collection for all collection members to see.
|
||||
*/
|
||||
export const pinDocumentToCollection = createAction({
|
||||
export const pinDocument = createAction({
|
||||
name: ({ t }) => t("Pin to collection"),
|
||||
section: DocumentSection,
|
||||
icon: <PinIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeCollectionId, activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId || !activeCollectionId) {
|
||||
return false;
|
||||
@@ -291,7 +188,6 @@ export const pinDocumentToHome = createAction({
|
||||
name: ({ t }) => t("Pin to home"),
|
||||
section: DocumentSection,
|
||||
icon: <PinIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, currentTeamId, stores }) => {
|
||||
if (!currentTeamId || !activeDocumentId) {
|
||||
return false;
|
||||
@@ -318,13 +214,6 @@ export const pinDocumentToHome = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const pinDocument = createAction({
|
||||
name: ({ t }) => t("Pin"),
|
||||
section: DocumentSection,
|
||||
icon: <PinIcon />,
|
||||
children: [pinDocumentToCollection, pinDocumentToHome],
|
||||
});
|
||||
|
||||
export const printDocument = createAction({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Print") : t("Print document"),
|
||||
@@ -358,8 +247,8 @@ export const importDocument = createAction({
|
||||
input.type = "file";
|
||||
input.accept = documents.importFileTypes.join(", ");
|
||||
|
||||
input.onchange = async (ev) => {
|
||||
const files = getEventFiles(ev);
|
||||
input.onchange = async (ev: Event) => {
|
||||
const files = getDataTransferFiles(ev);
|
||||
|
||||
try {
|
||||
const file = files[0];
|
||||
@@ -394,180 +283,42 @@ export const createTemplate = createAction({
|
||||
return false;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
return !!(
|
||||
return (
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).update &&
|
||||
!document?.isTemplate &&
|
||||
!document?.isDeleted
|
||||
!document?.isTemplate
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores, t, event }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Create template"),
|
||||
isCentered: true,
|
||||
content: <DocumentTemplatizeDialog documentId={activeDocumentId} />,
|
||||
content: (
|
||||
<DocumentTemplatize
|
||||
documentId={activeDocumentId}
|
||||
onSubmit={stores.dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const searchDocumentsForQuery = (searchQuery: string) =>
|
||||
createAction({
|
||||
id: "search",
|
||||
section: DocumentSection,
|
||||
name: ({ t }) =>
|
||||
t(`Search documents for "{{searchQuery}}"`, { searchQuery }),
|
||||
icon: <SearchIcon />,
|
||||
perform: () => history.push(searchPath(searchQuery)),
|
||||
visible: ({ location }) => location.pathname !== searchPath(),
|
||||
});
|
||||
|
||||
export const moveDocument = createAction({
|
||||
name: ({ t }) => t("Move"),
|
||||
section: DocumentSection,
|
||||
icon: <MoveIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
return !!stores.policies.abilities(activeDocumentId).move;
|
||||
},
|
||||
perform: ({ activeDocumentId, stores, t }) => {
|
||||
if (activeDocumentId) {
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Move {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
}),
|
||||
content: (
|
||||
<DocumentMove
|
||||
document={document}
|
||||
onRequestClose={stores.dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const archiveDocument = createAction({
|
||||
name: ({ t }) => t("Archive"),
|
||||
section: DocumentSection,
|
||||
icon: <ArchiveIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
return !!stores.policies.abilities(activeDocumentId).archive;
|
||||
},
|
||||
perform: async ({ activeDocumentId, stores, t }) => {
|
||||
if (activeDocumentId) {
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
await document.archive();
|
||||
stores.toasts.showToast(t("Document archived"), {
|
||||
type: "success",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteDocument = createAction({
|
||||
name: ({ t }) => t("Delete"),
|
||||
section: DocumentSection,
|
||||
icon: <TrashIcon />,
|
||||
dangerous: true,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
return !!stores.policies.abilities(activeDocumentId).delete;
|
||||
},
|
||||
perform: ({ activeDocumentId, stores, t }) => {
|
||||
if (activeDocumentId) {
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Delete {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
}),
|
||||
isCentered: true,
|
||||
content: (
|
||||
<DocumentDelete
|
||||
document={document}
|
||||
onSubmit={stores.dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const permanentlyDeleteDocument = createAction({
|
||||
name: ({ t }) => t("Permanently delete"),
|
||||
section: DocumentSection,
|
||||
icon: <CrossIcon />,
|
||||
dangerous: true,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
return !!stores.policies.abilities(activeDocumentId).permanentDelete;
|
||||
},
|
||||
perform: ({ activeDocumentId, stores, t }) => {
|
||||
if (activeDocumentId) {
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Permanently delete {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
}),
|
||||
isCentered: true,
|
||||
content: (
|
||||
<DocumentPermanentDelete
|
||||
document={document}
|
||||
onSubmit={stores.dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const rootDocumentActions = [
|
||||
openDocument,
|
||||
archiveDocument,
|
||||
createDocument,
|
||||
createTemplate,
|
||||
deleteDocument,
|
||||
importDocument,
|
||||
downloadDocument,
|
||||
starDocument,
|
||||
unstarDocument,
|
||||
subscribeDocument,
|
||||
unsubscribeDocument,
|
||||
duplicateDocument,
|
||||
moveDocument,
|
||||
permanentlyDeleteDocument,
|
||||
printDocument,
|
||||
pinDocumentToCollection,
|
||||
pinDocument,
|
||||
pinDocumentToHome,
|
||||
];
|
||||
|
||||
@@ -10,26 +10,23 @@ import {
|
||||
KeyboardIcon,
|
||||
EmailIcon,
|
||||
LogoutIcon,
|
||||
ProfileIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import {
|
||||
developersUrl,
|
||||
changelogUrl,
|
||||
feedbackUrl,
|
||||
mailToUrl,
|
||||
githubIssuesUrl,
|
||||
} from "@shared/utils/urlHelpers";
|
||||
import stores from "~/stores";
|
||||
import SearchQuery from "~/models/SearchQuery";
|
||||
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
|
||||
import { createAction } from "~/actions";
|
||||
import { NavigationSection, RecentSearchesSection } from "~/actions/sections";
|
||||
import { NavigationSection } from "~/actions/sections";
|
||||
import history from "~/utils/history";
|
||||
import {
|
||||
organizationSettingsPath,
|
||||
profileSettingsPath,
|
||||
settingsPath,
|
||||
homePath,
|
||||
searchPath,
|
||||
searchUrl,
|
||||
draftsPath,
|
||||
templatesPath,
|
||||
archivePath,
|
||||
@@ -45,13 +42,14 @@ export const navigateToHome = createAction({
|
||||
visible: ({ location }) => location.pathname !== homePath(),
|
||||
});
|
||||
|
||||
export const navigateToRecentSearchQuery = (searchQuery: SearchQuery) =>
|
||||
createAction({
|
||||
section: RecentSearchesSection,
|
||||
name: searchQuery.query,
|
||||
icon: <SearchIcon />,
|
||||
perform: () => history.push(searchPath(searchQuery.query)),
|
||||
});
|
||||
export const navigateToSearch = createAction({
|
||||
name: ({ t }) => t("Search"),
|
||||
section: NavigationSection,
|
||||
shortcut: ["/"],
|
||||
icon: <SearchIcon />,
|
||||
perform: () => history.push(searchUrl()),
|
||||
visible: ({ location }) => location.pathname !== searchUrl(),
|
||||
});
|
||||
|
||||
export const navigateToDrafts = createAction({
|
||||
name: ({ t }) => t("Drafts"),
|
||||
@@ -72,7 +70,6 @@ export const navigateToTemplates = createAction({
|
||||
export const navigateToArchive = createAction({
|
||||
name: ({ t }) => t("Archive"),
|
||||
section: NavigationSection,
|
||||
shortcut: ["g", "a"],
|
||||
icon: <ArchiveIcon />,
|
||||
perform: () => history.push(archivePath()),
|
||||
visible: ({ location }) => location.pathname !== archivePath(),
|
||||
@@ -90,18 +87,9 @@ export const navigateToSettings = createAction({
|
||||
name: ({ t }) => t("Settings"),
|
||||
section: NavigationSection,
|
||||
shortcut: ["g", "s"],
|
||||
icon: <SettingsIcon />,
|
||||
visible: ({ stores }) =>
|
||||
stores.policies.abilities(stores.auth.team?.id || "").update,
|
||||
perform: () => history.push(organizationSettingsPath()),
|
||||
});
|
||||
|
||||
export const navigateToProfileSettings = createAction({
|
||||
name: ({ t }) => t("Profile"),
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <ProfileIcon />,
|
||||
perform: () => history.push(profileSettingsPath()),
|
||||
icon: <SettingsIcon />,
|
||||
perform: () => history.push(settingsPath()),
|
||||
});
|
||||
|
||||
export const openAPIDocumentation = createAction({
|
||||
@@ -117,7 +105,7 @@ export const openFeedbackUrl = createAction({
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <EmailIcon />,
|
||||
perform: () => window.open(feedbackUrl()),
|
||||
perform: () => window.open(mailToUrl()),
|
||||
});
|
||||
|
||||
export const openBugReportUrl = createAction({
|
||||
@@ -157,10 +145,12 @@ export const logout = createAction({
|
||||
|
||||
export const rootNavigationActions = [
|
||||
navigateToHome,
|
||||
navigateToSearch,
|
||||
navigateToDrafts,
|
||||
navigateToTemplates,
|
||||
navigateToArchive,
|
||||
navigateToTrash,
|
||||
navigateToSettings,
|
||||
openAPIDocumentation,
|
||||
openFeedbackUrl,
|
||||
openBugReportUrl,
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { createAction } from "~/actions";
|
||||
import { loadSessionsFromCookie } from "~/hooks/useSessions";
|
||||
|
||||
export const changeTeam = createAction({
|
||||
name: ({ t }) => t("Switch team"),
|
||||
placeholder: ({ t }) => t("Select a team"),
|
||||
keywords: "change workspace organization",
|
||||
section: "Account",
|
||||
visible: ({ currentTeamId }) => {
|
||||
const sessions = loadSessionsFromCookie();
|
||||
const otherSessions = sessions.filter(
|
||||
(session) => session.teamId !== currentTeamId
|
||||
);
|
||||
return otherSessions.length > 0;
|
||||
},
|
||||
children: ({ currentTeamId }) => {
|
||||
const sessions = loadSessionsFromCookie();
|
||||
const otherSessions = sessions.filter(
|
||||
(session) => session.teamId !== currentTeamId
|
||||
);
|
||||
|
||||
return otherSessions.map((session) => ({
|
||||
id: session.url,
|
||||
name: session.name,
|
||||
section: "Account",
|
||||
icon: <Logo alt={session.name} src={session.logoUrl} />,
|
||||
perform: () => (window.location.href = session.url),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
const Logo = styled("img")`
|
||||
border-radius: 2px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
`;
|
||||
|
||||
export const rootTeamActions = [changeTeam];
|
||||
+42
-25
@@ -1,6 +1,6 @@
|
||||
import { flattenDeep } from "lodash";
|
||||
import * as React from "react";
|
||||
import { Optional } from "utility-types";
|
||||
import { $Diff } from "utility-types";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
Action,
|
||||
@@ -10,14 +10,17 @@ import {
|
||||
MenuItemWithChildren,
|
||||
} from "~/types";
|
||||
|
||||
function resolve<T>(value: any, context: ActionContext): T {
|
||||
return typeof value === "function" ? value(context) : value;
|
||||
}
|
||||
|
||||
export function createAction(definition: Optional<Action, "id">): Action {
|
||||
export function createAction(
|
||||
definition: $Diff<
|
||||
Action,
|
||||
{
|
||||
id?: string;
|
||||
}
|
||||
>
|
||||
): Action {
|
||||
return {
|
||||
...definition,
|
||||
id: uuidv4(),
|
||||
...definition,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,10 +28,18 @@ export function actionToMenuItem(
|
||||
action: Action,
|
||||
context: ActionContext
|
||||
): MenuItemButton | MenuItemWithChildren {
|
||||
const resolvedIcon = resolve<React.ReactElement<any>>(action.icon, context);
|
||||
const resolvedChildren = resolve<Action[]>(action.children, context);
|
||||
function resolve<T>(value: any): T {
|
||||
if (typeof value === "function") {
|
||||
return value(context);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
const resolvedIcon = resolve<React.ReactElement<any>>(action.icon);
|
||||
const resolvedChildren = resolve<Action[]>(action.children);
|
||||
const visible = action.visible ? action.visible(context) : true;
|
||||
const title = resolve<string>(action.name, context);
|
||||
const title = resolve<string>(action.name);
|
||||
const icon =
|
||||
resolvedIcon && action.iconInContextMenu !== false
|
||||
? React.cloneElement(resolvedIcon, {
|
||||
@@ -37,17 +48,14 @@ export function actionToMenuItem(
|
||||
: undefined;
|
||||
|
||||
if (resolvedChildren) {
|
||||
const items = resolvedChildren
|
||||
.map((a) => actionToMenuItem(a, context))
|
||||
.filter(Boolean)
|
||||
.filter((a) => a.visible);
|
||||
|
||||
return {
|
||||
type: "submenu",
|
||||
title,
|
||||
icon,
|
||||
items,
|
||||
visible: visible && items.length > 0,
|
||||
items: resolvedChildren
|
||||
.map((a) => actionToMenuItem(a, context))
|
||||
.filter((a) => !!a),
|
||||
visible,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -56,7 +64,6 @@ export function actionToMenuItem(
|
||||
title,
|
||||
icon,
|
||||
visible,
|
||||
dangerous: action.dangerous,
|
||||
onClick: () => action.perform && action.perform(context),
|
||||
selected: action.selected ? action.selected(context) : undefined,
|
||||
};
|
||||
@@ -66,15 +73,23 @@ export function actionToKBar(
|
||||
action: Action,
|
||||
context: ActionContext
|
||||
): CommandBarAction[] {
|
||||
function resolve<T>(value: any): T {
|
||||
if (typeof value === "function") {
|
||||
return value(context);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof action.visible === "function" && !action.visible(context)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const resolvedIcon = resolve<React.ReactElement<any>>(action.icon, context);
|
||||
const resolvedChildren = resolve<Action[]>(action.children, context);
|
||||
const resolvedSection = resolve<string>(action.section, context);
|
||||
const resolvedName = resolve<string>(action.name, context);
|
||||
const resolvedPlaceholder = resolve<string>(action.placeholder, context);
|
||||
const resolvedIcon = resolve<React.ReactElement<any>>(action.icon);
|
||||
const resolvedChildren = resolve<Action[]>(action.children);
|
||||
const resolvedSection = resolve<string>(action.section);
|
||||
const resolvedName = resolve<string>(action.name);
|
||||
const resolvedPlaceholder = resolve<string>(action.placeholder);
|
||||
const children = resolvedChildren
|
||||
? flattenDeep(resolvedChildren.map((a) => actionToKBar(a, context))).filter(
|
||||
(a) => !!a
|
||||
@@ -87,10 +102,12 @@ export function actionToKBar(
|
||||
name: resolvedName,
|
||||
section: resolvedSection,
|
||||
placeholder: resolvedPlaceholder,
|
||||
keywords: action.keywords ?? "",
|
||||
keywords: `${action.keywords}`,
|
||||
shortcut: action.shortcut || [],
|
||||
icon: resolvedIcon,
|
||||
perform: action.perform ? () => action?.perform?.(context) : undefined,
|
||||
perform: action.perform
|
||||
? () => action.perform && action.perform(context)
|
||||
: undefined,
|
||||
},
|
||||
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
|
||||
].concat(children.map((child) => ({ ...child, parent: action.id })));
|
||||
|
||||
+2
-4
@@ -1,9 +1,8 @@
|
||||
import { rootCollectionActions } from "./definitions/collections";
|
||||
import { rootDeveloperActions } from "./definitions/developer";
|
||||
import { rootDebugActions } from "./definitions/debug";
|
||||
import { rootDocumentActions } from "./definitions/documents";
|
||||
import { rootNavigationActions } from "./definitions/navigation";
|
||||
import { rootSettingsActions } from "./definitions/settings";
|
||||
import { rootTeamActions } from "./definitions/teams";
|
||||
import { rootUserActions } from "./definitions/users";
|
||||
|
||||
export default [
|
||||
@@ -12,6 +11,5 @@ export default [
|
||||
...rootUserActions,
|
||||
...rootNavigationActions,
|
||||
...rootSettingsActions,
|
||||
...rootDeveloperActions,
|
||||
...rootTeamActions,
|
||||
...rootDebugActions,
|
||||
];
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ActionContext } from "~/types";
|
||||
|
||||
export const CollectionSection = ({ t }: ActionContext) => t("Collection");
|
||||
|
||||
export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
|
||||
export const DebugSection = ({ t }: ActionContext) => t("Debug");
|
||||
|
||||
export const DocumentSection = ({ t }: ActionContext) => t("Document");
|
||||
|
||||
@@ -11,6 +11,3 @@ export const SettingsSection = ({ t }: ActionContext) => t("Settings");
|
||||
export const NavigationSection = ({ t }: ActionContext) => t("Navigation");
|
||||
|
||||
export const UserSection = ({ t }: ActionContext) => t("People");
|
||||
|
||||
export const RecentSearchesSection = ({ t }: ActionContext) =>
|
||||
t("Recent searches");
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import * as React from "react";
|
||||
import Tooltip, { Props as TooltipProps } from "~/components/Tooltip";
|
||||
import { Action, ActionContext } from "~/types";
|
||||
|
||||
export type Props = {
|
||||
/** 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?: Action;
|
||||
/** Context of action, must be provided with action */
|
||||
context?: ActionContext;
|
||||
/** If tooltip props are provided the button will be wrapped in a tooltip */
|
||||
tooltip?: Omit<TooltipProps, "children">;
|
||||
};
|
||||
|
||||
/**
|
||||
* Button that can be used to trigger an action definition.
|
||||
*/
|
||||
const ActionButton = React.forwardRef(
|
||||
(
|
||||
{
|
||||
action,
|
||||
context,
|
||||
tooltip,
|
||||
hideOnActionDisabled,
|
||||
...rest
|
||||
}: Props & React.HTMLAttributes<HTMLButtonElement>,
|
||||
ref: React.Ref<HTMLButtonElement>
|
||||
) => {
|
||||
const disabled = rest.disabled;
|
||||
|
||||
if (!context || !action) {
|
||||
return <button {...rest} ref={ref} />;
|
||||
}
|
||||
|
||||
if (action?.visible && !action.visible(context) && hideOnActionDisabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label =
|
||||
typeof action.name === "function" ? action.name(context) : action.name;
|
||||
|
||||
const button = (
|
||||
<button
|
||||
{...rest}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
ref={ref}
|
||||
onClick={
|
||||
action?.perform && context
|
||||
? (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
action.perform?.(context);
|
||||
}
|
||||
: rest.onClick
|
||||
}
|
||||
>
|
||||
{rest.children ?? label}
|
||||
</button>
|
||||
);
|
||||
|
||||
if (tooltip) {
|
||||
return <Tooltip {...tooltip}>{button}</Tooltip>;
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
);
|
||||
|
||||
export default ActionButton;
|
||||
@@ -2,7 +2,11 @@
|
||||
import * as React from "react";
|
||||
import env from "~/env";
|
||||
|
||||
export default class Analytics extends React.Component {
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export default class Analytics extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
if (!env.GOOGLE_ANALYTICS_ID) {
|
||||
return;
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import {
|
||||
useCompositeState,
|
||||
Composite,
|
||||
CompositeStateReturn,
|
||||
} from "reakit/Composite";
|
||||
|
||||
type Props = {
|
||||
children: (composite: CompositeStateReturn) => React.ReactNode;
|
||||
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
};
|
||||
|
||||
function ArrowKeyNavigation(
|
||||
{ children, onEscape, ...rest }: Props,
|
||||
ref: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const composite = useCompositeState();
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev) => {
|
||||
if (onEscape) {
|
||||
if (ev.key === "Escape") {
|
||||
onEscape(ev);
|
||||
}
|
||||
|
||||
if (
|
||||
ev.key === "ArrowUp" &&
|
||||
composite.currentId === composite.items[0].id
|
||||
) {
|
||||
onEscape(ev);
|
||||
}
|
||||
}
|
||||
},
|
||||
[composite.currentId, composite.items, onEscape]
|
||||
);
|
||||
|
||||
return (
|
||||
<Composite
|
||||
{...rest}
|
||||
{...composite}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="menu"
|
||||
ref={ref}
|
||||
>
|
||||
{children(composite)}
|
||||
</Composite>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(React.forwardRef(ArrowKeyNavigation));
|
||||
@@ -2,7 +2,9 @@ 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 env from "~/env";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { changeLanguage } from "~/utils/language";
|
||||
|
||||
@@ -13,7 +15,7 @@ type Props = {
|
||||
const Authenticated = ({ children }: Props) => {
|
||||
const { auth } = useStores();
|
||||
const { i18n } = useTranslation();
|
||||
const language = auth.user?.language;
|
||||
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
|
||||
@@ -23,11 +25,29 @@ const Authenticated = ({ children }: Props) => {
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,12 +11,11 @@ import Sidebar from "~/components/Sidebar";
|
||||
import SettingsSidebar from "~/components/Sidebar/Settings";
|
||||
import history from "~/utils/history";
|
||||
import {
|
||||
searchPath,
|
||||
searchUrl,
|
||||
matchDocumentSlug as slug,
|
||||
newDocumentPath,
|
||||
settingsPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
import Fade from "./Fade";
|
||||
import withStores from "./withStores";
|
||||
|
||||
const DocumentHistory = React.lazy(
|
||||
@@ -34,7 +33,10 @@ const CommandBar = React.lazy(
|
||||
)
|
||||
);
|
||||
|
||||
type Props = WithTranslation & RootStore;
|
||||
type Props = WithTranslation &
|
||||
RootStore & {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
@observer
|
||||
class AuthenticatedLayout extends React.Component<Props> {
|
||||
@@ -47,15 +49,11 @@ class AuthenticatedLayout extends React.Component<Props> {
|
||||
if (!ev.metaKey && !ev.ctrlKey) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
history.push(searchPath());
|
||||
history.push(searchUrl());
|
||||
}
|
||||
};
|
||||
|
||||
goToNewDocument = (event: KeyboardEvent) => {
|
||||
if (event.metaKey || event.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
goToNewDocument = () => {
|
||||
const { activeCollectionId } = this.props.ui;
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
@@ -76,12 +74,10 @@ class AuthenticatedLayout extends React.Component<Props> {
|
||||
}
|
||||
|
||||
const sidebar = showSidebar ? (
|
||||
<Fade>
|
||||
<Switch>
|
||||
<Route path={settingsPath()} component={SettingsSidebar} />
|
||||
<Route component={Sidebar} />
|
||||
</Switch>
|
||||
</Fade>
|
||||
<Switch>
|
||||
<Route path={settingsPath()} component={SettingsSidebar} />
|
||||
<Route component={Sidebar} />
|
||||
</Switch>
|
||||
) : undefined;
|
||||
|
||||
const rightRail = (
|
||||
|
||||
@@ -11,7 +11,6 @@ type Props = {
|
||||
icon?: React.ReactNode;
|
||||
user?: User;
|
||||
alt?: string;
|
||||
showBorder?: boolean;
|
||||
onClick?: React.MouseEventHandler<HTMLImageElement>;
|
||||
className?: string;
|
||||
};
|
||||
@@ -30,13 +29,12 @@ class Avatar extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { src, icon, showBorder, ...rest } = this.props;
|
||||
const { src, icon, ...rest } = this.props;
|
||||
return (
|
||||
<AvatarWrapper>
|
||||
<CircleImg
|
||||
onError={this.handleError}
|
||||
src={this.error ? placeholder : src}
|
||||
$showBorder={showBorder}
|
||||
{...rest}
|
||||
/>
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
@@ -61,14 +59,12 @@ const IconWrapper = styled.div`
|
||||
height: 20px;
|
||||
`;
|
||||
|
||||
const CircleImg = styled.img<{ size: number; $showBorder?: boolean }>`
|
||||
const CircleImg = styled.img<{ size: number }>`
|
||||
display: block;
|
||||
width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid
|
||||
${(props) =>
|
||||
props.$showBorder === false ? "transparent" : props.theme.background};
|
||||
border: 2px solid ${(props) => props.theme.background};
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 564 B After Width: | Height: | Size: 976 B |
@@ -1,7 +1,6 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths } from "@shared/styles";
|
||||
import env from "~/env";
|
||||
import OutlineLogo from "./OutlineLogo";
|
||||
|
||||
@@ -39,7 +38,7 @@ const Link = styled.a`
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
z-index: ${depths.sidebar + 1};
|
||||
z-index: ${(props: any) => props.theme.depths.sidebar + 1};
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
|
||||
@@ -9,15 +9,11 @@ import { MenuInternalLink } from "~/types";
|
||||
type Props = {
|
||||
items: MenuInternalLink[];
|
||||
max?: number;
|
||||
children?: React.ReactNode;
|
||||
highlightFirstItem?: boolean;
|
||||
};
|
||||
|
||||
function Breadcrumb({
|
||||
items,
|
||||
highlightFirstItem,
|
||||
children,
|
||||
max = 2,
|
||||
}: React.PropsWithChildren<Props>) {
|
||||
function Breadcrumb({ items, highlightFirstItem, children, max = 2 }: Props) {
|
||||
const totalItems = items.length;
|
||||
const topLevelItems: MenuInternalLink[] = [...items];
|
||||
let overflowItems;
|
||||
@@ -37,7 +33,7 @@ function Breadcrumb({
|
||||
return (
|
||||
<Flex justify="flex-start" align="center">
|
||||
{topLevelItems.map((item, index) => (
|
||||
<React.Fragment key={String(item.to) || index}>
|
||||
<React.Fragment key={item.to || index}>
|
||||
{item.icon}
|
||||
{item.to ? (
|
||||
<Item
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { bounceIn } from "~/styles/animations";
|
||||
|
||||
type Props = {
|
||||
count: number;
|
||||
};
|
||||
|
||||
const Bubble = ({ count }: Props) => {
|
||||
if (!count) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Count>{count}</Count>;
|
||||
};
|
||||
|
||||
const Count = styled.div`
|
||||
animation: ${bounceIn} 600ms;
|
||||
transform-origin: center center;
|
||||
color: ${(props) => props.theme.white};
|
||||
background: ${(props) => props.theme.slateDark};
|
||||
display: inline-block;
|
||||
font-feature-settings: "tnum";
|
||||
font-weight: 600;
|
||||
font-size: 9px;
|
||||
white-space: nowrap;
|
||||
vertical-align: baseline;
|
||||
min-width: 16px;
|
||||
min-height: 16px;
|
||||
line-height: 16px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
padding: 0 4px;
|
||||
margin-left: 8px;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
export default Bubble;
|
||||
@@ -1,4 +1,3 @@
|
||||
import { LocationDescriptor } from "history";
|
||||
import { ExpandedIcon } from "outline-icons";
|
||||
import { darken, lighten } from "polished";
|
||||
import * as React from "react";
|
||||
@@ -42,8 +41,7 @@ const RealButton = styled.button<{
|
||||
border: 0;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&[aria-expanded="true"] {
|
||||
&:hover:not(:disabled) {
|
||||
background: ${(props) => darken(0.05, props.theme.buttonBackground)};
|
||||
}
|
||||
|
||||
@@ -78,8 +76,7 @@ const RealButton = styled.button<{
|
||||
}
|
||||
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&[aria-expanded="true"] {
|
||||
&:hover:not(:disabled) {
|
||||
background: ${
|
||||
props.borderOnHover
|
||||
? props.theme.buttonNeutralBackground
|
||||
@@ -106,17 +103,12 @@ const RealButton = styled.button<{
|
||||
background: ${props.theme.danger};
|
||||
color: ${props.theme.white};
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&[aria-expanded="true"] {
|
||||
&:hover:not(:disabled) {
|
||||
background: ${darken(0.05, props.theme.danger)};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: ${lighten(0.05, props.theme.danger)};
|
||||
}
|
||||
|
||||
&.focus-visible {
|
||||
outline-color: ${darken(0.2, props.theme.danger)} !important;
|
||||
background: none;
|
||||
}
|
||||
`};
|
||||
`;
|
||||
@@ -156,7 +148,7 @@ export type Props<T> = {
|
||||
primary?: boolean;
|
||||
fullwidth?: boolean;
|
||||
as?: T;
|
||||
to?: LocationDescriptor;
|
||||
to?: string;
|
||||
borderOnHover?: boolean;
|
||||
href?: string;
|
||||
"data-on"?: string;
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
const ButtonLink = styled.button`
|
||||
type Props = {
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const ButtonLink = React.forwardRef(
|
||||
(props: Props, ref: React.Ref<HTMLButtonElement>) => {
|
||||
return <Button {...props} ref={ref} />;
|
||||
}
|
||||
);
|
||||
|
||||
const Button = styled.button`
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
|
||||
@@ -3,17 +3,17 @@ import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
withStickyHeader?: boolean;
|
||||
};
|
||||
|
||||
const Container = styled.div<Props>`
|
||||
const Container = styled.div<{ withStickyHeader?: boolean }>`
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
padding: ${(props) => (props.withStickyHeader ? "4px 12px" : "60px 12px")};
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: ${(props: Props) =>
|
||||
props.withStickyHeader ? "4px 44px 60px" : "60px 44px"};
|
||||
padding: ${(props: any) => (props.withStickyHeader ? "4px 60px" : "60px")};
|
||||
`};
|
||||
`;
|
||||
|
||||
@@ -26,7 +26,7 @@ const Content = styled.div`
|
||||
`};
|
||||
`;
|
||||
|
||||
const CenteredContent: React.FC<Props> = ({ children, ...rest }) => {
|
||||
const CenteredContent = ({ children, ...rest }: Props) => {
|
||||
return (
|
||||
<Container {...rest}>
|
||||
<Content>{children}</Content>
|
||||
|
||||
@@ -42,9 +42,8 @@ function Collaborators(props: Props) {
|
||||
filter(
|
||||
users.orderedData,
|
||||
(user) =>
|
||||
(presentIds.includes(user.id) ||
|
||||
document.collaboratorIds.includes(user.id)) &&
|
||||
!user.isSuspended
|
||||
presentIds.includes(user.id) ||
|
||||
document.collaboratorIds.includes(user.id)
|
||||
),
|
||||
(user) => presentIds.includes(user.id)
|
||||
),
|
||||
@@ -53,14 +52,18 @@ function Collaborators(props: Props) {
|
||||
|
||||
// load any users we don't yet have in memory
|
||||
React.useEffect(() => {
|
||||
const ids = uniq([...document.collaboratorIds, ...presentIds])
|
||||
.filter((userId) => !users.get(userId))
|
||||
.sort();
|
||||
const userIdsToFetch = uniq([
|
||||
...document.collaboratorIds,
|
||||
...presentIds,
|
||||
]).filter((userId) => !users.get(userId));
|
||||
|
||||
if (!isEqual(requestedUserIds, ids) && ids.length > 0) {
|
||||
setRequestedUserIds(ids);
|
||||
users.fetchPage({ ids, limit: 100 });
|
||||
if (!isEqual(requestedUserIds, userIdsToFetch)) {
|
||||
setRequestedUserIds(userIdsToFetch);
|
||||
}
|
||||
|
||||
userIdsToFetch
|
||||
.filter((userId) => requestedUserIds.includes(userId))
|
||||
.forEach((userId) => users.fetch(userId));
|
||||
}, [document, users, presentIds, document.collaboratorIds, requestedUserIds]);
|
||||
|
||||
const popover = usePopoverState({
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import debounce from "lodash/debounce";
|
||||
import { observer } from "mobx-react";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
@@ -10,7 +9,7 @@ import ButtonLink from "~/components/ButtonLink";
|
||||
import Editor from "~/components/Editor";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useDebouncedCallback from "~/hooks/useDebouncedCallback";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
|
||||
@@ -19,13 +18,13 @@ type Props = {
|
||||
};
|
||||
|
||||
function CollectionDescription({ collection }: Props) {
|
||||
const { collections } = useStores();
|
||||
const { collections, policies } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const { t } = useTranslation();
|
||||
const [isExpanded, setExpanded] = React.useState(false);
|
||||
const [isEditing, setEditing] = React.useState(false);
|
||||
const [isDirty, setDirty] = React.useState(false);
|
||||
const can = usePolicy(collection);
|
||||
const can = policies.abilities(collection.id);
|
||||
|
||||
const handleStartEditing = React.useCallback(() => {
|
||||
setEditing(true);
|
||||
@@ -49,25 +48,21 @@ function CollectionDescription({ collection }: Props) {
|
||||
[isExpanded]
|
||||
);
|
||||
|
||||
const handleSave = React.useMemo(
|
||||
() =>
|
||||
debounce(async (getValue) => {
|
||||
try {
|
||||
await collection.save({
|
||||
description: getValue(),
|
||||
});
|
||||
setDirty(false);
|
||||
} catch (err) {
|
||||
showToast(
|
||||
t("Sorry, an error occurred saving the collection", {
|
||||
type: "error",
|
||||
})
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}, 1000),
|
||||
[collection, showToast, t]
|
||||
);
|
||||
const handleSave = useDebouncedCallback(async (getValue) => {
|
||||
try {
|
||||
await collection.save({
|
||||
description: getValue(),
|
||||
});
|
||||
setDirty(false);
|
||||
} catch (err) {
|
||||
showToast(
|
||||
t("Sorry, an error occurred saving the collection", {
|
||||
type: "error",
|
||||
})
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(getValue) => {
|
||||
@@ -111,6 +106,7 @@ function CollectionDescription({ collection }: Props) {
|
||||
maxLength={1000}
|
||||
embedsDisabled
|
||||
readOnlyWriteCheckboxes
|
||||
grow
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : (
|
||||
|
||||
@@ -5,7 +5,6 @@ import * as React from "react";
|
||||
import Collection from "~/models/Collection";
|
||||
import { icons } from "~/components/IconPicker";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Logger from "~/utils/Logger";
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
@@ -37,9 +36,7 @@ function ResolvedCollectionIcon({
|
||||
const Component = icons[collection.icon].component;
|
||||
return <Component color={color} size={size} />;
|
||||
} catch (error) {
|
||||
Logger.warn("Failed to render custom icon", {
|
||||
icon: collection.icon,
|
||||
});
|
||||
console.warn("Failed to render custom icon " + collection.icon);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,32 +1,25 @@
|
||||
import { useKBar, KBarPositioner, KBarAnimator, KBarSearch } from "kbar";
|
||||
import { observer } from "mobx-react";
|
||||
import { QuestionMarkIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Portal } from "react-portal";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths } from "@shared/styles";
|
||||
import CommandBarResults from "~/components/CommandBarResults";
|
||||
import SearchActions from "~/components/SearchActions";
|
||||
import rootActions from "~/actions/root";
|
||||
import useCommandBarActions from "~/hooks/useCommandBarActions";
|
||||
import useSettingsActions from "~/hooks/useSettingsAction";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { CommandBarAction } from "~/types";
|
||||
import { metaDisplay } from "~/utils/keyboard";
|
||||
import Text from "./Text";
|
||||
|
||||
export const CommandBarOptions = {
|
||||
animations: {
|
||||
enterMs: 250,
|
||||
exitMs: 200,
|
||||
},
|
||||
};
|
||||
|
||||
function CommandBar() {
|
||||
const { t } = useTranslation();
|
||||
const { ui } = useStores();
|
||||
const settingsActions = useSettingsActions();
|
||||
const commandBarActions = React.useMemo(
|
||||
() => [...rootActions, settingsActions],
|
||||
[settingsActions]
|
||||
);
|
||||
|
||||
useCommandBarActions(commandBarActions);
|
||||
useCommandBarActions(rootActions);
|
||||
|
||||
const { rootAction } = useKBar((state) => ({
|
||||
rootAction: state.currentRootActionId
|
||||
@@ -37,38 +30,24 @@ function CommandBar() {
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchActions />
|
||||
<KBarPortal>
|
||||
<Positioner>
|
||||
<Animator>
|
||||
<SearchInput
|
||||
placeholder={`${
|
||||
rootAction?.placeholder ||
|
||||
rootAction?.name ||
|
||||
t("Type a command or search")
|
||||
}…`}
|
||||
/>
|
||||
<CommandBarResults />
|
||||
{ui.commandBarOpenedFromSidebar && (
|
||||
<Hint size="small" type="tertiary">
|
||||
<QuestionMarkIcon size={18} color="currentColor" />
|
||||
{t(
|
||||
"Open search from anywhere with the {{ shortcut }} shortcut",
|
||||
{
|
||||
shortcut: `${metaDisplay} + k`,
|
||||
}
|
||||
)}
|
||||
</Hint>
|
||||
)}
|
||||
</Animator>
|
||||
</Positioner>
|
||||
</KBarPortal>
|
||||
</>
|
||||
<KBarPortal>
|
||||
<Positioner>
|
||||
<Animator>
|
||||
<SearchInput
|
||||
placeholder={`${
|
||||
rootAction?.placeholder ||
|
||||
rootAction?.name ||
|
||||
t("Type a command or search")
|
||||
}…`}
|
||||
/>
|
||||
<CommandBarResults />
|
||||
</Animator>
|
||||
</Positioner>
|
||||
</KBarPortal>
|
||||
);
|
||||
}
|
||||
|
||||
const KBarPortal: React.FC = ({ children }) => {
|
||||
function KBarPortal({ children }: { children: React.ReactNode }) {
|
||||
const { showing } = useKBar((state) => ({
|
||||
showing: state.visualState !== "hidden",
|
||||
}));
|
||||
@@ -78,20 +57,10 @@ const KBarPortal: React.FC = ({ children }) => {
|
||||
}
|
||||
|
||||
return <Portal>{children}</Portal>;
|
||||
};
|
||||
|
||||
const Hint = styled(Text)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-top: 1px solid ${(props) => props.theme.background};
|
||||
margin: 1px 0 0;
|
||||
padding: 6px 16px;
|
||||
width: 100%;
|
||||
`;
|
||||
}
|
||||
|
||||
const Positioner = styled(KBarPositioner)`
|
||||
z-index: ${depths.commandBar};
|
||||
z-index: ${(props) => props.theme.depths.commandBar};
|
||||
`;
|
||||
|
||||
const SearchInput = styled(KBarSearch)`
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
|
||||
type Props = {
|
||||
/** Callback when the dialog is submitted */
|
||||
onSubmit: () => Promise<void> | void;
|
||||
/** Text to display on the submit button */
|
||||
submitText?: string;
|
||||
/** Text to display while the form is saving */
|
||||
savingText?: string;
|
||||
/** If true, the submit button will be a dangerous red */
|
||||
danger?: boolean;
|
||||
};
|
||||
|
||||
const ConfirmationDialog: React.FC<Props> = ({
|
||||
onSubmit,
|
||||
children,
|
||||
submitText,
|
||||
savingText,
|
||||
danger,
|
||||
}) => {
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const { dialogs } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSubmit();
|
||||
dialogs.closeAllModals();
|
||||
} catch (err) {
|
||||
showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[onSubmit, dialogs, showToast]
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Text type="secondary">{children}</Text>
|
||||
<Button type="submit" disabled={isSaving} danger={danger} autoFocus>
|
||||
{isSaving ? savingText : submitText}
|
||||
</Button>
|
||||
</form>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(ConfirmationDialog);
|
||||
@@ -1,7 +1,6 @@
|
||||
import isPrintableKeyEvent from "is-printable-key-event";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import useOnScreen from "~/hooks/useOnScreen";
|
||||
|
||||
type Props = Omit<React.HTMLAttributes<HTMLSpanElement>, "ref" | "onChange"> & {
|
||||
disabled?: boolean;
|
||||
@@ -18,13 +17,6 @@ type Props = Omit<React.HTMLAttributes<HTMLSpanElement>, "ref" | "onChange"> & {
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type RefHandle = {
|
||||
focus: () => void;
|
||||
focusAtStart: () => void;
|
||||
focusAtEnd: () => void;
|
||||
getComputedDirection: () => string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Defines a content editable component with the same interface as a native
|
||||
* HTMLInputElement (or, as close as we can get).
|
||||
@@ -48,36 +40,13 @@ const ContentEditable = React.forwardRef(
|
||||
onClick,
|
||||
...rest
|
||||
}: Props,
|
||||
ref: React.RefObject<RefHandle>
|
||||
forwardedRef: React.RefObject<HTMLSpanElement>
|
||||
) => {
|
||||
const contentRef = React.useRef<HTMLSpanElement>(null);
|
||||
const innerRef = React.useRef<HTMLSpanElement>(null);
|
||||
const ref = forwardedRef || innerRef;
|
||||
const [innerValue, setInnerValue] = React.useState<string>(value);
|
||||
const lastValue = React.useRef("");
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
focus: () => {
|
||||
contentRef.current?.focus();
|
||||
},
|
||||
focusAtStart: () => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.focus();
|
||||
placeCaret(contentRef.current, true);
|
||||
}
|
||||
},
|
||||
focusAtEnd: () => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.focus();
|
||||
placeCaret(contentRef.current, false);
|
||||
}
|
||||
},
|
||||
getComputedDirection: () => {
|
||||
if (contentRef.current) {
|
||||
return window.getComputedStyle(contentRef.current).direction;
|
||||
}
|
||||
return "ltr";
|
||||
},
|
||||
}));
|
||||
|
||||
const wrappedEvent = (
|
||||
callback:
|
||||
| React.FocusEventHandler<HTMLSpanElement>
|
||||
@@ -85,7 +54,7 @@ const ContentEditable = React.forwardRef(
|
||||
| React.KeyboardEventHandler<HTMLSpanElement>
|
||||
| undefined
|
||||
) => (event: any) => {
|
||||
const text = contentRef.current?.innerText || "";
|
||||
const text = ref.current?.innerText || "";
|
||||
|
||||
if (maxLength && isPrintableKeyEvent(event) && text.length >= maxLength) {
|
||||
event?.preventDefault();
|
||||
@@ -100,44 +69,26 @@ const ContentEditable = React.forwardRef(
|
||||
callback?.(event);
|
||||
};
|
||||
|
||||
// This is to account for being within a React.Suspense boundary, in this
|
||||
// case the component may be rendered with display: none. React 18 may solve
|
||||
// this in the future by delaying useEffect hooks:
|
||||
// https://github.com/facebook/react/issues/14536#issuecomment-861980492
|
||||
const isVisible = useOnScreen(contentRef);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (autoFocus && isVisible && !disabled && !readOnly) {
|
||||
contentRef.current?.focus();
|
||||
React.useLayoutEffect(() => {
|
||||
if (autoFocus) {
|
||||
ref.current?.focus();
|
||||
}
|
||||
}, [autoFocus, disabled, isVisible, readOnly, contentRef]);
|
||||
}, [autoFocus, ref]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (value !== contentRef.current?.innerText) {
|
||||
if (value !== ref.current?.innerText) {
|
||||
setInnerValue(value);
|
||||
}
|
||||
}, [value, contentRef]);
|
||||
|
||||
// Ensure only plain text can be pasted into title when pasting from another
|
||||
// rich text editor
|
||||
const handlePaste = React.useCallback(
|
||||
(event: React.ClipboardEvent<HTMLSpanElement>) => {
|
||||
event.preventDefault();
|
||||
const text = event.clipboardData.getData("text/plain");
|
||||
window.document.execCommand("insertText", false, text);
|
||||
},
|
||||
[]
|
||||
);
|
||||
}, [value, ref]);
|
||||
|
||||
return (
|
||||
<div className={className} dir={dir} onClick={onClick}>
|
||||
<Content
|
||||
ref={contentRef}
|
||||
ref={ref}
|
||||
contentEditable={!disabled && !readOnly}
|
||||
onInput={wrappedEvent(onInput)}
|
||||
onBlur={wrappedEvent(onBlur)}
|
||||
onKeyDown={wrappedEvent(onKeyDown)}
|
||||
onPaste={handlePaste}
|
||||
data-placeholder={placeholder}
|
||||
suppressContentEditableWarning
|
||||
role="textbox"
|
||||
@@ -151,29 +102,7 @@ const ContentEditable = React.forwardRef(
|
||||
}
|
||||
);
|
||||
|
||||
function placeCaret(element: HTMLElement, atStart: boolean) {
|
||||
if (
|
||||
typeof window.getSelection !== "undefined" &&
|
||||
typeof document.createRange !== "undefined"
|
||||
) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(element);
|
||||
range.collapse(atStart);
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
}
|
||||
}
|
||||
|
||||
const Content = styled.span`
|
||||
background: ${(props) => props.theme.background};
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
color: ${(props) => props.theme.text};
|
||||
-webkit-text-fill-color: ${(props) => props.theme.text};
|
||||
outline: none;
|
||||
resize: none;
|
||||
cursor: text;
|
||||
|
||||
&:empty {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { LocationDescriptor } from "history";
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { MenuItem as BaseMenuItem } from "reakit/Menu";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { hover } from "~/styles";
|
||||
import MenuIconWrapper from "../MenuIconWrapper";
|
||||
|
||||
type Props = {
|
||||
onClick?: (event: React.SyntheticEvent) => void | Promise<void>;
|
||||
onClick?: (arg0: React.SyntheticEvent) => void | Promise<void>;
|
||||
children?: React.ReactNode;
|
||||
selected?: boolean;
|
||||
disabled?: boolean;
|
||||
dangerous?: boolean;
|
||||
to?: LocationDescriptor;
|
||||
to?: string;
|
||||
href?: string;
|
||||
target?: "_blank";
|
||||
as?: string | React.ComponentType<any>;
|
||||
@@ -20,7 +21,7 @@ type Props = {
|
||||
icon?: React.ReactElement;
|
||||
};
|
||||
|
||||
const MenuItem: React.FC<Props> = ({
|
||||
const MenuItem = ({
|
||||
onClick,
|
||||
children,
|
||||
selected,
|
||||
@@ -29,7 +30,7 @@ const MenuItem: React.FC<Props> = ({
|
||||
hide,
|
||||
icon,
|
||||
...rest
|
||||
}) => {
|
||||
}: Props) => {
|
||||
const handleClick = React.useCallback(
|
||||
(ev) => {
|
||||
if (onClick) {
|
||||
@@ -92,14 +93,11 @@ const Spacer = styled.svg`
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
type MenuAnchorProps = {
|
||||
export const MenuAnchorCSS = css<{
|
||||
level?: number;
|
||||
disabled?: boolean;
|
||||
dangerous?: boolean;
|
||||
disclosure?: boolean;
|
||||
};
|
||||
|
||||
export const MenuAnchorCSS = css<MenuAnchorProps>`
|
||||
}>`
|
||||
display: flex;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
@@ -116,7 +114,6 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
|
||||
svg:not(:last-child) {
|
||||
margin-right: 4px;
|
||||
@@ -132,26 +129,22 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
|
||||
? "pointer-events: none;"
|
||||
: `
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover,
|
||||
&:focus,
|
||||
&.focus-visible {
|
||||
color: ${props.theme.white};
|
||||
background: ${props.dangerous ? props.theme.danger : props.theme.primary};
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
&:${hover},
|
||||
&:focus,
|
||||
&.focus-visible {
|
||||
color: ${props.theme.white};
|
||||
background: ${props.dangerous ? props.theme.danger : props.theme.primary};
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
fill: ${props.theme.white};
|
||||
}
|
||||
svg {
|
||||
fill: ${props.theme.white};
|
||||
}
|
||||
}
|
||||
`};
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: 4px 12px;
|
||||
padding-right: ${(props: MenuAnchorProps) =>
|
||||
props.disclosure ? 32 : 12}px;
|
||||
font-size: 14px;
|
||||
`};
|
||||
`;
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { useMousePosition } from "~/hooks/useMousePosition";
|
||||
|
||||
type Positions = {
|
||||
/* Sub-menu x */
|
||||
x: number;
|
||||
/* Sub-menu y */
|
||||
y: number;
|
||||
/* Sub-menu height */
|
||||
h: number;
|
||||
/* Sub-menu width */
|
||||
w: number;
|
||||
/* Mouse x */
|
||||
mouseX: number;
|
||||
/* Mouse y */
|
||||
mouseY: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Component to cover the area between the mouse cursor and the sub-menu, to
|
||||
* allow moving cursor to lower parts of sub-menu without the sub-menu
|
||||
* disappearing.
|
||||
*/
|
||||
export default function MouseSafeArea(props: {
|
||||
parentRef: React.RefObject<HTMLElement | null>;
|
||||
}) {
|
||||
const { x = 0, y = 0, height: h = 0, width: w = 0 } =
|
||||
props.parentRef.current?.getBoundingClientRect() || {};
|
||||
const [mouseX, mouseY] = useMousePosition();
|
||||
const positions = { x, y, h, w, mouseX, mouseY };
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
// backgroundColor: "rgba(255,0,0,0.1)", // Uncomment to debug
|
||||
right: getRight(positions),
|
||||
left: getLeft(positions),
|
||||
height: h,
|
||||
width: getWidth(positions),
|
||||
clipPath: getClipPath(positions),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const getLeft = ({ x, mouseX }: Positions) =>
|
||||
mouseX > x ? undefined : -Math.max(x - mouseX, 10) + "px";
|
||||
|
||||
const getRight = ({ x, w, mouseX }: Positions) =>
|
||||
mouseX > x ? -Math.max(mouseX - (x + w), 10) + "px" : undefined;
|
||||
|
||||
const getWidth = ({ x, w, mouseX }: Positions) =>
|
||||
mouseX > x
|
||||
? Math.max(mouseX - (x + w), 10) + "px"
|
||||
: Math.max(x - mouseX, 10) + "px";
|
||||
|
||||
const getClipPath = ({ x, y, h, mouseX, mouseY }: Positions) =>
|
||||
mouseX > x
|
||||
? `polygon(0% 0%, 0% 100%, 100% ${(100 * (mouseY - y)) / h - 10}%, 100% ${
|
||||
(100 * (mouseY - y)) / h + 5
|
||||
}%)`
|
||||
: `polygon(100% 0%, 0% ${(100 * (mouseY - y)) / h - 10}%, 0% ${
|
||||
(100 * (mouseY - y)) / h + 5
|
||||
}%, 100% 100%)`;
|
||||
@@ -2,7 +2,7 @@ import * as React from "react";
|
||||
import { MenuSeparator } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
|
||||
export default function Separator(rest: React.HTMLAttributes<HTMLHRElement>) {
|
||||
export default function Separator(rest: any) {
|
||||
return (
|
||||
<MenuSeparator {...rest}>
|
||||
{(props) => <HorizontalRule {...props} />}
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
} from "~/types";
|
||||
import Header from "./Header";
|
||||
import MenuItem, { MenuAnchor } from "./MenuItem";
|
||||
import MouseSafeArea from "./MouseSafeArea";
|
||||
import Separator from "./Separator";
|
||||
import ContextMenu from ".";
|
||||
|
||||
@@ -54,13 +53,12 @@ const Submenu = React.forwardRef(
|
||||
<>
|
||||
<MenuButton ref={ref} {...menu} {...rest}>
|
||||
{(props) => (
|
||||
<MenuAnchor disclosure {...props}>
|
||||
<MenuAnchor {...props}>
|
||||
{title} <Disclosure color={theme.textTertiary} />
|
||||
</MenuAnchor>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu {...menu} aria-label={t("Submenu")}>
|
||||
<MouseSafeArea parentRef={menu.unstable_popoverRef} />
|
||||
<Template {...menu} items={templateItems} />
|
||||
</ContextMenu>
|
||||
</>
|
||||
@@ -69,27 +67,29 @@ const Submenu = React.forwardRef(
|
||||
);
|
||||
|
||||
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
|
||||
return items
|
||||
.filter((item) => item.visible !== false)
|
||||
.reduce((acc, item) => {
|
||||
// trim separator if the previous item was a separator
|
||||
if (
|
||||
item.type === "separator" &&
|
||||
acc[acc.length - 1]?.type === "separator"
|
||||
) {
|
||||
return acc;
|
||||
}
|
||||
return [...acc, item];
|
||||
}, [] as TMenuItem[])
|
||||
.filter((item, index, arr) => {
|
||||
if (
|
||||
item.type === "separator" &&
|
||||
(index === 0 || index === arr.length - 1)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
let filtered = items.filter((item) => item.visible !== false);
|
||||
|
||||
// this block literally just trims unnecessary separators
|
||||
filtered = filtered.reduce((acc, item, index) => {
|
||||
// trim separators from start / end
|
||||
if (item.type === "separator" && index === 0) {
|
||||
return acc;
|
||||
}
|
||||
if (item.type === "separator" && index === filtered.length - 1) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
// trim double separators looking ahead / behind
|
||||
const prev = filtered[index - 1];
|
||||
if (prev && prev.type === "separator" && item.type === "separator") {
|
||||
return acc;
|
||||
}
|
||||
|
||||
// otherwise, continue
|
||||
return [...acc, item];
|
||||
}, []);
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function Template({ items, actions, context, ...menu }: Props) {
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { disableBodyScroll, enableBodyScroll } from "body-scroll-lock";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Portal } from "react-portal";
|
||||
import { Menu } from "reakit/Menu";
|
||||
import styled, { DefaultTheme } from "styled-components";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths } from "@shared/styles";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import useMenuContext from "~/hooks/useMenuContext";
|
||||
import useMenuHeight from "~/hooks/useMenuHeight";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useUnmount from "~/hooks/useUnmount";
|
||||
import {
|
||||
fadeIn,
|
||||
fadeAndSlideUp,
|
||||
@@ -41,70 +34,36 @@ type Props = {
|
||||
visible?: boolean;
|
||||
placement?: Placement;
|
||||
animating?: boolean;
|
||||
children: React.ReactNode;
|
||||
unstable_disclosureRef?: React.RefObject<HTMLElement | null>;
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
hide?: () => void;
|
||||
};
|
||||
|
||||
const ContextMenu: React.FC<Props> = ({
|
||||
export default function ContextMenu({
|
||||
children,
|
||||
onOpen,
|
||||
onClose,
|
||||
...rest
|
||||
}) => {
|
||||
}: Props) {
|
||||
const previousVisible = usePrevious(rest.visible);
|
||||
const maxHeight = useMenuHeight(rest.visible, rest.unstable_disclosureRef);
|
||||
const backgroundRef = React.useRef<HTMLDivElement>(null);
|
||||
const { ui } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const { setIsMenuOpen } = useMenuContext();
|
||||
|
||||
useUnmount(() => {
|
||||
setIsMenuOpen(false);
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (rest.visible && !previousVisible) {
|
||||
if (onOpen) {
|
||||
onOpen();
|
||||
}
|
||||
if (rest["aria-label"] !== t("Submenu")) {
|
||||
setIsMenuOpen(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (!rest.visible && previousVisible) {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
if (rest["aria-label"] !== t("Submenu")) {
|
||||
setIsMenuOpen(false);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
onOpen,
|
||||
onClose,
|
||||
previousVisible,
|
||||
rest.visible,
|
||||
ui.sidebarCollapsed,
|
||||
setIsMenuOpen,
|
||||
rest,
|
||||
t,
|
||||
]);
|
||||
|
||||
// We must manually manage scroll lock for iOS support so that the scrollable
|
||||
// element can be passed into body-scroll-lock. See:
|
||||
// https://github.com/ariakit/ariakit/issues/469
|
||||
React.useEffect(() => {
|
||||
const scrollElement = backgroundRef.current;
|
||||
if (rest.visible && scrollElement) {
|
||||
disableBodyScroll(scrollElement);
|
||||
}
|
||||
return () => {
|
||||
scrollElement && enableBodyScroll(scrollElement);
|
||||
};
|
||||
}, [rest.visible]);
|
||||
}, [onOpen, onClose, previousVisible, rest.visible]);
|
||||
|
||||
// Perf win – don't render anything until the menu has been opened
|
||||
if (!rest.visible && !previousVisible) {
|
||||
@@ -115,7 +74,7 @@ const ContextMenu: React.FC<Props> = ({
|
||||
// trigger and the bottom of the window
|
||||
return (
|
||||
<>
|
||||
<Menu hideOnClickOutside preventBodyScroll={false} {...rest}>
|
||||
<Menu hideOnClickOutside preventBodyScroll {...rest}>
|
||||
{(props) => {
|
||||
// kind of hacky, but this is an effective way of telling which way
|
||||
// the menu will _actually_ be placed when taking into account screen
|
||||
@@ -131,7 +90,6 @@ const ContextMenu: React.FC<Props> = ({
|
||||
topAnchor={topAnchor}
|
||||
rightAnchor={rightAnchor}
|
||||
ref={backgroundRef}
|
||||
hiddenScrollbars
|
||||
style={
|
||||
maxHeight && topAnchor
|
||||
? {
|
||||
@@ -153,9 +111,7 @@ const ContextMenu: React.FC<Props> = ({
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContextMenu;
|
||||
}
|
||||
|
||||
export const Backdrop = styled.div`
|
||||
animation: ${fadeIn} 200ms ease-in-out;
|
||||
@@ -165,7 +121,7 @@ export const Backdrop = styled.div`
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: ${(props) => props.theme.backdrop};
|
||||
z-index: ${depths.menu - 1};
|
||||
z-index: ${(props) => props.theme.depths.menu - 1};
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: none;
|
||||
@@ -174,12 +130,10 @@ export const Backdrop = styled.div`
|
||||
|
||||
export const Position = styled.div`
|
||||
position: absolute;
|
||||
z-index: ${depths.menu};
|
||||
z-index: ${(props) => props.theme.depths.menu};
|
||||
|
||||
/*
|
||||
* overrides make mobile-first coding style challenging
|
||||
* so we explicitly define mobile breakpoint here
|
||||
*/
|
||||
// overrides make mobile-first coding style challenging
|
||||
// so we explicitly define mobile breakpoint here
|
||||
${breakpoint("mobile", "tablet")`
|
||||
position: fixed !important;
|
||||
transform: none !important;
|
||||
@@ -190,13 +144,10 @@ export const Position = styled.div`
|
||||
`};
|
||||
`;
|
||||
|
||||
type BackgroundProps = {
|
||||
export const Background = styled.div<{
|
||||
topAnchor?: boolean;
|
||||
rightAnchor?: boolean;
|
||||
theme: DefaultTheme;
|
||||
};
|
||||
|
||||
export const Background = styled(Scrollable)<BackgroundProps>`
|
||||
}>`
|
||||
animation: ${mobileContextMenu} 200ms ease;
|
||||
transform-origin: 50% 100%;
|
||||
max-width: 100%;
|
||||
@@ -205,6 +156,8 @@ export const Background = styled(Scrollable)<BackgroundProps>`
|
||||
padding: 6px 0;
|
||||
min-width: 180px;
|
||||
min-height: 44px;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
max-height: 75vh;
|
||||
pointer-events: all;
|
||||
font-weight: normal;
|
||||
@@ -214,12 +167,11 @@ export const Background = styled(Scrollable)<BackgroundProps>`
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
animation: ${(props: BackgroundProps) =>
|
||||
animation: ${(props: any) =>
|
||||
props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease;
|
||||
transform-origin: ${(props: BackgroundProps) =>
|
||||
props.rightAnchor ? "75%" : "25%"} 0;
|
||||
transform-origin: ${(props: any) => (props.rightAnchor ? "75%" : "25%")} 0;
|
||||
max-width: 276px;
|
||||
background: ${(props: BackgroundProps) => props.theme.menuBackground};
|
||||
box-shadow: ${(props: BackgroundProps) => props.theme.menuShadow};
|
||||
background: ${(props: any) => props.theme.menuBackground};
|
||||
box-shadow: ${(props: any) => props.theme.menuShadow};
|
||||
`};
|
||||
`;
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import * as React from "react";
|
||||
import env from "~/env";
|
||||
|
||||
type Props = {
|
||||
text: string;
|
||||
children?: React.ReactElement;
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
onCopy?: () => void;
|
||||
onCopy: () => void;
|
||||
};
|
||||
|
||||
class CopyToClipboard extends React.PureComponent<Props> {
|
||||
@@ -15,11 +14,12 @@ class CopyToClipboard extends React.PureComponent<Props> {
|
||||
const elem = React.Children.only(children);
|
||||
|
||||
copy(text, {
|
||||
debug: env.ENVIRONMENT !== "production",
|
||||
debug: process.env.NODE_ENV !== "production",
|
||||
format: "text/plain",
|
||||
});
|
||||
|
||||
onCopy?.();
|
||||
if (onCopy) {
|
||||
onCopy();
|
||||
}
|
||||
|
||||
if (elem && elem.props && typeof elem.props.onClick === "function") {
|
||||
elem.props.onClick(ev);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { HomeIcon } from "outline-icons";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Optional } from "utility-types";
|
||||
import CollectionIcon from "~/components/CollectionIcon";
|
||||
import Flex from "~/components/Flex";
|
||||
import InputSelect from "~/components/InputSelect";
|
||||
@@ -9,9 +8,7 @@ import { IconWrapper } from "~/components/Sidebar/components/SidebarLink";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
|
||||
type DefaultCollectionInputSelectProps = Optional<
|
||||
React.ComponentProps<typeof InputSelect>
|
||||
> & {
|
||||
type DefaultCollectionInputSelectProps = {
|
||||
onSelectCollection: (collection: string) => void;
|
||||
defaultCollectionId: string | null;
|
||||
};
|
||||
@@ -19,7 +16,6 @@ type DefaultCollectionInputSelectProps = Optional<
|
||||
const DefaultCollectionInputSelect = ({
|
||||
onSelectCollection,
|
||||
defaultCollectionId,
|
||||
...rest
|
||||
}: DefaultCollectionInputSelectProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { collections } = useStores();
|
||||
@@ -92,11 +88,14 @@ const DefaultCollectionInputSelect = ({
|
||||
return (
|
||||
<InputSelect
|
||||
value={defaultCollectionId ?? "home"}
|
||||
label={t("Start view")}
|
||||
options={options}
|
||||
onChange={onSelectCollection}
|
||||
ariaLabel={t("Default collection")}
|
||||
note={t(
|
||||
"This is the screen that team members will first see when they sign in."
|
||||
)}
|
||||
short
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -22,7 +22,6 @@ function Dialogs() {
|
||||
<Modal
|
||||
key={id}
|
||||
isOpen={modal.isOpen}
|
||||
isCentered={modal.isCentered}
|
||||
onRequestClose={() => dialogs.closeModal(id)}
|
||||
title={modal.title}
|
||||
>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { collectionUrl } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
children?: React.ReactNode;
|
||||
onlyText?: boolean;
|
||||
};
|
||||
|
||||
@@ -48,11 +49,7 @@ function useCategory(document: Document): MenuInternalLink | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const DocumentBreadcrumb: React.FC<Props> = ({
|
||||
document,
|
||||
children,
|
||||
onlyText,
|
||||
}) => {
|
||||
const DocumentBreadcrumb = ({ document, children, onlyText }: Props) => {
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const category = useCategory(document);
|
||||
|
||||
@@ -108,7 +108,7 @@ function DocumentCard(props: Props) {
|
||||
<Actions dir={document.dir} gap={4}>
|
||||
{!isDragging && pin && (
|
||||
<Tooltip tooltip={t("Unpin")}>
|
||||
<PinButton onClick={handleUnpin} aria-label={t("Unpin")}>
|
||||
<PinButton onClick={handleUnpin}>
|
||||
<CloseIcon color="currentColor" />
|
||||
</PinButton>
|
||||
</Tooltip>
|
||||
|
||||
@@ -72,7 +72,6 @@ function DocumentHistory() {
|
||||
</Header>
|
||||
<Scrollable topShadow>
|
||||
<PaginatedEventList
|
||||
aria-label={t("History")}
|
||||
fetch={events.fetchPage}
|
||||
events={items}
|
||||
options={{
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
|
||||
import * as React from "react";
|
||||
import Document from "~/models/Document";
|
||||
import DocumentListItem from "~/components/DocumentListItem";
|
||||
|
||||
type Props = {
|
||||
documents: Document[];
|
||||
limit?: number;
|
||||
showCollection?: boolean;
|
||||
showPublished?: boolean;
|
||||
showPin?: boolean;
|
||||
showDraft?: boolean;
|
||||
showTemplate?: boolean;
|
||||
};
|
||||
|
||||
export default function DocumentList({ limit, documents, ...rest }: Props) {
|
||||
const items = limit ? documents.splice(0, limit) : documents;
|
||||
return (
|
||||
<ArrowKeyNavigation
|
||||
mode={ArrowKeyNavigation.mode.VERTICAL}
|
||||
defaultActiveChildIndex={0}
|
||||
>
|
||||
{items.map((document) => (
|
||||
<DocumentListItem key={document.id} document={document} {...rest} />
|
||||
))}
|
||||
</ArrowKeyNavigation>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { CompositeStateReturn, CompositeItem } from "reakit/Composite";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Document from "~/models/Document";
|
||||
@@ -13,13 +12,12 @@ import DocumentMeta from "~/components/DocumentMeta";
|
||||
import EventBoundary from "~/components/EventBoundary";
|
||||
import Flex from "~/components/Flex";
|
||||
import Highlight from "~/components/Highlight";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import StarButton, { AnimatedStar } from "~/components/Star";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import { hover } from "~/styles";
|
||||
import { newDocumentPath } from "~/utils/routeHelpers";
|
||||
@@ -34,8 +32,7 @@ type Props = {
|
||||
showPin?: boolean;
|
||||
showDraft?: boolean;
|
||||
showTemplate?: boolean;
|
||||
} & CompositeStateReturn;
|
||||
|
||||
};
|
||||
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
|
||||
|
||||
function replaceResultMarks(tag: string) {
|
||||
@@ -49,8 +46,9 @@ function DocumentListItem(
|
||||
ref: React.RefObject<HTMLAnchorElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
const team = useCurrentTeam();
|
||||
const { policies } = useStores();
|
||||
const currentUser = useCurrentUser();
|
||||
const currentTeam = useCurrentTeam();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
|
||||
const {
|
||||
@@ -63,22 +61,19 @@ function DocumentListItem(
|
||||
showTemplate,
|
||||
highlight,
|
||||
context,
|
||||
...rest
|
||||
} = props;
|
||||
const queryIsInTitle =
|
||||
!!highlight &&
|
||||
!!document.title.toLowerCase().includes(highlight.toLowerCase());
|
||||
const canStar =
|
||||
!document.isDraft && !document.isArchived && !document.isTemplate;
|
||||
const can = usePolicy(team);
|
||||
const canCollection = usePolicy(document.collectionId);
|
||||
const can = policies.abilities(currentTeam.id);
|
||||
const canCollection = policies.abilities(document.collectionId);
|
||||
|
||||
return (
|
||||
<CompositeItem
|
||||
as={DocumentLink}
|
||||
<DocumentLink
|
||||
ref={ref}
|
||||
dir={document.dir}
|
||||
role="menuitem"
|
||||
$isStarred={document.isStarred}
|
||||
$menuOpen={menuOpen}
|
||||
to={{
|
||||
@@ -87,7 +82,6 @@ function DocumentListItem(
|
||||
title: document.titleWithDefault,
|
||||
},
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
<Content>
|
||||
<Heading dir={document.dir}>
|
||||
@@ -96,7 +90,7 @@ function DocumentListItem(
|
||||
highlight={highlight}
|
||||
dir={document.dir}
|
||||
/>
|
||||
{document.isBadgedNew && document.createdBy.id !== user.id && (
|
||||
{document.isBadgedNew && document.createdBy.id !== currentUser.id && (
|
||||
<Badge yellow>{t("New")}</Badge>
|
||||
)}
|
||||
{canStar && (
|
||||
@@ -161,7 +155,7 @@ function DocumentListItem(
|
||||
modal={false}
|
||||
/>
|
||||
</Actions>
|
||||
</CompositeItem>
|
||||
</DocumentLink>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -178,13 +172,6 @@ const Actions = styled(EventBoundary)`
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
|
||||
${NudeButton} {
|
||||
&:hover,
|
||||
&[aria-expanded="true"] {
|
||||
background: ${(props) => props.theme.sidebarControlHoverBackground};
|
||||
}
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: flex;
|
||||
`};
|
||||
@@ -202,10 +189,6 @@ const DocumentLink = styled(Link)<{
|
||||
max-height: 50vh;
|
||||
width: calc(100vw - 8px);
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
width: auto;
|
||||
`};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { LocationDescriptor } from "history";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -36,10 +35,11 @@ type Props = {
|
||||
showLastViewed?: boolean;
|
||||
showParentDocuments?: boolean;
|
||||
document: Document;
|
||||
to?: LocationDescriptor;
|
||||
children?: React.ReactNode;
|
||||
to?: string;
|
||||
};
|
||||
|
||||
const DocumentMeta: React.FC<Props> = ({
|
||||
function DocumentMeta({
|
||||
showPublished,
|
||||
showCollection,
|
||||
showLastViewed,
|
||||
@@ -48,7 +48,7 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
children,
|
||||
to,
|
||||
...rest
|
||||
}) => {
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { collections } = useStores();
|
||||
const user = useCurrentUser();
|
||||
@@ -74,61 +74,42 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
|
||||
const collection = collections.get(document.collectionId);
|
||||
const lastUpdatedByCurrentUser = user.id === updatedBy.id;
|
||||
const userName = updatedBy.name;
|
||||
let content;
|
||||
|
||||
if (deletedAt) {
|
||||
content = (
|
||||
<span>
|
||||
{lastUpdatedByCurrentUser
|
||||
? t("You deleted")
|
||||
: t("{{ userName }} deleted", { userName })}{" "}
|
||||
<Time dateTime={deletedAt} addSuffix />
|
||||
{t("deleted")} <Time dateTime={deletedAt} addSuffix />
|
||||
</span>
|
||||
);
|
||||
} else if (archivedAt) {
|
||||
content = (
|
||||
<span>
|
||||
{lastUpdatedByCurrentUser
|
||||
? t("You archived")
|
||||
: t("{{ userName }} archived", { userName })}{" "}
|
||||
<Time dateTime={archivedAt} addSuffix />
|
||||
{t("archived")} <Time dateTime={archivedAt} addSuffix />
|
||||
</span>
|
||||
);
|
||||
} else if (createdAt === updatedAt) {
|
||||
content = (
|
||||
<span>
|
||||
{lastUpdatedByCurrentUser
|
||||
? t("You created")
|
||||
: t("{{ userName }} created", { userName })}{" "}
|
||||
<Time dateTime={updatedAt} addSuffix />
|
||||
{t("created")} <Time dateTime={updatedAt} addSuffix />
|
||||
</span>
|
||||
);
|
||||
} else if (publishedAt && (publishedAt === updatedAt || showPublished)) {
|
||||
content = (
|
||||
<span>
|
||||
{lastUpdatedByCurrentUser
|
||||
? t("You published")
|
||||
: t("{{ userName }} published", { userName })}{" "}
|
||||
<Time dateTime={publishedAt} addSuffix />
|
||||
{t("published")} <Time dateTime={publishedAt} addSuffix />
|
||||
</span>
|
||||
);
|
||||
} else if (isDraft) {
|
||||
content = (
|
||||
<span>
|
||||
{lastUpdatedByCurrentUser
|
||||
? t("You saved")
|
||||
: t("{{ userName }} saved", { userName })}{" "}
|
||||
<Time dateTime={updatedAt} addSuffix />
|
||||
{t("saved")} <Time dateTime={updatedAt} addSuffix />
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<Modified highlight={modifiedSinceViewed && !lastUpdatedByCurrentUser}>
|
||||
{lastUpdatedByCurrentUser
|
||||
? t("You updated")
|
||||
: t("{{ userName }} updated", { userName })}{" "}
|
||||
<Time dateTime={updatedAt} addSuffix />
|
||||
{t("updated")} <Time dateTime={updatedAt} addSuffix />
|
||||
</Modified>
|
||||
);
|
||||
}
|
||||
@@ -163,6 +144,7 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
|
||||
{lastUpdatedByCurrentUser ? t("You") : updatedBy.name}
|
||||
{to ? <Link to={to}>{content}</Link> : content}
|
||||
{showCollection && collection && (
|
||||
<span>
|
||||
@@ -190,6 +172,6 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
{children}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default observer(DocumentMeta);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { LocationDescriptor } from "history";
|
||||
import { observer, useObserver } from "mobx-react";
|
||||
import { useObserver } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
||||
@@ -13,7 +12,7 @@ import useStores from "~/hooks/useStores";
|
||||
type Props = {
|
||||
document: Document;
|
||||
isDraft: boolean;
|
||||
to?: LocationDescriptor;
|
||||
to?: string;
|
||||
rtl?: boolean;
|
||||
};
|
||||
|
||||
@@ -84,4 +83,4 @@ const Meta = styled(DocumentMeta)<{ rtl?: boolean }>`
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(DocumentMetaWithViews);
|
||||
export default DocumentMetaWithViews;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { DoneIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, TFunction } from "react-i18next";
|
||||
@@ -61,4 +60,4 @@ const Done = styled(DoneIcon)<{ $animated: boolean }>`
|
||||
transform-origin: center center;
|
||||
`;
|
||||
|
||||
export default observer(DocumentTasks);
|
||||
export default DocumentTasks;
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import invariant from "invariant";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import { documentUrl } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
documentId: string;
|
||||
};
|
||||
|
||||
function DocumentTemplatizeDialog({ documentId }: Props) {
|
||||
const history = useHistory();
|
||||
const { showToast } = useToasts();
|
||||
const { t } = useTranslation();
|
||||
const { documents } = useStores();
|
||||
const document = documents.get(documentId);
|
||||
invariant(document, "Document must exist");
|
||||
|
||||
const handleSubmit = React.useCallback(async () => {
|
||||
const template = await document?.templatize();
|
||||
if (template) {
|
||||
history.push(documentUrl(template));
|
||||
showToast(t("Template created, go ahead and customize it"), {
|
||||
type: "info",
|
||||
});
|
||||
}
|
||||
}, [document, showToast, history, t]);
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
submitText={t("Create template")}
|
||||
savingText={`${t("Creating")}…`}
|
||||
>
|
||||
<Trans
|
||||
defaults="Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action – we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents."
|
||||
values={{
|
||||
titleWithDefault: document.titleWithDefault,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(DocumentTemplatizeDialog);
|
||||
@@ -4,7 +4,6 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Document from "~/models/Document";
|
||||
import User from "~/models/User";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
@@ -41,9 +40,8 @@ function DocumentViews({ document, isOpen }: Props) {
|
||||
<>
|
||||
{isOpen && (
|
||||
<PaginatedList
|
||||
aria-label={t("Viewers")}
|
||||
items={users}
|
||||
renderItem={(item: User) => {
|
||||
renderItem={(item) => {
|
||||
const view = documentViews.find((v) => v.user.id === item.id);
|
||||
const isPresent = presentIds.includes(item.id);
|
||||
const isEditing = editingIds.includes(item.id);
|
||||
@@ -63,6 +61,7 @@ function DocumentViews({ document, isOpen }: Props) {
|
||||
subtitle={subtitle}
|
||||
image={<Avatar key={item.id} src={item.avatarUrl} size={32} />}
|
||||
border={false}
|
||||
compact
|
||||
small
|
||||
/>
|
||||
);
|
||||
|
||||
+24
-233
@@ -1,34 +1,17 @@
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { deburr, sortBy } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
|
||||
import { TextSelection } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import { Optional } from "utility-types";
|
||||
import insertFiles from "@shared/editor/commands/insertFiles";
|
||||
import { Heading } from "@shared/editor/lib/getHeadings";
|
||||
import { getDataTransferFiles } from "@shared/utils/files";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import embeds from "@shared/editor/embeds";
|
||||
import { isInternalUrl } from "@shared/utils/urls";
|
||||
import { AttachmentValidation } from "@shared/validations";
|
||||
import Document from "~/models/Document";
|
||||
import ClickablePadding from "~/components/ClickablePadding";
|
||||
import ErrorBoundary from "~/components/ErrorBoundary";
|
||||
import HoverPreview from "~/components/HoverPreview";
|
||||
import type { Props as EditorProps, Editor as SharedEditor } from "~/editor";
|
||||
import { Props as EditorProps } from "~/editor";
|
||||
import useDictionary from "~/hooks/useDictionary";
|
||||
import useEmbeds from "~/hooks/useEmbeds";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import { NotFoundError } from "~/utils/errors";
|
||||
import { uploadFile } from "~/utils/files";
|
||||
import history from "~/utils/history";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
import { uploadFile } from "~/utils/uploadFile";
|
||||
import { isHash } from "~/utils/urls";
|
||||
import DocumentBreadcrumb from "./DocumentBreadcrumb";
|
||||
|
||||
const LazyLoadedEditor = React.lazy(
|
||||
const SharedEditor = React.lazy(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "shared-editor" */
|
||||
@@ -38,96 +21,21 @@ const LazyLoadedEditor = React.lazy(
|
||||
|
||||
export type Props = Optional<
|
||||
EditorProps,
|
||||
| "placeholder"
|
||||
| "defaultValue"
|
||||
| "onClickLink"
|
||||
| "embeds"
|
||||
| "dictionary"
|
||||
| "onShowToast"
|
||||
| "extensions"
|
||||
"placeholder" | "defaultValue" | "onClickLink" | "embeds" | "dictionary"
|
||||
> & {
|
||||
shareId?: string | undefined;
|
||||
embedsDisabled?: boolean;
|
||||
grow?: boolean;
|
||||
onHeadingsChange?: (headings: Heading[]) => void;
|
||||
onSynced?: () => Promise<void>;
|
||||
onPublish?: (event: React.MouseEvent) => any;
|
||||
};
|
||||
|
||||
function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
const { id, shareId, onChange, onHeadingsChange } = props;
|
||||
const { documents } = useStores();
|
||||
function Editor(props: Props, ref: React.Ref<any>) {
|
||||
const { id, shareId } = props;
|
||||
const { showToast } = useToasts();
|
||||
const dictionary = useDictionary();
|
||||
const embeds = useEmbeds(!shareId);
|
||||
const [
|
||||
activeLinkEvent,
|
||||
setActiveLinkEvent,
|
||||
] = React.useState<MouseEvent | null>(null);
|
||||
const previousHeadings = React.useRef<Heading[] | null>(null);
|
||||
|
||||
const handleLinkActive = React.useCallback((event: MouseEvent) => {
|
||||
setActiveLinkEvent(event);
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
const handleLinkInactive = React.useCallback(() => {
|
||||
setActiveLinkEvent(null);
|
||||
}, []);
|
||||
|
||||
const handleSearchLink = React.useCallback(
|
||||
async (term: string) => {
|
||||
if (isInternalUrl(term)) {
|
||||
// search for exact internal document
|
||||
const slug = parseDocumentSlug(term);
|
||||
if (!slug) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const document = await documents.fetch(slug);
|
||||
const time = formatDistanceToNow(Date.parse(document.updatedAt), {
|
||||
addSuffix: true,
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
title: document.title,
|
||||
subtitle: `Updated ${time}`,
|
||||
url: document.url,
|
||||
},
|
||||
];
|
||||
} catch (error) {
|
||||
// NotFoundError could not find document for slug
|
||||
if (!(error instanceof NotFoundError)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// default search for anything that doesn't look like a URL
|
||||
const results = await documents.searchTitles(term);
|
||||
|
||||
return sortBy(
|
||||
results.map((document: Document) => {
|
||||
return {
|
||||
title: document.title,
|
||||
subtitle: <DocumentBreadcrumb document={document} onlyText />,
|
||||
url: document.url,
|
||||
};
|
||||
}),
|
||||
(document) =>
|
||||
deburr(document.title)
|
||||
.toLowerCase()
|
||||
.startsWith(deburr(term).toLowerCase())
|
||||
? -1
|
||||
: 1
|
||||
);
|
||||
},
|
||||
[documents]
|
||||
);
|
||||
|
||||
const onUploadFile = React.useCallback(
|
||||
const onUploadImage = React.useCallback(
|
||||
async (file: File) => {
|
||||
const result = await uploadFile(file, {
|
||||
documentId: id,
|
||||
@@ -171,145 +79,28 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
[shareId]
|
||||
);
|
||||
|
||||
const focusAtEnd = React.useCallback(() => {
|
||||
ref?.current?.focusAtEnd();
|
||||
}, [ref]);
|
||||
|
||||
const handleDrop = React.useCallback(
|
||||
(event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const files = getDataTransferFiles(event);
|
||||
|
||||
const view = ref?.current?.view;
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find a valid position at the end of the document to insert our content
|
||||
const pos = TextSelection.near(
|
||||
view.state.doc.resolve(view.state.doc.nodeSize - 2)
|
||||
).from;
|
||||
|
||||
// If there are no files in the drop event attempt to parse the html
|
||||
// as a fragment and insert it at the end of the document
|
||||
if (files.length === 0) {
|
||||
const text =
|
||||
event.dataTransfer.getData("text/html") ||
|
||||
event.dataTransfer.getData("text/plain");
|
||||
|
||||
const dom = new DOMParser().parseFromString(text, "text/html");
|
||||
|
||||
view.dispatch(
|
||||
view.state.tr.insert(
|
||||
pos,
|
||||
ProsemirrorDOMParser.fromSchema(view.state.schema).parse(dom)
|
||||
)
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert all files as attachments if any of the files are not images.
|
||||
const isAttachment = files.some(
|
||||
(file) => !AttachmentValidation.imageContentTypes.includes(file.type)
|
||||
);
|
||||
|
||||
insertFiles(view, event, pos, files, {
|
||||
uploadFile: onUploadFile,
|
||||
onFileUploadStart: props.onFileUploadStart,
|
||||
onFileUploadStop: props.onFileUploadStop,
|
||||
onShowToast: showToast,
|
||||
dictionary,
|
||||
isAttachment,
|
||||
});
|
||||
const onShowToast = React.useCallback(
|
||||
(message: string) => {
|
||||
showToast(message);
|
||||
},
|
||||
[
|
||||
ref,
|
||||
props.onFileUploadStart,
|
||||
props.onFileUploadStop,
|
||||
dictionary,
|
||||
onUploadFile,
|
||||
showToast,
|
||||
]
|
||||
);
|
||||
|
||||
// see: https://stackoverflow.com/a/50233827/192065
|
||||
const handleDragOver = React.useCallback(
|
||||
(event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Calculate if headings have changed and trigger callback if so
|
||||
const updateHeadings = React.useCallback(() => {
|
||||
if (onHeadingsChange) {
|
||||
const headings = ref?.current?.getHeadings();
|
||||
if (
|
||||
headings &&
|
||||
headings.map((h) => h.level + h.title).join("") !==
|
||||
previousHeadings.current?.map((h) => h.level + h.title).join("")
|
||||
) {
|
||||
previousHeadings.current = headings;
|
||||
onHeadingsChange(headings);
|
||||
}
|
||||
}
|
||||
}, [ref, onHeadingsChange]);
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(event) => {
|
||||
onChange?.(event);
|
||||
updateHeadings();
|
||||
},
|
||||
[onChange, updateHeadings]
|
||||
);
|
||||
|
||||
const handleRefChanged = React.useCallback(
|
||||
(node: SharedEditor | null) => {
|
||||
if (node && !previousHeadings.current) {
|
||||
updateHeadings();
|
||||
}
|
||||
},
|
||||
[updateHeadings]
|
||||
[showToast]
|
||||
);
|
||||
|
||||
return (
|
||||
<ErrorBoundary reloadOnChunkMissing>
|
||||
<>
|
||||
<LazyLoadedEditor
|
||||
ref={mergeRefs([ref, handleRefChanged])}
|
||||
uploadFile={onUploadFile}
|
||||
onShowToast={showToast}
|
||||
embeds={embeds}
|
||||
dictionary={dictionary}
|
||||
{...props}
|
||||
onHoverLink={handleLinkActive}
|
||||
onClickLink={onClickLink}
|
||||
onSearchLink={handleSearchLink}
|
||||
onChange={handleChange}
|
||||
placeholder={props.placeholder || ""}
|
||||
defaultValue={props.defaultValue || ""}
|
||||
/>
|
||||
{props.grow && !props.readOnly && (
|
||||
<ClickablePadding
|
||||
onClick={focusAtEnd}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
grow
|
||||
/>
|
||||
)}
|
||||
{activeLinkEvent && !shareId && (
|
||||
<HoverPreview
|
||||
node={activeLinkEvent.target as HTMLAnchorElement}
|
||||
event={activeLinkEvent}
|
||||
onClose={handleLinkInactive}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<SharedEditor
|
||||
ref={ref}
|
||||
uploadImage={onUploadImage}
|
||||
onShowToast={onShowToast}
|
||||
embeds={embeds}
|
||||
dictionary={dictionary}
|
||||
{...props}
|
||||
onClickLink={onClickLink}
|
||||
placeholder={props.placeholder || ""}
|
||||
defaultValue={props.defaultValue || ""}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(React.forwardRef(Editor));
|
||||
export default React.forwardRef(Editor);
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
/* The emoji to render */
|
||||
emoji: string;
|
||||
/* The size of the emoji, 24px is default to match standard icons */
|
||||
size?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* EmojiIcon is a component that renders an emoji in the size of a standard icon
|
||||
* in a way that can be used wherever an Icon would be.
|
||||
*/
|
||||
export default function EmojiIcon({ size = 24, emoji, ...rest }: Props) {
|
||||
return (
|
||||
<Span $size={size} {...rest}>
|
||||
{emoji}
|
||||
</Span>
|
||||
);
|
||||
}
|
||||
|
||||
const Span = styled.span<{ $size: number }>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
width: ${(props) => props.$size}px;
|
||||
height: ${(props) => props.$size}px;
|
||||
text-indent: -0.15em;
|
||||
font-size: 14px;
|
||||
`;
|
||||
@@ -2,7 +2,6 @@ import styled from "styled-components";
|
||||
|
||||
const Empty = styled.p`
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
export default Empty;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
@@ -9,10 +10,9 @@ import CenteredContent from "~/components/CenteredContent";
|
||||
import PageTitle from "~/components/PageTitle";
|
||||
import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
import Logger from "~/utils/Logger";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
|
||||
type Props = WithTranslation & {
|
||||
children: React.ReactNode;
|
||||
reloadOnChunkMissing?: boolean;
|
||||
};
|
||||
|
||||
@@ -26,6 +26,7 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
|
||||
componentDidCatch(error: Error) {
|
||||
this.error = error;
|
||||
console.error(error);
|
||||
|
||||
if (
|
||||
this.props.reloadOnChunkMissing &&
|
||||
@@ -39,7 +40,9 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.error("ErrorBoundary", error);
|
||||
if (env.SENTRY_DSN) {
|
||||
Sentry.captureException(error);
|
||||
}
|
||||
}
|
||||
|
||||
handleReload = () => {
|
||||
@@ -59,7 +62,7 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
|
||||
if (this.error) {
|
||||
const error = this.error;
|
||||
const isReported = !!env.SENTRY_DSN && isCloudHosted;
|
||||
const isReported = !!env.SENTRY_DSN && env.DEPLOYMENT === "hosted";
|
||||
const isChunkError = this.error.message.match(/chunk/);
|
||||
|
||||
if (isChunkError) {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const EventBoundary: React.FC<Props> = ({ children, className }) => {
|
||||
export default function EventBoundary({ children, className }: Props) {
|
||||
const handleClick = React.useCallback((event: React.SyntheticEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -15,6 +16,4 @@ const EventBoundary: React.FC<Props> = ({ children, className }) => {
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventBoundary;
|
||||
}
|
||||
|
||||
@@ -5,22 +5,17 @@ import {
|
||||
PublishIcon,
|
||||
MoveIcon,
|
||||
CheckboxIcon,
|
||||
UnpublishIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { CompositeStateReturn } from "reakit/Composite";
|
||||
import styled, { css } from "styled-components";
|
||||
import styled from "styled-components";
|
||||
import Document from "~/models/Document";
|
||||
import Event from "~/models/Event";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import CompositeItem, {
|
||||
Props as ItemProps,
|
||||
} from "~/components/List/CompositeItem";
|
||||
import Item, { Actions } from "~/components/List/Item";
|
||||
import Time from "~/components/Time";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import RevisionMenu from "~/menus/RevisionMenu";
|
||||
import { documentHistoryUrl } from "~/utils/routeHelpers";
|
||||
|
||||
@@ -28,25 +23,19 @@ type Props = {
|
||||
document: Document;
|
||||
event: Event;
|
||||
latest?: boolean;
|
||||
} & CompositeStateReturn;
|
||||
};
|
||||
|
||||
const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
const EventListItem = ({ event, latest, document }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { policies } = useStores();
|
||||
const location = useLocation();
|
||||
const can = usePolicy(document);
|
||||
const can = policies.abilities(document.id);
|
||||
const opts = {
|
||||
userName: event.actor.name,
|
||||
};
|
||||
const isRevision = event.name === "revisions.create";
|
||||
let meta, icon, to;
|
||||
|
||||
const ref = React.useRef<HTMLAnchorElement>(null);
|
||||
// the time component tends to steal focus when clicked
|
||||
// ...so forward the focus back to the parent item
|
||||
const handleTimeClick = React.useCallback(() => {
|
||||
ref.current?.focus();
|
||||
}, [ref]);
|
||||
|
||||
switch (event.name) {
|
||||
case "revisions.create":
|
||||
case "documents.latest_version": {
|
||||
@@ -86,11 +75,6 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
meta = t("{{userName}} published", opts);
|
||||
break;
|
||||
|
||||
case "documents.unpublish":
|
||||
icon = <UnpublishIcon color="currentColor" size={16} />;
|
||||
meta = t("{{userName}} unpublished", opts);
|
||||
break;
|
||||
|
||||
case "documents.move":
|
||||
icon = <MoveIcon color="currentColor" size={16} />;
|
||||
meta = t("{{userName}} moved", opts);
|
||||
@@ -106,26 +90,18 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
|
||||
const isActive = location.pathname === to;
|
||||
|
||||
if (document.isDeleted) {
|
||||
to = undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseItem
|
||||
<ListItem
|
||||
small
|
||||
exact
|
||||
to={to}
|
||||
to={document.isDeleted ? undefined : to}
|
||||
title={
|
||||
<Time
|
||||
dateTime={event.createdAt}
|
||||
tooltipDelay={500}
|
||||
format={{
|
||||
en_US: "MMM do, h:mm a",
|
||||
fr_FR: "'Le 'd MMMM 'à' H:mm",
|
||||
}}
|
||||
format="MMM do, h:mm a"
|
||||
relative={false}
|
||||
addSuffix
|
||||
onClick={handleTimeClick}
|
||||
/>
|
||||
}
|
||||
image={<Avatar src={event.actor?.avatarUrl} size={32} />}
|
||||
@@ -140,22 +116,10 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
<RevisionMenu document={document} revisionId={event.modelId} />
|
||||
) : undefined
|
||||
}
|
||||
ref={ref}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const BaseItem = React.forwardRef(
|
||||
({ to, ...rest }: ItemProps, ref?: React.Ref<HTMLAnchorElement>) => {
|
||||
if (to) {
|
||||
return <CompositeListItem to={to} ref={ref} {...rest} />;
|
||||
}
|
||||
|
||||
return <ListItem ref={ref} {...rest} />;
|
||||
}
|
||||
);
|
||||
|
||||
const Subtitle = styled.span`
|
||||
svg {
|
||||
margin: -3px;
|
||||
@@ -163,7 +127,7 @@ const Subtitle = styled.span`
|
||||
}
|
||||
`;
|
||||
|
||||
const ItemStyle = css`
|
||||
const ListItem = styled(Item)`
|
||||
border: 0;
|
||||
position: relative;
|
||||
margin: 8px;
|
||||
@@ -209,12 +173,4 @@ const ItemStyle = css`
|
||||
}
|
||||
`;
|
||||
|
||||
const ListItem = styled(Item)`
|
||||
${ItemStyle}
|
||||
`;
|
||||
|
||||
const CompositeListItem = styled(CompositeItem)`
|
||||
${ItemStyle}
|
||||
`;
|
||||
|
||||
export default EventListItem;
|
||||
|
||||
+13
-12
@@ -1,9 +1,18 @@
|
||||
import { CSSProperties } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
type JustifyValues = CSSProperties["justifyContent"];
|
||||
type JustifyValues =
|
||||
| "center"
|
||||
| "space-around"
|
||||
| "space-between"
|
||||
| "flex-start"
|
||||
| "flex-end";
|
||||
|
||||
type AlignValues = CSSProperties["alignItems"];
|
||||
type AlignValues =
|
||||
| "stretch"
|
||||
| "center"
|
||||
| "baseline"
|
||||
| "flex-start"
|
||||
| "flex-end";
|
||||
|
||||
const Flex = styled.div<{
|
||||
auto?: boolean;
|
||||
@@ -11,19 +20,11 @@ const Flex = styled.div<{
|
||||
align?: AlignValues;
|
||||
justify?: JustifyValues;
|
||||
shrink?: boolean;
|
||||
reverse?: boolean;
|
||||
gap?: number;
|
||||
}>`
|
||||
display: flex;
|
||||
flex: ${({ auto }) => (auto ? "1 1 auto" : "initial")};
|
||||
flex-direction: ${({ column, reverse }) =>
|
||||
reverse
|
||||
? column
|
||||
? "column-reverse"
|
||||
: "row-reverse"
|
||||
: column
|
||||
? "column"
|
||||
: "row"};
|
||||
flex-direction: ${({ column }) => (column ? "column" : "row")};
|
||||
align-items: ${({ align }) => align};
|
||||
justify-content: ${({ justify }) => justify};
|
||||
flex-shrink: ${({ shrink }) => (shrink ? 1 : "initial")};
|
||||
|
||||
@@ -19,7 +19,7 @@ type Props = RootStore & {
|
||||
membership?: CollectionGroupMembership;
|
||||
showFacepile?: boolean;
|
||||
showAvatar?: boolean;
|
||||
renderActions: (params: { openMembersModal: () => void }) => React.ReactNode;
|
||||
renderActions: (arg0: { openMembersModal: () => void }) => React.ReactNode;
|
||||
};
|
||||
|
||||
@observer
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Dialog, DialogBackdrop, useDialogState } from "reakit/Dialog";
|
||||
import styled from "styled-components";
|
||||
import { depths } from "@shared/styles";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
isOpen: boolean;
|
||||
title?: string;
|
||||
onRequestClose: () => void;
|
||||
};
|
||||
|
||||
const Guide: React.FC<Props> = ({
|
||||
const Guide = ({
|
||||
children,
|
||||
isOpen,
|
||||
title = "Untitled",
|
||||
onRequestClose,
|
||||
...rest
|
||||
}) => {
|
||||
}: Props) => {
|
||||
const dialog = useDialogState({
|
||||
animated: 250,
|
||||
});
|
||||
@@ -72,7 +73,7 @@ const Backdrop = styled.div`
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: ${(props) => props.theme.backdrop} !important;
|
||||
z-index: ${depths.modalOverlay};
|
||||
z-index: ${(props) => props.theme.depths.modalOverlay};
|
||||
transition: opacity 200ms ease-in-out;
|
||||
opacity: 0;
|
||||
|
||||
@@ -87,7 +88,7 @@ const Scene = styled.div`
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin: 12px;
|
||||
z-index: ${depths.modal};
|
||||
z-index: ${(props) => props.theme.depths.modal};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
@@ -111,4 +112,4 @@ const Content = styled(Scrollable)`
|
||||
padding: 16px;
|
||||
`;
|
||||
|
||||
export default Guide;
|
||||
export default observer(Guide);
|
||||
|
||||
+12
-12
@@ -5,11 +5,9 @@ import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths } from "@shared/styles";
|
||||
import Button from "~/components/Button";
|
||||
import Fade from "~/components/Fade";
|
||||
import Flex from "~/components/Flex";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { supportsPassiveListener } from "~/utils/browser";
|
||||
@@ -30,17 +28,19 @@ function Header({ breadcrumb, title, actions, hasSidebar }: Props) {
|
||||
const passThrough = !actions && !breadcrumb && !title;
|
||||
|
||||
const [isScrolled, setScrolled] = React.useState(false);
|
||||
const handleScroll = React.useMemo(
|
||||
() => throttle(() => setScrolled(window.scrollY > 75), 50),
|
||||
const handleScroll = React.useCallback(
|
||||
throttle(() => setScrolled(window.scrollY > 75), 50),
|
||||
[]
|
||||
);
|
||||
|
||||
useEventListener(
|
||||
"scroll",
|
||||
handleScroll,
|
||||
window,
|
||||
supportsPassiveListener ? { passive: true } : { capture: false }
|
||||
);
|
||||
React.useEffect(() => {
|
||||
window.addEventListener(
|
||||
"scroll",
|
||||
handleScroll,
|
||||
supportsPassiveListener ? { passive: true } : false
|
||||
);
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, [handleScroll]);
|
||||
|
||||
const handleClickTitle = React.useCallback(() => {
|
||||
window.scrollTo({
|
||||
@@ -100,7 +100,7 @@ const Actions = styled(Flex)`
|
||||
|
||||
const Wrapper = styled(Flex)<{ $passThrough?: boolean }>`
|
||||
top: 0;
|
||||
z-index: ${depths.header};
|
||||
z-index: ${(props) => props.theme.depths.header};
|
||||
position: sticky;
|
||||
background: ${(props) => props.theme.background};
|
||||
|
||||
@@ -118,7 +118,7 @@ const Wrapper = styled(Flex)<{ $passThrough?: boolean }>`
|
||||
padding: 12px;
|
||||
transition: all 100ms ease-out;
|
||||
transform: translate3d(0, 0, 0);
|
||||
min-height: 64px;
|
||||
min-height: 56px;
|
||||
justify-content: flex-start;
|
||||
|
||||
@supports (backdrop-filter: blur(20px)) {
|
||||
|
||||
@@ -5,6 +5,14 @@ const Heading = styled.h1<{ centered?: boolean }>`
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
${(props) => (props.centered ? "text-align: center;" : "")}
|
||||
|
||||
svg {
|
||||
margin-top: 4px;
|
||||
margin-left: -6px;
|
||||
margin-right: 2px;
|
||||
align-self: flex-start;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export default Heading;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { escapeRegExp } from "lodash";
|
||||
import * as React from "react";
|
||||
import replace from "string-replace-to-array";
|
||||
import styled from "styled-components";
|
||||
@@ -24,7 +23,7 @@ function Highlight({
|
||||
regex = highlight;
|
||||
} else {
|
||||
regex = new RegExp(
|
||||
escapeRegExp(highlight || ""),
|
||||
(highlight || "").replace(/[-\\^$*+?.()|[\]{}]/g, "\\$&"),
|
||||
caseSensitive ? "g" : "gi"
|
||||
);
|
||||
}
|
||||
@@ -42,10 +41,10 @@ function Highlight({
|
||||
);
|
||||
}
|
||||
|
||||
export const Mark = styled.mark`
|
||||
const Mark = styled.mark`
|
||||
background: ${(props) => props.theme.searchHighlight};
|
||||
border-radius: 2px;
|
||||
padding: 0 2px;
|
||||
padding: 0 4px;
|
||||
`;
|
||||
|
||||
export default Highlight;
|
||||
|
||||
@@ -2,11 +2,9 @@ import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import { Portal } from "react-portal";
|
||||
import styled from "styled-components";
|
||||
import { depths } from "@shared/styles";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { isExternalUrl } from "@shared/utils/urls";
|
||||
import { isInternalUrl } from "@shared/utils/urls";
|
||||
import HoverPreviewDocument from "~/components/HoverPreviewDocument";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { fadeAndSlideDown } from "~/styles/animations";
|
||||
|
||||
@@ -125,13 +123,8 @@ function HoverPreviewInternal({ node, onClose }: Props) {
|
||||
}
|
||||
|
||||
function HoverPreview({ node, ...rest }: Props) {
|
||||
const isMobile = useMobile();
|
||||
if (isMobile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// previews only work for internal doc links for now
|
||||
if (isExternalUrl(node.href)) {
|
||||
if (!isInternalUrl(node.href)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -157,7 +150,7 @@ const Margin = styled.div`
|
||||
|
||||
const CardContent = styled.div`
|
||||
overflow: hidden;
|
||||
max-height: 20em;
|
||||
max-height: 350px;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
@@ -202,7 +195,7 @@ const Card = styled.div`
|
||||
const Position = styled.div<{ fixed?: boolean; top?: number; left?: number }>`
|
||||
margin-top: 10px;
|
||||
position: ${({ fixed }) => (fixed ? "fixed" : "absolute")};
|
||||
z-index: ${depths.hoverPreview};
|
||||
z-index: ${(props) => props.theme.depths.hoverPreview};
|
||||
display: flex;
|
||||
max-height: 75%;
|
||||
|
||||
|
||||
@@ -40,7 +40,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { useMenuState, MenuButton, MenuItem } from "reakit/Menu";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import Flex from "~/components/Flex";
|
||||
import { LabelText } from "~/components/Input";
|
||||
@@ -201,7 +200,18 @@ export const icons = {
|
||||
keywords: "warning alert error",
|
||||
},
|
||||
};
|
||||
|
||||
const colors = [
|
||||
"#4E5C6E",
|
||||
"#0366d6",
|
||||
"#9E5CF7",
|
||||
"#FF825C",
|
||||
"#FF5C80",
|
||||
"#FFBE0B",
|
||||
"#42DED1",
|
||||
"#00D084",
|
||||
"#FF4DFA",
|
||||
"#2F362F",
|
||||
];
|
||||
type Props = {
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
@@ -262,7 +272,7 @@ function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
|
||||
<ColorPicker
|
||||
color={color}
|
||||
onChange={(color) => onChange(color.hex, icon)}
|
||||
colors={colorPalette}
|
||||
colors={colors}
|
||||
triangle="hide"
|
||||
styles={{
|
||||
default: {
|
||||
|
||||
+33
-37
@@ -38,13 +38,6 @@ const RealInput = styled.input<{ hasIcon?: boolean }>`
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
}
|
||||
|
||||
&:-webkit-autofill,
|
||||
&:-webkit-autofill:hover,
|
||||
&:-webkit-autofill:focus {
|
||||
-webkit-box-shadow: 0 0 0px 1000px ${(props) => props.theme.background}
|
||||
inset;
|
||||
}
|
||||
|
||||
&::-webkit-search-cancel-button {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
@@ -104,24 +97,36 @@ export const LabelText = styled.div`
|
||||
display: inline-block;
|
||||
`;
|
||||
|
||||
export type Props = React.InputHTMLAttributes<
|
||||
HTMLInputElement | HTMLTextAreaElement
|
||||
> & {
|
||||
export type Props = {
|
||||
type?: "text" | "email" | "checkbox" | "search" | "textarea";
|
||||
labelHidden?: boolean;
|
||||
value?: string;
|
||||
label?: string;
|
||||
className?: string;
|
||||
labelHidden?: boolean;
|
||||
flex?: boolean;
|
||||
short?: boolean;
|
||||
margin?: string | number;
|
||||
icon?: React.ReactNode;
|
||||
innerRef?: React.Ref<any>;
|
||||
name?: string;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
autoFocus?: boolean;
|
||||
autoComplete?: boolean | string;
|
||||
readOnly?: boolean;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
onChange?: (
|
||||
ev: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => unknown;
|
||||
onKeyDown?: (ev: React.KeyboardEvent<HTMLInputElement>) => unknown;
|
||||
onFocus?: (ev: React.SyntheticEvent) => unknown;
|
||||
onBlur?: (ev: React.SyntheticEvent) => unknown;
|
||||
};
|
||||
|
||||
@observer
|
||||
class Input extends React.Component<Props> {
|
||||
input = this.props.innerRef;
|
||||
input = React.createRef<HTMLInputElement | HTMLTextAreaElement>();
|
||||
|
||||
@observable
|
||||
focused = false;
|
||||
@@ -142,6 +147,10 @@ class Input extends React.Component<Props> {
|
||||
}
|
||||
};
|
||||
|
||||
focus() {
|
||||
this.input.current?.focus();
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
type = "text",
|
||||
@@ -157,6 +166,8 @@ class Input extends React.Component<Props> {
|
||||
...rest
|
||||
} = this.props;
|
||||
|
||||
const InputComponent: React.ComponentType =
|
||||
type === "textarea" ? RealTextarea : RealInput;
|
||||
const wrappedLabel = <LabelText>{label}</LabelText>;
|
||||
|
||||
return (
|
||||
@@ -170,24 +181,15 @@ class Input extends React.Component<Props> {
|
||||
))}
|
||||
<Outline focused={this.focused} margin={margin}>
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
{type === "textarea" ? (
|
||||
<RealTextarea
|
||||
ref={this.props.innerRef}
|
||||
onBlur={this.props.onBlur}
|
||||
onFocus={this.handleFocus}
|
||||
hasIcon={!!icon}
|
||||
{...rest}
|
||||
/>
|
||||
) : (
|
||||
<RealInput
|
||||
ref={this.props.innerRef}
|
||||
onBlur={this.props.onBlur}
|
||||
onFocus={this.handleFocus}
|
||||
hasIcon={!!icon}
|
||||
type={type}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
<InputComponent
|
||||
// @ts-expect-error no idea why this is not working
|
||||
ref={this.input}
|
||||
onBlur={this.handleBlur}
|
||||
onFocus={this.handleFocus}
|
||||
hasIcon={!!icon}
|
||||
type={type === "textarea" ? undefined : type}
|
||||
{...rest}
|
||||
/>
|
||||
</Outline>
|
||||
</label>
|
||||
</Wrapper>
|
||||
@@ -195,10 +197,4 @@ class Input extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
export const ReactHookWrappedInput = React.forwardRef(
|
||||
(props: Omit<Props, "innerRef">, ref: React.Ref<any>) => {
|
||||
return <Input {...{ ...props, innerRef: ref }} />;
|
||||
}
|
||||
);
|
||||
|
||||
export default Input;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { SearchIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "styled-components";
|
||||
import Input, { Props as InputProps } from "~/components/Input";
|
||||
import Input, { Props as InputProps } from "./Input";
|
||||
|
||||
type Props = InputProps & {
|
||||
placeholder?: string;
|
||||
@@ -11,10 +11,7 @@ type Props = InputProps & {
|
||||
onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => unknown;
|
||||
};
|
||||
|
||||
function InputSearch(
|
||||
props: Props,
|
||||
ref: React.RefObject<HTMLInputElement | HTMLTextAreaElement>
|
||||
) {
|
||||
export default function InputSearch(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const [isFocused, setIsFocused] = React.useState(false);
|
||||
@@ -42,10 +39,7 @@ function InputSearch(
|
||||
onBlur={handleBlur}
|
||||
margin={0}
|
||||
labelHidden
|
||||
innerRef={ref}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.forwardRef(InputSearch);
|
||||
|
||||
@@ -7,8 +7,8 @@ import styled, { useTheme } from "styled-components";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
import { searchPath } from "~/utils/routeHelpers";
|
||||
import Input, { Outline } from "./Input";
|
||||
import { searchUrl } from "~/utils/routeHelpers";
|
||||
import Input from "./Input";
|
||||
|
||||
type Props = {
|
||||
source: string;
|
||||
@@ -30,7 +30,7 @@ function InputSearchPage({
|
||||
collectionId,
|
||||
source,
|
||||
}: Props) {
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const inputRef = React.useRef<Input>(null);
|
||||
const theme = useTheme();
|
||||
const history = useHistory();
|
||||
const { t } = useTranslation();
|
||||
@@ -51,7 +51,7 @@ function InputSearchPage({
|
||||
if (ev.key === "Enter") {
|
||||
ev.preventDefault();
|
||||
history.push(
|
||||
searchPath(ev.currentTarget.value, {
|
||||
searchUrl(ev.currentTarget.value, {
|
||||
collectionId,
|
||||
ref: source,
|
||||
})
|
||||
@@ -67,7 +67,7 @@ function InputSearchPage({
|
||||
|
||||
return (
|
||||
<InputMaxWidth
|
||||
innerRef={inputRef}
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
placeholder={placeholder || `${t("Search")}…`}
|
||||
value={value}
|
||||
@@ -89,10 +89,6 @@ function InputSearchPage({
|
||||
|
||||
const InputMaxWidth = styled(Input)`
|
||||
max-width: 30vw;
|
||||
|
||||
${Outline} {
|
||||
border-radius: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(InputSearchPage);
|
||||
|
||||
@@ -23,8 +23,6 @@ export type Option = {
|
||||
};
|
||||
|
||||
export type Props = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
value?: string | null;
|
||||
label?: string;
|
||||
nude?: boolean;
|
||||
@@ -56,7 +54,6 @@ const InputSelect = (props: Props) => {
|
||||
disabled,
|
||||
note,
|
||||
icon,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const select = useSelectState({
|
||||
@@ -131,7 +128,7 @@ const InputSelect = (props: Props) => {
|
||||
wrappedLabel
|
||||
))}
|
||||
|
||||
<Select {...select} disabled={disabled} {...rest} ref={buttonRef}>
|
||||
<Select {...select} disabled={disabled} ref={buttonRef}>
|
||||
{(props) => (
|
||||
<StyledButton
|
||||
neutral
|
||||
@@ -232,7 +229,6 @@ const StyledButton = styled(Button)<{ nude?: boolean }>`
|
||||
margin-bottom: 16px;
|
||||
display: block;
|
||||
width: 100%;
|
||||
cursor: default;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: ${(props) => props.theme.buttonNeutralBackground};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { $Diff } from "utility-types";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import InputSelect, { Props, Option } from "./InputSelect";
|
||||
|
||||
export default function InputSelectPermission(
|
||||
@@ -32,11 +31,11 @@ export default function InputSelectPermission(
|
||||
options={[
|
||||
{
|
||||
label: t("View and edit"),
|
||||
value: CollectionPermission.ReadWrite,
|
||||
value: "read_write",
|
||||
},
|
||||
{
|
||||
label: t("View only"),
|
||||
value: CollectionPermission.Read,
|
||||
value: "read",
|
||||
},
|
||||
{
|
||||
label: t("No access"),
|
||||
|
||||
@@ -5,9 +5,10 @@ import Flex from "~/components/Flex";
|
||||
|
||||
type Props = {
|
||||
label: React.ReactNode | string;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const Labeled: React.FC<Props> = ({ label, children, ...props }) => (
|
||||
const Labeled = ({ label, children, ...props }: Props) => (
|
||||
<Flex column {...props}>
|
||||
<Label>{label}</Label>
|
||||
{children}
|
||||
|
||||
@@ -10,7 +10,7 @@ import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { detectLanguage } from "~/utils/language";
|
||||
|
||||
function Icon({ className }: { className?: string }) {
|
||||
function Icon(props: any) {
|
||||
return (
|
||||
<svg
|
||||
width="32"
|
||||
@@ -18,7 +18,7 @@ function Icon({ className }: { className?: string }) {
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Helmet } from "react-helmet";
|
||||
import styled, { DefaultTheme } from "styled-components";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Flex from "~/components/Flex";
|
||||
import { LoadingIndicatorBar } from "~/components/LoadingIndicator";
|
||||
import SkipNavContent from "~/components/SkipNavContent";
|
||||
import SkipNavLink from "~/components/SkipNavLink";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import { MenuProvider } from "~/hooks/useMenuContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
|
||||
type Props = {
|
||||
title?: string;
|
||||
children?: React.ReactNode;
|
||||
sidebar?: React.ReactNode;
|
||||
rightRail?: React.ReactNode;
|
||||
};
|
||||
|
||||
const Layout: React.FC<Props> = ({ title, children, sidebar, rightRail }) => {
|
||||
function Layout({ title, children, sidebar, rightRail }: Props) {
|
||||
const { ui } = useStores();
|
||||
const sidebarCollapsed = !sidebar || ui.isEditing || ui.sidebarCollapsed;
|
||||
|
||||
@@ -40,7 +40,7 @@ const Layout: React.FC<Props> = ({ title, children, sidebar, rightRail }) => {
|
||||
{ui.progressBarVisible && <LoadingIndicatorBar />}
|
||||
|
||||
<Container auto>
|
||||
<MenuProvider>{sidebar}</MenuProvider>
|
||||
{sidebar}
|
||||
|
||||
<SkipNavContent />
|
||||
<Content
|
||||
@@ -64,7 +64,7 @@ const Layout: React.FC<Props> = ({ title, children, sidebar, rightRail }) => {
|
||||
</Container>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const Container = styled(Flex)`
|
||||
background: ${(props) => props.theme.background};
|
||||
@@ -74,14 +74,11 @@ const Container = styled(Flex)`
|
||||
min-height: 100%;
|
||||
`;
|
||||
|
||||
type ContentProps = {
|
||||
const Content = styled(Flex)<{
|
||||
$isResizing?: boolean;
|
||||
$sidebarCollapsed?: boolean;
|
||||
$hasSidebar?: boolean;
|
||||
theme: DefaultTheme;
|
||||
};
|
||||
|
||||
const Content = styled(Flex)<ContentProps>`
|
||||
}>`
|
||||
margin: 0;
|
||||
transition: ${(props) =>
|
||||
props.$isResizing ? "none" : `margin-left 100ms ease-out`};
|
||||
@@ -95,7 +92,7 @@ const Content = styled(Flex)<ContentProps>`
|
||||
`}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
${(props: ContentProps) =>
|
||||
${(props: any) =>
|
||||
props.$hasSidebar &&
|
||||
props.$sidebarCollapsed &&
|
||||
`margin-left: ${props.theme.sidebarCollapsedWidth}px;`}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { loadPolyfills } from "~/utils/polyfills";
|
||||
|
||||
/**
|
||||
* Asyncronously load required polyfills. Should wrap the React tree.
|
||||
*/
|
||||
export const LazyPolyfill: React.FC = ({ children }) => {
|
||||
const [isLoaded, setIsLoaded] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
loadPolyfills().then(() => {
|
||||
setIsLoaded(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!isLoaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default LazyPolyfill;
|
||||
@@ -1,17 +0,0 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
CompositeStateReturn,
|
||||
CompositeItem as BaseCompositeItem,
|
||||
} from "reakit/Composite";
|
||||
import Item, { Props as ItemProps } from "./Item";
|
||||
|
||||
export type Props = ItemProps & CompositeStateReturn;
|
||||
|
||||
function CompositeItem(
|
||||
{ to, ...rest }: Props,
|
||||
ref?: React.Ref<HTMLAnchorElement>
|
||||
) {
|
||||
return <BaseCompositeItem as={Item} to={to} {...rest} ref={ref} />;
|
||||
}
|
||||
|
||||
export default React.forwardRef(CompositeItem);
|
||||
@@ -1,53 +0,0 @@
|
||||
import { DisconnectedIcon, WarningIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Empty from "~/components/Empty";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
import { OfflineError } from "~/utils/errors";
|
||||
import ButtonLink from "../ButtonLink";
|
||||
import Flex from "../Flex";
|
||||
|
||||
type Props = {
|
||||
error: Error;
|
||||
retry: () => void;
|
||||
};
|
||||
|
||||
export default function LoadingError({ error, retry, ...rest }: Props) {
|
||||
const { t } = useTranslation();
|
||||
useEventListener("online", retry);
|
||||
|
||||
const message =
|
||||
error instanceof OfflineError ? (
|
||||
<>
|
||||
<DisconnectedIcon color="currentColor" /> {t("You’re offline.")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<WarningIcon color="currentColor" /> {t("Sorry, an error occurred.")}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Content {...rest}>
|
||||
<Flex align="center" gap={4}>
|
||||
{message}{" "}
|
||||
<ButtonLink onClick={() => retry()}>{t("Click to retry")}…</ButtonLink>
|
||||
</Flex>
|
||||
</Content>
|
||||
);
|
||||
}
|
||||
|
||||
const Content = styled(Empty)`
|
||||
padding: 8px 0;
|
||||
white-space: nowrap;
|
||||
|
||||
${ButtonLink} {
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.textSecondary};
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -3,13 +3,14 @@ import styled, { useTheme } from "styled-components";
|
||||
import Flex from "~/components/Flex";
|
||||
import NavLink from "~/components/NavLink";
|
||||
|
||||
export type Props = {
|
||||
type Props = {
|
||||
image?: React.ReactNode;
|
||||
to?: string;
|
||||
exact?: boolean;
|
||||
title: React.ReactNode;
|
||||
subtitle?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
compact?: boolean;
|
||||
border?: boolean;
|
||||
small?: boolean;
|
||||
};
|
||||
@@ -49,7 +50,7 @@ const ListItem = (
|
||||
<Wrapper
|
||||
ref={ref}
|
||||
$border={border}
|
||||
$small={small}
|
||||
$compact={compact}
|
||||
activeStyle={{
|
||||
background: theme.primary,
|
||||
}}
|
||||
@@ -63,21 +64,16 @@ const ListItem = (
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper ref={ref} $border={border} $small={small} {...rest}>
|
||||
<Wrapper $compact={compact} $border={border} {...rest}>
|
||||
{content(false)}
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const Wrapper = styled.a<{
|
||||
$small?: boolean;
|
||||
$border?: boolean;
|
||||
to?: string;
|
||||
}>`
|
||||
const Wrapper = styled.div<{ $compact?: boolean; $border?: boolean }>`
|
||||
display: flex;
|
||||
padding: ${(props) => (props.$border === false ? 0 : "8px 0")};
|
||||
margin: ${(props) =>
|
||||
props.$border === false ? (props.$small ? "8px 0" : "16px 0") : 0};
|
||||
margin: ${(props) => (props.$compact === false ? 0 : "8px 0")};
|
||||
padding: ${(props) => (props.$compact === false ? "8px 0" : 0)};
|
||||
border-bottom: 1px solid
|
||||
${(props) =>
|
||||
props.$border === false ? "transparent" : props.theme.divider};
|
||||
@@ -85,8 +81,6 @@ const Wrapper = styled.a<{
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
cursor: ${({ to }) => (to ? "pointer" : "default")};
|
||||
`;
|
||||
|
||||
const Image = styled(Flex)`
|
||||
@@ -96,7 +90,6 @@ const Image = styled(Flex)`
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
color: ${(props) => props.theme.text};
|
||||
`;
|
||||
|
||||
const Heading = styled.p<{ $small?: boolean }>`
|
||||
|
||||
@@ -3,24 +3,19 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Fade from "~/components/Fade";
|
||||
import Flex from "~/components/Flex";
|
||||
import PlaceholderText, {
|
||||
Props as PlaceholderTextProps,
|
||||
} from "~/components/PlaceholderText";
|
||||
import PlaceholderText from "~/components/PlaceholderText";
|
||||
|
||||
type Props = {
|
||||
count?: number;
|
||||
className?: string;
|
||||
header?: PlaceholderTextProps;
|
||||
body?: PlaceholderTextProps;
|
||||
};
|
||||
|
||||
const ListPlaceHolder = ({ count, className, header, body }: Props) => {
|
||||
const ListPlaceHolder = ({ count }: Props) => {
|
||||
return (
|
||||
<Fade>
|
||||
{times(count || 2, (index) => (
|
||||
<Item key={index} className={className} column auto>
|
||||
<PlaceholderText {...header} header delay={0.2 * index} />
|
||||
<PlaceholderText {...body} delay={0.2 * index} />
|
||||
<Item key={index} column auto>
|
||||
<PlaceholderText header delay={0.2 * index} />
|
||||
<PlaceholderText delay={0.2 * index} />
|
||||
</Item>
|
||||
))}
|
||||
</Fade>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import * as React from "react";
|
||||
import styled, { keyframes } from "styled-components";
|
||||
import { depths } from "@shared/styles";
|
||||
|
||||
const LoadingIndicatorBar = () => {
|
||||
return (
|
||||
@@ -18,7 +17,7 @@ const loadingFrame = keyframes`
|
||||
const Container = styled.div`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
z-index: ${depths.loadingIndicatorBar};
|
||||
z-index: ${(props) => props.theme.depths.loadingIndicatorBar};
|
||||
width: 100%;
|
||||
animation: ${loadingFrame} 4s ease-in-out infinite;
|
||||
animation-delay: 250ms;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { format as formatDate, formatDistanceToNow } from "date-fns";
|
||||
import * as React from "react";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useUserLocale from "~/hooks/useUserLocale";
|
||||
import { dateLocale, locales } from "~/utils/i18n";
|
||||
import { dateLocale } from "~/utils/i18n";
|
||||
|
||||
let callbacks: (() => void)[] = [];
|
||||
|
||||
@@ -22,14 +22,15 @@ function eachMinute(fn: () => void) {
|
||||
|
||||
type Props = {
|
||||
dateTime: string;
|
||||
children?: React.ReactNode;
|
||||
tooltipDelay?: number;
|
||||
addSuffix?: boolean;
|
||||
shorten?: boolean;
|
||||
relative?: boolean;
|
||||
format?: Partial<Record<keyof typeof locales, string>>;
|
||||
format?: string;
|
||||
};
|
||||
|
||||
const LocaleTime: React.FC<Props> = ({
|
||||
function LocaleTime({
|
||||
addSuffix,
|
||||
children,
|
||||
dateTime,
|
||||
@@ -37,14 +38,8 @@ const LocaleTime: React.FC<Props> = ({
|
||||
format,
|
||||
relative,
|
||||
tooltipDelay,
|
||||
}) => {
|
||||
const userLocale: string = useUserLocale() || "";
|
||||
const dateFormatLong = {
|
||||
en_US: "MMMM do, yyyy h:mm a",
|
||||
fr_FR: "'Le 'd MMMM yyyy 'à' H:mm",
|
||||
};
|
||||
const formatLocaleLong = dateFormatLong[userLocale] ?? "MMMM do, yyyy h:mm a";
|
||||
const formatLocale = format?.[userLocale] ?? formatLocaleLong;
|
||||
}: Props) {
|
||||
const userLocale = useUserLocale();
|
||||
const [_, setMinutesMounted] = React.useState(0); // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
const callback = React.useRef<() => void>();
|
||||
|
||||
@@ -72,13 +67,17 @@ const LocaleTime: React.FC<Props> = ({
|
||||
.replace("minute", "min");
|
||||
}
|
||||
|
||||
const tooltipContent = formatDate(Date.parse(dateTime), formatLocaleLong, {
|
||||
locale,
|
||||
});
|
||||
const tooltipContent = formatDate(
|
||||
Date.parse(dateTime),
|
||||
"MMMM do, yyyy h:mm a",
|
||||
{
|
||||
locale,
|
||||
}
|
||||
);
|
||||
const content =
|
||||
relative !== false
|
||||
? relativeContent
|
||||
: formatDate(Date.parse(dateTime), formatLocale, {
|
||||
: formatDate(Date.parse(dateTime), format || "MMMM do, yyyy h:mm a", {
|
||||
locale,
|
||||
});
|
||||
|
||||
@@ -87,6 +86,6 @@ const LocaleTime: React.FC<Props> = ({
|
||||
<time dateTime={dateTime}>{children || content}</time>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default LocaleTime;
|
||||
|
||||
+43
-119
@@ -4,39 +4,34 @@ import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Dialog, DialogBackdrop, useDialogState } from "reakit/Dialog";
|
||||
import styled, { DefaultTheme } from "styled-components";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import Text from "~/components/Text";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useUnmount from "~/hooks/useUnmount";
|
||||
import { fadeAndScaleIn } from "~/styles/animations";
|
||||
|
||||
let openModals = 0;
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
isOpen: boolean;
|
||||
isCentered?: boolean;
|
||||
title?: React.ReactNode;
|
||||
onRequestClose: () => void;
|
||||
};
|
||||
|
||||
const Modal: React.FC<Props> = ({
|
||||
const Modal = ({
|
||||
children,
|
||||
isOpen,
|
||||
isCentered,
|
||||
title = "Untitled",
|
||||
onRequestClose,
|
||||
}) => {
|
||||
}: Props) => {
|
||||
const dialog = useDialogState({
|
||||
animated: 250,
|
||||
});
|
||||
const [depth, setDepth] = React.useState(0);
|
||||
const wasOpen = usePrevious(isOpen);
|
||||
const isMobile = useMobile();
|
||||
const { t } = useTranslation();
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -64,66 +59,37 @@ const Modal: React.FC<Props> = ({
|
||||
return (
|
||||
<DialogBackdrop {...dialog}>
|
||||
{(props) => (
|
||||
<Backdrop $isCentered={isCentered} {...props}>
|
||||
<Backdrop {...props}>
|
||||
<Dialog
|
||||
{...dialog}
|
||||
aria-label={typeof title === "string" ? title : undefined}
|
||||
preventBodyScroll
|
||||
hideOnEsc
|
||||
hideOnClickOutside={!!isCentered}
|
||||
hideOnClickOutside={false}
|
||||
hide={onRequestClose}
|
||||
>
|
||||
{(props) =>
|
||||
isCentered && !isMobile ? (
|
||||
<Small {...props}>
|
||||
<Centered
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
column
|
||||
reverse
|
||||
>
|
||||
<SmallContent shadow>{children}</SmallContent>
|
||||
<Header>
|
||||
{title && (
|
||||
<Text as="span" size="large">
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
<Text as="span" size="large">
|
||||
<NudeButton onClick={onRequestClose}>
|
||||
<CloseIcon color="currentColor" />
|
||||
</NudeButton>
|
||||
</Text>
|
||||
</Header>
|
||||
{(props) => (
|
||||
<Scene
|
||||
$nested={!!depth}
|
||||
style={{
|
||||
marginLeft: `${depth * 12}px`,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<Content>
|
||||
<Centered onClick={(ev) => ev.stopPropagation()} column>
|
||||
{title && <h1>{title}</h1>}
|
||||
{children}
|
||||
</Centered>
|
||||
</Small>
|
||||
) : (
|
||||
<Fullscreen
|
||||
$nested={!!depth}
|
||||
style={
|
||||
isMobile
|
||||
? undefined
|
||||
: {
|
||||
marginLeft: `${depth * 12}px`,
|
||||
}
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
<Content>
|
||||
<Centered onClick={(ev) => ev.stopPropagation()} column>
|
||||
{title && <h1>{title}</h1>}
|
||||
{children}
|
||||
</Centered>
|
||||
</Content>
|
||||
<Close onClick={onRequestClose}>
|
||||
<CloseIcon size={32} color="currentColor" />
|
||||
</Close>
|
||||
<Back onClick={onRequestClose}>
|
||||
<BackIcon size={32} color="currentColor" />
|
||||
<Text as="span">{t("Back")} </Text>
|
||||
</Back>
|
||||
</Fullscreen>
|
||||
)
|
||||
}
|
||||
</Content>
|
||||
<Back onClick={onRequestClose}>
|
||||
<BackIcon size={32} color="currentColor" />
|
||||
<Text>{t("Back")}</Text>
|
||||
</Back>
|
||||
<Close onClick={onRequestClose}>
|
||||
<CloseIcon size={32} color="currentColor" />
|
||||
</Close>
|
||||
</Scene>
|
||||
)}
|
||||
</Dialog>
|
||||
</Backdrop>
|
||||
)}
|
||||
@@ -131,17 +97,15 @@ const Modal: React.FC<Props> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const Backdrop = styled(Flex)<{ $isCentered?: boolean }>`
|
||||
const Backdrop = styled.div`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: ${(props) =>
|
||||
props.$isCentered
|
||||
? props.theme.modalBackdrop
|
||||
: transparentize(0.25, props.theme.background)} !important;
|
||||
z-index: ${depths.modalOverlay};
|
||||
transparentize(0.25, props.theme.background)} !important;
|
||||
z-index: ${(props) => props.theme.depths.modalOverlay};
|
||||
transition: opacity 50ms ease-in-out;
|
||||
opacity: 0;
|
||||
|
||||
@@ -150,12 +114,7 @@ const Backdrop = styled(Flex)<{ $isCentered?: boolean }>`
|
||||
}
|
||||
`;
|
||||
|
||||
type FullscreenProps = {
|
||||
$nested: boolean;
|
||||
theme: DefaultTheme;
|
||||
};
|
||||
|
||||
const Fullscreen = styled.div<FullscreenProps>`
|
||||
const Scene = styled.div<{ $nested: boolean }>`
|
||||
animation: ${fadeAndScaleIn} 250ms ease;
|
||||
|
||||
position: absolute;
|
||||
@@ -163,7 +122,7 @@ const Fullscreen = styled.div<FullscreenProps>`
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: ${depths.modal};
|
||||
z-index: ${(props) => props.theme.depths.modal};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
@@ -172,7 +131,7 @@ const Fullscreen = styled.div<FullscreenProps>`
|
||||
outline: none;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
${(props: FullscreenProps) =>
|
||||
${(props: any) =>
|
||||
props.$nested &&
|
||||
`
|
||||
box-shadow: 0 -2px 10px ${props.theme.shadow};
|
||||
@@ -184,10 +143,10 @@ const Fullscreen = styled.div<FullscreenProps>`
|
||||
|
||||
const Content = styled(Scrollable)`
|
||||
width: 100%;
|
||||
padding: 8vh 32px;
|
||||
padding: 8vh 2rem 2rem;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: 13vh 2rem 2rem;
|
||||
padding-top: 13vh;
|
||||
`};
|
||||
`;
|
||||
|
||||
@@ -198,6 +157,13 @@ const Centered = styled(Flex)`
|
||||
margin: 0 auto;
|
||||
`;
|
||||
|
||||
const Text = styled.span`
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
padding-right: 12px;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
const Close = styled(NudeButton)`
|
||||
position: absolute;
|
||||
display: block;
|
||||
@@ -226,7 +192,6 @@ const Back = styled(NudeButton)`
|
||||
left: 2rem;
|
||||
opacity: 0.75;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-weight: 500;
|
||||
width: auto;
|
||||
height: auto;
|
||||
|
||||
@@ -239,45 +204,4 @@ const Back = styled(NudeButton)`
|
||||
`};
|
||||
`;
|
||||
|
||||
const Header = styled(Flex)`
|
||||
color: ${(props) => props.theme.textSecondary};
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-weight: 600;
|
||||
padding: 24px 24px 4px;
|
||||
`;
|
||||
|
||||
const Small = styled.div`
|
||||
animation: ${fadeAndScaleIn} 250ms ease;
|
||||
|
||||
margin: auto auto;
|
||||
min-width: 350px;
|
||||
max-width: 30vw;
|
||||
z-index: ${depths.modal};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
background: ${(props) => props.theme.modalBackground};
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
box-shadow: ${(props) => props.theme.modalShadow};
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
|
||||
${NudeButton} {
|
||||
&:hover,
|
||||
&[aria-expanded="true"] {
|
||||
background: ${(props) => props.theme.sidebarControlHoverBackground};
|
||||
}
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
${Header} {
|
||||
align-items: start;
|
||||
}
|
||||
`;
|
||||
|
||||
const SmallContent = styled(Scrollable)`
|
||||
padding: 12px 24px 24px;
|
||||
`;
|
||||
|
||||
export default observer(Modal);
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import * as React from "react";
|
||||
import { match, NavLink, Route } from "react-router-dom";
|
||||
import { NavLink, Route } from "react-router-dom";
|
||||
|
||||
type Props = React.ComponentProps<typeof NavLink> & {
|
||||
children?: (
|
||||
match:
|
||||
| match<{
|
||||
[x: string]: string | undefined;
|
||||
}>
|
||||
| boolean
|
||||
| null
|
||||
) => React.ReactNode;
|
||||
children?: (match: any) => React.ReactNode;
|
||||
exact?: boolean;
|
||||
activeStyle?: React.CSSProperties;
|
||||
to: string;
|
||||
@@ -21,11 +14,9 @@ function NavLinkWithChildrenFunc(
|
||||
) {
|
||||
return (
|
||||
<Route path={to} exact={exact}>
|
||||
{({ match, location }) => (
|
||||
{({ match }) => (
|
||||
<NavLink {...rest} to={to} exact={exact} ref={ref}>
|
||||
{children
|
||||
? children(rest.isActive ? rest.isActive(match, location) : match)
|
||||
: null}
|
||||
{children ? children(match) : null}
|
||||
</NavLink>
|
||||
)}
|
||||
</Route>
|
||||
|
||||
@@ -4,11 +4,12 @@ import Flex from "./Flex";
|
||||
import Text from "./Text";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
icon?: JSX.Element;
|
||||
description?: JSX.Element;
|
||||
};
|
||||
|
||||
const Notice: React.FC<Props> = ({ children, icon, description }) => {
|
||||
const Notice = ({ children, icon, description }: Props) => {
|
||||
return (
|
||||
<Container>
|
||||
<Flex as="span" gap={8}>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import * as React from "react";
|
||||
import Notice from "~/components/Notice";
|
||||
|
||||
const AlertNotice: React.FC = ({ children }) => {
|
||||
export default function AlertNotice({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Notice>
|
||||
<svg
|
||||
@@ -24,6 +28,4 @@ const AlertNotice: React.FC = ({ children }) => {
|
||||
{children}
|
||||
</Notice>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertNotice;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user