mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
240 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a715e4520 | |||
| eb07aa61ab | |||
| 5988bc087a | |||
| b38f085604 | |||
| fc7ebaccd8 | |||
| d9d0e90ae0 | |||
| ec5e3120d2 | |||
| 8a76dd49a0 | |||
| 33524a1322 | |||
| b616292fce | |||
| 94a2e453eb | |||
| a674a8668b | |||
| 28ab3402ac | |||
| 8a9c09c646 | |||
| 30a80fa92d | |||
| e899616081 | |||
| 411a76f9ff | |||
| 87125223de | |||
| 6a64dfe4b2 | |||
| 87e8ef8fe6 | |||
| c597f2d9a2 | |||
| 37be7f99c4 | |||
| 9865eab61c | |||
| a600a897c3 | |||
| e170a63094 | |||
| c30908e858 | |||
| 3ac7a839ad | |||
| 7bc7d7cd6b | |||
| dcec3dd4ec | |||
| b2a1e6b309 | |||
| 5dd5df6268 | |||
| 8cdb78c94a | |||
| 398c7eb25c | |||
| ee270abbe9 | |||
| 70ec8c551e | |||
| 3a29e157b2 | |||
| ed8334d77a | |||
| 6df8e9e13f | |||
| eb9ff990ac | |||
| 1a6921f6c7 | |||
| 89115a53ca | |||
| ad3bb98087 | |||
| a839f2ed5b | |||
| ea12ebea0e | |||
| 57fa1305a6 | |||
| 5f00e1394d | |||
| b6a058147e | |||
| 2c6ec11708 | |||
| 5900176b58 | |||
| e2c80e5a28 | |||
| 61d56922d5 | |||
| 9a1c5c187e | |||
| f2b007bcf5 | |||
| 578d4c4517 | |||
| 313fd0c1b4 | |||
| 1641423106 | |||
| 67f06895e7 | |||
| 030419fa80 | |||
| 3987de1d7e | |||
| 12b9e750e9 | |||
| 1819920c04 | |||
| a33bac66e4 | |||
| 043a7b41b5 | |||
| 4266a95569 | |||
| 1d6bae05e6 | |||
| bb36425175 | |||
| adca894e83 | |||
| 2e56bdc388 | |||
| 7f3df8158a | |||
| 1b539dcf83 | |||
| 1d22b7ae0c | |||
| b1f04145e5 | |||
| 2a32a4095d | |||
| 33b6fbdee9 | |||
| dc92e1ead4 | |||
| 248c8b3c01 | |||
| d9f8d2e6d4 | |||
| 99684d0900 | |||
| 6c2d43075c | |||
| b44c15c6eb | |||
| f7b587b5a5 | |||
| c79a22b857 | |||
| 63c0daf483 | |||
| 51971d2c9a | |||
| d443abfc57 | |||
| 3610a7f4a2 | |||
| 90fdf5106a | |||
| 77fb76ff0c | |||
| 583353e904 | |||
| 26e2ae4bf1 | |||
| 4f34b69cfa | |||
| 8c1979465f | |||
| cc7a50fbb1 | |||
| 5299ada3c9 | |||
| 96fc95a9f3 | |||
| 2219cfd83e | |||
| 6a1566c275 | |||
| b9346fe6ea | |||
| 18572cf9de | |||
| 59f4b3bd97 | |||
| bb9d7d310b | |||
| 3a19c02e34 | |||
| a6b3dbc894 | |||
| e0405cca0e | |||
| 09a409b494 | |||
| ccd947c6e8 | |||
| 4e05728218 | |||
| 40e09dd829 | |||
| 99381d10ff | |||
| 36c73051b4 | |||
| 81718c8ee1 | |||
| be905a6993 | |||
| b39d4aade7 | |||
| c5fb5f875f | |||
| 552755dace | |||
| e61c71766f | |||
| df5dc2f691 | |||
| 28097835d0 | |||
| 3de51c1a67 | |||
| 223a47af95 | |||
| 7c8675ce17 | |||
| 157c3ce80f | |||
| 0ed7286fc6 | |||
| 78464f315c | |||
| 79790de9b0 | |||
| 252459f1cf | |||
| 20a72481dc | |||
| 765c7cdc27 | |||
| 6f136e342f | |||
| 9545113d9e | |||
| c00001086a | |||
| 95dbc8168c | |||
| 0021553518 | |||
| bcca4b91ee | |||
| c1bd30aac8 | |||
| fd7dd83a4b | |||
| 26f02cdd05 | |||
| fec2baf361 | |||
| e1601fbe72 | |||
| a88b54d26d | |||
| 88cc964d69 | |||
| b8efe772fe | |||
| b2f00d71d3 | |||
| c2edfca6e5 | |||
| 9c3c0fe418 | |||
| 313067ff7b | |||
| be64c2b206 | |||
| d576ce1734 | |||
| 0f624958bc | |||
| 162da9a3ad | |||
| d7e9ad4f13 | |||
| bcf773a1d6 | |||
| 97082e8cba | |||
| bc3f2e4876 | |||
| 49a9b91708 | |||
| 01cea549a5 | |||
| a9df3f64cf | |||
| e6cc8f5550 | |||
| f6c2a95a55 | |||
| 27736f66ef | |||
| cde2909296 | |||
| 1f6e1a71f9 | |||
| 15ef8f7dff | |||
| 83a61b87ed | |||
| 6c605cf720 | |||
| fb335887cb | |||
| 88e7d4c539 | |||
| 400e32da70 | |||
| a699dea286 | |||
| 2aca760ee0 | |||
| f1c9c6fdf9 | |||
| 801f6681ba | |||
| 0a998789a3 | |||
| 92016bbd06 | |||
| 231ab2da03 | |||
| bd880ee984 | |||
| 995c6f90b7 | |||
| 8ac853bb8b | |||
| 2f5cf90cb7 | |||
| c709e54738 | |||
| 47953b3354 | |||
| d96099b5b8 | |||
| 4b2bf28531 | |||
| a3df9e868f | |||
| 476b5e03f9 | |||
| 23a6459ae8 | |||
| 4929fbaccb | |||
| 08a8fea69a | |||
| 2024c6e64f | |||
| 3dfd336f59 | |||
| 9a875920ac | |||
| f389ac6414 | |||
| e4b7aa6761 | |||
| 00ba65f3ef | |||
| 28aef82af9 | |||
| 86f008293a | |||
| 835fd26a95 | |||
| cc9468e2c5 | |||
| 22ba4d0f48 | |||
| d335670b91 | |||
| cabaee2d0a | |||
| f6d889f759 | |||
| a50471959b | |||
| d8ad2fc1a2 | |||
| 0c48227b57 | |||
| 72da0653cc | |||
| e613ec732b | |||
| 0be40609ed | |||
| ec8fde0a5f | |||
| 2c52a8cb8b | |||
| 1db31eed41 | |||
| 8ba8013c6a | |||
| 1521d4dbac | |||
| a1a4fd1baf | |||
| 31f4424018 | |||
| 1f5b83aaeb | |||
| 77db0c2e95 | |||
| 4cbae1cf7d | |||
| e985078b80 | |||
| 09b73401de | |||
| 42b384688d | |||
| 5bdee1204e | |||
| 9db72217af | |||
| 57a2524fbd | |||
| bd148f4790 | |||
| 28d32af613 | |||
| f2f550e1d2 | |||
| dad21b2186 | |||
| 5fb5f1e8b5 | |||
| 2d0690697c | |||
| 6b551749d4 | |||
| 52fc861bcf | |||
| c81c9a9d2d | |||
| 29c742a673 | |||
| dd249021e7 | |||
| 21d3b9c7e0 | |||
| 6665dfff28 | |||
| cdfe3a7fc3 | |||
| 401c91f90b | |||
| ed5320507d |
+94
-2
@@ -1,4 +1,12 @@
|
||||
version: 2
|
||||
version: 2.1
|
||||
|
||||
executors:
|
||||
docker-publisher:
|
||||
environment:
|
||||
IMAGE_NAME: outlinewiki/outline
|
||||
docker:
|
||||
- image: circleci/buildpack-deps:stretch
|
||||
|
||||
jobs:
|
||||
build:
|
||||
working_directory: ~/outline
|
||||
@@ -40,4 +48,88 @@ jobs:
|
||||
command: yarn test
|
||||
- run:
|
||||
name: build-webpack
|
||||
command: yarn build:webpack
|
||||
command: yarn build:webpack
|
||||
build-image:
|
||||
executor: docker-publisher
|
||||
steps:
|
||||
- checkout
|
||||
- setup_remote_docker:
|
||||
version: 20.10.6
|
||||
- run:
|
||||
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: |
|
||||
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: Load archived Docker image
|
||||
command: docker load -i /tmp/workspace/image.tar
|
||||
- run:
|
||||
name: Publish Docker Image to Docker Hub
|
||||
command: |
|
||||
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
|
||||
build-and-test:
|
||||
jobs:
|
||||
- build:
|
||||
filters:
|
||||
tags:
|
||||
ignore: /^v.*/
|
||||
build-docker:
|
||||
jobs:
|
||||
- build-image:
|
||||
filters:
|
||||
tags:
|
||||
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: /.*/
|
||||
|
||||
+39
-17
@@ -1,6 +1,6 @@
|
||||
# 👋 Welcome, we're glad you're setting up an installation of Outline. Copy this
|
||||
# file to .env or set the variables in your local environment manually. For
|
||||
# development with docker this should mostly work out of the box other than
|
||||
# 👋 Welcome, we're glad you're setting up an installation of Outline. Copy this
|
||||
# file to .env or set the variables in your local environment manually. For
|
||||
# development with docker this should mostly work out of the box other than
|
||||
# setting the Slack keys and the SECRET_KEY.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
# in your terminal to generate a random value.
|
||||
SECRET_KEY=generate_a_new_key
|
||||
|
||||
# Generate a unique random key. The format is not important but you could still use
|
||||
# Generate a unique random key. The format is not important but you could still use
|
||||
# `openssl rand -hex 32` in your terminal to produce this.
|
||||
UTILS_SECRET=generate_a_new_key
|
||||
|
||||
@@ -29,9 +29,13 @@ REDIS_URL=redis://localhost:6479
|
||||
URL=http://localhost:3000
|
||||
PORT=3000
|
||||
|
||||
# See [documentation](docs/SERVICES.md) on running a separate collaboration
|
||||
# server, for normal operation this does not need to be set.
|
||||
COLLABORATION_URL=
|
||||
|
||||
# To support uploading of images for avatars and document attachments an
|
||||
# s3-compatible storage must be provided. AWS S3 is recommended for redundency
|
||||
# however if you want to keep all file storage local an alternative such as
|
||||
# however if you want to keep all file storage local an alternative such as
|
||||
# minio (https://github.com/minio/minio) can be used.
|
||||
|
||||
# A more detailed guide on setting up S3 is available here:
|
||||
@@ -69,24 +73,42 @@ SLACK_SECRET=get_the_secret_of_above_key
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
# To configure Microsoft/Azure auth, you'll need to create an OAuth Client. See
|
||||
# To configure Microsoft/Azure auth, you'll need to create an OAuth Client. See
|
||||
# the guide for details on setting up your Azure App:
|
||||
# => https://wiki.generaloutline.com/share/dfa77e56-d4d2-4b51-8ff8-84ea6608faa4
|
||||
AZURE_CLIENT_ID=
|
||||
AZURE_CLIENT_ID=
|
||||
AZURE_CLIENT_SECRET=
|
||||
AZURE_RESOURCE_APP_ID=
|
||||
|
||||
# To configure generic OIDC auth, you'll need some kind of identity provider.
|
||||
# See documentation for whichever IdP you use to acquire the following info:
|
||||
# Redirect URI is https://<URL>/auth/oidc.callback
|
||||
OIDC_CLIENT_ID=
|
||||
OIDC_CLIENT_SECRET=
|
||||
OIDC_AUTH_URI=
|
||||
OIDC_TOKEN_URI=
|
||||
OIDC_USERINFO_URI=
|
||||
|
||||
# Specify which claims to derive user information from
|
||||
# Supports any valid JSON path with the JWT payload
|
||||
OIDC_USERNAME_CLAIM=preferred_username
|
||||
|
||||
# Display name for OIDC authentication
|
||||
OIDC_DISPLAY_NAME=OpenID
|
||||
|
||||
# Space separated auth scopes.
|
||||
OIDC_SCOPES="openid profile email"
|
||||
|
||||
|
||||
# –––––––––––––––– OPTIONAL ––––––––––––––––
|
||||
|
||||
# If using a Cloudfront/Cloudflare distribution or similar it can be set below.
|
||||
# This will cause paths to javascript, stylesheets, and images to be updated to
|
||||
# the hostname defined in CDN_URL. In your CDN configuration the origin server
|
||||
# This will cause paths to javascript, stylesheets, and images to be updated to
|
||||
# the hostname defined in CDN_URL. In your CDN configuration the origin server
|
||||
# should be set to the same as URL.
|
||||
CDN_URL=
|
||||
|
||||
# Auto-redirect to https in production. The default is true but you may set to
|
||||
# Auto-redirect to https in production. The default is true but you may set to
|
||||
# false if you can be sure that SSL is terminated at an external loadbalancer.
|
||||
FORCE_HTTPS=true
|
||||
|
||||
@@ -102,15 +124,15 @@ WEB_CONCURRENCY=1
|
||||
# especially large Word documents with embedded imagery
|
||||
MAXIMUM_IMPORT_SIZE=5120000
|
||||
|
||||
# You may enable or disable debugging categories to increase the noisiness of
|
||||
# logs. The default is a good balance
|
||||
DEBUG=cache,presenters,events,emails,mailer,utils,http,server,services
|
||||
# You can remove this line if your reverse proxy already logs incoming http
|
||||
# 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
|
||||
# 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
|
||||
#
|
||||
@@ -118,13 +140,13 @@ SLACK_VERIFICATION_TOKEN=your_token
|
||||
SLACK_APP_ID=A0XXXXXXX
|
||||
SLACK_MESSAGE_ACTIONS=true
|
||||
|
||||
# Optionally enable google analytics to track pageviews in the knowledge base
|
||||
# Optionally enable google analytics to track pageviews in the knowledge base
|
||||
GOOGLE_ANALYTICS_ID=
|
||||
|
||||
# Optionally enable Sentry (sentry.io) to track errors and performance
|
||||
SENTRY_DSN=
|
||||
|
||||
# To support sending outgoing transactional emails such as "document updated" or
|
||||
# To support sending outgoing transactional emails such as "document updated" or
|
||||
# "you've been invited" you'll need to provide authentication for an SMTP server
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=
|
||||
@@ -138,6 +160,6 @@ SMTP_SECURE=true
|
||||
# Custom logo that displays on the authentication screen, scaled to height: 60px
|
||||
# TEAM_LOGO=https://example.com/images/logo.png
|
||||
|
||||
# The default interface language. See translate.getoutline.com for a list of
|
||||
# The default interface language. See translate.getoutline.com for a list of
|
||||
# available language codes and their rough percentage translated.
|
||||
DEFAULT_LANGUAGE=en_US
|
||||
|
||||
@@ -11,6 +11,11 @@
|
||||
.*/node_modules/react-side-effect/.*
|
||||
.*/node_modules/fbjs/.*
|
||||
.*/node_modules/config-chain/.*
|
||||
.*/node_modules/yjs/.*
|
||||
.*/node_modules/y-prosemirror/.*
|
||||
.*/node_modules/y-protocols/.*
|
||||
.*/node_modules/y-indexeddb/.*
|
||||
.*/node_modules/lib0/.*
|
||||
.*/server/scripts/.*
|
||||
*.test.js
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ main ]
|
||||
schedule:
|
||||
- cron: '28 15 * * 2'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'javascript' ]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||
# Learn more about CodeQL language support at https://git.io/codeql-language-support
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
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.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v1
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v1
|
||||
@@ -8,3 +8,4 @@ stats.json
|
||||
.DS_Store
|
||||
fakes3/*
|
||||
.idea
|
||||
*.pem
|
||||
|
||||
+40
-13
@@ -1,23 +1,50 @@
|
||||
FROM node:14-alpine
|
||||
# syntax=docker/dockerfile:1.2
|
||||
ARG APP_PATH=/opt/outline
|
||||
FROM node:14-alpine AS deps-common
|
||||
|
||||
ENV APP_PATH /opt/outline
|
||||
RUN mkdir -p $APP_PATH
|
||||
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:14-alpine AS builder
|
||||
|
||||
ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
|
||||
COPY package.json ./
|
||||
COPY yarn.lock ./
|
||||
|
||||
RUN yarn --pure-lockfile
|
||||
|
||||
COPY . .
|
||||
COPY --from=deps-dev $APP_PATH/node_modules ./node_modules
|
||||
RUN yarn build
|
||||
|
||||
RUN yarn build && \
|
||||
yarn --production --ignore-scripts --prefer-offline && \
|
||||
rm -rf shared && \
|
||||
rm -rf app
|
||||
# ---
|
||||
FROM node:14-alpine AS runner
|
||||
|
||||
ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
ENV NODE_ENV production
|
||||
CMD yarn start
|
||||
|
||||
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 && \
|
||||
chown -R nodejs:nodejs $APP_PATH/build
|
||||
|
||||
USER nodejs
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["yarn", "start"]
|
||||
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.55.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: 2024-04-22
|
||||
Change Date: 2025-11-11
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ up:
|
||||
docker-compose up -d redis postgres s3
|
||||
yarn install --pure-lockfile
|
||||
yarn sequelize db:migrate
|
||||
yarn dev
|
||||
yarn dev:watch
|
||||
|
||||
build:
|
||||
docker-compose build --pull outline
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
web: node ./build/server/index.js
|
||||
web: yarn start --services=web,websockets
|
||||
worker: yarn start --services=worker
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
|
||||
|
||||
<p align="center">
|
||||
<img src="https://user-images.githubusercontent.com/31465/34380645-bd67f474-eb0b-11e7-8d03-0151c1730654.png" height="29" />
|
||||
</p>
|
||||
@@ -28,8 +26,7 @@ Outline requires the following dependencies:
|
||||
- [Postgres](https://www.postgresql.org/download/) >=9.5
|
||||
- [Redis](https://redis.io/) >= 4
|
||||
- AWS S3 bucket or compatible API for file storage
|
||||
- Slack or Google developer application for authentication
|
||||
|
||||
- Slack, Google, Azure, or OIDC application for authentication
|
||||
|
||||
## Self-Hosted Production
|
||||
|
||||
@@ -41,16 +38,20 @@ For a manual self-hosted production installation these are the recommended steps
|
||||
1. Download the latest official Docker image, new releases are available around the middle of every month:
|
||||
|
||||
`docker pull outlinewiki/outline`
|
||||
|
||||
1. Using the [.env.sample](.env.sample) as a reference, set the required variables in your production environment. You can export the environment variables directly, or create a `.env` file and pass it to the docker image like so:
|
||||
|
||||
`docker run --env-file=.env outlinewiki/outline`
|
||||
|
||||
1. Setup the database with `yarn db:migrate`. Production assumes an SSL connection to the database by default, if
|
||||
Postgres is on the same machine and is not SSL you can migrate with `yarn db:migrate --env=production-ssl-disabled`, for example:
|
||||
Postgres is on the same machine and is not SSL you can migrate with `yarn db:migrate --env=production-ssl-disabled`, for example:
|
||||
|
||||
`docker run --rm outlinewiki/outline yarn db:migrate`
|
||||
|
||||
1. Start the container:
|
||||
|
||||
`docker run outlinewiki/outline`
|
||||
|
||||
1. Visit http://you_server_ip:3000 and you should be able to see Outline page
|
||||
|
||||
> Port number can be changed using the `PORT` environment variable
|
||||
@@ -79,28 +80,33 @@ If you're running Outline by cloning this repository, run the following command
|
||||
yarn run upgrade
|
||||
```
|
||||
|
||||
|
||||
## Local Development
|
||||
|
||||
For contributing features and fixes you can quickly get an environment running using Docker by following these steps:
|
||||
|
||||
1. Install these dependencies if you don't already have them
|
||||
1. [Docker for Desktop](https://www.docker.com)
|
||||
1. [Node.js](https://nodejs.org/) (v12 LTS preferred)
|
||||
1. [Yarn](https://yarnpkg.com)
|
||||
1. [Docker for Desktop](https://www.docker.com)
|
||||
1. [Node.js](https://nodejs.org/) (v12 LTS preferred)
|
||||
1. [Yarn](https://yarnpkg.com)
|
||||
1. Clone this repo
|
||||
1. Register a Slack app at https://api.slack.com/apps
|
||||
1. Copy the file `.env.sample` to `.env`
|
||||
1. Fill out the following fields:
|
||||
1. `SECRET_KEY` (follow instructions in the comments at the top of `.env`)
|
||||
1. `SLACK_KEY` (this is called "Client ID" in Slack admin)
|
||||
1. `SLACK_SECRET` (this is called "Client Secret" in Slack admin)
|
||||
1. Configure your Slack app's Oauth & Permissions settings
|
||||
1. Slack recently prevented the use of `http` protocol for localhost. For local development, you can use a tool like [ngrok](https://ngrok.com) or a package like `mkcert`. ([How to use HTTPS for local development](https://web.dev/how-to-use-local-https/))
|
||||
1. Add `https://my_ngrok_address/auth/slack.callback` as an Oauth redirect URL
|
||||
1. Ensure that the bot token scope contains at least `users:read`
|
||||
1. `SECRET_KEY` (follow instructions in the comments at the top of `.env`)
|
||||
1. `SLACK_KEY` (this is called "Client ID" in Slack admin)
|
||||
1. `SLACK_SECRET` (this is called "Client Secret" in Slack admin)
|
||||
1. Configure your Slack app's Oauth & Permissions settings
|
||||
1. Slack recently prevented the use of `http` protocol for localhost. For local development, you can use a tool like [ngrok](https://ngrok.com) or a package like `mkcert`. ([How to use HTTPS for local development](https://web.dev/how-to-use-local-https/))
|
||||
1. Add `https://my_ngrok_address/auth/slack.callback` as an Oauth redirect URL and update the `URL` env var to match
|
||||
1. Ensure that the bot token scope contains at least `users:read`
|
||||
1. Run `make up`. This will download dependencies, build and launch a development version of Outline
|
||||
|
||||
### Testing
|
||||
|
||||
The `Makefile` has other useful scripts, including some test automation.
|
||||
|
||||
1. To run the entire test suite, run `make test`
|
||||
1. During development, it's often useful, to re-run some tests every time a file is changed. Use `make watch` to start the test daemon and follow the instructions in the console
|
||||
|
||||
# Contributing
|
||||
|
||||
@@ -110,26 +116,22 @@ Before submitting a pull request please let the core team know by creating or co
|
||||
|
||||
If you’re looking for ways to get started, here's a list of ways to help us improve Outline:
|
||||
|
||||
* [Translation](TRANSLATION.md) into other languages
|
||||
* Issues with [`good first issue`](https://github.com/outline/outline/labels/good%20first%20issue) label
|
||||
* Performance improvements, both on server and frontend
|
||||
* Developer happiness and documentation
|
||||
* Bugs and other issues listed on GitHub
|
||||
|
||||
- [Translation](docs/TRANSLATION.md) into other languages
|
||||
- Issues with [`good first issue`](https://github.com/outline/outline/labels/good%20first%20issue) label
|
||||
- Performance improvements, both on server and frontend
|
||||
- Developer happiness and documentation
|
||||
- Bugs and other issues listed on GitHub
|
||||
|
||||
## Architecture
|
||||
|
||||
If you're interested in contributing or learning more about the Outline codebase
|
||||
please refer to the [architecture document](ARCHITECTURE.md) first for a high level overview of how the application is put together.
|
||||
|
||||
please refer to the [architecture document](docs/ARCHITECTURE.md) first for a high level overview of how the application is put together.
|
||||
|
||||
## Debugging
|
||||
|
||||
Outline uses [debug](https://www.npmjs.com/package/debug). To enable debugging output, the following categories are available:
|
||||
In development Outline outputs simple logging to the console, prefixed by categories. In production it outputs JSON logs, these can be easily parsed by your preferred log ingestion pipeline.
|
||||
|
||||
```
|
||||
DEBUG=sql,cache,presenters,events,importer,exporter,emails,mailer
|
||||
```
|
||||
HTTP logging is disabled by default, but can be enabled by setting the `DEBUG=http` environment variable.
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -145,7 +147,7 @@ make test
|
||||
make watch
|
||||
```
|
||||
|
||||
Once the test database is created with `make test` you may individually run
|
||||
Once the test database is created with `make test` you may individually run
|
||||
frontend and backend tests directly.
|
||||
|
||||
```shell
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// @flow
|
||||
import { CollectionIcon, EditIcon, PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import stores from "stores";
|
||||
import CollectionEdit from "scenes/CollectionEdit";
|
||||
import CollectionNew from "scenes/CollectionNew";
|
||||
import DynamicCollectionIcon from "components/CollectionIcon";
|
||||
import { createAction } from "actions";
|
||||
import { CollectionSection } from "actions/sections";
|
||||
import history from "utils/history";
|
||||
|
||||
export const openCollection = createAction({
|
||||
name: ({ t }) => t("Open collection"),
|
||||
section: CollectionSection,
|
||||
shortcut: ["o", "c"],
|
||||
icon: <CollectionIcon />,
|
||||
children: ({ stores }) => {
|
||||
const collections = stores.collections.orderedData;
|
||||
|
||||
return collections.map((collection) => ({
|
||||
// Note: using url which includes the slug rather than id here to bust
|
||||
// cache if the collection is renamed
|
||||
id: collection.url,
|
||||
name: collection.name,
|
||||
icon: <DynamicCollectionIcon collection={collection} />,
|
||||
section: CollectionSection,
|
||||
perform: () => history.push(collection.url),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
export const createCollection = createAction({
|
||||
name: ({ t }) => t("New collection"),
|
||||
section: CollectionSection,
|
||||
icon: <PlusIcon />,
|
||||
keywords: "create",
|
||||
visible: ({ stores }) =>
|
||||
stores.policies.abilities(stores.auth.team?.id || "").createCollection,
|
||||
perform: ({ t, event }) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Create a collection"),
|
||||
content: <CollectionNew onSubmit={stores.dialogs.closeAllModals} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const editCollection = createAction({
|
||||
name: ({ t }) => t("Edit collection"),
|
||||
section: CollectionSection,
|
||||
icon: <EditIcon />,
|
||||
visible: ({ stores, activeCollectionId }) =>
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).update,
|
||||
perform: ({ t, activeCollectionId }) => {
|
||||
stores.dialogs.openModal({
|
||||
title: t("Edit collection"),
|
||||
content: (
|
||||
<CollectionEdit
|
||||
onSubmit={stores.dialogs.closeAllModals}
|
||||
collectionId={activeCollectionId}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const rootCollectionActions = [openCollection, createCollection];
|
||||
@@ -0,0 +1,33 @@
|
||||
// @flow
|
||||
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];
|
||||
@@ -0,0 +1,240 @@
|
||||
// @flow
|
||||
import invariant from "invariant";
|
||||
import {
|
||||
DownloadIcon,
|
||||
DuplicateIcon,
|
||||
StarredIcon,
|
||||
PrintIcon,
|
||||
UnstarredIcon,
|
||||
DocumentIcon,
|
||||
NewDocumentIcon,
|
||||
ShapesIcon,
|
||||
ImportIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import DocumentTemplatize from "scenes/DocumentTemplatize";
|
||||
import { createAction } from "actions";
|
||||
import { DocumentSection } from "actions/sections";
|
||||
import getDataTransferFiles from "utils/getDataTransferFiles";
|
||||
import history from "utils/history";
|
||||
import { newDocumentPath } from "utils/routeHelpers";
|
||||
|
||||
export const openDocument = createAction({
|
||||
name: ({ t }) => t("Open document"),
|
||||
section: DocumentSection,
|
||||
shortcut: ["o", "d"],
|
||||
keywords: "go to",
|
||||
icon: <DocumentIcon />,
|
||||
children: ({ stores }) => {
|
||||
const paths = stores.collections.pathsToDocuments;
|
||||
|
||||
return paths
|
||||
.filter((path) => path.type === "document")
|
||||
.map((path) => ({
|
||||
// Note: using url which includes the slug rather than id here to bust
|
||||
// cache if the document is renamed
|
||||
id: path.url,
|
||||
name: path.title,
|
||||
icon: () =>
|
||||
stores.documents.get(path.id)?.isStarred ? (
|
||||
<StarredIcon />
|
||||
) : undefined,
|
||||
section: DocumentSection,
|
||||
perform: () => history.push(path.url),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
export const createDocument = createAction({
|
||||
name: ({ t }) => t("New document"),
|
||||
section: DocumentSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "create",
|
||||
visible: ({ activeCollectionId, stores }) =>
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).update,
|
||||
perform: ({ activeCollectionId }) =>
|
||||
activeCollectionId && history.push(newDocumentPath(activeCollectionId)),
|
||||
});
|
||||
|
||||
export const starDocument = createAction({
|
||||
name: ({ t }) => t("Star"),
|
||||
section: DocumentSection,
|
||||
icon: <StarredIcon />,
|
||||
keywords: "favorite bookmark",
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) return false;
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
return (
|
||||
!document?.isStarred && stores.policies.abilities(activeDocumentId).star
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) return false;
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
document?.star();
|
||||
},
|
||||
});
|
||||
|
||||
export const unstarDocument = createAction({
|
||||
name: ({ t }) => t("Unstar"),
|
||||
section: DocumentSection,
|
||||
icon: <UnstarredIcon />,
|
||||
keywords: "unfavorite unbookmark",
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) return false;
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
return (
|
||||
!!document?.isStarred &&
|
||||
stores.policies.abilities(activeDocumentId).unstar
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) return false;
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
document?.unstar();
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocument = createAction({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Download") : t("Download document"),
|
||||
section: DocumentSection,
|
||||
icon: <DownloadIcon />,
|
||||
keywords: "export",
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) return false;
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
document?.download();
|
||||
},
|
||||
});
|
||||
|
||||
export const duplicateDocument = createAction({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Duplicate") : t("Duplicate document"),
|
||||
section: DocumentSection,
|
||||
icon: <DuplicateIcon />,
|
||||
keywords: "copy",
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).update,
|
||||
perform: async ({ activeDocumentId, t, stores }) => {
|
||||
if (!activeDocumentId) return false;
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
invariant(document, "Document must exist");
|
||||
|
||||
const duped = await document.duplicate();
|
||||
|
||||
// when duplicating, go straight to the duplicated document content
|
||||
history.push(duped.url);
|
||||
stores.toasts.showToast(t("Document duplicated"), { type: "success" });
|
||||
},
|
||||
});
|
||||
|
||||
export const printDocument = createAction({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Print") : t("Print document"),
|
||||
section: DocumentSection,
|
||||
icon: <PrintIcon />,
|
||||
visible: ({ activeDocumentId }) => !!activeDocumentId,
|
||||
perform: async () => {
|
||||
window.print();
|
||||
},
|
||||
});
|
||||
|
||||
export const importDocument = createAction({
|
||||
name: ({ t, activeDocumentId }) => t("Import document"),
|
||||
section: DocumentSection,
|
||||
icon: <ImportIcon />,
|
||||
keywords: "upload",
|
||||
visible: ({ activeCollectionId, activeDocumentId, stores }) => {
|
||||
if (activeDocumentId) {
|
||||
return !!stores.policies.abilities(activeDocumentId).createChildDocument;
|
||||
}
|
||||
if (activeCollectionId) {
|
||||
return !!stores.policies.abilities(activeCollectionId).update;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
perform: ({ activeCollectionId, activeDocumentId, stores }) => {
|
||||
const { documents, toasts } = stores;
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = documents.importFileTypes.join(", ");
|
||||
input.onchange = async (ev: SyntheticEvent<>) => {
|
||||
const files = getDataTransferFiles(ev);
|
||||
|
||||
try {
|
||||
const file = files[0];
|
||||
const document = await documents.import(
|
||||
file,
|
||||
activeDocumentId,
|
||||
activeCollectionId,
|
||||
{
|
||||
publish: true,
|
||||
}
|
||||
);
|
||||
history.push(document.url);
|
||||
} catch (err) {
|
||||
toasts.showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
},
|
||||
});
|
||||
|
||||
export const createTemplate = createAction({
|
||||
name: ({ t }) => t("Templatize"),
|
||||
section: DocumentSection,
|
||||
icon: <ShapesIcon />,
|
||||
keywords: "new create template",
|
||||
visible: ({ activeCollectionId, activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) return false;
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
return (
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).update &&
|
||||
!document?.isTemplate
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores, t, event }) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Create template"),
|
||||
content: (
|
||||
<DocumentTemplatize
|
||||
documentId={activeDocumentId}
|
||||
onSubmit={stores.dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const rootDocumentActions = [
|
||||
openDocument,
|
||||
createDocument,
|
||||
createTemplate,
|
||||
importDocument,
|
||||
downloadDocument,
|
||||
starDocument,
|
||||
unstarDocument,
|
||||
duplicateDocument,
|
||||
printDocument,
|
||||
];
|
||||
@@ -0,0 +1,159 @@
|
||||
// @flow
|
||||
import {
|
||||
HomeIcon,
|
||||
SearchIcon,
|
||||
ArchiveIcon,
|
||||
TrashIcon,
|
||||
EditIcon,
|
||||
OpenIcon,
|
||||
SettingsIcon,
|
||||
ShapesIcon,
|
||||
KeyboardIcon,
|
||||
EmailIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import {
|
||||
developersUrl,
|
||||
changelogUrl,
|
||||
mailToUrl,
|
||||
githubIssuesUrl,
|
||||
} from "shared/utils/routeHelpers";
|
||||
import stores from "stores";
|
||||
import KeyboardShortcuts from "scenes/KeyboardShortcuts";
|
||||
import { createAction } from "actions";
|
||||
import { NavigationSection } from "actions/sections";
|
||||
import history from "utils/history";
|
||||
import {
|
||||
settingsPath,
|
||||
homePath,
|
||||
searchUrl,
|
||||
draftsPath,
|
||||
templatesPath,
|
||||
archivePath,
|
||||
trashPath,
|
||||
} from "utils/routeHelpers";
|
||||
|
||||
export const navigateToHome = createAction({
|
||||
name: ({ t }) => t("Home"),
|
||||
section: NavigationSection,
|
||||
shortcut: ["d"],
|
||||
icon: <HomeIcon />,
|
||||
perform: () => history.push(homePath()),
|
||||
visible: ({ location }) => location.pathname !== homePath(),
|
||||
});
|
||||
|
||||
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"),
|
||||
section: NavigationSection,
|
||||
icon: <EditIcon />,
|
||||
perform: () => history.push(draftsPath()),
|
||||
visible: ({ location }) => location.pathname !== draftsPath(),
|
||||
});
|
||||
|
||||
export const navigateToTemplates = createAction({
|
||||
name: ({ t }) => t("Templates"),
|
||||
section: NavigationSection,
|
||||
icon: <ShapesIcon />,
|
||||
perform: () => history.push(templatesPath()),
|
||||
visible: ({ location }) => location.pathname !== templatesPath(),
|
||||
});
|
||||
|
||||
export const navigateToArchive = createAction({
|
||||
name: ({ t }) => t("Archive"),
|
||||
section: NavigationSection,
|
||||
icon: <ArchiveIcon />,
|
||||
perform: () => history.push(archivePath()),
|
||||
visible: ({ location }) => location.pathname !== archivePath(),
|
||||
});
|
||||
|
||||
export const navigateToTrash = createAction({
|
||||
name: ({ t }) => t("Trash"),
|
||||
section: NavigationSection,
|
||||
icon: <TrashIcon />,
|
||||
perform: () => history.push(trashPath()),
|
||||
visible: ({ location }) => location.pathname !== trashPath(),
|
||||
});
|
||||
|
||||
export const navigateToSettings = createAction({
|
||||
name: ({ t }) => t("Settings"),
|
||||
section: NavigationSection,
|
||||
shortcut: ["g", "s"],
|
||||
iconInContextMenu: false,
|
||||
icon: <SettingsIcon />,
|
||||
perform: () => history.push(settingsPath()),
|
||||
});
|
||||
|
||||
export const openAPIDocumentation = createAction({
|
||||
name: ({ t }) => t("API documentation"),
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <OpenIcon />,
|
||||
perform: () => window.open(developersUrl()),
|
||||
});
|
||||
|
||||
export const openFeedbackUrl = createAction({
|
||||
name: ({ t }) => t("Send us feedback"),
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <EmailIcon />,
|
||||
perform: () => window.open(mailToUrl()),
|
||||
});
|
||||
|
||||
export const openBugReportUrl = createAction({
|
||||
name: ({ t }) => t("Report a bug"),
|
||||
section: NavigationSection,
|
||||
perform: () => window.open(githubIssuesUrl()),
|
||||
});
|
||||
|
||||
export const openChangelog = createAction({
|
||||
name: ({ t }) => t("Changelog"),
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <OpenIcon />,
|
||||
perform: () => window.open(changelogUrl()),
|
||||
});
|
||||
|
||||
export const openKeyboardShortcuts = createAction({
|
||||
name: ({ t }) => t("Keyboard shortcuts"),
|
||||
section: NavigationSection,
|
||||
shortcut: ["?"],
|
||||
iconInContextMenu: false,
|
||||
icon: <KeyboardIcon />,
|
||||
perform: ({ t }) => {
|
||||
stores.dialogs.openGuide({
|
||||
title: t("Keyboard shortcuts"),
|
||||
content: <KeyboardShortcuts />,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const logout = createAction({
|
||||
name: ({ t }) => t("Log out"),
|
||||
section: NavigationSection,
|
||||
perform: () => stores.auth.logout(),
|
||||
});
|
||||
|
||||
export const rootNavigationActions = [
|
||||
navigateToHome,
|
||||
navigateToSearch,
|
||||
navigateToDrafts,
|
||||
navigateToTemplates,
|
||||
navigateToArchive,
|
||||
navigateToTrash,
|
||||
navigateToSettings,
|
||||
openAPIDocumentation,
|
||||
openFeedbackUrl,
|
||||
openBugReportUrl,
|
||||
openChangelog,
|
||||
openKeyboardShortcuts,
|
||||
logout,
|
||||
];
|
||||
@@ -0,0 +1,48 @@
|
||||
// @flow
|
||||
import { SunIcon, MoonIcon, BrowserIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import stores from "stores";
|
||||
import { createAction } from "actions";
|
||||
import { SettingsSection } from "actions/sections";
|
||||
|
||||
export const changeToDarkTheme = createAction({
|
||||
name: ({ t }) => t("Dark"),
|
||||
icon: <MoonIcon />,
|
||||
iconInContextMenu: false,
|
||||
keywords: "theme dark night",
|
||||
section: SettingsSection,
|
||||
selected: () => stores.ui.theme === "dark",
|
||||
perform: () => stores.ui.setTheme("dark"),
|
||||
});
|
||||
|
||||
export const changeToLightTheme = createAction({
|
||||
name: ({ t }) => t("Light"),
|
||||
icon: <SunIcon />,
|
||||
iconInContextMenu: false,
|
||||
keywords: "theme light day",
|
||||
section: SettingsSection,
|
||||
selected: () => stores.ui.theme === "light",
|
||||
perform: () => stores.ui.setTheme("light"),
|
||||
});
|
||||
|
||||
export const changeToSystemTheme = createAction({
|
||||
name: ({ t }) => t("System"),
|
||||
icon: <BrowserIcon />,
|
||||
iconInContextMenu: false,
|
||||
keywords: "theme system default",
|
||||
section: SettingsSection,
|
||||
selected: () => stores.ui.theme === "system",
|
||||
perform: () => stores.ui.setTheme("system"),
|
||||
});
|
||||
|
||||
export const changeTheme = createAction({
|
||||
name: ({ t }) => t("Change theme"),
|
||||
placeholder: ({ t }) => t("Change theme to"),
|
||||
icon: () =>
|
||||
stores.ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />,
|
||||
keywords: "appearance display",
|
||||
section: SettingsSection,
|
||||
children: [changeToLightTheme, changeToDarkTheme, changeToSystemTheme],
|
||||
});
|
||||
|
||||
export const rootSettingsActions = [changeTheme];
|
||||
@@ -0,0 +1,24 @@
|
||||
// @flow
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import stores from "stores";
|
||||
import Invite from "scenes/Invite";
|
||||
import { createAction } from "actions";
|
||||
import { UserSection } from "actions/sections";
|
||||
|
||||
export const inviteUser = createAction({
|
||||
name: ({ t }) => `${t("Invite people")}…`,
|
||||
icon: <PlusIcon />,
|
||||
keywords: "team member user",
|
||||
section: UserSection,
|
||||
visible: ({ stores }) =>
|
||||
stores.policies.abilities(stores.auth.team?.id || "").inviteUser,
|
||||
perform: ({ t }) => {
|
||||
stores.dialogs.openModal({
|
||||
title: t("Invite people"),
|
||||
content: <Invite onSubmit={stores.dialogs.closeAllModals} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const rootUserActions = [inviteUser];
|
||||
@@ -0,0 +1,117 @@
|
||||
// @flow
|
||||
import { flattenDeep } from "lodash";
|
||||
import * as React from "react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type {
|
||||
Action,
|
||||
ActionContext,
|
||||
CommandBarAction,
|
||||
MenuItemClickable,
|
||||
MenuItemWithChildren,
|
||||
} from "types";
|
||||
|
||||
export function createAction(
|
||||
definition: $Diff<Action, { id?: string }>
|
||||
): Action {
|
||||
return {
|
||||
id: uuidv4(),
|
||||
...definition,
|
||||
};
|
||||
}
|
||||
|
||||
export function actionToMenuItem(
|
||||
action: Action,
|
||||
context: ActionContext
|
||||
): MenuItemClickable | MenuItemWithChildren {
|
||||
function resolve<T>(value: any): T {
|
||||
if (typeof value === "function") {
|
||||
return value(context);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
const resolvedIcon = resolve<React.Element<any>>(action.icon);
|
||||
const resolvedChildren = resolve<Action[]>(action.children);
|
||||
|
||||
const visible = action.visible ? action.visible(context) : true;
|
||||
const title = resolve<string>(action.name);
|
||||
const icon =
|
||||
resolvedIcon && action.iconInContextMenu !== false
|
||||
? React.cloneElement(resolvedIcon, { color: "currentColor" })
|
||||
: undefined;
|
||||
|
||||
if (resolvedChildren) {
|
||||
return {
|
||||
title,
|
||||
icon,
|
||||
items: resolvedChildren
|
||||
.map((a) => actionToMenuItem(a, context))
|
||||
.filter((a) => !!a),
|
||||
visible,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
icon,
|
||||
visible,
|
||||
onClick: () => action.perform && action.perform(context),
|
||||
selected: action.selected ? action.selected(context) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
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.Element<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
|
||||
)
|
||||
: [];
|
||||
|
||||
return [
|
||||
{
|
||||
id: action.id,
|
||||
name: resolvedName,
|
||||
section: resolvedSection,
|
||||
placeholder: resolvedPlaceholder,
|
||||
keywords: `${action.keywords || ""} ${children
|
||||
.filter((c) => !!c.keywords)
|
||||
.map((c) => c.keywords)
|
||||
.join(" ")}`,
|
||||
shortcut: action.shortcut,
|
||||
icon: resolvedIcon
|
||||
? React.cloneElement(resolvedIcon, { color: "currentColor" })
|
||||
: undefined,
|
||||
perform: action.perform
|
||||
? () => action.perform && action.perform(context)
|
||||
: undefined,
|
||||
children: children.length ? children.map((a) => a.id) : undefined,
|
||||
},
|
||||
].concat(
|
||||
children.map((child) => ({
|
||||
...child,
|
||||
parent: action.id,
|
||||
}))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// @flow
|
||||
import { rootCollectionActions } from "./definitions/collections";
|
||||
import { rootDebugActions } from "./definitions/debug";
|
||||
import { rootDocumentActions } from "./definitions/documents";
|
||||
import { rootNavigationActions } from "./definitions/navigation";
|
||||
import { rootSettingsActions } from "./definitions/settings";
|
||||
import { rootUserActions } from "./definitions/users";
|
||||
|
||||
export default [
|
||||
...rootCollectionActions,
|
||||
...rootDocumentActions,
|
||||
...rootUserActions,
|
||||
...rootNavigationActions,
|
||||
...rootSettingsActions,
|
||||
...rootDebugActions,
|
||||
];
|
||||
@@ -0,0 +1,14 @@
|
||||
// @flow
|
||||
import { type ActionContext } from "types";
|
||||
|
||||
export const CollectionSection = ({ t }: ActionContext) => t("Collection");
|
||||
|
||||
export const DebugSection = ({ t }: ActionContext) => t("Debug");
|
||||
|
||||
export const DocumentSection = ({ t }: ActionContext) => t("Document");
|
||||
|
||||
export const SettingsSection = ({ t }: ActionContext) => t("Settings");
|
||||
|
||||
export const NavigationSection = ({ t }: ActionContext) => t("Navigation");
|
||||
|
||||
export const UserSection = ({ t }: ActionContext) => t("People");
|
||||
@@ -11,6 +11,7 @@ type Props = {|
|
||||
size: number,
|
||||
icon?: React.Node,
|
||||
user?: User,
|
||||
alt?: string,
|
||||
onClick?: () => void,
|
||||
className?: string,
|
||||
|};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { EditIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
@@ -65,7 +64,6 @@ class AvatarWithPresence extends React.Component<Props> {
|
||||
: this.handleOpenProfile
|
||||
}
|
||||
size={32}
|
||||
icon={isEditing ? <EditIcon size={16} color="#FFF" /> : undefined}
|
||||
/>
|
||||
</AvatarWrapper>
|
||||
</Tooltip>
|
||||
|
||||
@@ -35,7 +35,7 @@ const RealButton = styled.button`
|
||||
border: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
&:hover:not(:disabled) {
|
||||
background: ${(props) => darken(0.05, props.theme.buttonBackground)};
|
||||
}
|
||||
|
||||
@@ -43,6 +43,10 @@ const RealButton = styled.button`
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
color: ${(props) => props.theme.white50};
|
||||
|
||||
svg {
|
||||
fill: ${(props) => props.theme.white50};
|
||||
}
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
@@ -65,8 +69,12 @@ const RealButton = styled.button`
|
||||
}
|
||||
|
||||
|
||||
&:hover {
|
||||
background: ${darken(0.05, props.theme.buttonNeutralBackground)};
|
||||
&:hover:not(:disabled) {
|
||||
background: ${
|
||||
props.borderOnHover
|
||||
? props.theme.buttonNeutralBackground
|
||||
: darken(0.05, props.theme.buttonNeutralBackground)
|
||||
};
|
||||
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, ${
|
||||
props.theme.buttonNeutralBorder
|
||||
} 0 0 0 1px inset;
|
||||
@@ -74,6 +82,10 @@ const RealButton = styled.button`
|
||||
|
||||
&:disabled {
|
||||
color: ${props.theme.textTertiary};
|
||||
|
||||
svg {
|
||||
fill: ${props.theme.textTertiary};
|
||||
}
|
||||
}
|
||||
`} ${(props) =>
|
||||
props.danger &&
|
||||
@@ -124,11 +136,11 @@ export type Props = {|
|
||||
fullwidth?: boolean,
|
||||
autoFocus?: boolean,
|
||||
style?: Object,
|
||||
as?: React.ComponentType<any>,
|
||||
as?: React.ComponentType<any> | string,
|
||||
to?: string,
|
||||
onClick?: (event: SyntheticEvent<>) => mixed,
|
||||
borderOnHover?: boolean,
|
||||
|
||||
href?: string,
|
||||
"data-on"?: string,
|
||||
"data-event-category"?: string,
|
||||
"data-event-action"?: string,
|
||||
|
||||
@@ -12,7 +12,7 @@ export type Props = {|
|
||||
name?: string,
|
||||
disabled?: boolean,
|
||||
onChange: (event: SyntheticInputEvent<HTMLInputElement>) => mixed,
|
||||
note?: string,
|
||||
note?: React.Node,
|
||||
short?: boolean,
|
||||
small?: boolean,
|
||||
|};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @flow
|
||||
import { sortBy, filter, uniq } from "lodash";
|
||||
import { sortBy, filter, uniq, isEqual } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -12,17 +12,20 @@ import DocumentViews from "components/DocumentViews";
|
||||
import Facepile from "components/Facepile";
|
||||
import NudeButton from "components/NudeButton";
|
||||
import Popover from "components/Popover";
|
||||
import useCurrentUser from "hooks/useCurrentUser";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {|
|
||||
document: Document,
|
||||
currentUserId: string,
|
||||
|};
|
||||
|
||||
function Collaborators(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
const currentUserId = user?.id;
|
||||
const [requestedUserIds, setRequestedUserIds] = React.useState<string[]>([]);
|
||||
const { users, presence } = useStores();
|
||||
const { document, currentUserId } = props;
|
||||
const { document } = props;
|
||||
|
||||
let documentPresence = presence.get(document.id);
|
||||
documentPresence = documentPresence
|
||||
@@ -49,18 +52,21 @@ function Collaborators(props: Props) {
|
||||
[document.collaboratorIds, users.orderedData, presentIds]
|
||||
);
|
||||
|
||||
// load any users we don't know about
|
||||
// load any users we don't yet have in memory
|
||||
React.useEffect(() => {
|
||||
if (users.isFetching) {
|
||||
return;
|
||||
const userIdsToFetch = uniq([
|
||||
...document.collaboratorIds,
|
||||
...presentIds,
|
||||
]).filter((userId) => !users.get(userId));
|
||||
|
||||
if (!isEqual(requestedUserIds, userIdsToFetch)) {
|
||||
setRequestedUserIds(userIdsToFetch);
|
||||
}
|
||||
|
||||
uniq([...document.collaboratorIds, ...presentIds]).forEach((userId) => {
|
||||
if (!users.get(userId)) {
|
||||
return users.fetch(userId);
|
||||
}
|
||||
});
|
||||
}, [document, users, presentIds, document.collaboratorIds]);
|
||||
userIdsToFetch
|
||||
.filter((userId) => requestedUserIds.includes(userId))
|
||||
.forEach((userId) => users.fetch(userId));
|
||||
}, [document, users, presentIds, document.collaboratorIds, requestedUserIds]);
|
||||
|
||||
const popover = usePopoverState({
|
||||
gutter: 0,
|
||||
|
||||
@@ -20,7 +20,7 @@ function ResolvedCollectionIcon({ collection, expanded, size }: Props) {
|
||||
// otherwise it will be impossible to see against the dark background.
|
||||
const color =
|
||||
ui.resolvedTheme === "dark" && collection.color !== "currentColor"
|
||||
? getLuminance(collection.color) > 0.12
|
||||
? getLuminance(collection.color) > 0.09
|
||||
? collection.color
|
||||
: "currentColor"
|
||||
: collection.color;
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// @flow
|
||||
import { useKBar, KBarPositioner, KBarAnimator, KBarSearch } from "kbar";
|
||||
import { observer } from "mobx-react";
|
||||
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 CommandBarResults from "components/CommandBarResults";
|
||||
import rootActions from "actions/root";
|
||||
import useCommandBarActions from "hooks/useCommandBarActions";
|
||||
|
||||
export const CommandBarOptions = {
|
||||
animations: {
|
||||
enterMs: 250,
|
||||
exitMs: 200,
|
||||
},
|
||||
};
|
||||
|
||||
function CommandBar() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
useCommandBarActions(rootActions);
|
||||
|
||||
const { rootAction } = useKBar((state) => ({
|
||||
rootAction: state.actions[state.currentRootActionId],
|
||||
}));
|
||||
|
||||
return (
|
||||
<KBarPortal>
|
||||
<Positioner>
|
||||
<Animator>
|
||||
<SearchInput
|
||||
placeholder={`${
|
||||
rootAction?.placeholder ||
|
||||
rootAction?.name ||
|
||||
t("Type a command or search")
|
||||
}…`}
|
||||
/>
|
||||
<CommandBarResults />
|
||||
</Animator>
|
||||
</Positioner>
|
||||
</KBarPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function KBarPortal({ children }: { children: React.Node }) {
|
||||
const { showing } = useKBar((state) => ({
|
||||
showing: state.visualState !== "hidden",
|
||||
}));
|
||||
|
||||
if (!showing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Portal>{children}</Portal>;
|
||||
}
|
||||
|
||||
const Positioner = styled(KBarPositioner)`
|
||||
z-index: ${(props) => props.theme.depths.commandBar};
|
||||
`;
|
||||
|
||||
const SearchInput = styled(KBarSearch)`
|
||||
padding: 16px 20px;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
border: none;
|
||||
background: ${(props) => props.theme.menuBackground};
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
&:disabled,
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
}
|
||||
`;
|
||||
|
||||
const Animator = styled(KBarAnimator)`
|
||||
max-width: 600px;
|
||||
max-height: 75vh;
|
||||
width: 90vw;
|
||||
background: ${(props) => props.theme.menuBackground};
|
||||
color: ${(props) => props.theme.text};
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: rgb(0 0 0 / 40%) 0px 16px 60px;
|
||||
transition: max-width 0.2s ease-in-out;
|
||||
|
||||
${breakpoint("desktopLarge")`
|
||||
max-width: 740px;
|
||||
`};
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(CommandBar);
|
||||
@@ -0,0 +1,71 @@
|
||||
// @flow
|
||||
import { BackIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Flex from "components/Flex";
|
||||
import Key from "components/Key";
|
||||
import type { CommandBarAction } from "types";
|
||||
|
||||
type Props = {|
|
||||
action: CommandBarAction,
|
||||
active: Boolean,
|
||||
|};
|
||||
|
||||
function CommandBarItem({ action, active }: Props, ref) {
|
||||
return (
|
||||
<Item active={active} ref={ref}>
|
||||
<Text align="center" gap={8}>
|
||||
<Icon>
|
||||
{action.icon ? (
|
||||
React.cloneElement(action.icon, { size: 22 })
|
||||
) : (
|
||||
<ForwardIcon color="currentColor" size={22} />
|
||||
)}
|
||||
</Icon>
|
||||
{action.name}
|
||||
{action.children?.length ? "…" : ""}
|
||||
</Text>
|
||||
{action.shortcut?.length ? (
|
||||
<div style={{ display: "grid", gridAutoFlow: "column", gap: "4px" }}>
|
||||
{action.shortcut.map((sc) => (
|
||||
<Key key={sc}>{sc}</Key>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</Item>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = styled.div`
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: ${(props) => props.theme.textSecondary};
|
||||
`;
|
||||
|
||||
const Text = styled(Flex)`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex-shrink: 1;
|
||||
`;
|
||||
|
||||
const Item = styled.div`
|
||||
font-size: 15px;
|
||||
padding: 12px 16px;
|
||||
background: ${(props) =>
|
||||
props.active ? props.theme.menuItemSelected : "none"};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const ForwardIcon = styled(BackIcon)`
|
||||
transform: rotate(180deg);
|
||||
`;
|
||||
|
||||
export default React.forwardRef<Props, HTMLDivElement>(CommandBarItem);
|
||||
@@ -0,0 +1,44 @@
|
||||
// @flow
|
||||
import { useMatches, KBarResults, NO_GROUP } from "kbar";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import CommandBarItem from "components/CommandBarItem";
|
||||
|
||||
export default function CommandBarResults() {
|
||||
const matches = useMatches();
|
||||
const items = React.useMemo(
|
||||
() =>
|
||||
matches
|
||||
.reduce((acc, curr) => {
|
||||
const { actions, name } = curr;
|
||||
acc.push(name);
|
||||
acc.push(...actions);
|
||||
return acc;
|
||||
}, [])
|
||||
.filter((i) => i !== NO_GROUP),
|
||||
[matches]
|
||||
);
|
||||
|
||||
return (
|
||||
<KBarResults
|
||||
items={items}
|
||||
maxHeight={400}
|
||||
onRender={({ item, active }) =>
|
||||
typeof item === "string" ? (
|
||||
<Header>{item}</Header>
|
||||
) : (
|
||||
<CommandBarItem action={item} active={active} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const Header = styled.h3`
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.04em;
|
||||
margin: 0;
|
||||
padding: 16px 0 4px 20px;
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
height: 36px;
|
||||
`;
|
||||
@@ -0,0 +1,59 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { DisconnectedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Fade from "components/Fade";
|
||||
import NudeButton from "components/NudeButton";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
function ConnectionStatus() {
|
||||
const { ui } = useStores();
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return ui.multiplayerStatus === "connecting" ||
|
||||
ui.multiplayerStatus === "disconnected" ? (
|
||||
<Tooltip
|
||||
tooltip={
|
||||
<Centered>
|
||||
<strong>{t("Server connection lost")}</strong>
|
||||
<br />
|
||||
{t("Edits you make will sync once you’re online")}
|
||||
</Centered>
|
||||
}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button>
|
||||
<Fade>
|
||||
<DisconnectedIcon color={theme.sidebarText} />
|
||||
</Fade>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null;
|
||||
}
|
||||
|
||||
const Button = styled(NudeButton)`
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
right: 32px;
|
||||
margin: 24px;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: block;
|
||||
`};
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const Centered = styled.div`
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
export default observer(ConnectionStatus);
|
||||
@@ -0,0 +1,107 @@
|
||||
// @flow
|
||||
import isPrintableKeyEvent from "is-printable-key-event";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {|
|
||||
disabled?: boolean,
|
||||
readOnly?: boolean,
|
||||
onChange?: (text: string) => void,
|
||||
onBlur?: (event: SyntheticInputEvent<>) => void,
|
||||
onInput?: (event: SyntheticInputEvent<>) => void,
|
||||
onKeyDown?: (event: SyntheticInputEvent<>) => void,
|
||||
placeholder?: string,
|
||||
maxLength?: number,
|
||||
autoFocus?: boolean,
|
||||
className?: string,
|
||||
children?: React.Node,
|
||||
value: string,
|
||||
|};
|
||||
|
||||
/**
|
||||
* Defines a content editable component with the same interface as a native
|
||||
* HTMLInputElement (or, as close as we can get).
|
||||
*/
|
||||
function ContentEditable({
|
||||
disabled,
|
||||
onChange,
|
||||
onInput,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
value,
|
||||
children,
|
||||
className,
|
||||
maxLength,
|
||||
autoFocus,
|
||||
placeholder,
|
||||
readOnly,
|
||||
...rest
|
||||
}: Props) {
|
||||
const ref = React.useRef<?HTMLSpanElement>();
|
||||
const [innerHTML, setInnerHTML] = React.useState<string>(value);
|
||||
const lastValue = React.useRef("");
|
||||
|
||||
const wrappedEvent = (callback) => (
|
||||
event: SyntheticInputEvent<HTMLInputElement>
|
||||
) => {
|
||||
const text = ref.current?.innerText || "";
|
||||
|
||||
if (maxLength && isPrintableKeyEvent(event) && text.length >= maxLength) {
|
||||
event.preventDefault();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (text !== lastValue.current) {
|
||||
lastValue.current = text;
|
||||
onChange && onChange(text);
|
||||
}
|
||||
|
||||
callback && callback(event);
|
||||
};
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (autoFocus) {
|
||||
ref.current?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (value !== ref.current?.innerText) {
|
||||
setInnerHTML(value);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Content
|
||||
contentEditable={!disabled && !readOnly}
|
||||
onInput={wrappedEvent(onInput)}
|
||||
onBlur={wrappedEvent(onBlur)}
|
||||
onKeyDown={wrappedEvent(onKeyDown)}
|
||||
ref={ref}
|
||||
data-placeholder={placeholder}
|
||||
role="textbox"
|
||||
dangerouslySetInnerHTML={{ __html: innerHTML }}
|
||||
{...rest}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Content = styled.span`
|
||||
&:empty {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
&:empty::before {
|
||||
display: inline-block;
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
-webkit-text-fill-color: ${(props) => props.theme.placeholder};
|
||||
content: attr(data-placeholder);
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo<Props>(ContentEditable);
|
||||
@@ -2,8 +2,9 @@
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { MenuItem as BaseMenuItem } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import MenuIconWrapper from "../MenuIconWrapper";
|
||||
|
||||
type Props = {|
|
||||
onClick?: (SyntheticEvent<>) => void | Promise<void>,
|
||||
@@ -16,6 +17,7 @@ type Props = {|
|
||||
as?: string | React.ComponentType<*>,
|
||||
hide?: () => void,
|
||||
level?: number,
|
||||
icon?: React.Node,
|
||||
|};
|
||||
|
||||
const MenuItem = ({
|
||||
@@ -25,6 +27,7 @@ const MenuItem = ({
|
||||
disabled,
|
||||
as,
|
||||
hide,
|
||||
icon,
|
||||
...rest
|
||||
}: Props) => {
|
||||
const handleClick = React.useCallback(
|
||||
@@ -71,6 +74,7 @@ const MenuItem = ({
|
||||
|
||||
</>
|
||||
)}
|
||||
{icon && <MenuIconWrapper>{icon}</MenuIconWrapper>}
|
||||
{children}
|
||||
</MenuAnchor>
|
||||
)}
|
||||
@@ -84,12 +88,12 @@ const Spacer = styled.svg`
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export const MenuAnchor = styled.a`
|
||||
export const MenuAnchorCSS = css`
|
||||
display: flex;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
padding: 12px;
|
||||
padding-left: ${(props) => 12 + props.level * 10}px;
|
||||
padding-left: ${(props) => 12 + (props.level || 0) * 10}px;
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
background: none;
|
||||
@@ -130,9 +134,12 @@ export const MenuAnchor = styled.a`
|
||||
`};
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: ${(props) => (props.$toggleable ? "4px 12px" : "6px 12px")};
|
||||
font-size: 15px;
|
||||
padding: 4px 12px;
|
||||
font-size: 14px;
|
||||
`};
|
||||
`;
|
||||
export const MenuAnchor = styled.a`
|
||||
${MenuAnchorCSS}
|
||||
`;
|
||||
|
||||
export default MenuItem;
|
||||
|
||||
@@ -2,21 +2,33 @@
|
||||
import { ExpandedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import {
|
||||
useMenuState,
|
||||
MenuButton,
|
||||
MenuItem as BaseMenuItem,
|
||||
} from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import Flex from "components/Flex";
|
||||
import MenuIconWrapper from "components/MenuIconWrapper";
|
||||
import Header from "./Header";
|
||||
import MenuItem, { MenuAnchor } from "./MenuItem";
|
||||
import Separator from "./Separator";
|
||||
import ContextMenu from ".";
|
||||
import { type MenuItem as TMenuItem } from "types";
|
||||
import { actionToMenuItem } from "actions";
|
||||
import useStores from "hooks/useStores";
|
||||
import type {
|
||||
MenuItem as TMenuItem,
|
||||
Action,
|
||||
ActionContext,
|
||||
MenuSeparator,
|
||||
MenuHeading,
|
||||
} from "types";
|
||||
|
||||
type Props = {|
|
||||
items: TMenuItem[],
|
||||
actions: (Action | MenuSeparator | MenuHeading)[],
|
||||
context?: $Shape<ActionContext>,
|
||||
|};
|
||||
|
||||
const Disclosure = styled(ExpandedIcon)`
|
||||
@@ -48,7 +60,7 @@ const Submenu = React.forwardRef(({ templateItems, title, ...rest }, ref) => {
|
||||
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
|
||||
let filtered = items.filter((item) => item.visible !== false);
|
||||
|
||||
// this block literally just trims unneccessary separators
|
||||
// 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;
|
||||
@@ -66,8 +78,39 @@ export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function Template({ items, ...menu }: Props): React.Node {
|
||||
return filterTemplateItems(items).map((item, index) => {
|
||||
function Template({ items, actions, context, ...menu }: Props): React.Node {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const stores = useStores();
|
||||
const { ui } = stores;
|
||||
|
||||
const ctx = {
|
||||
t,
|
||||
isCommandBar: false,
|
||||
isContextMenu: true,
|
||||
activeCollectionId: ui.activeCollectionId,
|
||||
activeDocumentId: ui.activeDocumentId,
|
||||
location,
|
||||
stores,
|
||||
...context,
|
||||
};
|
||||
|
||||
const filteredTemplates = filterTemplateItems(
|
||||
actions
|
||||
? actions.map((action) =>
|
||||
action.type ? action : actionToMenuItem(action, ctx)
|
||||
)
|
||||
: items
|
||||
);
|
||||
const iconIsPresentInAnyMenuItem = filteredTemplates.find(
|
||||
(item) => !item.type && !!item.icon
|
||||
);
|
||||
|
||||
return filteredTemplates.map((item, index) => {
|
||||
if (iconIsPresentInAnyMenuItem && !item.type) {
|
||||
item.icon = item.icon || <MenuIconWrapper />;
|
||||
}
|
||||
|
||||
if (item.to) {
|
||||
return (
|
||||
<MenuItem
|
||||
@@ -76,6 +119,7 @@ function Template({ items, ...menu }: Props): React.Node {
|
||||
key={index}
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
icon={item.icon}
|
||||
{...menu}
|
||||
>
|
||||
{item.title}
|
||||
@@ -92,6 +136,7 @@ function Template({ items, ...menu }: Props): React.Node {
|
||||
selected={item.selected}
|
||||
level={item.level}
|
||||
target={item.href.startsWith("#") ? undefined : "_blank"}
|
||||
icon={item.icon}
|
||||
{...menu}
|
||||
>
|
||||
{item.title}
|
||||
@@ -107,6 +152,7 @@ function Template({ items, ...menu }: Props): React.Node {
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
key={index}
|
||||
icon={item.icon}
|
||||
{...menu}
|
||||
>
|
||||
{item.title}
|
||||
@@ -120,7 +166,7 @@ function Template({ items, ...menu }: Props): React.Node {
|
||||
key={index}
|
||||
as={Submenu}
|
||||
templateItems={item.items}
|
||||
title={item.title}
|
||||
title={<Title title={item.title} icon={item.icon} />}
|
||||
{...menu}
|
||||
/>
|
||||
);
|
||||
@@ -139,4 +185,13 @@ function Template({ items, ...menu }: Props): React.Node {
|
||||
});
|
||||
}
|
||||
|
||||
function Title({ title, icon }) {
|
||||
return (
|
||||
<Flex align="center">
|
||||
{icon && <MenuIconWrapper>{icon}</MenuIconWrapper>}
|
||||
{title}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo<Props>(Template);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Portal } from "react-portal";
|
||||
import { Menu } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import useMenuHeight from "hooks/useMenuHeight";
|
||||
import usePrevious from "hooks/usePrevious";
|
||||
import {
|
||||
fadeIn,
|
||||
@@ -18,8 +19,12 @@ type Props = {|
|
||||
placement?: string,
|
||||
animating?: boolean,
|
||||
children: React.Node,
|
||||
unstable_disclosureRef?: {
|
||||
current: null | React.ElementRef<"button">,
|
||||
},
|
||||
onOpen?: () => void,
|
||||
onClose?: () => void,
|
||||
hide?: () => void,
|
||||
|};
|
||||
|
||||
export default function ContextMenu({
|
||||
@@ -29,6 +34,8 @@ export default function ContextMenu({
|
||||
...rest
|
||||
}: Props) {
|
||||
const previousVisible = usePrevious(rest.visible);
|
||||
const maxHeight = useMenuHeight(rest.visible, rest.unstable_disclosureRef);
|
||||
const backgroundRef = React.useRef();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (rest.visible && !previousVisible) {
|
||||
@@ -43,6 +50,8 @@ export default function ContextMenu({
|
||||
}
|
||||
}, [onOpen, onClose, previousVisible, rest.visible]);
|
||||
|
||||
// sets the menu height based on the available space between the disclosure/
|
||||
// trigger and the bottom of the window
|
||||
return (
|
||||
<>
|
||||
<Menu hideOnClickOutside preventBodyScroll {...rest}>
|
||||
@@ -59,6 +68,8 @@ export default function ContextMenu({
|
||||
dir="auto"
|
||||
topAnchor={topAnchor}
|
||||
rightAnchor={rightAnchor}
|
||||
ref={backgroundRef}
|
||||
style={maxHeight && topAnchor ? { maxHeight } : undefined}
|
||||
>
|
||||
{rest.visible || rest.animating ? children : null}
|
||||
</Background>
|
||||
@@ -68,14 +79,14 @@ export default function ContextMenu({
|
||||
</Menu>
|
||||
{(rest.visible || rest.animating) && (
|
||||
<Portal>
|
||||
<Backdrop />
|
||||
<Backdrop onClick={rest.hide} />
|
||||
</Portal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const Backdrop = styled.div`
|
||||
export const Backdrop = styled.div`
|
||||
animation: ${fadeIn} 200ms ease-in-out;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@@ -90,7 +101,7 @@ const Backdrop = styled.div`
|
||||
`};
|
||||
`;
|
||||
|
||||
const Position = styled.div`
|
||||
export const Position = styled.div`
|
||||
position: absolute;
|
||||
z-index: ${(props) => props.theme.depths.menu};
|
||||
|
||||
@@ -104,7 +115,7 @@ const Position = styled.div`
|
||||
`};
|
||||
`;
|
||||
|
||||
const Background = styled.div`
|
||||
export const Background = styled.div`
|
||||
animation: ${mobileContextMenu} 200ms ease;
|
||||
transform-origin: 50% 100%;
|
||||
max-width: 100%;
|
||||
@@ -112,6 +123,7 @@ const Background = styled.div`
|
||||
border-radius: 6px;
|
||||
padding: 6px 0;
|
||||
min-width: 180px;
|
||||
min-height: 44px;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
max-height: 75vh;
|
||||
@@ -125,12 +137,9 @@ const Background = styled.div`
|
||||
${breakpoint("tablet")`
|
||||
animation: ${(props) =>
|
||||
props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease;
|
||||
transform-origin: ${(props) =>
|
||||
props.rightAnchor === "bottom-end" ? "75%" : "25%"} 0;
|
||||
transform-origin: ${(props) => (props.rightAnchor ? "75%" : "25%")} 0;
|
||||
max-width: 276px;
|
||||
background: ${(props) => props.theme.menuBackground};
|
||||
box-shadow: ${(props) => props.theme.menuShadow};
|
||||
border: ${(props) =>
|
||||
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
|
||||
`};
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react-lite";
|
||||
import * as React from "react";
|
||||
import Guide from "components/Guide";
|
||||
import Modal from "components/Modal";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
function Dialogs() {
|
||||
const { dialogs } = useStores();
|
||||
const { guide, modalStack } = dialogs;
|
||||
|
||||
return (
|
||||
<>
|
||||
{guide ? (
|
||||
<Guide
|
||||
isOpen={guide.isOpen}
|
||||
onRequestClose={dialogs.closeGuide}
|
||||
title={guide.title}
|
||||
>
|
||||
{guide.content}
|
||||
</Guide>
|
||||
) : undefined}
|
||||
{[...modalStack].map(([id, modal]) => (
|
||||
<Modal
|
||||
key={id}
|
||||
isOpen={modal.isOpen}
|
||||
onRequestClose={() => dialogs.closeModal(id)}
|
||||
title={modal.title}
|
||||
>
|
||||
{modal.content}
|
||||
</Modal>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(Dialogs);
|
||||
@@ -0,0 +1,124 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { CloseIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory, useRouteMatch } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Event from "models/Event";
|
||||
import Button from "components/Button";
|
||||
import Empty from "components/Empty";
|
||||
import Flex from "components/Flex";
|
||||
import PaginatedEventList from "components/PaginatedEventList";
|
||||
import Scrollable from "components/Scrollable";
|
||||
import useStores from "hooks/useStores";
|
||||
import { documentUrl } from "utils/routeHelpers";
|
||||
|
||||
const EMPTY_ARRAY = [];
|
||||
|
||||
function DocumentHistory() {
|
||||
const { events, documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const match = useRouteMatch();
|
||||
const history = useHistory();
|
||||
|
||||
const document = documents.getByUrl(match.params.documentSlug);
|
||||
const eventsInDocument = document
|
||||
? events.inDocument(document.id)
|
||||
: EMPTY_ARRAY;
|
||||
|
||||
const onCloseHistory = () => {
|
||||
history.push(documentUrl(document));
|
||||
};
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
if (
|
||||
eventsInDocument[0] &&
|
||||
document &&
|
||||
eventsInDocument[0].createdAt !== document.updatedAt
|
||||
) {
|
||||
eventsInDocument.unshift(
|
||||
new Event({
|
||||
name: "documents.latest_version",
|
||||
documentId: document.id,
|
||||
createdAt: document.updatedAt,
|
||||
actor: document.updatedBy,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return eventsInDocument;
|
||||
}, [eventsInDocument, document]);
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
{document ? (
|
||||
<Position column>
|
||||
<Header>
|
||||
<Title>{t("History")}</Title>
|
||||
<Button
|
||||
icon={<CloseIcon />}
|
||||
onClick={onCloseHistory}
|
||||
borderOnHover
|
||||
neutral
|
||||
/>
|
||||
</Header>
|
||||
<Scrollable topShadow>
|
||||
<PaginatedEventList
|
||||
fetch={events.fetchPage}
|
||||
events={items}
|
||||
options={{ documentId: document.id }}
|
||||
document={document}
|
||||
empty={<Empty>{t("Oh weird, there's nothing here")}</Empty>}
|
||||
/>
|
||||
</Scrollable>
|
||||
</Position>
|
||||
) : null}
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
const Position = styled(Flex)`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: ${(props) => props.theme.sidebarWidth}px;
|
||||
`;
|
||||
|
||||
const Sidebar = styled(Flex)`
|
||||
display: none;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
background: ${(props) => props.theme.background};
|
||||
width: ${(props) => props.theme.sidebarWidth}px;
|
||||
border-left: 1px solid ${(props) => props.theme.divider};
|
||||
z-index: 1;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: flex;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Title = styled(Flex)`
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
width: 0;
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
const Header = styled(Flex)`
|
||||
align-items: center;
|
||||
position: relative;
|
||||
padding: 12px;
|
||||
color: ${(props) => props.theme.text};
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export default observer(DocumentHistory);
|
||||
@@ -1,199 +0,0 @@
|
||||
// @flow
|
||||
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
|
||||
import { action, observable } from "mobx";
|
||||
import { inject, observer } from "mobx-react";
|
||||
import { CloseIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { type Match, Redirect, type RouterHistory } from "react-router-dom";
|
||||
import { Waypoint } from "react-waypoint";
|
||||
import styled from "styled-components";
|
||||
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import RevisionsStore from "stores/RevisionsStore";
|
||||
|
||||
import Button from "components/Button";
|
||||
import Flex from "components/Flex";
|
||||
import PlaceholderList from "components/List/Placeholder";
|
||||
import Revision from "./components/Revision";
|
||||
import { documentHistoryUrl, documentUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
match: Match,
|
||||
documents: DocumentsStore,
|
||||
revisions: RevisionsStore,
|
||||
history: RouterHistory,
|
||||
};
|
||||
|
||||
@observer
|
||||
class DocumentHistory extends React.Component<Props> {
|
||||
@observable isLoaded: boolean = false;
|
||||
@observable isFetching: boolean = false;
|
||||
@observable offset: number = 0;
|
||||
@observable allowLoadMore: boolean = true;
|
||||
@observable redirectTo: ?string;
|
||||
|
||||
async componentDidMount() {
|
||||
await this.loadMoreResults();
|
||||
this.selectFirstRevision();
|
||||
}
|
||||
|
||||
fetchResults = async () => {
|
||||
this.isFetching = true;
|
||||
|
||||
const limit = DEFAULT_PAGINATION_LIMIT;
|
||||
const results = await this.props.revisions.fetchPage({
|
||||
limit,
|
||||
offset: this.offset,
|
||||
documentId: this.props.match.params.documentSlug,
|
||||
});
|
||||
|
||||
if (
|
||||
results &&
|
||||
(results.length === 0 || results.length < DEFAULT_PAGINATION_LIMIT)
|
||||
) {
|
||||
this.allowLoadMore = false;
|
||||
} else {
|
||||
this.offset += DEFAULT_PAGINATION_LIMIT;
|
||||
}
|
||||
|
||||
this.isLoaded = true;
|
||||
this.isFetching = false;
|
||||
};
|
||||
|
||||
selectFirstRevision = () => {
|
||||
if (this.revisions.length) {
|
||||
const document = this.props.documents.getByUrl(
|
||||
this.props.match.params.documentSlug
|
||||
);
|
||||
if (!document) return;
|
||||
|
||||
this.props.history.replace(
|
||||
documentHistoryUrl(document, this.revisions[0].id)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@action
|
||||
loadMoreResults = async () => {
|
||||
// Don't paginate if there aren't more results or we’re in the middle of fetching
|
||||
if (!this.allowLoadMore || this.isFetching) return;
|
||||
await this.fetchResults();
|
||||
};
|
||||
|
||||
get revisions() {
|
||||
const document = this.props.documents.getByUrl(
|
||||
this.props.match.params.documentSlug
|
||||
);
|
||||
if (!document) return [];
|
||||
return this.props.revisions.getDocumentRevisions(document.id);
|
||||
}
|
||||
|
||||
onCloseHistory = () => {
|
||||
const document = this.props.documents.getByUrl(
|
||||
this.props.match.params.documentSlug
|
||||
);
|
||||
|
||||
this.redirectTo = documentUrl(document);
|
||||
};
|
||||
|
||||
render() {
|
||||
const document = this.props.documents.getByUrl(
|
||||
this.props.match.params.documentSlug
|
||||
);
|
||||
const showLoading = (!this.isLoaded && this.isFetching) || !document;
|
||||
|
||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<Wrapper column>
|
||||
<Header>
|
||||
<Title>History</Title>
|
||||
<Button
|
||||
icon={<CloseIcon />}
|
||||
onClick={this.onCloseHistory}
|
||||
borderOnHover
|
||||
neutral
|
||||
/>
|
||||
</Header>
|
||||
{showLoading ? (
|
||||
<Loading>
|
||||
<PlaceholderList count={5} />
|
||||
</Loading>
|
||||
) : (
|
||||
<ArrowKeyNavigation
|
||||
mode={ArrowKeyNavigation.mode.VERTICAL}
|
||||
defaultActiveChildIndex={0}
|
||||
>
|
||||
{this.revisions.map((revision, index) => (
|
||||
<Revision
|
||||
key={revision.id}
|
||||
revision={revision}
|
||||
document={document}
|
||||
showMenu={index !== 0}
|
||||
selected={this.props.match.params.revisionId === revision.id}
|
||||
/>
|
||||
))}
|
||||
</ArrowKeyNavigation>
|
||||
)}
|
||||
{this.allowLoadMore && (
|
||||
<Waypoint key={this.offset} onEnter={this.loadMoreResults} />
|
||||
)}
|
||||
</Wrapper>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Loading = styled.div`
|
||||
margin: 0 16px;
|
||||
`;
|
||||
|
||||
const Wrapper = styled(Flex)`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
min-width: ${(props) => props.theme.sidebarWidth}px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: none;
|
||||
`;
|
||||
|
||||
const Sidebar = styled(Flex)`
|
||||
display: none;
|
||||
background: ${(props) => props.theme.background};
|
||||
min-width: ${(props) => props.theme.sidebarWidth}px;
|
||||
border-left: 1px solid ${(props) => props.theme.divider};
|
||||
z-index: 1;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: flex;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Title = styled(Flex)`
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
width: 0;
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
const Header = styled(Flex)`
|
||||
align-items: center;
|
||||
position: relative;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid ${(props) => props.theme.divider};
|
||||
color: ${(props) => props.theme.text};
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export default inject("documents", "revisions")(DocumentHistory);
|
||||
@@ -1,87 +0,0 @@
|
||||
// @flow
|
||||
import { format } from "date-fns";
|
||||
import * as React from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
|
||||
import Document from "models/Document";
|
||||
import Revision from "models/Revision";
|
||||
import Avatar from "components/Avatar";
|
||||
import Flex from "components/Flex";
|
||||
import Time from "components/Time";
|
||||
import RevisionMenu from "menus/RevisionMenu";
|
||||
import { type Theme } from "types";
|
||||
|
||||
import { documentHistoryUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
theme: Theme,
|
||||
showMenu: boolean,
|
||||
selected: boolean,
|
||||
document: Document,
|
||||
revision: Revision,
|
||||
};
|
||||
|
||||
class RevisionListItem extends React.Component<Props> {
|
||||
render() {
|
||||
const { revision, document, showMenu, selected, theme } = this.props;
|
||||
|
||||
return (
|
||||
<StyledNavLink
|
||||
to={documentHistoryUrl(document, revision.id)}
|
||||
activeStyle={{ background: theme.primary, color: theme.white }}
|
||||
>
|
||||
<Author>
|
||||
<StyledAvatar src={revision.createdBy.avatarUrl} />{" "}
|
||||
{revision.createdBy.name}
|
||||
</Author>
|
||||
<Meta>
|
||||
<Time dateTime={revision.createdAt} tooltipDelay={250}>
|
||||
{format(Date.parse(revision.createdAt), "MMMM do, yyyy h:mm a")}
|
||||
</Time>
|
||||
</Meta>
|
||||
{showMenu && (
|
||||
<StyledRevisionMenu
|
||||
document={document}
|
||||
revision={revision}
|
||||
iconColor={selected ? theme.white : theme.textTertiary}
|
||||
/>
|
||||
)}
|
||||
</StyledNavLink>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const StyledAvatar = styled(Avatar)`
|
||||
border-color: transparent;
|
||||
margin-right: 4px;
|
||||
`;
|
||||
|
||||
const StyledRevisionMenu = styled(RevisionMenu)`
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 20px;
|
||||
`;
|
||||
|
||||
const StyledNavLink = styled(NavLink)`
|
||||
color: ${(props) => props.theme.text};
|
||||
display: block;
|
||||
padding: 8px 16px;
|
||||
font-size: 15px;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const Author = styled(Flex)`
|
||||
font-weight: 500;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const Meta = styled.p`
|
||||
font-size: 14px;
|
||||
opacity: 0.75;
|
||||
margin: 0 0 2px;
|
||||
padding: 0;
|
||||
`;
|
||||
|
||||
export default withTheme(RevisionListItem);
|
||||
@@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import DocumentHistory from "./DocumentHistory";
|
||||
export default DocumentHistory;
|
||||
@@ -20,7 +20,7 @@ import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import useCurrentUser from "hooks/useCurrentUser";
|
||||
import useStores from "hooks/useStores";
|
||||
import DocumentMenu from "menus/DocumentMenu";
|
||||
import { newDocumentUrl } from "utils/routeHelpers";
|
||||
import { newDocumentPath } from "utils/routeHelpers";
|
||||
|
||||
type Props = {|
|
||||
document: Document,
|
||||
@@ -66,6 +66,7 @@ function DocumentListItem(props: Props, ref) {
|
||||
const canStar =
|
||||
!document.isDraft && !document.isArchived && !document.isTemplate;
|
||||
const can = policies.abilities(currentTeam.id);
|
||||
const canCollection = policies.abilities(document.collectionId);
|
||||
|
||||
return (
|
||||
<DocumentLink
|
||||
@@ -126,11 +127,12 @@ function DocumentListItem(props: Props, ref) {
|
||||
{document.isTemplate &&
|
||||
!document.isArchived &&
|
||||
!document.isDeleted &&
|
||||
can.createDocument && (
|
||||
can.createDocument &&
|
||||
canCollection.update && (
|
||||
<>
|
||||
<Button
|
||||
as={Link}
|
||||
to={newDocumentUrl(document.collectionId, {
|
||||
to={newDocumentPath(document.collectionId, {
|
||||
templateId: document.id,
|
||||
})}
|
||||
icon={<PlusIcon />}
|
||||
|
||||
@@ -3,18 +3,19 @@ import { lighten } from "polished";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { withRouter, type RouterHistory } from "react-router-dom";
|
||||
import { Extension } from "rich-markdown-editor";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import embeds from "shared/embeds";
|
||||
import { light } from "shared/theme";
|
||||
import UiStore from "stores/UiStore";
|
||||
import ErrorBoundary from "components/ErrorBoundary";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import embeds from "../embeds";
|
||||
import useMediaQuery from "hooks/useMediaQuery";
|
||||
import useToasts from "hooks/useToasts";
|
||||
import { type Theme } from "types";
|
||||
import { isModKey } from "utils/keyboard";
|
||||
import { uploadFile } from "utils/uploadFile";
|
||||
import { isInternalUrl } from "utils/urls";
|
||||
import { isInternalUrl, isHash } from "utils/urls";
|
||||
|
||||
const RichMarkdownEditor = React.lazy(() =>
|
||||
import(/* webpackChunkName: "rich-markdown-editor" */ "rich-markdown-editor")
|
||||
@@ -30,6 +31,8 @@ export type Props = {|
|
||||
grow?: boolean,
|
||||
disableEmbeds?: boolean,
|
||||
ui?: UiStore,
|
||||
style?: Object,
|
||||
extensions?: Extension[],
|
||||
shareId?: ?string,
|
||||
autoFocus?: boolean,
|
||||
template?: boolean,
|
||||
@@ -37,6 +40,7 @@ export type Props = {|
|
||||
maxLength?: number,
|
||||
scrollTo?: string,
|
||||
theme?: Theme,
|
||||
className?: string,
|
||||
handleDOMEvents?: Object,
|
||||
readOnlyWriteCheckboxes?: boolean,
|
||||
onBlur?: (event: SyntheticEvent<>) => any,
|
||||
@@ -75,7 +79,7 @@ function Editor(props: PropsWithRef) {
|
||||
const onClickLink = React.useCallback(
|
||||
(href: string, event: MouseEvent) => {
|
||||
// on page hash
|
||||
if (href[0] === "#") {
|
||||
if (isHash(href)) {
|
||||
window.location.href = href;
|
||||
return;
|
||||
}
|
||||
@@ -224,7 +228,8 @@ const StyledEditor = styled(RichMarkdownEditor)`
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.heading-name:first-child {
|
||||
.heading-name:first-child,
|
||||
.heading-name:first-child + .ProseMirror-yjs-cursor {
|
||||
& + h1,
|
||||
& + h2,
|
||||
& + h3,
|
||||
@@ -246,6 +251,60 @@ const StyledEditor = styled(RichMarkdownEditor)`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
& > .ProseMirror-yjs-cursor {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ProseMirror-yjs-cursor {
|
||||
position: relative;
|
||||
margin-left: -1px;
|
||||
margin-right: -1px;
|
||||
border-left: 1px solid black;
|
||||
border-right: 1px solid black;
|
||||
height: 1em;
|
||||
word-break: normal;
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: -8px;
|
||||
right: -8px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
> div {
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
position: absolute;
|
||||
top: -1.8em;
|
||||
font-size: 13px;
|
||||
background-color: rgb(250, 129, 0);
|
||||
font-style: normal;
|
||||
line-height: normal;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
color: white;
|
||||
padding: 2px 6px;
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
pointer-events: none;
|
||||
left: -1px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
> div {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.show-cursor-names .ProseMirror-yjs-cursor > div {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const EditorTooltip = ({ children, ...props }) => (
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// @flow
|
||||
import {
|
||||
TrashIcon,
|
||||
ArchiveIcon,
|
||||
EditIcon,
|
||||
PublishIcon,
|
||||
MoveIcon,
|
||||
CheckboxIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Document from "models/Document";
|
||||
import Event from "models/Event";
|
||||
import Avatar from "components/Avatar";
|
||||
import Item, { Actions } from "components/List/Item";
|
||||
import Time from "components/Time";
|
||||
import RevisionMenu from "menus/RevisionMenu";
|
||||
import { documentHistoryUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {|
|
||||
document: Document,
|
||||
event: Event,
|
||||
latest?: boolean,
|
||||
|};
|
||||
|
||||
const EventListItem = ({ event, latest, document }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const opts = { userName: event.actor.name };
|
||||
const isRevision = event.name === "revisions.create";
|
||||
let meta, icon, to;
|
||||
|
||||
switch (event.name) {
|
||||
case "revisions.create":
|
||||
case "documents.latest_version": {
|
||||
if (latest) {
|
||||
icon = <CheckboxIcon color="currentColor" size={16} checked />;
|
||||
meta = t("Latest version");
|
||||
to = documentHistoryUrl(document);
|
||||
break;
|
||||
} else {
|
||||
icon = <EditIcon color="currentColor" size={16} />;
|
||||
meta = t("{{userName}} edited", opts);
|
||||
to = documentHistoryUrl(document, event.modelId || "");
|
||||
break;
|
||||
}
|
||||
}
|
||||
case "documents.archive":
|
||||
icon = <ArchiveIcon color="currentColor" size={16} />;
|
||||
meta = t("{{userName}} archived", opts);
|
||||
break;
|
||||
case "documents.unarchive":
|
||||
meta = t("{{userName}} restored", opts);
|
||||
break;
|
||||
case "documents.delete":
|
||||
icon = <TrashIcon color="currentColor" size={16} />;
|
||||
meta = t("{{userName}} deleted", opts);
|
||||
break;
|
||||
case "documents.restore":
|
||||
meta = t("{{userName}} moved from trash", opts);
|
||||
break;
|
||||
case "documents.publish":
|
||||
icon = <PublishIcon color="currentColor" size={16} />;
|
||||
meta = t("{{userName}} published", opts);
|
||||
break;
|
||||
case "documents.move":
|
||||
icon = <MoveIcon color="currentColor" size={16} />;
|
||||
meta = t("{{userName}} moved", opts);
|
||||
break;
|
||||
default:
|
||||
console.warn("Unhandled event: ", event.name);
|
||||
}
|
||||
|
||||
if (!meta) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
small
|
||||
exact
|
||||
to={to}
|
||||
title={
|
||||
<Time
|
||||
dateTime={event.createdAt}
|
||||
tooltipDelay={250}
|
||||
format="MMMM do, h:mm a"
|
||||
relative={false}
|
||||
addSuffix
|
||||
/>
|
||||
}
|
||||
image={<Avatar src={event.actor?.avatarUrl} size={32} />}
|
||||
subtitle={
|
||||
<Subtitle>
|
||||
{icon}
|
||||
{meta}
|
||||
</Subtitle>
|
||||
}
|
||||
actions={
|
||||
isRevision ? (
|
||||
<RevisionMenu document={document} revisionId={event.modelId} />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Subtitle = styled.span`
|
||||
svg {
|
||||
margin: -3px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
`;
|
||||
|
||||
const ListItem = styled(Item)`
|
||||
border: 0;
|
||||
position: relative;
|
||||
margin: 8px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
|
||||
img {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: 23px;
|
||||
width: 2px;
|
||||
height: calc(100% + 8px);
|
||||
background: ${(props) => props.theme.textSecondary};
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
&:nth-child(2)::before {
|
||||
height: 50%;
|
||||
top: auto;
|
||||
bottom: -4px;
|
||||
}
|
||||
|
||||
&:last-child::before {
|
||||
height: 50%;
|
||||
}
|
||||
|
||||
&:first-child:last-child::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
${Actions} {
|
||||
opacity: 0.25;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
${Actions} {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default EventListItem;
|
||||
@@ -76,11 +76,6 @@ const FilterOptions = ({
|
||||
);
|
||||
};
|
||||
|
||||
const LabelWithNote = styled.div`
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
`;
|
||||
|
||||
const Note = styled(HelpText)`
|
||||
margin-top: 2px;
|
||||
margin-bottom: 0;
|
||||
@@ -90,6 +85,15 @@ const Note = styled(HelpText)`
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
`;
|
||||
|
||||
const LabelWithNote = styled.div`
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
|
||||
&:hover ${Note} {
|
||||
color: ${(props) => props.theme.white50};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledButton = styled(Button)`
|
||||
box-shadow: none;
|
||||
text-transform: none;
|
||||
|
||||
@@ -39,7 +39,8 @@ const Guide = ({
|
||||
<Dialog
|
||||
{...dialog}
|
||||
aria-label={title}
|
||||
preventBodyScrollhideOnEsc
|
||||
preventBodyScroll
|
||||
hideOnEsc
|
||||
hide={onRequestClose}
|
||||
>
|
||||
{(props) => (
|
||||
|
||||
@@ -36,7 +36,7 @@ function Header({ breadcrumb, title, actions }: Props) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Wrapper align="center" isCompact={isScrolled} shrink={false}>
|
||||
<Wrapper align="center" shrink={false}>
|
||||
{breadcrumb ? <Breadcrumbs>{breadcrumb}</Breadcrumbs> : null}
|
||||
{isScrolled ? (
|
||||
<Title align="center" justify="flex-start" onClick={handleClickTitle}>
|
||||
@@ -95,7 +95,7 @@ const Wrapper = styled(Flex)`
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: ${(props) => (props.isCompact ? "12px" : `24px 24px 0`)};
|
||||
padding: 16px 16px 0;
|
||||
justify-content: "center";
|
||||
`};
|
||||
`;
|
||||
|
||||
@@ -162,8 +162,6 @@ const CardContent = styled.div`
|
||||
const Card = styled.div`
|
||||
backdrop-filter: blur(10px);
|
||||
background: ${(props) => props.theme.background};
|
||||
border: ${(props) =>
|
||||
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 30px 90px -20px rgba(0, 0, 0, 0.3),
|
||||
0 0 1px 1px rgba(0, 0, 0, 0.05);
|
||||
|
||||
@@ -1,28 +1,41 @@
|
||||
// @flow
|
||||
import {
|
||||
BookmarkedIcon,
|
||||
CollectionIcon,
|
||||
CoinsIcon,
|
||||
AcademicCapIcon,
|
||||
BeakerIcon,
|
||||
BuildingBlocksIcon,
|
||||
CameraIcon,
|
||||
CloudIcon,
|
||||
CodeIcon,
|
||||
EditIcon,
|
||||
EmailIcon,
|
||||
EyeIcon,
|
||||
GlobeIcon,
|
||||
InfoIcon,
|
||||
ImageIcon,
|
||||
LeafIcon,
|
||||
LightBulbIcon,
|
||||
MathIcon,
|
||||
MoonIcon,
|
||||
NotepadIcon,
|
||||
PadlockIcon,
|
||||
PaletteIcon,
|
||||
PromoteIcon,
|
||||
QuestionMarkIcon,
|
||||
SportIcon,
|
||||
SunIcon,
|
||||
TargetIcon,
|
||||
ToolsIcon,
|
||||
VehicleIcon,
|
||||
WarningIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMenuState, MenuButton, MenuItem } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import ContextMenu from "components/ContextMenu";
|
||||
import Flex from "components/Flex";
|
||||
import HelpText from "components/HelpText";
|
||||
@@ -39,6 +52,10 @@ const TwitterPicker = React.lazy(() =>
|
||||
);
|
||||
|
||||
export const icons = {
|
||||
bookmark: {
|
||||
component: BookmarkedIcon,
|
||||
keywords: "bookmark",
|
||||
},
|
||||
collection: {
|
||||
component: CollectionIcon,
|
||||
keywords: "collection",
|
||||
@@ -47,6 +64,10 @@ export const icons = {
|
||||
component: CoinsIcon,
|
||||
keywords: "coins money finance sales income revenue cash",
|
||||
},
|
||||
camera: {
|
||||
component: CameraIcon,
|
||||
keywords: "photo picture",
|
||||
},
|
||||
academicCap: {
|
||||
component: AcademicCapIcon,
|
||||
keywords: "learn teach lesson guide tutorial onboarding training",
|
||||
@@ -67,10 +88,26 @@ export const icons = {
|
||||
component: CodeIcon,
|
||||
keywords: "developer api code development engineering programming",
|
||||
},
|
||||
email: {
|
||||
component: EmailIcon,
|
||||
keywords: "email at",
|
||||
},
|
||||
eye: {
|
||||
component: EyeIcon,
|
||||
keywords: "eye view",
|
||||
},
|
||||
globe: {
|
||||
component: GlobeIcon,
|
||||
keywords: "world translate",
|
||||
},
|
||||
info: {
|
||||
component: InfoIcon,
|
||||
keywords: "info information",
|
||||
},
|
||||
image: {
|
||||
component: ImageIcon,
|
||||
keywords: "image photo picture",
|
||||
},
|
||||
leaf: {
|
||||
component: LeafIcon,
|
||||
keywords: "leaf plant outdoors nature ecosystem climate",
|
||||
@@ -79,6 +116,10 @@ export const icons = {
|
||||
component: LightBulbIcon,
|
||||
keywords: "lightbulb idea",
|
||||
},
|
||||
math: {
|
||||
component: MathIcon,
|
||||
keywords: "math formula",
|
||||
},
|
||||
moon: {
|
||||
component: MoonIcon,
|
||||
keywords: "night moon dark",
|
||||
@@ -99,6 +140,10 @@ export const icons = {
|
||||
component: EditIcon,
|
||||
keywords: "copy writing post blog",
|
||||
},
|
||||
promote: {
|
||||
component: PromoteIcon,
|
||||
keywords: "marketing promotion",
|
||||
},
|
||||
question: {
|
||||
component: QuestionMarkIcon,
|
||||
keywords: "question help support faq",
|
||||
@@ -107,10 +152,26 @@ export const icons = {
|
||||
component: SunIcon,
|
||||
keywords: "day sun weather",
|
||||
},
|
||||
sport: {
|
||||
component: SportIcon,
|
||||
keywords: "sport outdoor racket game",
|
||||
},
|
||||
target: {
|
||||
component: TargetIcon,
|
||||
keywords: "target goal sales",
|
||||
},
|
||||
tools: {
|
||||
component: ToolsIcon,
|
||||
keywords: "tool settings",
|
||||
},
|
||||
vehicle: {
|
||||
component: VehicleIcon,
|
||||
keywords: "truck car travel transport",
|
||||
},
|
||||
warning: {
|
||||
component: WarningIcon,
|
||||
keywords: "warning alert error",
|
||||
},
|
||||
};
|
||||
|
||||
const colors = [
|
||||
@@ -128,18 +189,18 @@ const colors = [
|
||||
|
||||
type Props = {|
|
||||
onOpen?: () => void,
|
||||
onClose?: () => void,
|
||||
onChange: (color: string, icon: string) => void,
|
||||
icon: string,
|
||||
color: string,
|
||||
|};
|
||||
|
||||
function IconPicker({ onOpen, icon, color, onChange }: Props) {
|
||||
function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
placement: "bottom-end",
|
||||
});
|
||||
const Component = icons[icon || "collection"].component;
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
@@ -149,14 +210,22 @@ function IconPicker({ onOpen, icon, color, onChange }: Props) {
|
||||
<MenuButton {...menu}>
|
||||
{(props) => (
|
||||
<Button aria-label={t("Show menu")} {...props}>
|
||||
<Component color={color} size={30} />
|
||||
<Icon
|
||||
as={icons[icon || "collection"].component}
|
||||
color={color}
|
||||
size={30}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu {...menu} onOpen={onOpen} aria-label={t("Choose icon")}>
|
||||
<ContextMenu
|
||||
{...menu}
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
aria-label={t("Choose icon")}
|
||||
>
|
||||
<Icons>
|
||||
{Object.keys(icons).map((name) => {
|
||||
const Component = icons[name].component;
|
||||
return (
|
||||
<MenuItem
|
||||
key={name}
|
||||
@@ -165,7 +234,7 @@ function IconPicker({ onOpen, icon, color, onChange }: Props) {
|
||||
>
|
||||
{(props) => (
|
||||
<IconButton style={style} {...props}>
|
||||
<Component color={color} size={30} />
|
||||
<Icon as={icons[name].component} color={color} size={30} />
|
||||
</IconButton>
|
||||
)}
|
||||
</MenuItem>
|
||||
@@ -187,13 +256,20 @@ function IconPicker({ onOpen, icon, color, onChange }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = styled.svg`
|
||||
transition: fill 150ms ease-in-out;
|
||||
`;
|
||||
|
||||
const Label = styled.label`
|
||||
display: block;
|
||||
`;
|
||||
|
||||
const Icons = styled.div`
|
||||
padding: 15px 9px 9px 15px;
|
||||
width: 276px;
|
||||
padding: 16px 8px 0 16px;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
width: 276px;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Button = styled(NudeButton)`
|
||||
@@ -216,6 +292,11 @@ const Loading = styled(HelpText)`
|
||||
const ColorPicker = styled(TwitterPicker)`
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
width: auto !important;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
width: 276px;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Wrapper = styled("div")`
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { SearchIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import keydown from "react-keydown";
|
||||
import { withRouter, type RouterHistory } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import Input from "./Input";
|
||||
import { type Theme } from "types";
|
||||
import { meta } from "utils/keyboard";
|
||||
import useBoolean from "hooks/useBoolean";
|
||||
import useKeyDown from "hooks/useKeyDown";
|
||||
import { isModKey } from "utils/keyboard";
|
||||
import { searchUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
history: RouterHistory,
|
||||
theme: Theme,
|
||||
type Props = {|
|
||||
source: string,
|
||||
placeholder?: string,
|
||||
label?: string,
|
||||
@@ -23,73 +20,77 @@ type Props = {
|
||||
value: string,
|
||||
onChange: (event: SyntheticInputEvent<>) => mixed,
|
||||
onKeyDown: (event: SyntheticKeyboardEvent<HTMLInputElement>) => mixed,
|
||||
t: TFunction,
|
||||
};
|
||||
|};
|
||||
|
||||
@observer
|
||||
class InputSearchPage extends React.Component<Props> {
|
||||
input: ?Input;
|
||||
@observable focused: boolean = false;
|
||||
function InputSearchPage({
|
||||
onKeyDown,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
label,
|
||||
collectionId,
|
||||
source,
|
||||
}: Props) {
|
||||
const inputRef = React.useRef();
|
||||
const theme = useTheme();
|
||||
const history = useHistory();
|
||||
const { t } = useTranslation();
|
||||
const [isFocused, setFocused, setUnfocused] = useBoolean(false);
|
||||
|
||||
@keydown(`${meta}+f`)
|
||||
focus(ev: SyntheticEvent<>) {
|
||||
ev.preventDefault();
|
||||
const focus = React.useCallback(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
if (this.input) {
|
||||
this.input.focus();
|
||||
useKeyDown("f", (ev: KeyboardEvent) => {
|
||||
if (isModKey(ev)) {
|
||||
ev.preventDefault();
|
||||
focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
handleSearchInput = (ev: SyntheticInputEvent<>) => {
|
||||
ev.preventDefault();
|
||||
this.props.history.push(
|
||||
searchUrl(ev.target.value, {
|
||||
collectionId: this.props.collectionId,
|
||||
ref: this.props.source,
|
||||
})
|
||||
);
|
||||
};
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev: SyntheticKeyboardEvent<HTMLInputElement>) => {
|
||||
if (ev.key === "Enter") {
|
||||
ev.preventDefault();
|
||||
history.push(
|
||||
searchUrl(ev.currentTarget.value, {
|
||||
collectionId,
|
||||
ref: source,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
handleFocus = () => {
|
||||
this.focused = true;
|
||||
};
|
||||
if (onKeyDown) {
|
||||
onKeyDown(ev);
|
||||
}
|
||||
},
|
||||
[history, collectionId, source, onKeyDown]
|
||||
);
|
||||
|
||||
handleBlur = () => {
|
||||
this.focused = false;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { t, value, onChange, onKeyDown } = this.props;
|
||||
const { theme, placeholder = `${t("Search")}…` } = this.props;
|
||||
|
||||
return (
|
||||
<InputMaxWidth
|
||||
ref={(ref) => (this.input = ref)}
|
||||
type="search"
|
||||
placeholder={placeholder}
|
||||
onInput={this.handleSearchInput}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
icon={
|
||||
<SearchIcon
|
||||
color={this.focused ? theme.inputBorderFocused : theme.inputBorder}
|
||||
/>
|
||||
}
|
||||
label={this.props.label}
|
||||
onFocus={this.handleFocus}
|
||||
onBlur={this.handleBlur}
|
||||
margin={0}
|
||||
labelHidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<InputMaxWidth
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
placeholder={placeholder || `${t("Search")}…`}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
icon={
|
||||
<SearchIcon
|
||||
color={isFocused ? theme.inputBorderFocused : theme.inputBorder}
|
||||
/>
|
||||
}
|
||||
label={label}
|
||||
onFocus={setFocused}
|
||||
onBlur={setUnfocused}
|
||||
margin={0}
|
||||
labelHidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const InputMaxWidth = styled(Input)`
|
||||
max-width: 30vw;
|
||||
`;
|
||||
|
||||
export default withTranslation()<InputSearchPage>(
|
||||
withTheme(withRouter(InputSearchPage))
|
||||
);
|
||||
export default observer(InputSearchPage);
|
||||
|
||||
+247
-62
@@ -1,74 +1,125 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
Select,
|
||||
SelectOption,
|
||||
useSelectState,
|
||||
useSelectPopover,
|
||||
SelectPopover,
|
||||
} from "@renderlesskit/react";
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import styled from "styled-components";
|
||||
import { Outline, LabelText } from "./Input";
|
||||
|
||||
const Select = styled.select`
|
||||
border: 0;
|
||||
flex: 1;
|
||||
padding: 4px 0;
|
||||
margin: 0 12px;
|
||||
outline: none;
|
||||
background: none;
|
||||
color: ${(props) => props.theme.text};
|
||||
height: 30px;
|
||||
|
||||
option {
|
||||
background: ${(props) => props.theme.buttonNeutralBackground};
|
||||
}
|
||||
|
||||
&:disabled,
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
}
|
||||
`;
|
||||
|
||||
const Wrapper = styled.label`
|
||||
display: block;
|
||||
max-width: ${(props) => (props.short ? "350px" : "100%")};
|
||||
`;
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import styled, { css } from "styled-components";
|
||||
import Button, { Inner } from "components/Button";
|
||||
import HelpText from "components/HelpText";
|
||||
import { Position, Background, Backdrop } from "./ContextMenu";
|
||||
import { MenuAnchorCSS } from "./ContextMenu/MenuItem";
|
||||
import { LabelText } from "./Input";
|
||||
import useMenuHeight from "hooks/useMenuHeight";
|
||||
|
||||
export type Option = { label: string, value: string };
|
||||
|
||||
export type Props = {
|
||||
value?: string,
|
||||
label?: string,
|
||||
nude?: boolean,
|
||||
ariaLabel: string,
|
||||
short?: boolean,
|
||||
disabled?: boolean,
|
||||
className?: string,
|
||||
labelHidden?: boolean,
|
||||
icon?: React.Node,
|
||||
options: Option[],
|
||||
onBlur?: () => void,
|
||||
onFocus?: () => void,
|
||||
note?: React.Node,
|
||||
onChange: (string) => Promise<void> | void,
|
||||
};
|
||||
|
||||
@observer
|
||||
class InputSelect extends React.Component<Props> {
|
||||
@observable focused: boolean = false;
|
||||
const getOptionFromValue = (options: Option[], value) => {
|
||||
return options.find((option) => option.value === value) || {};
|
||||
};
|
||||
|
||||
handleBlur = () => {
|
||||
this.focused = false;
|
||||
};
|
||||
const InputSelect = (props: Props) => {
|
||||
const {
|
||||
value,
|
||||
label,
|
||||
className,
|
||||
labelHidden,
|
||||
options,
|
||||
short,
|
||||
ariaLabel,
|
||||
onChange,
|
||||
disabled,
|
||||
nude,
|
||||
note,
|
||||
icon,
|
||||
} = props;
|
||||
|
||||
handleFocus = () => {
|
||||
this.focused = true;
|
||||
};
|
||||
const select = useSelectState({
|
||||
gutter: 0,
|
||||
modal: true,
|
||||
selectedValue: value,
|
||||
animated: 200,
|
||||
});
|
||||
|
||||
render() {
|
||||
const {
|
||||
label,
|
||||
className,
|
||||
labelHidden,
|
||||
options,
|
||||
short,
|
||||
...rest
|
||||
} = this.props;
|
||||
const popOver = useSelectPopover({
|
||||
...select,
|
||||
hideOnClickOutside: true,
|
||||
preventBodyScroll: true,
|
||||
disabled,
|
||||
});
|
||||
|
||||
const wrappedLabel = <LabelText>{label}</LabelText>;
|
||||
const previousValue = React.useRef(value);
|
||||
const contentRef = React.useRef();
|
||||
const selectedRef = React.useRef();
|
||||
const buttonRef = React.useRef();
|
||||
const [offset, setOffset] = React.useState(0);
|
||||
const minWidth = buttonRef.current?.offsetWidth || 0;
|
||||
|
||||
return (
|
||||
const maxHeight = useMenuHeight(
|
||||
select.visible,
|
||||
select.unstable_disclosureRef
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (previousValue.current === select.selectedValue) return;
|
||||
|
||||
previousValue.current = select.selectedValue;
|
||||
async function load() {
|
||||
await onChange(select.selectedValue);
|
||||
}
|
||||
load();
|
||||
}, [onChange, select.selectedValue]);
|
||||
|
||||
const wrappedLabel = <LabelText>{label}</LabelText>;
|
||||
|
||||
const selectedValueIndex = options.findIndex(
|
||||
(option) => option.value === select.selectedValue
|
||||
);
|
||||
|
||||
// Ensure selected option is visible when opening the input
|
||||
React.useEffect(() => {
|
||||
if (!select.animating && selectedRef.current) {
|
||||
scrollIntoView(selectedRef.current, {
|
||||
scrollMode: "if-needed",
|
||||
behavior: "instant",
|
||||
block: "start",
|
||||
});
|
||||
}
|
||||
}, [select.animating]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (select.visible) {
|
||||
const offset = Math.round(
|
||||
(selectedRef.current?.getBoundingClientRect().top || 0) -
|
||||
(contentRef.current?.getBoundingClientRect().top || 0)
|
||||
);
|
||||
setOffset(offset);
|
||||
}
|
||||
}, [select.visible]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Wrapper short={short}>
|
||||
{label &&
|
||||
(labelHidden ? (
|
||||
@@ -76,18 +127,152 @@ class InputSelect extends React.Component<Props> {
|
||||
) : (
|
||||
wrappedLabel
|
||||
))}
|
||||
<Outline focused={this.focused} className={className}>
|
||||
<Select onBlur={this.handleBlur} onFocus={this.handleFocus} {...rest}>
|
||||
{options.map((option) => (
|
||||
<option value={option.value} key={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Outline>
|
||||
|
||||
<Select {...select} disabled={disabled} ref={buttonRef}>
|
||||
{(props) => (
|
||||
<StyledButton
|
||||
neutral
|
||||
disclosure
|
||||
className={className}
|
||||
nude={nude}
|
||||
icon={icon}
|
||||
{...props}
|
||||
>
|
||||
{getOptionFromValue(options, select.selectedValue).label || (
|
||||
<Placeholder>Select a {ariaLabel.toLowerCase()}</Placeholder>
|
||||
)}
|
||||
</StyledButton>
|
||||
)}
|
||||
</Select>
|
||||
<SelectPopover {...select} {...popOver} aria-label={ariaLabel}>
|
||||
{(props) => {
|
||||
const topAnchor = props.style.top === "0";
|
||||
const rightAnchor = props.placement === "bottom-end";
|
||||
|
||||
// offset top of select to place selected item under the cursor
|
||||
if (selectedValueIndex !== -1) {
|
||||
props.style.top = `-${offset + 32}px`;
|
||||
}
|
||||
|
||||
return (
|
||||
<Positioner {...props}>
|
||||
<Background
|
||||
dir="auto"
|
||||
ref={contentRef}
|
||||
topAnchor={topAnchor}
|
||||
rightAnchor={rightAnchor}
|
||||
style={
|
||||
maxHeight && topAnchor
|
||||
? { maxHeight, minWidth }
|
||||
: { minWidth }
|
||||
}
|
||||
>
|
||||
{select.visible || select.animating
|
||||
? options.map((option) => (
|
||||
<StyledSelectOption
|
||||
{...select}
|
||||
value={option.value}
|
||||
key={option.value}
|
||||
animating={select.animating}
|
||||
ref={
|
||||
select.selectedValue === option.value
|
||||
? selectedRef
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{select.selectedValue !== undefined && (
|
||||
<>
|
||||
{select.selectedValue === option.value ? (
|
||||
<CheckmarkIcon color="currentColor" />
|
||||
) : (
|
||||
<Spacer />
|
||||
)}
|
||||
|
||||
</>
|
||||
)}
|
||||
{option.label}
|
||||
</StyledSelectOption>
|
||||
))
|
||||
: null}
|
||||
</Background>
|
||||
</Positioner>
|
||||
);
|
||||
}}
|
||||
</SelectPopover>
|
||||
</Wrapper>
|
||||
);
|
||||
{note && <HelpText small>{note}</HelpText>}
|
||||
|
||||
{(select.visible || select.animating) && <Backdrop />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Placeholder = styled.span`
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
`;
|
||||
|
||||
const Spacer = styled.div`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const StyledButton = styled(Button)`
|
||||
font-weight: normal;
|
||||
text-transform: none;
|
||||
margin-bottom: 16px;
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
${(props) =>
|
||||
props.nude &&
|
||||
css`
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
`}
|
||||
|
||||
${Inner} {
|
||||
line-height: 28px;
|
||||
padding-left: 16px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
justify-self: flex-end;
|
||||
margin-left: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
export const StyledSelectOption = styled(SelectOption)`
|
||||
${MenuAnchorCSS}
|
||||
|
||||
${(props) =>
|
||||
props.animating &&
|
||||
css`
|
||||
pointer-events: none;
|
||||
`}
|
||||
`;
|
||||
|
||||
const Wrapper = styled.label`
|
||||
display: block;
|
||||
max-width: ${(props) => (props.short ? "350px" : "100%")};
|
||||
`;
|
||||
|
||||
const Positioner = styled(Position)`
|
||||
&.focus-visible {
|
||||
${StyledSelectOption} {
|
||||
&[aria-selected="true"] {
|
||||
color: ${(props) => props.theme.white};
|
||||
background: ${(props) => props.theme.primary};
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
fill: ${(props) => props.theme.white};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default InputSelect;
|
||||
|
||||
@@ -4,19 +4,33 @@ import { useTranslation } from "react-i18next";
|
||||
import InputSelect, { type Props, type Option } from "./InputSelect";
|
||||
|
||||
export default function InputSelectPermission(
|
||||
props: $Rest<Props, { options: Array<Option> }>
|
||||
props: $Rest<$Exact<Props>, {| options: Array<Option>, ariaLabel: string |}>
|
||||
) {
|
||||
const { value, onChange, ...rest } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(value) => {
|
||||
if (value === "no_access") {
|
||||
value = "";
|
||||
}
|
||||
onChange(value);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<InputSelect
|
||||
label={t("Default access")}
|
||||
options={[
|
||||
{ label: t("View and edit"), value: "read_write" },
|
||||
{ label: t("View only"), value: "read" },
|
||||
{ label: t("No access"), value: "" },
|
||||
{ label: t("No access"), value: "no_access" },
|
||||
]}
|
||||
{...props}
|
||||
ariaLabel={t("Default access")}
|
||||
value={value || "no_access"}
|
||||
onChange={handleChange}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import InputSelect, { type Props, type Option } from "components/InputSelect";
|
||||
|
||||
const InputSelectRole = (
|
||||
props: $Rest<$Exact<Props>, {| options: Array<Option>, ariaLabel: string |}>
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<InputSelect
|
||||
label={t("Role")}
|
||||
options={[
|
||||
{ label: t("Member"), value: "member" },
|
||||
{ label: t("Viewer"), value: "viewer" },
|
||||
{ label: t("Admin"), value: "admin" },
|
||||
]}
|
||||
ariaLabel={t("Role")}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default InputSelectRole;
|
||||
+46
-61
@@ -4,12 +4,10 @@ import { observer, inject } from "mobx-react";
|
||||
import { MenuIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import keydown from "react-keydown";
|
||||
import { withTranslation } from "react-i18next";
|
||||
import {
|
||||
Switch,
|
||||
Route,
|
||||
Redirect,
|
||||
withRouter,
|
||||
type RouterHistory,
|
||||
} from "react-router-dom";
|
||||
@@ -20,24 +18,35 @@ import DocumentsStore from "stores/DocumentsStore";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import ErrorSuspended from "scenes/ErrorSuspended";
|
||||
import KeyboardShortcuts from "scenes/KeyboardShortcuts";
|
||||
import Button from "components/Button";
|
||||
import DocumentHistory from "components/DocumentHistory";
|
||||
import Flex from "components/Flex";
|
||||
import Guide from "components/Guide";
|
||||
import { LoadingIndicatorBar } from "components/LoadingIndicator";
|
||||
import RegisterKeyDown from "components/RegisterKeyDown";
|
||||
import Sidebar from "components/Sidebar";
|
||||
import SettingsSidebar from "components/Sidebar/Settings";
|
||||
import SkipNavContent from "components/SkipNavContent";
|
||||
import SkipNavLink from "components/SkipNavLink";
|
||||
import { meta } from "utils/keyboard";
|
||||
import { isModKey } from "utils/keyboard";
|
||||
import {
|
||||
homeUrl,
|
||||
searchUrl,
|
||||
matchDocumentSlug as slug,
|
||||
newDocumentUrl,
|
||||
newDocumentPath,
|
||||
settingsPath,
|
||||
} from "utils/routeHelpers";
|
||||
|
||||
const DocumentHistory = React.lazy(() =>
|
||||
import(
|
||||
/* webpackChunkName: "document-history" */ "components/DocumentHistory"
|
||||
)
|
||||
);
|
||||
|
||||
const CommandBar = React.lazy(() =>
|
||||
import(
|
||||
/* webpackChunkName: "command-bar" */
|
||||
"components/CommandBar"
|
||||
)
|
||||
);
|
||||
|
||||
type Props = {
|
||||
documents: DocumentsStore,
|
||||
children?: ?React.Node,
|
||||
@@ -48,70 +57,50 @@ type Props = {
|
||||
history: RouterHistory,
|
||||
policies: PoliciesStore,
|
||||
notifications?: React.Node,
|
||||
i18n: Object,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
@observer
|
||||
class Layout extends React.Component<Props> {
|
||||
scrollable: ?HTMLDivElement;
|
||||
@observable redirectTo: ?string;
|
||||
@observable keyboardShortcutsOpen: boolean = false;
|
||||
|
||||
componentDidUpdate() {
|
||||
if (this.redirectTo) {
|
||||
this.redirectTo = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@keydown(`${meta}+.`)
|
||||
handleToggleSidebar() {
|
||||
this.props.ui.toggleCollapsedSidebar();
|
||||
}
|
||||
|
||||
@keydown("shift+/")
|
||||
handleOpenKeyboardShortcuts() {
|
||||
this.keyboardShortcutsOpen = true;
|
||||
}
|
||||
|
||||
handleCloseKeyboardShortcuts = () => {
|
||||
this.keyboardShortcutsOpen = false;
|
||||
};
|
||||
|
||||
@keydown(["t", "/", `${meta}+k`])
|
||||
goToSearch(ev: SyntheticEvent<>) {
|
||||
goToSearch = (ev: KeyboardEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.redirectTo = searchUrl();
|
||||
}
|
||||
this.props.history.push(searchUrl());
|
||||
};
|
||||
|
||||
@keydown("d")
|
||||
goToDashboard() {
|
||||
this.redirectTo = homeUrl();
|
||||
}
|
||||
|
||||
@keydown("n")
|
||||
goToNewDocument() {
|
||||
goToNewDocument = () => {
|
||||
const { activeCollectionId } = this.props.ui;
|
||||
if (!activeCollectionId) return;
|
||||
|
||||
const can = this.props.policies.abilities(activeCollectionId);
|
||||
if (!can.update) return;
|
||||
|
||||
this.props.history.push(newDocumentUrl(activeCollectionId));
|
||||
}
|
||||
this.props.history.push(newDocumentPath(activeCollectionId));
|
||||
};
|
||||
|
||||
render() {
|
||||
const { auth, t, ui } = this.props;
|
||||
const { auth, ui } = this.props;
|
||||
const { user, team } = auth;
|
||||
const showSidebar = auth.authenticated && user && team;
|
||||
const sidebarCollapsed = ui.isEditing || ui.sidebarCollapsed;
|
||||
|
||||
if (auth.isSuspended) return <ErrorSuspended />;
|
||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
||||
|
||||
return (
|
||||
<Container column auto>
|
||||
<RegisterKeyDown trigger="n" handler={this.goToNewDocument} />
|
||||
<RegisterKeyDown trigger="t" handler={this.goToSearch} />
|
||||
<RegisterKeyDown trigger="/" handler={this.goToSearch} />
|
||||
<RegisterKeyDown
|
||||
trigger="."
|
||||
handler={(event) => {
|
||||
if (isModKey(event)) {
|
||||
ui.toggleCollapsedSidebar();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Helmet>
|
||||
<title>{team && team.name ? team.name : "Outline"}</title>
|
||||
<meta
|
||||
@@ -134,7 +123,7 @@ class Layout extends React.Component<Props> {
|
||||
<Container auto>
|
||||
{showSidebar && (
|
||||
<Switch>
|
||||
<Route path="/settings" component={SettingsSidebar} />
|
||||
<Route path={settingsPath()} component={SettingsSidebar} />
|
||||
<Route component={Sidebar} />
|
||||
</Switch>
|
||||
)}
|
||||
@@ -154,20 +143,16 @@ class Layout extends React.Component<Props> {
|
||||
{this.props.children}
|
||||
</Content>
|
||||
|
||||
<Switch>
|
||||
<Route
|
||||
path={`/doc/${slug}/history/:revisionId?`}
|
||||
component={DocumentHistory}
|
||||
/>
|
||||
</Switch>
|
||||
<React.Suspense fallback={null}>
|
||||
<Switch>
|
||||
<Route
|
||||
path={`/doc/${slug}/history/:revisionId?`}
|
||||
component={DocumentHistory}
|
||||
/>
|
||||
</Switch>
|
||||
</React.Suspense>
|
||||
</Container>
|
||||
<Guide
|
||||
isOpen={this.keyboardShortcutsOpen}
|
||||
onRequestClose={this.handleCloseKeyboardShortcuts}
|
||||
title={t("Keyboard shortcuts")}
|
||||
>
|
||||
<KeyboardShortcuts />
|
||||
</Guide>
|
||||
<CommandBar />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
+52
-22
@@ -1,40 +1,64 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import Flex from "components/Flex";
|
||||
import NavLink from "components/NavLink";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
image?: React.Node,
|
||||
to?: string,
|
||||
title: React.Node,
|
||||
subtitle?: React.Node,
|
||||
actions?: React.Node,
|
||||
border?: boolean,
|
||||
small?: boolean,
|
||||
};
|
||||
|};
|
||||
|
||||
const ListItem = ({
|
||||
image,
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
small,
|
||||
border,
|
||||
}: Props) => {
|
||||
const ListItem = (
|
||||
{ image, title, subtitle, actions, small, border, to, ...rest }: Props,
|
||||
ref
|
||||
) => {
|
||||
const theme = useTheme();
|
||||
const compact = !subtitle;
|
||||
|
||||
return (
|
||||
<Wrapper compact={compact} $border={border}>
|
||||
const content = (selected) => (
|
||||
<>
|
||||
{image && <Image>{image}</Image>}
|
||||
<Content align={compact ? "center" : undefined} column={!compact}>
|
||||
<Content
|
||||
justify={compact ? "center" : undefined}
|
||||
column={!compact}
|
||||
$selected={selected}
|
||||
>
|
||||
<Heading $small={small}>{title}</Heading>
|
||||
{subtitle && <Subtitle $small={small}>{subtitle}</Subtitle>}
|
||||
{subtitle && (
|
||||
<Subtitle $small={small} $selected={selected}>
|
||||
{subtitle}
|
||||
</Subtitle>
|
||||
)}
|
||||
</Content>
|
||||
{actions && <Actions>{actions}</Actions>}
|
||||
{actions && (
|
||||
<Actions $selected={selected} gap={4}>
|
||||
{actions}
|
||||
</Actions>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
ref={ref}
|
||||
$border={border}
|
||||
activeStyle={{ background: theme.primary }}
|
||||
{...rest}
|
||||
as={to ? NavLink : undefined}
|
||||
to={to}
|
||||
>
|
||||
{to ? content : content(false)}
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const Wrapper = styled.li`
|
||||
const Wrapper = styled.div`
|
||||
display: flex;
|
||||
padding: ${(props) => (props.$border === false ? 0 : "8px 0")};
|
||||
margin: ${(props) => (props.$border === false ? "8px 0" : 0)};
|
||||
@@ -57,28 +81,34 @@ const Image = styled(Flex)`
|
||||
`;
|
||||
|
||||
const Heading = styled.p`
|
||||
font-size: ${(props) => (props.$small ? 15 : 16)}px;
|
||||
font-size: ${(props) => (props.$small ? 14 : 16)}px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
line-height: 1.2;
|
||||
line-height: ${(props) => (props.$small ? 1.3 : 1.2)};
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const Content = styled(Flex)`
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
color: ${(props) => (props.$selected ? props.theme.white : props.theme.text)};
|
||||
`;
|
||||
|
||||
const Subtitle = styled.p`
|
||||
margin: 0;
|
||||
font-size: ${(props) => (props.$small ? 13 : 14)}px;
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
color: ${(props) =>
|
||||
props.$selected ? props.theme.white50 : props.theme.textTertiary};
|
||||
margin-top: -2px;
|
||||
`;
|
||||
|
||||
const Actions = styled.div`
|
||||
export const Actions = styled(Flex)`
|
||||
align-self: center;
|
||||
justify-content: center;
|
||||
color: ${(props) =>
|
||||
props.$selected ? props.theme.white : props.theme.textSecondary};
|
||||
`;
|
||||
|
||||
export default ListItem;
|
||||
export default React.forwardRef<Props, HTMLDivElement>(ListItem);
|
||||
|
||||
@@ -1,39 +1,9 @@
|
||||
// @flow
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
enUS,
|
||||
de,
|
||||
faIR,
|
||||
fr,
|
||||
es,
|
||||
it,
|
||||
ja,
|
||||
ko,
|
||||
ptBR,
|
||||
pt,
|
||||
zhCN,
|
||||
zhTW,
|
||||
ru,
|
||||
} from "date-fns/locale";
|
||||
import { format as formatDate, formatDistanceToNow } from "date-fns";
|
||||
import * as React from "react";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import useUserLocale from "hooks/useUserLocale";
|
||||
|
||||
const locales = {
|
||||
en_US: enUS,
|
||||
de_DE: de,
|
||||
es_ES: es,
|
||||
fa_IR: faIR,
|
||||
fr_FR: fr,
|
||||
it_IT: it,
|
||||
ja_JP: ja,
|
||||
ko_KR: ko,
|
||||
pt_BR: ptBR,
|
||||
pt_PT: pt,
|
||||
zh_CN: zhCN,
|
||||
zh_TW: zhTW,
|
||||
ru_RU: ru,
|
||||
};
|
||||
import { dateLocale } from "utils/i18n";
|
||||
|
||||
let callbacks = [];
|
||||
|
||||
@@ -57,6 +27,8 @@ type Props = {
|
||||
tooltipDelay?: number,
|
||||
addSuffix?: boolean,
|
||||
shorten?: boolean,
|
||||
relative?: boolean,
|
||||
format?: string,
|
||||
};
|
||||
|
||||
function LocaleTime({
|
||||
@@ -64,6 +36,8 @@ function LocaleTime({
|
||||
children,
|
||||
dateTime,
|
||||
shorten,
|
||||
format,
|
||||
relative,
|
||||
tooltipDelay,
|
||||
}: Props) {
|
||||
const userLocale = useUserLocale();
|
||||
@@ -82,25 +56,31 @@ function LocaleTime({
|
||||
};
|
||||
}, []);
|
||||
|
||||
let content = formatDistanceToNow(Date.parse(dateTime), {
|
||||
const locale = dateLocale(userLocale);
|
||||
let relativeContent = formatDistanceToNow(Date.parse(dateTime), {
|
||||
addSuffix,
|
||||
locale: userLocale ? locales[userLocale] : undefined,
|
||||
locale,
|
||||
});
|
||||
|
||||
if (shorten) {
|
||||
content = content
|
||||
relativeContent = relativeContent
|
||||
.replace("about", "")
|
||||
.replace("less than a minute ago", "just now")
|
||||
.replace("minute", "min");
|
||||
}
|
||||
|
||||
const tooltipContent = formatDate(
|
||||
Date.parse(dateTime),
|
||||
format || "MMMM do, yyyy h:mm a",
|
||||
{ locale }
|
||||
);
|
||||
|
||||
const content =
|
||||
children || relative !== false ? relativeContent : tooltipContent;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
tooltip={format(Date.parse(dateTime), "MMMM do, yyyy h:mm a")}
|
||||
delay={tooltipDelay}
|
||||
placement="bottom"
|
||||
>
|
||||
<time dateTime={dateTime}>{children || content}</time>
|
||||
<Tooltip tooltip={tooltipContent} delay={tooltipDelay} placement="bottom">
|
||||
<time dateTime={dateTime}>{content}</time>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// @flow
|
||||
import styled from "styled-components";
|
||||
|
||||
const MenuIconWrapper = styled.span`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 6px;
|
||||
margin-left: -4px;
|
||||
`;
|
||||
|
||||
export default MenuIconWrapper;
|
||||
@@ -52,7 +52,9 @@ const Modal = ({
|
||||
}
|
||||
});
|
||||
|
||||
if (!isOpen) return null;
|
||||
if (!isOpen && !wasOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogBackdrop {...dialog}>
|
||||
@@ -61,7 +63,8 @@ const Modal = ({
|
||||
<Dialog
|
||||
{...dialog}
|
||||
aria-label={title}
|
||||
preventBodyScrollhideOnEsc
|
||||
preventBodyScroll
|
||||
hideOnEsc
|
||||
hide={onRequestClose}
|
||||
>
|
||||
{(props) => (
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { NavLink, Route, type Match } from "react-router-dom";
|
||||
|
||||
type Props = {
|
||||
children?: (match: Match) => React.Node,
|
||||
exact?: boolean,
|
||||
to: string,
|
||||
};
|
||||
|
||||
export default function NavLinkWithChildrenFunc({
|
||||
to,
|
||||
exact = false,
|
||||
children,
|
||||
...rest
|
||||
}: Props) {
|
||||
return (
|
||||
<Route path={to} exact={exact}>
|
||||
{({ match }) => (
|
||||
<NavLink {...rest} to={to} exact={exact}>
|
||||
{children ? children(match) : null}
|
||||
</NavLink>
|
||||
)}
|
||||
</Route>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ const Button = styled.button`
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
color: inherit;
|
||||
`;
|
||||
|
||||
export default React.forwardRef<any, typeof Button>(
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { cdnPath } from "../../shared/utils/urls";
|
||||
import useStores from "hooks/useStores";
|
||||
import { cdnPath } from "utils/urls";
|
||||
|
||||
type Props = {|
|
||||
title: string,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Document from "models/Document";
|
||||
import Event from "models/Event";
|
||||
import PaginatedList from "components/PaginatedList";
|
||||
import EventListItem from "./EventListItem";
|
||||
|
||||
type Props = {|
|
||||
events: Event[],
|
||||
document: Document,
|
||||
fetch: (options: ?Object) => Promise<void>,
|
||||
options?: Object,
|
||||
heading?: React.Node,
|
||||
empty?: React.Node,
|
||||
|};
|
||||
|
||||
const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
|
||||
empty,
|
||||
heading,
|
||||
events,
|
||||
fetch,
|
||||
options,
|
||||
document,
|
||||
...rest
|
||||
}: Props) {
|
||||
return (
|
||||
<PaginatedList
|
||||
items={events}
|
||||
empty={empty}
|
||||
heading={heading}
|
||||
fetch={fetch}
|
||||
options={options}
|
||||
renderItem={(item, index) => (
|
||||
<EventListItem
|
||||
key={item.id}
|
||||
event={item}
|
||||
document={document}
|
||||
latest={index === 0}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
renderHeading={(name) => <Heading>{name}</Heading>}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const Heading = styled("h3")`
|
||||
font-size: 14px;
|
||||
padding: 0 12px;
|
||||
`;
|
||||
|
||||
export default PaginatedEventList;
|
||||
@@ -2,20 +2,26 @@
|
||||
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
|
||||
import { isEqual } from "lodash";
|
||||
import { observable, action } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import { Waypoint } from "react-waypoint";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
|
||||
import DelayedMount from "components/DelayedMount";
|
||||
import PlaceholderList from "components/List/Placeholder";
|
||||
import { dateToHeading } from "utils/dates";
|
||||
|
||||
type Props = {
|
||||
fetch?: (options: ?Object) => Promise<void>,
|
||||
fetch?: (options: ?Object) => Promise<any>,
|
||||
options?: Object,
|
||||
heading?: React.Node,
|
||||
empty?: React.Node,
|
||||
items: any[],
|
||||
renderItem: (any) => React.Node,
|
||||
auth: AuthStore,
|
||||
renderItem: (any, index: number) => React.Node,
|
||||
renderHeading?: (name: React.Element<any> | string) => React.Node,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
@observer
|
||||
@@ -101,8 +107,9 @@ class PaginatedList extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { items, heading, empty } = this.props;
|
||||
const { items, heading, auth, empty, renderHeading } = this.props;
|
||||
|
||||
let previousHeading = "";
|
||||
const showLoading =
|
||||
this.isFetching && !this.isFetchingMore && !this.isInitiallyLoaded;
|
||||
const showEmpty = !items.length && !showLoading;
|
||||
@@ -119,7 +126,41 @@ class PaginatedList extends React.Component<Props> {
|
||||
mode={ArrowKeyNavigation.mode.VERTICAL}
|
||||
defaultActiveChildIndex={0}
|
||||
>
|
||||
{items.slice(0, this.renderCount).map(this.props.renderItem)}
|
||||
{items.slice(0, this.renderCount).map((item, index) => {
|
||||
const children = this.props.renderItem(item, index);
|
||||
|
||||
// If there is no renderHeading method passed then no date
|
||||
// headings are rendered
|
||||
if (!renderHeading) {
|
||||
return children;
|
||||
}
|
||||
|
||||
// Our models have standard date fields, updatedAt > createdAt.
|
||||
// Get what a heading would look like for this item
|
||||
const currentDate =
|
||||
item.updatedAt || item.createdAt || previousHeading;
|
||||
const currentHeading = dateToHeading(
|
||||
currentDate,
|
||||
this.props.t,
|
||||
auth.user?.language
|
||||
);
|
||||
|
||||
// If the heading is different to any previous heading then we
|
||||
// should render it, otherwise the item can go under the previous
|
||||
// heading
|
||||
if (!previousHeading || currentHeading !== previousHeading) {
|
||||
previousHeading = currentHeading;
|
||||
|
||||
return (
|
||||
<React.Fragment key={item.id}>
|
||||
{renderHeading(currentHeading)}
|
||||
{children}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
})}
|
||||
</ArrowKeyNavigation>
|
||||
{this.allowLoadMore && (
|
||||
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
|
||||
@@ -136,4 +177,6 @@ class PaginatedList extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
export default PaginatedList;
|
||||
export const Component = PaginatedList;
|
||||
|
||||
export default withTranslation()<PaginatedList>(inject("auth")(PaginatedList));
|
||||
|
||||
@@ -2,15 +2,21 @@
|
||||
import "../stores";
|
||||
import { shallow } from "enzyme";
|
||||
import * as React from "react";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
|
||||
import RootStore from "stores/RootStore";
|
||||
import { runAllPromises } from "../test/support";
|
||||
import PaginatedList from "./PaginatedList";
|
||||
import { Component as PaginatedList } from "./PaginatedList";
|
||||
|
||||
describe("PaginatedList", () => {
|
||||
const render = () => null;
|
||||
const rootStore = new RootStore();
|
||||
const props = { auth: new AuthStore(rootStore), t: (string) => "test" };
|
||||
|
||||
it("with no items renders nothing", () => {
|
||||
const list = shallow(<PaginatedList items={[]} renderItem={render} />);
|
||||
const list = shallow(
|
||||
<PaginatedList items={[]} renderItem={render} {...props} />
|
||||
);
|
||||
expect(list).toEqual({});
|
||||
});
|
||||
|
||||
@@ -20,6 +26,7 @@ describe("PaginatedList", () => {
|
||||
items={[]}
|
||||
empty={<p>Sorry, no results</p>}
|
||||
renderItem={render}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
expect(list.text()).toEqual("Sorry, no results");
|
||||
@@ -35,6 +42,7 @@ describe("PaginatedList", () => {
|
||||
fetch={fetch}
|
||||
options={options}
|
||||
renderItem={render}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
expect(fetch).toHaveBeenCalledWith({
|
||||
@@ -46,7 +54,7 @@ describe("PaginatedList", () => {
|
||||
|
||||
it("calls fetch when options prop changes", async () => {
|
||||
const fetchedItems = Array(DEFAULT_PAGINATION_LIMIT).fill();
|
||||
const fetch = jest.fn().mockReturnValue(fetchedItems);
|
||||
const fetch = jest.fn().mockReturnValue(Promise.resolve(fetchedItems));
|
||||
|
||||
const list = shallow(
|
||||
<PaginatedList
|
||||
@@ -54,6 +62,7 @@ describe("PaginatedList", () => {
|
||||
fetch={fetch}
|
||||
options={{ id: "one" }}
|
||||
renderItem={render}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -6,18 +6,45 @@ import Fade from "components/Fade";
|
||||
import Flex from "components/Flex";
|
||||
import PlaceholderText from "components/PlaceholderText";
|
||||
|
||||
export default function PlaceholderDocument(props: Object) {
|
||||
export default function PlaceholderDocument({
|
||||
includeTitle,
|
||||
delay,
|
||||
}: {
|
||||
includeTitle?: boolean,
|
||||
delay?: number,
|
||||
}) {
|
||||
const content = (
|
||||
<>
|
||||
<PlaceholderText delay={0.2} />
|
||||
<PlaceholderText delay={0.4} />
|
||||
<PlaceholderText delay={0.6} />
|
||||
</>
|
||||
);
|
||||
|
||||
if (includeTitle === false) {
|
||||
return (
|
||||
<DelayedMount delay={delay}>
|
||||
<Fade>
|
||||
<Flex column auto>
|
||||
{content}
|
||||
</Flex>
|
||||
</Fade>
|
||||
</DelayedMount>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DelayedMount>
|
||||
<DelayedMount delay={delay}>
|
||||
<Wrapper>
|
||||
<Flex column auto {...props}>
|
||||
<PlaceholderText height={34} maxWidth={70} />
|
||||
<PlaceholderText delay={0.2} maxWidth={40} />
|
||||
<br />
|
||||
<PlaceholderText delay={0.2} />
|
||||
<PlaceholderText delay={0.4} />
|
||||
<PlaceholderText delay={0.6} />
|
||||
</Flex>
|
||||
<Fade>
|
||||
<Flex column auto>
|
||||
<PlaceholderText height={34} maxWidth={70} />
|
||||
<PlaceholderText delay={0.2} maxWidth={40} />
|
||||
<br />
|
||||
|
||||
{content}
|
||||
</Flex>
|
||||
</Fade>
|
||||
</Wrapper>
|
||||
</DelayedMount>
|
||||
);
|
||||
|
||||
@@ -27,8 +27,6 @@ const Contents = styled.div`
|
||||
overflow-y: scroll;
|
||||
width: ${(props) => props.width}px;
|
||||
box-shadow: ${(props) => props.theme.menuShadow};
|
||||
border: ${(props) =>
|
||||
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
|
||||
`;
|
||||
|
||||
export default Popover;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// @flow
|
||||
import useKeyDown, { type KeyFilter } from "hooks/useKeyDown";
|
||||
|
||||
type Props = {
|
||||
trigger: KeyFilter,
|
||||
handler: (event: KeyboardEvent) => void,
|
||||
};
|
||||
|
||||
/**
|
||||
* This method is a wrapper around the useKeyDown hook to allow easier use in
|
||||
* class components that have not yet been converted to functions. Do not use
|
||||
* this method in functional components.
|
||||
*/
|
||||
export default function RegisterKeyDown({ trigger, handler }: Props) {
|
||||
useKeyDown(trigger, handler);
|
||||
return null;
|
||||
}
|
||||
@@ -1,31 +1,29 @@
|
||||
// @flow
|
||||
// based on: https://reacttraining.com/react-router/web/guides/scroll-restoration
|
||||
import * as React from "react";
|
||||
import { withRouter } from "react-router-dom";
|
||||
import type { Location } from "react-router-dom";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import usePrevious from "hooks/usePrevious";
|
||||
|
||||
type Props = {
|
||||
location: Location,
|
||||
type Props = {|
|
||||
children: React.Node,
|
||||
};
|
||||
|};
|
||||
|
||||
class ScrollToTop extends React.Component<Props> {
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.location.pathname === prevProps.location.pathname) return;
|
||||
export default function ScrollToTop({ children }: Props) {
|
||||
const location = useLocation();
|
||||
const previousLocationPathname = usePrevious(location.pathname);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (location.pathname === previousLocationPathname) return;
|
||||
|
||||
// exception for when entering or exiting document edit, scroll position should not reset
|
||||
if (
|
||||
this.props.location.pathname.match(/\/edit\/?$/) ||
|
||||
prevProps.location.pathname.match(/\/edit\/?$/)
|
||||
location.pathname.match(/\/edit\/?$/) ||
|
||||
previousLocationPathname?.match(/\/edit\/?$/)
|
||||
)
|
||||
return;
|
||||
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
}, [location.pathname, previousLocationPathname]);
|
||||
|
||||
render() {
|
||||
return this.props.children;
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
export default withRouter(ScrollToTop);
|
||||
|
||||
@@ -11,14 +11,17 @@ type Props = {|
|
||||
flex?: boolean,
|
||||
|};
|
||||
|
||||
function Scrollable({ shadow, topShadow, bottomShadow, flex, ...rest }: Props) {
|
||||
const ref = React.useRef<?HTMLDivElement>();
|
||||
function Scrollable(
|
||||
{ shadow, topShadow, bottomShadow, flex, ...rest }: Props,
|
||||
ref: any
|
||||
) {
|
||||
const fallbackRef = React.useRef<?HTMLDivElement>();
|
||||
const [topShadowVisible, setTopShadow] = React.useState(false);
|
||||
const [bottomShadowVisible, setBottomShadow] = React.useState(false);
|
||||
const { height } = useWindowSize();
|
||||
|
||||
const updateShadows = React.useCallback(() => {
|
||||
const c = ref.current;
|
||||
const c = (ref || fallbackRef).current;
|
||||
if (!c) return;
|
||||
|
||||
const scrollTop = c.scrollTop;
|
||||
@@ -33,7 +36,14 @@ function Scrollable({ shadow, topShadow, bottomShadow, flex, ...rest }: Props) {
|
||||
if (bsv !== bottomShadowVisible) {
|
||||
setBottomShadow(bsv);
|
||||
}
|
||||
}, [shadow, topShadow, bottomShadow, topShadowVisible, bottomShadowVisible]);
|
||||
}, [
|
||||
shadow,
|
||||
topShadow,
|
||||
bottomShadow,
|
||||
ref,
|
||||
topShadowVisible,
|
||||
bottomShadowVisible,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
updateShadows();
|
||||
@@ -41,7 +51,7 @@ function Scrollable({ shadow, topShadow, bottomShadow, flex, ...rest }: Props) {
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
ref={ref}
|
||||
ref={ref || fallbackRef}
|
||||
onScroll={updateShadows}
|
||||
$flex={flex}
|
||||
$topShadowVisible={topShadowVisible}
|
||||
@@ -75,4 +85,4 @@ const Wrapper = styled.div`
|
||||
transition: all 100ms ease-in-out;
|
||||
`;
|
||||
|
||||
export default observer(Scrollable);
|
||||
export default observer(React.forwardRef(Scrollable));
|
||||
|
||||
@@ -2,73 +2,52 @@
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
EditIcon,
|
||||
HomeIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
SettingsIcon,
|
||||
ShapesIcon,
|
||||
StarredIcon,
|
||||
HomeIcon,
|
||||
SettingsIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { DndProvider } from "react-dnd";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import CollectionNew from "scenes/CollectionNew";
|
||||
import Invite from "scenes/Invite";
|
||||
import Bubble from "components/Bubble";
|
||||
import Flex from "components/Flex";
|
||||
import Modal from "components/Modal";
|
||||
import Scrollable from "components/Scrollable";
|
||||
import Sidebar from "./Sidebar";
|
||||
import ArchiveLink from "./components/ArchiveLink";
|
||||
import Collections from "./components/Collections";
|
||||
import Section from "./components/Section";
|
||||
import SidebarAction from "./components/SidebarAction";
|
||||
import SidebarLink from "./components/SidebarLink";
|
||||
import Starred from "./components/Starred";
|
||||
import TeamButton from "./components/TeamButton";
|
||||
import TrashLink from "./components/TrashLink";
|
||||
import { inviteUser } from "actions/definitions/users";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import useCurrentUser from "hooks/useCurrentUser";
|
||||
import useStores from "hooks/useStores";
|
||||
import AccountMenu from "menus/AccountMenu";
|
||||
import {
|
||||
homePath,
|
||||
searchUrl,
|
||||
draftsPath,
|
||||
templatesPath,
|
||||
settingsPath,
|
||||
} from "utils/routeHelpers";
|
||||
|
||||
function MainSidebar() {
|
||||
const { t } = useTranslation();
|
||||
const { policies, documents } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const user = useCurrentUser();
|
||||
const [inviteModalOpen, setInviteModalOpen] = React.useState(false);
|
||||
const [
|
||||
createCollectionModalOpen,
|
||||
setCreateCollectionModalOpen,
|
||||
] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
documents.fetchDrafts();
|
||||
documents.fetchTemplates();
|
||||
}, [documents]);
|
||||
|
||||
const handleCreateCollectionModalOpen = React.useCallback(
|
||||
(ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
setCreateCollectionModalOpen(true);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleCreateCollectionModalClose = React.useCallback(() => {
|
||||
setCreateCollectionModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleInviteModalOpen = React.useCallback((ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
setInviteModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleInviteModalClose = React.useCallback(() => {
|
||||
setInviteModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const [dndArea, setDndArea] = React.useState();
|
||||
const handleSidebarRef = React.useCallback((node) => setDndArea(node), []);
|
||||
const html5Options = React.useMemo(() => ({ rootElement: dndArea }), [
|
||||
@@ -95,29 +74,23 @@ function MainSidebar() {
|
||||
<Scrollable flex topShadow>
|
||||
<Section>
|
||||
<SidebarLink
|
||||
to="/home"
|
||||
to={homePath()}
|
||||
icon={<HomeIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Home")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to={{
|
||||
pathname: "/search",
|
||||
pathname: searchUrl(),
|
||||
state: { fromMenu: true },
|
||||
}}
|
||||
icon={<SearchIcon color="currentColor" />}
|
||||
label={t("Search")}
|
||||
exact={false}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/starred"
|
||||
icon={<StarredIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Starred")}
|
||||
/>
|
||||
{can.createDocument && (
|
||||
<SidebarLink
|
||||
to="/drafts"
|
||||
to={draftsPath()}
|
||||
icon={<EditIcon color="currentColor" />}
|
||||
label={
|
||||
<Drafts align="center">
|
||||
@@ -135,21 +108,24 @@ function MainSidebar() {
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
<Starred />
|
||||
<Section auto>
|
||||
<Collections
|
||||
onCreateCollection={handleCreateCollectionModalOpen}
|
||||
/>
|
||||
<Collections />
|
||||
</Section>
|
||||
<Section>
|
||||
{can.createDocument && (
|
||||
<>
|
||||
<SidebarLink
|
||||
to="/templates"
|
||||
to={templatesPath()}
|
||||
icon={<ShapesIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Templates")}
|
||||
active={
|
||||
documents.active ? documents.active.template : undefined
|
||||
documents.active
|
||||
? documents.active.isTemplate &&
|
||||
!documents.active.isDeleted &&
|
||||
!documents.active.isArchived
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<ArchiveLink documents={documents} />
|
||||
@@ -157,37 +133,14 @@ function MainSidebar() {
|
||||
</>
|
||||
)}
|
||||
<SidebarLink
|
||||
to="/settings"
|
||||
to={settingsPath()}
|
||||
icon={<SettingsIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Settings")}
|
||||
/>
|
||||
{can.inviteUser && (
|
||||
<SidebarLink
|
||||
to="/settings/members"
|
||||
onClick={handleInviteModalOpen}
|
||||
icon={<PlusIcon color="currentColor" />}
|
||||
label={`${t("Invite people")}…`}
|
||||
/>
|
||||
)}
|
||||
<SidebarAction action={inviteUser} />
|
||||
</Section>
|
||||
</Scrollable>
|
||||
{can.inviteUser && (
|
||||
<Modal
|
||||
title={t("Invite people")}
|
||||
onRequestClose={handleInviteModalClose}
|
||||
isOpen={inviteModalOpen}
|
||||
>
|
||||
<Invite onSubmit={handleInviteModalClose} />
|
||||
</Modal>
|
||||
)}
|
||||
<Modal
|
||||
title={t("Create a collection")}
|
||||
onRequestClose={handleCreateCollectionModalClose}
|
||||
isOpen={createCollectionModalOpen}
|
||||
>
|
||||
<CollectionNew onSubmit={handleCreateCollectionModalClose} />
|
||||
</Modal>
|
||||
</DndProvider>
|
||||
)}
|
||||
</Sidebar>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
LinkIcon,
|
||||
TeamIcon,
|
||||
ExpandedIcon,
|
||||
BeakerIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -95,6 +96,13 @@ function SettingsSidebar() {
|
||||
label={t("Security")}
|
||||
/>
|
||||
)}
|
||||
{can.update && env.DEPLOYMENT !== "hosted" && (
|
||||
<SidebarLink
|
||||
to="/settings/features"
|
||||
icon={<BeakerIcon color="currentColor" />}
|
||||
label={t("Features")}
|
||||
/>
|
||||
)}
|
||||
<SidebarLink
|
||||
to="/settings/members"
|
||||
icon={<UserIcon color="currentColor" />}
|
||||
@@ -120,14 +128,16 @@ function SettingsSidebar() {
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
{can.update && (
|
||||
{can.update && (env.SLACK_KEY || isHosted) && (
|
||||
<Section>
|
||||
<Header>{t("Integrations")}</Header>
|
||||
<SidebarLink
|
||||
to="/settings/integrations/slack"
|
||||
icon={<SlackIcon color="currentColor" />}
|
||||
label="Slack"
|
||||
/>
|
||||
{env.SLACK_KEY && (
|
||||
<SidebarLink
|
||||
to="/settings/integrations/slack"
|
||||
icon={<SlackIcon color="currentColor" />}
|
||||
label="Slack"
|
||||
/>
|
||||
)}
|
||||
{isHosted && (
|
||||
<SidebarLink
|
||||
to="/settings/integrations/zapier"
|
||||
|
||||
@@ -4,9 +4,10 @@ import { ArchiveIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useStores from "../../../hooks/useStores";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import useStores from "hooks/useStores";
|
||||
import useToasts from "hooks/useToasts";
|
||||
import { archivePath } from "utils/routeHelpers";
|
||||
|
||||
function ArchiveLink({ documents }) {
|
||||
const { policies } = useStores();
|
||||
@@ -29,7 +30,7 @@ function ArchiveLink({ documents }) {
|
||||
return (
|
||||
<div ref={dropToArchiveDocument}>
|
||||
<SidebarLink
|
||||
to="/archive"
|
||||
to={archivePath()}
|
||||
icon={<ArchiveIcon color="currentColor" open={isDocumentDropping} />}
|
||||
exact={false}
|
||||
label={t("Archive")}
|
||||
|
||||
@@ -3,10 +3,14 @@ import fractionalIndex from "fractional-index";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useDrop, useDrag } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useHistory } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import Collection from "models/Collection";
|
||||
import Document from "models/Document";
|
||||
import DocumentReparent from "scenes/DocumentReparent";
|
||||
import CollectionIcon from "components/CollectionIcon";
|
||||
import Modal from "components/Modal";
|
||||
import DocumentLink from "./DocumentLink";
|
||||
import DropCursor from "./DropCursor";
|
||||
import DropToImport from "./DropToImport";
|
||||
@@ -36,13 +40,23 @@ function CollectionLink({
|
||||
isDraggingAnyCollection,
|
||||
onChangeDragging,
|
||||
}: Props) {
|
||||
const history = useHistory();
|
||||
const { t } = useTranslation();
|
||||
const { search } = useLocation();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const [
|
||||
permissionOpen,
|
||||
handlePermissionOpen,
|
||||
handlePermissionClose,
|
||||
] = useBoolean();
|
||||
const itemRef = React.useRef();
|
||||
|
||||
const handleTitleChange = React.useCallback(
|
||||
async (name: string) => {
|
||||
await collection.save({ name });
|
||||
history.push(collection.url);
|
||||
},
|
||||
[collection]
|
||||
[collection, history]
|
||||
);
|
||||
|
||||
const { ui, documents, policies, collections } = useStores();
|
||||
@@ -52,12 +66,17 @@ function CollectionLink({
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
// If we're viewing a starred document through the starred menu then don't
|
||||
// touch the expanded / collapsed state of the collections
|
||||
if (search === "?starred") {
|
||||
return;
|
||||
}
|
||||
if (isDraggingAnyCollection) {
|
||||
setExpanded(false);
|
||||
} else {
|
||||
setExpanded(collection.id === ui.activeCollectionId);
|
||||
}
|
||||
}, [isDraggingAnyCollection, collection.id, ui.activeCollectionId]);
|
||||
}, [isDraggingAnyCollection, collection.id, ui.activeCollectionId, search]);
|
||||
|
||||
const manualSort = collection.sort.field === "index";
|
||||
const can = policies.abilities(collection.id);
|
||||
@@ -67,9 +86,22 @@ function CollectionLink({
|
||||
const [{ isOver, canDrop }, drop] = useDrop({
|
||||
accept: "document",
|
||||
drop: (item, monitor) => {
|
||||
const { id, collectionId } = item;
|
||||
if (monitor.didDrop()) return;
|
||||
if (!collection) return;
|
||||
documents.move(item.id, collection.id);
|
||||
if (collection.id === collectionId) return;
|
||||
const prevCollection = collections.get(collectionId);
|
||||
|
||||
if (
|
||||
prevCollection &&
|
||||
prevCollection.permission === null &&
|
||||
prevCollection.permission !== collection.permission
|
||||
) {
|
||||
itemRef.current = item;
|
||||
handlePermissionOpen();
|
||||
} else {
|
||||
documents.move(id, collection.id);
|
||||
}
|
||||
},
|
||||
canDrop: (item, monitor) => {
|
||||
return policies.abilities(collection.id).update;
|
||||
@@ -147,8 +179,6 @@ function CollectionLink({
|
||||
icon={
|
||||
<CollectionIcon collection={collection} expanded={expanded} />
|
||||
}
|
||||
iconColor={collection.color}
|
||||
expanded={expanded}
|
||||
showActions={menuOpen || expanded}
|
||||
isActiveDrop={isOver && canDrop}
|
||||
label={
|
||||
@@ -159,6 +189,7 @@ function CollectionLink({
|
||||
/>
|
||||
}
|
||||
exact={false}
|
||||
depth={0.5}
|
||||
menu={
|
||||
<>
|
||||
{can.update && (
|
||||
@@ -198,10 +229,22 @@ function CollectionLink({
|
||||
activeDocument={activeDocument}
|
||||
prefetchDocument={prefetchDocument}
|
||||
canUpdate={canUpdate}
|
||||
depth={1.5}
|
||||
depth={2}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
<Modal
|
||||
title={t("Move document")}
|
||||
onRequestClose={handlePermissionClose}
|
||||
isOpen={permissionOpen}
|
||||
>
|
||||
<DocumentReparent
|
||||
item={itemRef.current}
|
||||
collection={collection}
|
||||
onSubmit={handlePermissionClose}
|
||||
onCancel={handlePermissionClose}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,35 +1,31 @@
|
||||
// @flow
|
||||
import fractionalIndex from "fractional-index";
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import { CollapsedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Fade from "components/Fade";
|
||||
import Flex from "components/Flex";
|
||||
import useStores from "../../../hooks/useStores";
|
||||
import CollectionLink from "./CollectionLink";
|
||||
import DropCursor from "./DropCursor";
|
||||
import Header from "./Header";
|
||||
import PlaceholderCollections from "./PlaceholderCollections";
|
||||
import SidebarAction from "./SidebarAction";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import { createCollection } from "actions/definitions/collections";
|
||||
import useToasts from "hooks/useToasts";
|
||||
|
||||
type Props = {
|
||||
onCreateCollection: () => void,
|
||||
};
|
||||
|
||||
function Collections({ onCreateCollection }: Props) {
|
||||
function Collections() {
|
||||
const [isFetching, setFetching] = React.useState(false);
|
||||
const [fetchError, setFetchError] = React.useState();
|
||||
const { ui, policies, documents, collections } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const [expanded, setExpanded] = React.useState(true);
|
||||
const isPreloaded: boolean = !!collections.orderedData.length;
|
||||
const { t } = useTranslation();
|
||||
const team = useCurrentTeam();
|
||||
const orderedCollections = collections.orderedData;
|
||||
const can = policies.abilities(team.id);
|
||||
const [isDraggingAnyCollection, setIsDraggingAnyCollection] = React.useState(
|
||||
false
|
||||
);
|
||||
@@ -92,22 +88,17 @@ function Collections({ onCreateCollection }: Props) {
|
||||
belowCollection={orderedCollections[index + 1]}
|
||||
/>
|
||||
))}
|
||||
{can.createCollection && (
|
||||
<SidebarLink
|
||||
to="/collections"
|
||||
onClick={onCreateCollection}
|
||||
icon={<PlusIcon color="currentColor" />}
|
||||
label={`${t("New collection")}…`}
|
||||
exact
|
||||
/>
|
||||
)}
|
||||
<SidebarAction action={createCollection} depth={0.5} />
|
||||
</>
|
||||
);
|
||||
|
||||
if (!collections.isLoaded || fetchError) {
|
||||
return (
|
||||
<Flex column>
|
||||
<Header>{t("Collections")}</Header>
|
||||
<SidebarLink
|
||||
label={t("Collections")}
|
||||
icon={<Disclosure expanded={expanded} color="currentColor" />}
|
||||
/>
|
||||
<PlaceholderCollections />
|
||||
</Flex>
|
||||
);
|
||||
@@ -115,10 +106,19 @@ function Collections({ onCreateCollection }: Props) {
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<Header>{t("Collections")}</Header>
|
||||
{isPreloaded ? content : <Fade>{content}</Fade>}
|
||||
<SidebarLink
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
label={t("Collections")}
|
||||
icon={<Disclosure expanded={expanded} color="currentColor" />}
|
||||
/>
|
||||
{expanded && (isPreloaded ? content : <Fade>{content}</Fade>)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
const Disclosure = styled(CollapsedIcon)`
|
||||
transition: transform 100ms ease, fill 50ms !important;
|
||||
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
|
||||
`;
|
||||
|
||||
export default observer(Collections);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// @flow
|
||||
import { CollapsedIcon } from "outline-icons";
|
||||
import styled from "styled-components";
|
||||
|
||||
const Disclosure = styled(CollapsedIcon)`
|
||||
transition: transform 100ms ease, fill 50ms !important;
|
||||
position: absolute;
|
||||
left: -24px;
|
||||
|
||||
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
|
||||
`;
|
||||
|
||||
export default Disclosure;
|
||||
@@ -1,13 +1,14 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { CollapsedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useDrag, useDrop } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { MAX_TITLE_LENGTH } from "shared/constants";
|
||||
import Collection from "models/Collection";
|
||||
import Document from "models/Document";
|
||||
import Fade from "components/Fade";
|
||||
import Disclosure from "./Disclosure";
|
||||
import DropCursor from "./DropCursor";
|
||||
import DropToImport from "./DropToImport";
|
||||
import EditableTitle from "./EditableTitle";
|
||||
@@ -128,7 +129,12 @@ function DocumentLink(
|
||||
// Draggable
|
||||
const [{ isDragging }, drag] = useDrag({
|
||||
type: "document",
|
||||
item: () => ({ ...node, depth, active: isActiveDocument }),
|
||||
item: () => ({
|
||||
...node,
|
||||
depth,
|
||||
active: isActiveDocument,
|
||||
collectionId: collection?.id || "",
|
||||
}),
|
||||
collect: (monitor) => ({
|
||||
isDragging: !!monitor.isDragging(),
|
||||
}),
|
||||
@@ -210,7 +216,7 @@ function DocumentLink(
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ position: "relative" }} onDragLeave={resetHoverExpanding}>
|
||||
<Relative onDragLeave={resetHoverExpanding}>
|
||||
<Draggable
|
||||
key={node.id}
|
||||
ref={drag}
|
||||
@@ -237,13 +243,18 @@ function DocumentLink(
|
||||
title={node.title || t("Untitled")}
|
||||
onSubmit={handleTitleChange}
|
||||
canUpdate={canUpdate}
|
||||
maxLength={MAX_TITLE_LENGTH}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
isActive={(match, location) =>
|
||||
match && location.search !== "?starred"
|
||||
}
|
||||
isActiveDrop={isOverReparent && canDropToReparent}
|
||||
depth={depth}
|
||||
exact={false}
|
||||
showActions={menuOpen}
|
||||
scrollIntoViewIfNeeded={!document?.isStarred}
|
||||
ref={ref}
|
||||
menu={
|
||||
document && !isMoving ? (
|
||||
@@ -263,7 +274,7 @@ function DocumentLink(
|
||||
{manualSort && (
|
||||
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
|
||||
)}
|
||||
</div>
|
||||
</Relative>
|
||||
{expanded && !isDragging && (
|
||||
<>
|
||||
{node.children.map((childNode, index) => (
|
||||
@@ -285,17 +296,13 @@ function DocumentLink(
|
||||
);
|
||||
}
|
||||
|
||||
const Draggable = styled("div")`
|
||||
opacity: ${(props) => (props.$isDragging || props.$isMoving ? 0.5 : 1)};
|
||||
pointer-events: ${(props) => (props.$isMoving ? "none" : "all")};
|
||||
const Relative = styled.div`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const Disclosure = styled(CollapsedIcon)`
|
||||
transition: transform 100ms ease, fill 50ms !important;
|
||||
position: absolute;
|
||||
left: -24px;
|
||||
|
||||
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
|
||||
const Draggable = styled.div`
|
||||
opacity: ${(props) => (props.$isDragging || props.$isMoving ? 0.5 : 1)};
|
||||
pointer-events: ${(props) => (props.$isMoving ? "none" : "all")};
|
||||
`;
|
||||
|
||||
const ObservedDocumentLink = observer(React.forwardRef(DocumentLink));
|
||||
|
||||
@@ -27,7 +27,7 @@ const Cursor = styled("div")`
|
||||
|
||||
width: 100%;
|
||||
height: 14px;
|
||||
${(props) => (props.from === "collections" ? "top: 15px;" : "bottom: -7px;")}
|
||||
${(props) => (props.from === "collections" ? "top: 25px;" : "bottom: -7px;")}
|
||||
background: transparent;
|
||||
|
||||
::after {
|
||||
|
||||
@@ -14,7 +14,6 @@ type Props = {|
|
||||
collectionId: string,
|
||||
documentId?: string,
|
||||
disabled: boolean,
|
||||
staticContext: Object,
|
||||
|};
|
||||
|
||||
function DropToImport({ disabled, children, collectionId, documentId }: Props) {
|
||||
@@ -56,7 +55,7 @@ function DropToImport({ disabled, children, collectionId, documentId }: Props) {
|
||||
}) => (
|
||||
<DropzoneContainer
|
||||
{...getRootProps()}
|
||||
{...{ isDragActive }}
|
||||
$isDragActive={isDragActive}
|
||||
tabIndex="-1"
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
@@ -71,8 +70,8 @@ function DropToImport({ disabled, children, collectionId, documentId }: Props) {
|
||||
const DropzoneContainer = styled.div`
|
||||
border-radius: 4px;
|
||||
|
||||
${({ isDragActive, theme }) =>
|
||||
isDragActive &&
|
||||
${({ $isDragActive, theme }) =>
|
||||
$isDragActive &&
|
||||
css`
|
||||
background: ${theme.slateDark};
|
||||
a {
|
||||
|
||||
@@ -7,9 +7,10 @@ type Props = {|
|
||||
onSubmit: (title: string) => Promise<void>,
|
||||
title: string,
|
||||
canUpdate: boolean,
|
||||
maxLength?: number,
|
||||
|};
|
||||
|
||||
function EditableTitle({ title, onSubmit, canUpdate }: Props) {
|
||||
function EditableTitle({ title, onSubmit, canUpdate, ...rest }: Props) {
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const [originalValue, setOriginalValue] = React.useState(title);
|
||||
const [value, setValue] = React.useState(title);
|
||||
@@ -79,6 +80,7 @@ function EditableTitle({ title, onSubmit, canUpdate }: Props) {
|
||||
onChange={handleChange}
|
||||
onBlur={handleSave}
|
||||
autoFocus
|
||||
{...rest}
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
|
||||
@@ -5,10 +5,11 @@ import Flex from "components/Flex";
|
||||
const Header = styled(Flex)`
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
text-transform: uppercase;
|
||||
color: ${(props) => props.theme.sidebarText};
|
||||
letter-spacing: 0.04em;
|
||||
margin: 4px 16px;
|
||||
margin: 4px 12px;
|
||||
`;
|
||||
|
||||
export default Header;
|
||||
|
||||
@@ -27,10 +27,11 @@ const joinClassnames = (...classnames) => {
|
||||
return classnames.filter((i) => i).join(" ");
|
||||
};
|
||||
|
||||
type Props = {|
|
||||
export type Props = {|
|
||||
activeClassName?: String,
|
||||
activeStyle?: Object,
|
||||
className?: string,
|
||||
scrollIntoViewIfNeeded?: boolean,
|
||||
exact?: boolean,
|
||||
isActive?: any,
|
||||
location?: Location,
|
||||
@@ -52,6 +53,7 @@ const NavLink = ({
|
||||
location: locationProp,
|
||||
strict,
|
||||
style: styleProp,
|
||||
scrollIntoViewIfNeeded,
|
||||
to,
|
||||
...rest
|
||||
}: Props) => {
|
||||
@@ -83,13 +85,13 @@ const NavLink = ({
|
||||
const style = isActive ? { ...styleProp, ...activeStyle } : styleProp;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isActive && linkRef.current) {
|
||||
if (isActive && linkRef.current && scrollIntoViewIfNeeded !== false) {
|
||||
scrollIntoView(linkRef.current, {
|
||||
scrollMode: "if-needed",
|
||||
behavior: "instant",
|
||||
});
|
||||
}
|
||||
}, [linkRef, isActive]);
|
||||
}, [linkRef, scrollIntoViewIfNeeded, isActive]);
|
||||
|
||||
const props = {
|
||||
"aria-current": (isActive && ariaCurrent) || null,
|
||||
|
||||
@@ -15,6 +15,7 @@ function PlaceholderCollections() {
|
||||
|
||||
const Wrapper = styled.div`
|
||||
margin: 4px 16px;
|
||||
margin-left: 40px;
|
||||
width: 75%;
|
||||
`;
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import Flex from "components/Flex";
|
||||
const Section = styled(Flex)`
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
margin: 0 8px 20px;
|
||||
margin: 0 8px 12px;
|
||||
min-width: ${(props) => props.theme.sidebarMinWidth}px;
|
||||
flex-shrink: 0;
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// @flow
|
||||
import invariant from "invariant";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import { actionToMenuItem } from "actions";
|
||||
import useStores from "hooks/useStores";
|
||||
import type { Action } from "types";
|
||||
|
||||
type Props = {|
|
||||
action: Action,
|
||||
|};
|
||||
|
||||
function SidebarAction({ action, ...rest }: Props) {
|
||||
const stores = useStores();
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
|
||||
const context = {
|
||||
isContextMenu: false,
|
||||
isCommandBar: false,
|
||||
activeCollectionId: undefined,
|
||||
activeDocumentId: undefined,
|
||||
location,
|
||||
stores,
|
||||
t,
|
||||
};
|
||||
|
||||
const menuItem = actionToMenuItem(action, context);
|
||||
invariant(menuItem.onClick, "passed action must have perform");
|
||||
|
||||
return (
|
||||
<SidebarLink
|
||||
onClick={menuItem.onClick}
|
||||
icon={menuItem.icon}
|
||||
label={menuItem.title}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(SidebarAction);
|
||||
@@ -1,33 +1,31 @@
|
||||
// @flow
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import { withRouter, type RouterHistory, type Match } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import EventBoundary from "components/EventBoundary";
|
||||
import NavLink from "./NavLink";
|
||||
import { type Theme } from "types";
|
||||
import NavLink, { type Props as NavLinkProps } from "./NavLink";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
...NavLinkProps,
|
||||
to?: string | Object,
|
||||
href?: string | Object,
|
||||
innerRef?: (?HTMLElement) => void,
|
||||
onClick?: (SyntheticEvent<>) => void,
|
||||
onClick?: (SyntheticEvent<>) => mixed,
|
||||
onMouseEnter?: (SyntheticEvent<>) => void,
|
||||
className?: string,
|
||||
children?: React.Node,
|
||||
icon?: React.Node,
|
||||
label?: React.Node,
|
||||
menu?: React.Node,
|
||||
showActions?: boolean,
|
||||
iconColor?: string,
|
||||
active?: boolean,
|
||||
isActiveDrop?: boolean,
|
||||
history: RouterHistory,
|
||||
match: Match,
|
||||
theme: Theme,
|
||||
exact?: boolean,
|
||||
depth?: number,
|
||||
scrollIntoViewIfNeeded?: boolean,
|
||||
|};
|
||||
|
||||
const activeDropStyle = {
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
function SidebarLink(
|
||||
@@ -42,37 +40,38 @@ function SidebarLink(
|
||||
isActiveDrop,
|
||||
menu,
|
||||
showActions,
|
||||
theme,
|
||||
exact,
|
||||
href,
|
||||
depth,
|
||||
history,
|
||||
match,
|
||||
className,
|
||||
scrollIntoViewIfNeeded,
|
||||
...rest
|
||||
}: Props,
|
||||
ref
|
||||
) {
|
||||
const style = React.useMemo(() => {
|
||||
return {
|
||||
paddingLeft: `${(depth || 0) * 16 + 16}px`,
|
||||
};
|
||||
}, [depth]);
|
||||
const theme = useTheme();
|
||||
const style = React.useMemo(
|
||||
() => ({
|
||||
paddingLeft: `${(depth || 0) * 16 + 12}px`,
|
||||
}),
|
||||
[depth]
|
||||
);
|
||||
|
||||
const activeStyle = {
|
||||
fontWeight: 600,
|
||||
color: theme.text,
|
||||
background: theme.sidebarItemBackground,
|
||||
...style,
|
||||
};
|
||||
|
||||
const activeDropStyle = {
|
||||
fontWeight: 600,
|
||||
};
|
||||
const activeStyle = React.useMemo(
|
||||
() => ({
|
||||
fontWeight: 600,
|
||||
color: theme.text,
|
||||
background: theme.sidebarItemBackground,
|
||||
...style,
|
||||
}),
|
||||
[theme, style]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link
|
||||
$isActiveDrop={isActiveDrop}
|
||||
scrollIntoViewIfNeeded={scrollIntoViewIfNeeded}
|
||||
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
|
||||
style={active ? activeStyle : style}
|
||||
onClick={onClick}
|
||||
@@ -83,6 +82,7 @@ function SidebarLink(
|
||||
href={href}
|
||||
className={className}
|
||||
ref={ref}
|
||||
{...rest}
|
||||
>
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
<Label>{label}</Label>
|
||||
@@ -131,6 +131,7 @@ const Link = styled(NavLink)`
|
||||
padding: 6px 16px;
|
||||
border-radius: 4px;
|
||||
transition: background 50ms, color 50ms;
|
||||
user-select: none;
|
||||
background: ${(props) =>
|
||||
props.$isActiveDrop ? props.theme.slateDark : "inherit"};
|
||||
color: ${(props) =>
|
||||
@@ -156,13 +157,11 @@ const Link = styled(NavLink)`
|
||||
`}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover + ${Actions},
|
||||
&:active + ${Actions} {
|
||||
display: inline-flex;
|
||||
&:hover + ${Actions}, &:active + ${Actions} {
|
||||
display: inline-flex;
|
||||
|
||||
svg {
|
||||
opacity: 0.75;
|
||||
}
|
||||
svg {
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,4 +182,4 @@ const Label = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
export default withRouter(withTheme(React.forwardRef(SidebarLink)));
|
||||
export default React.forwardRef<Props, HTMLAnchorElement>(SidebarLink);
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { CollapsedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Flex from "components/Flex";
|
||||
import PlaceholderCollections from "./PlaceholderCollections";
|
||||
import Section from "./Section";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import StarredLink from "./StarredLink";
|
||||
import useStores from "hooks/useStores";
|
||||
import useToasts from "hooks/useToasts";
|
||||
|
||||
const STARRED_PAGINATION_LIMIT = 10;
|
||||
const STARRED = "STARRED";
|
||||
|
||||
function Starred() {
|
||||
const [isFetching, setIsFetching] = React.useState(false);
|
||||
const [fetchError, setFetchError] = React.useState();
|
||||
const [expanded, setExpanded] = React.useState(true);
|
||||
const [show, setShow] = React.useState("Nothing");
|
||||
const [offset, setOffset] = React.useState(0);
|
||||
const [upperBound, setUpperBound] = React.useState(STARRED_PAGINATION_LIMIT);
|
||||
const { showToast } = useToasts();
|
||||
const { documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const { fetchStarred, starred } = documents;
|
||||
|
||||
const fetchResults = React.useCallback(async () => {
|
||||
try {
|
||||
setIsFetching(true);
|
||||
await fetchStarred({
|
||||
limit: STARRED_PAGINATION_LIMIT,
|
||||
offset,
|
||||
});
|
||||
} catch (error) {
|
||||
showToast(t("Starred documents could not be loaded"), {
|
||||
type: "error",
|
||||
});
|
||||
setFetchError(error);
|
||||
} finally {
|
||||
setIsFetching(false);
|
||||
}
|
||||
}, [fetchStarred, offset, showToast, t]);
|
||||
|
||||
useEffect(() => {
|
||||
let stateInLocal;
|
||||
|
||||
try {
|
||||
stateInLocal = localStorage.getItem(STARRED);
|
||||
} catch (_) {
|
||||
// no-op Safari private mode
|
||||
}
|
||||
|
||||
if (!stateInLocal) {
|
||||
localStorage.setItem(STARRED, expanded ? "true" : "false");
|
||||
} else {
|
||||
setExpanded(stateInLocal === "true");
|
||||
}
|
||||
}, [expanded]);
|
||||
|
||||
useEffect(() => {
|
||||
setOffset(starred.length);
|
||||
if (starred.length <= STARRED_PAGINATION_LIMIT) {
|
||||
setShow("Nothing");
|
||||
} else if (starred.length >= upperBound) {
|
||||
setShow("More");
|
||||
} else if (starred.length < upperBound) {
|
||||
setShow("Less");
|
||||
}
|
||||
}, [starred, upperBound]);
|
||||
|
||||
useEffect(() => {
|
||||
if (offset === 0) {
|
||||
fetchResults();
|
||||
}
|
||||
}, [fetchResults, offset]);
|
||||
|
||||
const handleShowMore = React.useCallback(
|
||||
async (ev) => {
|
||||
setUpperBound(
|
||||
(previousUpperBound) => previousUpperBound + STARRED_PAGINATION_LIMIT
|
||||
);
|
||||
await fetchResults();
|
||||
},
|
||||
[fetchResults]
|
||||
);
|
||||
|
||||
const handleShowLess = React.useCallback((ev) => {
|
||||
setUpperBound(STARRED_PAGINATION_LIMIT);
|
||||
setShow("More");
|
||||
}, []);
|
||||
|
||||
const handleExpandClick = React.useCallback(
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
try {
|
||||
localStorage.setItem(STARRED, !expanded ? "true" : "false");
|
||||
} catch (_) {
|
||||
// no-op Safari private mode
|
||||
}
|
||||
setExpanded((prev) => !prev);
|
||||
},
|
||||
[expanded]
|
||||
);
|
||||
|
||||
const content = starred.slice(0, upperBound).map((document, index) => {
|
||||
return (
|
||||
<StarredLink
|
||||
key={document.id}
|
||||
documentId={document.id}
|
||||
collectionId={document.collectionId}
|
||||
to={document.url}
|
||||
title={document.title}
|
||||
url={document.url}
|
||||
depth={2}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
if (!starred.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<Flex column>
|
||||
<SidebarLink
|
||||
onClick={handleExpandClick}
|
||||
label={t("Starred")}
|
||||
icon={<Disclosure expanded={expanded} color="currentColor" />}
|
||||
/>
|
||||
{expanded && (
|
||||
<>
|
||||
{content}
|
||||
{show === "More" && !isFetching && (
|
||||
<SidebarLink
|
||||
onClick={handleShowMore}
|
||||
label={`${t("Show more")}…`}
|
||||
depth={2}
|
||||
/>
|
||||
)}
|
||||
{show === "Less" && !isFetching && (
|
||||
<SidebarLink
|
||||
onClick={handleShowLess}
|
||||
label={`${t("Show less")}…`}
|
||||
depth={2}
|
||||
/>
|
||||
)}
|
||||
{(isFetching || fetchError) && (
|
||||
<Flex column>
|
||||
<PlaceholderCollections />
|
||||
</Flex>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
const Disclosure = styled(CollapsedIcon)`
|
||||
transition: transform 100ms ease, fill 50ms !important;
|
||||
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
|
||||
`;
|
||||
|
||||
export default observer(Starred);
|
||||
@@ -0,0 +1,129 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { MAX_TITLE_LENGTH } from "shared/constants";
|
||||
import Fade from "components/Fade";
|
||||
import useStores from "../../../hooks/useStores";
|
||||
import Disclosure from "./Disclosure";
|
||||
import EditableTitle from "./EditableTitle";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import useBoolean from "hooks/useBoolean";
|
||||
import DocumentMenu from "menus/DocumentMenu";
|
||||
|
||||
type Props = {|
|
||||
depth: number,
|
||||
title: string,
|
||||
to: string,
|
||||
documentId: string,
|
||||
collectionId: string,
|
||||
|};
|
||||
|
||||
function StarredLink({ depth, title, to, documentId, collectionId }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { collections, documents, policies } = useStores();
|
||||
const collection = collections.get(collectionId);
|
||||
const document = documents.get(documentId);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const canUpdate = policies.abilities(documentId).update;
|
||||
|
||||
const childDocuments = collection
|
||||
? collection.getDocumentChildren(documentId)
|
||||
: [];
|
||||
|
||||
const hasChildDocuments = childDocuments.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
if (!document) {
|
||||
await documents.fetch(documentId);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, [collection, collectionId, collections, document, documentId, documents]);
|
||||
|
||||
const handleDisclosureClick = React.useCallback((ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setExpanded((prevExpanded) => !prevExpanded);
|
||||
}, []);
|
||||
|
||||
const handleTitleChange = React.useCallback(
|
||||
async (title: string) => {
|
||||
if (!document) return;
|
||||
|
||||
await documents.update({
|
||||
id: document.id,
|
||||
lastRevision: document.revision,
|
||||
text: document.text,
|
||||
title,
|
||||
});
|
||||
},
|
||||
[documents, document]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Relative>
|
||||
<SidebarLink
|
||||
depth={depth}
|
||||
to={`${to}?starred`}
|
||||
isActive={(match, location) =>
|
||||
match && location.search === "?starred"
|
||||
}
|
||||
label={
|
||||
<>
|
||||
{hasChildDocuments && (
|
||||
<Disclosure
|
||||
expanded={expanded}
|
||||
onClick={handleDisclosureClick}
|
||||
/>
|
||||
)}
|
||||
<EditableTitle
|
||||
title={title || t("Untitled")}
|
||||
onSubmit={handleTitleChange}
|
||||
canUpdate={canUpdate}
|
||||
maxLength={MAX_TITLE_LENGTH}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
exact={false}
|
||||
showActions={menuOpen}
|
||||
menu={
|
||||
document ? (
|
||||
<Fade>
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
</Fade>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</Relative>
|
||||
{expanded &&
|
||||
childDocuments.map((childDocument) => (
|
||||
<ObserveredStarredLink
|
||||
key={childDocument.id}
|
||||
depth={depth + 1}
|
||||
title={childDocument.title}
|
||||
to={childDocument.url}
|
||||
documentId={childDocument.id}
|
||||
collectionId={collectionId}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const Relative = styled.div`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const ObserveredStarredLink = observer(StarredLink);
|
||||
|
||||
export default ObserveredStarredLink;
|
||||
@@ -1,4 +1,5 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { ExpandedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
@@ -84,4 +85,4 @@ const Header = styled.button`
|
||||
}
|
||||
`;
|
||||
|
||||
export default TeamButton;
|
||||
export default observer(TeamButton);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Arrow from "components/Arrow";
|
||||
@@ -12,9 +13,16 @@ type Props = {
|
||||
|
||||
const Toggle = React.forwardRef<Props, HTMLButtonElement>(
|
||||
({ direction = "left", onClick, style }: Props, ref) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Positioner style={style}>
|
||||
<ToggleButton ref={ref} $direction={direction} onClick={onClick}>
|
||||
<ToggleButton
|
||||
ref={ref}
|
||||
$direction={direction}
|
||||
onClick={onClick}
|
||||
aria-label={t("Toggle sidebar")}
|
||||
>
|
||||
<Arrow />
|
||||
</ToggleButton>
|
||||
</Positioner>
|
||||
|
||||
@@ -7,8 +7,9 @@ import { useDrop } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import DocumentDelete from "scenes/DocumentDelete";
|
||||
import Modal from "components/Modal";
|
||||
import useStores from "../../../hooks/useStores";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import useStores from "hooks/useStores";
|
||||
import { trashPath } from "utils/routeHelpers";
|
||||
|
||||
function TrashLink({ documents }) {
|
||||
const { policies } = useStores();
|
||||
@@ -33,7 +34,7 @@ function TrashLink({ documents }) {
|
||||
<>
|
||||
<div ref={dropToTrashDocument}>
|
||||
<SidebarLink
|
||||
to="/trash"
|
||||
to={trashPath()}
|
||||
icon={<TrashIcon color="currentColor" open={isDocumentDropping} />}
|
||||
exact={false}
|
||||
label={t("Trash")}
|
||||
|
||||
@@ -8,6 +8,7 @@ import AuthStore from "stores/AuthStore";
|
||||
import CollectionsStore from "stores/CollectionsStore";
|
||||
import DocumentPresenceStore from "stores/DocumentPresenceStore";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import FileOperationsStore from "stores/FileOperationsStore";
|
||||
import GroupsStore from "stores/GroupsStore";
|
||||
import MembershipsStore from "stores/MembershipsStore";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
@@ -28,6 +29,7 @@ type Props = {
|
||||
views: ViewsStore,
|
||||
auth: AuthStore,
|
||||
toasts: ToastsStore,
|
||||
fileOperations: FileOperationsStore,
|
||||
};
|
||||
|
||||
@observer
|
||||
@@ -80,6 +82,7 @@ class SocketProvider extends React.Component<Props> {
|
||||
policies,
|
||||
presence,
|
||||
views,
|
||||
fileOperations,
|
||||
} = this.props;
|
||||
if (!auth.token) return;
|
||||
|
||||
@@ -240,6 +243,10 @@ class SocketProvider extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.teamIds) {
|
||||
await auth.fetch();
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on("documents.star", (event) => {
|
||||
@@ -287,6 +294,21 @@ class SocketProvider extends React.Component<Props> {
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on("fileOperations.update", async (event) => {
|
||||
const user = auth.user;
|
||||
let collection = null;
|
||||
|
||||
if (event.collectionId)
|
||||
collection = await collections.fetch(event.collectionId);
|
||||
if (user) {
|
||||
fileOperations.add({
|
||||
...event,
|
||||
user,
|
||||
collection,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// received a message from the API server that we should request
|
||||
// to join a specific room. Forward that to the ws server.
|
||||
this.socket.on("join", (event) => {
|
||||
@@ -345,5 +367,6 @@ export default inject(
|
||||
"memberships",
|
||||
"presence",
|
||||
"policies",
|
||||
"views"
|
||||
"views",
|
||||
"fileOperations"
|
||||
)(SocketProvider);
|
||||
|
||||
+4
-17
@@ -1,25 +1,13 @@
|
||||
// @flow
|
||||
import { m } from "framer-motion";
|
||||
import * as React from "react";
|
||||
import { NavLink, Route } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import { type Theme } from "types";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import NavLinkWithChildrenFunc from "components/NavLink";
|
||||
|
||||
type Props = {
|
||||
theme: Theme,
|
||||
children: React.Node,
|
||||
};
|
||||
|
||||
const NavLinkWithChildrenFunc = ({ to, exact = false, children, ...rest }) => (
|
||||
<Route path={to} exact={exact}>
|
||||
{({ match }) => (
|
||||
<NavLink to={to} exact={exact} {...rest}>
|
||||
{children(match)}
|
||||
</NavLink>
|
||||
)}
|
||||
</Route>
|
||||
);
|
||||
|
||||
const TabLink = styled(NavLinkWithChildrenFunc)`
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
@@ -53,7 +41,8 @@ const transition = {
|
||||
damping: 30,
|
||||
};
|
||||
|
||||
function Tab({ theme, children, ...rest }: Props) {
|
||||
export default function Tab({ children, ...rest }: Props) {
|
||||
const theme = useTheme();
|
||||
const activeStyle = {
|
||||
color: theme.textSecondary,
|
||||
};
|
||||
@@ -75,5 +64,3 @@ function Tab({ theme, children, ...rest }: Props) {
|
||||
</TabLink>
|
||||
);
|
||||
}
|
||||
|
||||
export default withTheme(Tab);
|
||||
|
||||
+55
-16
@@ -1,4 +1,5 @@
|
||||
// @flow
|
||||
import { isEqual } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import { CollapsedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
@@ -24,6 +25,7 @@ export type Props = {|
|
||||
onChangePage: (index: number) => void,
|
||||
onChangeSort: (sort: ?string, direction: "ASC" | "DESC") => void,
|
||||
columns: any,
|
||||
defaultSortDirection: "ASC" | "DESC",
|
||||
|};
|
||||
|
||||
function Table({
|
||||
@@ -39,6 +41,7 @@ function Table({
|
||||
topRef,
|
||||
onChangeSort,
|
||||
onChangePage,
|
||||
defaultSortDirection,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
@@ -62,32 +65,52 @@ function Table({
|
||||
autoResetPage: false,
|
||||
pageCount: totalPages,
|
||||
initialState: {
|
||||
sortBy: [{ id: defaultSort, desc: false }],
|
||||
sortBy: [
|
||||
{
|
||||
id: defaultSort,
|
||||
desc: defaultSortDirection === "DESC" ? true : false,
|
||||
},
|
||||
],
|
||||
pageSize,
|
||||
pageIndex: page,
|
||||
},
|
||||
stateReducer: (newState, action, prevState) => {
|
||||
if (!isEqual(newState.sortBy, prevState.sortBy)) {
|
||||
return { ...newState, pageIndex: 0 };
|
||||
}
|
||||
return newState;
|
||||
},
|
||||
},
|
||||
useSortBy,
|
||||
usePagination
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
onChangePage(pageIndex);
|
||||
}, [pageIndex]);
|
||||
const prevSortBy = React.useRef(sortBy);
|
||||
|
||||
React.useEffect(() => {
|
||||
onChangePage(0);
|
||||
onChangeSort(
|
||||
sortBy.length ? sortBy[0].id : undefined,
|
||||
sortBy.length && sortBy[0].desc ? "DESC" : "ASC"
|
||||
);
|
||||
}, [sortBy]);
|
||||
if (!isEqual(sortBy, prevSortBy.current)) {
|
||||
prevSortBy.current = sortBy;
|
||||
onChangePage(0);
|
||||
onChangeSort(
|
||||
sortBy.length ? sortBy[0].id : undefined,
|
||||
!sortBy.length ? defaultSortDirection : sortBy[0].desc ? "DESC" : "ASC"
|
||||
);
|
||||
}
|
||||
}, [defaultSortDirection, onChangePage, onChangeSort, sortBy]);
|
||||
|
||||
const handleNextPage = () => {
|
||||
nextPage();
|
||||
onChangePage(pageIndex + 1);
|
||||
};
|
||||
|
||||
const handlePreviousPage = () => {
|
||||
previousPage();
|
||||
onChangePage(pageIndex - 1);
|
||||
};
|
||||
|
||||
const isEmpty = !isLoading && data.length === 0;
|
||||
const showPlaceholder = isLoading && data.length === 0;
|
||||
|
||||
console.log({ canNextPage, pageIndex, totalPages, rows, data });
|
||||
|
||||
return (
|
||||
<>
|
||||
<Anchor ref={topRef} />
|
||||
@@ -142,12 +165,12 @@ function Table({
|
||||
>
|
||||
{/* Note: the page > 0 check shouldn't be needed here but is */}
|
||||
{canPreviousPage && page > 0 && (
|
||||
<Button onClick={previousPage} neutral>
|
||||
<Button onClick={handlePreviousPage} neutral>
|
||||
{t("Previous page")}
|
||||
</Button>
|
||||
)}
|
||||
{canNextPage && (
|
||||
<Button onClick={nextPage} neutral>
|
||||
<Button onClick={handleNextPage} neutral>
|
||||
{t("Next page")}
|
||||
</Button>
|
||||
)}
|
||||
@@ -209,7 +232,7 @@ const SortWrapper = styled(Flex)`
|
||||
`;
|
||||
|
||||
const Cell = styled.td`
|
||||
padding: 8px 0;
|
||||
padding: 6px;
|
||||
border-bottom: 1px solid ${(props) => props.theme.divider};
|
||||
font-size: 14px;
|
||||
|
||||
@@ -226,6 +249,14 @@ const Cell = styled.td`
|
||||
`;
|
||||
|
||||
const Row = styled.tr`
|
||||
${Cell} {
|
||||
&:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
&:last-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
&:last-child {
|
||||
${Cell} {
|
||||
border-bottom: 0;
|
||||
@@ -237,7 +268,7 @@ const Head = styled.th`
|
||||
text-align: left;
|
||||
position: sticky;
|
||||
top: 54px;
|
||||
padding: 6px 0;
|
||||
padding: 6px;
|
||||
border-bottom: 1px solid ${(props) => props.theme.divider};
|
||||
background: ${(props) => props.theme.background};
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
@@ -245,6 +276,14 @@ const Head = styled.th`
|
||||
color: ${(props) => props.theme.textSecondary};
|
||||
font-weight: 500;
|
||||
z-index: 1;
|
||||
|
||||
:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
:last-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(Table);
|
||||
|
||||
@@ -11,6 +11,7 @@ type Props = {
|
||||
children?: React.Node,
|
||||
tooltipDelay?: number,
|
||||
addSuffix?: boolean,
|
||||
format?: string,
|
||||
shorten?: boolean,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// @flow
|
||||
import { useRegisterActions } from "kbar";
|
||||
import { flattenDeep } from "lodash";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { actionToKBar } from "actions";
|
||||
import useStores from "hooks/useStores";
|
||||
import type { Action } from "types";
|
||||
|
||||
export default function useCommandBarActions(actions: Action[]) {
|
||||
const stores = useStores();
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
|
||||
const context = {
|
||||
t,
|
||||
isCommandBar: true,
|
||||
isContextMenu: false,
|
||||
activeCollectionId: stores.ui.activeCollectionId,
|
||||
activeDocumentId: stores.ui.activeDocumentId,
|
||||
location,
|
||||
stores,
|
||||
};
|
||||
|
||||
const registerable = flattenDeep(
|
||||
actions.map((action) => actionToKBar(action, context))
|
||||
);
|
||||
|
||||
useRegisterActions(registerable, [
|
||||
registerable.map((r) => r.id).join(""),
|
||||
location.pathname,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// @flow
|
||||
import invariant from "invariant";
|
||||
import useStores from "./useStores";
|
||||
|
||||
export default function useCurrentToken() {
|
||||
const { auth } = useStores();
|
||||
invariant(auth.token, "token is required");
|
||||
return auth.token;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
|
||||
const activityEvents = [
|
||||
"click",
|
||||
"mousemove",
|
||||
"keydown",
|
||||
"DOMMouseScroll",
|
||||
"mousewheel",
|
||||
"mousedown",
|
||||
"touchstart",
|
||||
"touchmove",
|
||||
"focus",
|
||||
];
|
||||
|
||||
/**
|
||||
* Hook to detect user idle state.
|
||||
*
|
||||
* @param {number} timeToIdle
|
||||
* @returns boolean if the user is idle
|
||||
*/
|
||||
export default function useIdle(timeToIdle: number = 3 * 60 * 1000) {
|
||||
const [isIdle, setIsIdle] = React.useState(false);
|
||||
const timeout = React.useRef();
|
||||
|
||||
const onActivity = React.useCallback(() => {
|
||||
if (timeout.current) {
|
||||
clearTimeout(timeout.current);
|
||||
}
|
||||
|
||||
timeout.current = setTimeout(() => {
|
||||
setIsIdle(true);
|
||||
}, timeToIdle);
|
||||
}, [timeToIdle]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleUserActivityEvent = () => {
|
||||
setIsIdle(false);
|
||||
onActivity();
|
||||
};
|
||||
|
||||
activityEvents.forEach((eventName) =>
|
||||
window.addEventListener(eventName, handleUserActivityEvent)
|
||||
);
|
||||
|
||||
return () => {
|
||||
activityEvents.forEach((eventName) =>
|
||||
window.removeEventListener(eventName, handleUserActivityEvent)
|
||||
);
|
||||
};
|
||||
}, [onActivity]);
|
||||
|
||||
return isIdle;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* Hook to check if component is still mounted
|
||||
*
|
||||
* @returns {boolean} true if the component is mounted, false otherwise
|
||||
*/
|
||||
export default function useIsMounted() {
|
||||
const isMounted = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
isMounted.current = true;
|
||||
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return React.useCallback(() => isMounted.current, []);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import isTextInput from "utils/isTextInput";
|
||||
|
||||
export type KeyFilter = ((event: KeyboardEvent) => boolean) | string;
|
||||
|
||||
// Registered keyboard event callbacks
|
||||
let callbacks = [];
|
||||
|
||||
// Track if IME input suggestions are open so we can ignore keydown shortcuts
|
||||
// in this case, they should never be triggered from mobile keyboards.
|
||||
let imeOpen = false;
|
||||
|
||||
// Based on implementation in react-use
|
||||
// https://github.com/streamich/react-use/blob/master/src/useKey.ts#L15-L22
|
||||
const createKeyPredicate = (keyFilter: KeyFilter) =>
|
||||
typeof keyFilter === "function"
|
||||
? keyFilter
|
||||
: typeof keyFilter === "string"
|
||||
? (event: KeyboardEvent) =>
|
||||
event.key === keyFilter ||
|
||||
event.code === `Key${keyFilter.toUpperCase()}`
|
||||
: keyFilter
|
||||
? (_event) => true
|
||||
: (_event) => false;
|
||||
|
||||
export default function useKeyDown(
|
||||
key: KeyFilter,
|
||||
fn: (event: KeyboardEvent) => void
|
||||
): void {
|
||||
const predicate = createKeyPredicate(key);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
if (predicate(event)) {
|
||||
fn(event);
|
||||
}
|
||||
};
|
||||
|
||||
callbacks.push(handler);
|
||||
|
||||
return () => {
|
||||
callbacks = callbacks.filter((cb) => cb !== handler);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (imeOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
// reverse so that the last registered callbacks get executed first
|
||||
for (const callback of callbacks.reverse()) {
|
||||
if (event.defaultPrevented === true) {
|
||||
break;
|
||||
}
|
||||
if (!isTextInput(event.target) || event.ctrlKey || event.metaKey) {
|
||||
callback(event);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("compositionstart", () => {
|
||||
imeOpen = true;
|
||||
});
|
||||
window.addEventListener("compositionend", () => {
|
||||
imeOpen = false;
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { type ElementRef } from "reakit";
|
||||
import useMobile from "hooks/useMobile";
|
||||
import useWindowSize from "hooks/useWindowSize";
|
||||
|
||||
const useMenuHeight = (
|
||||
visible: void | boolean,
|
||||
unstable_disclosureRef: void | { current: null | ElementRef<"button"> }
|
||||
) => {
|
||||
const [maxHeight, setMaxHeight] = React.useState(undefined);
|
||||
const isMobile = useMobile();
|
||||
const { height: windowHeight } = useWindowSize();
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const padding = 8;
|
||||
|
||||
if (visible && !isMobile) {
|
||||
setMaxHeight(
|
||||
unstable_disclosureRef?.current
|
||||
? windowHeight -
|
||||
unstable_disclosureRef.current.getBoundingClientRect().bottom -
|
||||
padding
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
}, [visible, unstable_disclosureRef, windowHeight, isMobile]);
|
||||
|
||||
return maxHeight;
|
||||
};
|
||||
|
||||
export default useMenuHeight;
|
||||
@@ -0,0 +1,22 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* Hook to return page visibility state.
|
||||
*
|
||||
* @returns boolean if the page is visible
|
||||
*/
|
||||
export default function usePageVisibility(): boolean {
|
||||
const [visible, setVisible] = React.useState(true);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleVisibilityChange = () => setVisible(!document.hidden);
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return visible;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
|
||||
export default function usePrevious(value: any) {
|
||||
export default function usePrevious<T>(value: T): T | void {
|
||||
const ref = React.useRef();
|
||||
React.useEffect(() => {
|
||||
ref.current = value;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { getCookie } from "tiny-cookie";
|
||||
|
||||
type Session = {|
|
||||
url: string,
|
||||
logoUrl: string,
|
||||
name: string,
|
||||
teamId: string,
|
||||
|};
|
||||
|
||||
function loadSessionsFromCookie(): Session[] {
|
||||
const sessions = JSON.parse(getCookie("sessions") || "{}");
|
||||
|
||||
return Object.keys(sessions).map((teamId) => ({
|
||||
teamId,
|
||||
...sessions[teamId],
|
||||
}));
|
||||
}
|
||||
|
||||
export default function useSessions() {
|
||||
const [sessions, setSessions] = React.useState(loadSessionsFromCookie);
|
||||
|
||||
const reload = React.useCallback(() => {
|
||||
setSessions(loadSessionsFromCookie());
|
||||
}, []);
|
||||
|
||||
return [sessions, reload];
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user