mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
209 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa7f8d3592 | |||
| c598c61afe | |||
| 68b07eb466 | |||
| 06a149407a | |||
| b9387734c7 | |||
| 810b7908e4 | |||
| 6b76a898fa | |||
| 8ba83e2173 | |||
| 5a4b8c5faa | |||
| 3f8bdf7ac2 | |||
| 9c4b4f4989 | |||
| c5d534b2ad | |||
| bed3d1078e | |||
| 83e87254c6 | |||
| f576ddccfe | |||
| 0a674eacfa | |||
| ceac57bd64 | |||
| 97f31e3f2a | |||
| a06671e8ce | |||
| fd3c21d28b | |||
| c0c36bacbb | |||
| 7bd1ea7c40 | |||
| 5ebb1e8a61 | |||
| 96d6987858 | |||
| 3602198cd8 | |||
| 00bab31cff | |||
| 3ef2b7cf42 | |||
| 18743da2fc | |||
| fe1307d7e7 | |||
| a226889143 | |||
| 347f033802 | |||
| f5c659f902 | |||
| 722d10e7de | |||
| ce001547b5 | |||
| 8d05e2b095 | |||
| 19e40cf814 | |||
| 2bb9b50637 | |||
| 4885612661 | |||
| e2dd6221f8 | |||
| 7f513a6950 | |||
| 6440d78b6f | |||
| 7e05fc1017 | |||
| 2bc47cfcef | |||
| e8e46a438c | |||
| 3156f62e94 | |||
| 9274f56ef6 | |||
| 4bb9ac40c7 | |||
| 36772f1444 | |||
| e503225f04 | |||
| 762140e493 | |||
| 21e756c357 | |||
| 2cc5846f1b | |||
| de6c1735d9 | |||
| b7c13f092b | |||
| 298298223b | |||
| 21f37c0d14 | |||
| 18bc93c9c2 | |||
| 6a12822829 | |||
| adcab68b59 | |||
| 943fd7e2e1 | |||
| 01db19a0b1 | |||
| 51cb5bffce | |||
| d37b7fa31e | |||
| f86225c332 | |||
| e53c90f25f | |||
| d84d5a4b09 | |||
| 0031fc1562 | |||
| 9b73635727 | |||
| 5cefb534cc | |||
| 8fb6f7f8c6 | |||
| 6b497cf1ec | |||
| 05a61927af | |||
| 2b07f412e2 | |||
| 65bb3b11f3 | |||
| e1e334dd5f | |||
| 6e9092bcaf | |||
| 09a4b76aae | |||
| 5789d65bf5 | |||
| 03a0f54236 | |||
| 1e7244c737 | |||
| 96c41ce823 | |||
| 0702570b0d | |||
| 4b209a7913 | |||
| 6393bd02f4 | |||
| 1776aad833 | |||
| 0c6b37cb60 | |||
| d664044579 | |||
| b3ca434c51 | |||
| 631b75def4 | |||
| d183dab063 | |||
| f082da6456 | |||
| ad72210714 | |||
| 9c85b26d43 | |||
| bf6a56849e | |||
| 68e8b2791a | |||
| 89db519b72 | |||
| 31c412b4a6 | |||
| 199584428a | |||
| f22780e944 | |||
| a71381785c | |||
| a61b53aa74 | |||
| 45f0885533 | |||
| 9c9657f4cc | |||
| c3de0cf0ec | |||
| f7b00e72f1 | |||
| e499881110 | |||
| 016c8c802c | |||
| d4bc189e12 | |||
| 4d435cd5ec | |||
| 59c611b24f | |||
| 1ea40c03c5 | |||
| f9919e90cf | |||
| 0d09e54757 | |||
| 9ce7133837 | |||
| 01a5ff031a | |||
| 5659aeb360 | |||
| d300e34447 | |||
| a4040a93a2 | |||
| c769432993 | |||
| 270bb85417 | |||
| fe8e50da92 | |||
| 31d1f566bc | |||
| f9476770ce | |||
| 2e018e74b8 | |||
| a11ab56117 | |||
| 66e4ec32ed | |||
| bde9d5fbf4 | |||
| 70bb878a8c | |||
| 4237377d47 | |||
| a30f6b717b | |||
| 1edc23c5ae | |||
| ff6ec3a5b8 | |||
| 52c2729490 | |||
| 82f4281a02 | |||
| 12b6e30e3a | |||
| 567ca7e3f1 | |||
| 97c3ea7da8 | |||
| 4af2b032dd | |||
| c52d9a850d | |||
| 588e5bc17f | |||
| a2bd0edd82 | |||
| ca0f0638c9 | |||
| f13e6a3691 | |||
| dcb7b86df8 | |||
| 45c6e72c6d | |||
| a51456deb3 | |||
| 3ffe7e7671 | |||
| a7fe6c9af3 | |||
| 52c673261b | |||
| 60c0a53a1f | |||
| 66fae19034 | |||
| 37ea6bb92b | |||
| 762816adbc | |||
| d1b24b15d5 | |||
| 877b7ad0df | |||
| e98d931aaa | |||
| ba7d102a72 | |||
| ab1f00e919 | |||
| 34cb31ff43 | |||
| aac95c2b2e | |||
| 0dd6ef5196 | |||
| 5cd11002d1 | |||
| 5334f7ae08 | |||
| df1de2b822 | |||
| deb93ef767 | |||
| 5bef4c4b55 | |||
| 72bff1ec8a | |||
| c12b257098 | |||
| f6da244c33 | |||
| ab55e0bed9 | |||
| 84ae9a2c31 | |||
| 5c4eb32c26 | |||
| 10b8f11e0b | |||
| 0a4c3bd633 | |||
| 580cf52fd3 | |||
| ee1fd65a19 | |||
| 323c5f5978 | |||
| bdb34a202c | |||
| 40278b2d9a | |||
| a69ef1f3c9 | |||
| 6e98568e5b | |||
| 8b65ad3cfa | |||
| 533a14369c | |||
| 0ec6440506 | |||
| 6fde025ce4 | |||
| 18bbe6ecf6 | |||
| a48f6c7a85 | |||
| ec9f45f310 | |||
| dd053c4152 | |||
| 5565034486 | |||
| 42cfac97aa | |||
| f369c2f8bf | |||
| 08f91aa60c | |||
| 0fe50c179c | |||
| ae249f720d | |||
| 0a9e76f600 | |||
| 912b9159f0 | |||
| 307f4a1351 | |||
| 021a286d99 | |||
| 6869d4cb02 | |||
| c311ee915e | |||
| b195b0e3b5 | |||
| c783ccad1e | |||
| 619c56102c | |||
| 7c41c1360b | |||
| f3a1b47ccf | |||
| af234465f0 | |||
| 5a1aeed989 | |||
| 6ea4ce72ec |
+9
-5
@@ -127,6 +127,10 @@ GITHUB_APP_NAME=
|
||||
GITHUB_APP_ID=
|
||||
GITHUB_APP_PRIVATE_KEY=
|
||||
|
||||
# Linear
|
||||
LINEAR_CLIENT_ID=
|
||||
LINEAR_CLIENT_SECRET=
|
||||
|
||||
# To configure Discord auth, you'll need to create a Discord Application at
|
||||
# => https://discord.com/developers/applications/
|
||||
#
|
||||
@@ -147,6 +151,10 @@ DISCORD_SERVER_ID=
|
||||
# DISCORD_SERVER_ID and DISCORD_SERVER_ROLES must be set together.
|
||||
DISCORD_SERVER_ROLES=
|
||||
|
||||
# –––––––––––––– IMPORTS ––––––––––––––
|
||||
NOTION_CLIENT_ID=
|
||||
NOTION_CLIENT_SECRET=
|
||||
|
||||
# –––––––––––––––– OPTIONAL ––––––––––––––––
|
||||
|
||||
# Base64 encoded private key and certificate for HTTPS termination. This is only
|
||||
@@ -201,14 +209,10 @@ SENTRY_TUNNEL=
|
||||
|
||||
# To support sending outgoing transactional emails such as "document updated" or
|
||||
# "you've been invited" you'll need to provide authentication for an SMTP server
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=
|
||||
SMTP_SERVICE=
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM_EMAIL=
|
||||
SMTP_REPLY_EMAIL=
|
||||
SMTP_TLS_CIPHERS=
|
||||
SMTP_SECURE=true
|
||||
|
||||
# The default interface language. See translate.getoutline.com for a list of
|
||||
# available language codes and their rough percentage translated.
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
],
|
||||
"padding-line-between-statements": ["error", { "blankLine": "always", "prev": "*", "next": "export" }],
|
||||
"lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }],
|
||||
"lodash/import-scope": ["warn", "method"],
|
||||
"lodash/import-scope": ["error", "method"],
|
||||
"import/no-named-as-default": "off",
|
||||
"import/no-named-as-default-member": "off",
|
||||
"import/newline-after-import": 2,
|
||||
|
||||
@@ -15,6 +15,8 @@ requestInfoDefaultTitles:
|
||||
|
||||
requestInfoLabelToAdd: more information needed
|
||||
|
||||
requestInfoUserstoExclude:
|
||||
- tommoor
|
||||
|
||||
# Configuration for new-pr-welcome - https://github.com/behaviorbot/new-pr-welcome
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ env:
|
||||
DATABASE_URL: postgres://postgres:password@localhost:5432/outline_test
|
||||
REDIS_URL: redis://127.0.0.1:6379
|
||||
URL: http://localhost:3000
|
||||
NODE_OPTIONS: --max-old-space-size=8000
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
SECRET_KEY: F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
|
||||
UTILS_SECRET: 123456
|
||||
SLACK_VERIFICATION_TOKEN: 123456
|
||||
@@ -63,6 +63,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
server: ${{ steps.filter.outputs.server }}
|
||||
app: ${{ steps.filter.outputs.app }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dorny/paths-filter@v2
|
||||
@@ -81,7 +82,7 @@ jobs:
|
||||
- 'yarn.lock'
|
||||
|
||||
test:
|
||||
needs: build
|
||||
needs: [build, changes]
|
||||
if: ${{ needs.changes.outputs.app == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
@@ -143,8 +144,8 @@ jobs:
|
||||
yarn test --maxWorkers=2 $TESTFILES
|
||||
|
||||
bundle-size:
|
||||
needs: [build, types]
|
||||
if: ${{ needs.changes.outputs.app == 'true' }}
|
||||
needs: [build, types, changes]
|
||||
if: ${{ needs.changes.outputs.app == 'true' && github.repository == 'outline/outline' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -161,3 +162,4 @@ jobs:
|
||||
with:
|
||||
key: ${{ secrets.RELATIVE_CI_KEY }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
webpackStatsFile: ./build/app/webpack-stats.json
|
||||
|
||||
+179
-19
@@ -3,25 +3,32 @@ name: Docker
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- "v*"
|
||||
|
||||
env:
|
||||
IMAGE_NAME: outlinewiki/outline
|
||||
BASE_IMAGE_NAME: outlinewiki/outline-base
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
build-arm:
|
||||
runs-on: ubicloud-standard-8-arm
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Docker base meta
|
||||
id: base_meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.BASE_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -29,24 +36,177 @@ jobs:
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push base image
|
||||
uses: docker/build-push-action@v5
|
||||
id: base_build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.base
|
||||
push: true
|
||||
tags: ${{ env.BASE_IMAGE_NAME }}:latest
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x
|
||||
labels: ${{ steps.base_meta.outputs.labels }}
|
||||
tags: ${{ env.BASE_IMAGE_NAME }}
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
platforms: linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
pull: false
|
||||
|
||||
- name: Extract version
|
||||
id: version
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: Build and push main image
|
||||
uses: docker/build-push-action@v5
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x
|
||||
file: Dockerfile
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ env.IMAGE_NAME }}
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
platforms: linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
pull: false
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}@${{ steps.base_build.outputs.digest }}
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p ${{ runner.temp }}/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-linux-arm64
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
build-amd:
|
||||
runs-on: ubicloud-standard-8
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Docker base meta
|
||||
id: base_meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.BASE_IMAGE_NAME }}
|
||||
tags: |
|
||||
${{ env.IMAGE_NAME }}:latest
|
||||
${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push base image
|
||||
id: base_build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.base
|
||||
labels: ${{ steps.base_meta.outputs.labels }}
|
||||
tags: ${{ env.BASE_IMAGE_NAME }}
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
pull: false
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ env.IMAGE_NAME }}
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
pull: false
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}@${{ steps.base_build.outputs.digest }}
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p ${{ runner.temp }}/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-linux-amd64
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
runs-on: ubicloud-standard-8
|
||||
needs:
|
||||
- build-amd
|
||||
- build-arm
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
run-linters:
|
||||
if: startsWith(github.actor, 'codegen-sh')
|
||||
name: Run linters
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
# Give the default GITHUB_TOKEN write permission to commit and push the
|
||||
# added or changed files to the repository.
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: 'yarn'
|
||||
- run: yarn install --frozen-lockfile
|
||||
- run: yarn lint --fix
|
||||
|
||||
- name: Commit changes
|
||||
uses: stefanzweifel/git-auto-commit-action@v5
|
||||
with:
|
||||
commit_message: 'Applied automatic fixes'
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
ARG APP_PATH=/opt/outline
|
||||
FROM outlinewiki/outline-base AS base
|
||||
ARG BASE_IMAGE=outlinewiki/outline-base
|
||||
FROM ${BASE_IMAGE} AS base
|
||||
|
||||
ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
@@ -30,7 +31,7 @@ RUN addgroup --gid 1001 nodejs && \
|
||||
adduser --uid 1001 --ingroup nodejs nodejs && \
|
||||
chown -R nodejs:nodejs $APP_PATH/build && \
|
||||
mkdir -p /var/lib/outline && \
|
||||
chown -R nodejs:nodejs /var/lib/outline
|
||||
chown -R nodejs:nodejs /var/lib/outline
|
||||
|
||||
ENV FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
|
||||
RUN mkdir -p "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
|
||||
|
||||
+4
-1
@@ -1,11 +1,14 @@
|
||||
ARG APP_PATH=/opt/outline
|
||||
FROM node:20-slim AS deps
|
||||
FROM node:20 AS deps
|
||||
|
||||
ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
COPY ./package.json ./yarn.lock ./
|
||||
COPY ./patches ./patches
|
||||
|
||||
RUN apt-get update && apt-get install -y cmake
|
||||
ENV NODE_OPTIONS="--max-old-space-size=24000"
|
||||
|
||||
RUN yarn install --no-optional --frozen-lockfile --network-timeout 1000000 && \
|
||||
yarn cache clean
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.82.0
|
||||
Licensed Work: Outline 0.83.0
|
||||
The Licensed Work is (c) 2025 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: 2029-02-15
|
||||
Change Date: 2029-04-11
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -171,6 +171,10 @@
|
||||
"description": "smtp.example.com (optional)",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_SERVICE": {
|
||||
"description": "Well-known SMTP service name for nodemailer (optional, e.g. 'gmail', 'SES')",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_PORT": {
|
||||
"description": "1234 (optional)",
|
||||
"required": false
|
||||
|
||||
@@ -149,7 +149,7 @@ export const searchInCollection = createAction({
|
||||
},
|
||||
|
||||
perform: ({ activeCollectionId }) => {
|
||||
history.push(searchPath(undefined, { collectionId: activeCollectionId }));
|
||||
history.push(searchPath({ collectionId: activeCollectionId }));
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
PadlockIcon,
|
||||
GlobeIcon,
|
||||
LogoutIcon,
|
||||
CaseSensitiveIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -510,6 +511,25 @@ export const copyDocumentAsMarkdown = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const copyDocumentAsPlainText = createAction({
|
||||
name: ({ t }) => t("Copy as text"),
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "clipboard",
|
||||
icon: <CaseSensitiveIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
perform: ({ stores, activeDocumentId, t }) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (document) {
|
||||
copy(document.toPlainText());
|
||||
toast.success(t("Text copied to clipboard"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const copyDocumentShareLink = createAction({
|
||||
name: ({ t }) => t("Copy public link"),
|
||||
section: ActiveDocumentSection,
|
||||
@@ -555,7 +575,12 @@ export const copyDocument = createAction({
|
||||
section: ActiveDocumentSection,
|
||||
icon: <CopyIcon />,
|
||||
keywords: "clipboard",
|
||||
children: [copyDocumentLink, copyDocumentShareLink, copyDocumentAsMarkdown],
|
||||
children: [
|
||||
copyDocumentLink,
|
||||
copyDocumentShareLink,
|
||||
copyDocumentAsMarkdown,
|
||||
copyDocumentAsPlainText,
|
||||
],
|
||||
});
|
||||
|
||||
export const duplicateDocument = createAction({
|
||||
@@ -693,7 +718,7 @@ export const searchInDocument = createAction({
|
||||
return !!document?.isActive;
|
||||
},
|
||||
perform: ({ activeDocumentId }) => {
|
||||
history.push(searchPath(undefined, { documentId: activeDocumentId }));
|
||||
history.push(searchPath({ documentId: activeDocumentId }));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -806,15 +831,15 @@ export const openRandomDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const searchDocumentsForQuery = (searchQuery: string) =>
|
||||
export const searchDocumentsForQuery = (query: string) =>
|
||||
createAction({
|
||||
id: "search",
|
||||
name: ({ t }) =>
|
||||
t(`Search documents for "{{searchQuery}}"`, { searchQuery }),
|
||||
t(`Search documents for "{{searchQuery}}"`, { searchQuery: query }),
|
||||
analyticsName: "Search documents",
|
||||
section: DocumentSection,
|
||||
icon: <SearchIcon />,
|
||||
perform: () => history.push(searchPath(searchQuery)),
|
||||
perform: () => history.push(searchPath({ query })),
|
||||
visible: ({ location }) => location.pathname !== searchPath(),
|
||||
});
|
||||
|
||||
@@ -1205,6 +1230,7 @@ export const rootDocumentActions = [
|
||||
copyDocumentLink,
|
||||
copyDocumentShareLink,
|
||||
copyDocumentAsMarkdown,
|
||||
copyDocumentAsPlainText,
|
||||
starDocument,
|
||||
unstarDocument,
|
||||
publishDocument,
|
||||
|
||||
@@ -50,7 +50,7 @@ export const navigateToRecentSearchQuery = (searchQuery: SearchQuery) =>
|
||||
name: searchQuery.query,
|
||||
analyticsName: "Navigate to recent search query",
|
||||
icon: <SearchIcon />,
|
||||
perform: () => history.push(searchPath(searchQuery.query)),
|
||||
perform: () => history.push(searchPath({ query: searchQuery.query })),
|
||||
});
|
||||
|
||||
export const navigateToDrafts = createAction({
|
||||
@@ -62,6 +62,15 @@ export const navigateToDrafts = createAction({
|
||||
visible: ({ location }) => location.pathname !== draftsPath(),
|
||||
});
|
||||
|
||||
export const navigateToSearch = createAction({
|
||||
name: ({ t }) => t("Search"),
|
||||
analyticsName: "Navigate to search",
|
||||
section: NavigationSection,
|
||||
icon: <SearchIcon />,
|
||||
perform: () => history.push(searchPath()),
|
||||
visible: ({ location }) => location.pathname !== searchPath(),
|
||||
});
|
||||
|
||||
export const navigateToArchive = createAction({
|
||||
name: ({ t }) => t("Archive"),
|
||||
analyticsName: "Navigate to archive",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import stores from "~/stores";
|
||||
import { OAuthClientNew } from "~/components/OAuthClient/OAuthClientNew";
|
||||
import { createAction } from "..";
|
||||
import { SettingsSection } from "../sections";
|
||||
|
||||
export const createOAuthClient = createAction({
|
||||
name: ({ t }) => t("New App"),
|
||||
analyticsName: "New App",
|
||||
section: SettingsSection,
|
||||
icon: <PlusIcon />,
|
||||
keywords: "create",
|
||||
visible: () =>
|
||||
stores.policies.abilities(stores.auth.team?.id || "").createOAuthClient,
|
||||
perform: ({ t, event }) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("New Application"),
|
||||
content: <OAuthClientNew onSubmit={stores.dialogs.closeAllModals} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import { ActionContext } from "~/types";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import { TeamSection } from "../sections";
|
||||
|
||||
export const createTeamsList = ({ stores }: { stores: RootStore }) =>
|
||||
export const switchTeamsList = ({ stores }: { stores: RootStore }) =>
|
||||
stores.auth.availableTeams?.map((session) => ({
|
||||
id: `switch-${session.id}`,
|
||||
name: session.name,
|
||||
@@ -44,7 +44,7 @@ export const switchTeam = createAction({
|
||||
section: TeamSection,
|
||||
visible: ({ stores }) =>
|
||||
!!stores.auth.availableTeams && stores.auth.availableTeams?.length > 1,
|
||||
children: createTeamsList,
|
||||
children: switchTeamsList,
|
||||
});
|
||||
|
||||
export const createTeam = createAction({
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Redirect } from "react-router-dom";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { changeLanguage } from "~/utils/language";
|
||||
import { logoutPath } from "~/utils/routeHelpers";
|
||||
import LoadingIndicator from "./LoadingIndicator";
|
||||
|
||||
type Props = {
|
||||
@@ -33,7 +32,7 @@ const Authenticated = ({ children }: Props) => {
|
||||
}
|
||||
|
||||
void auth.logout(true);
|
||||
return <Redirect to={logoutPath()} />;
|
||||
return <Redirect to="/" />;
|
||||
};
|
||||
|
||||
export default observer(Authenticated);
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Switch, Route, useLocation, matchPath } from "react-router-dom";
|
||||
import {
|
||||
Switch,
|
||||
Route,
|
||||
useLocation,
|
||||
matchPath,
|
||||
Redirect,
|
||||
} from "react-router-dom";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import ErrorSuspended from "~/scenes/ErrorSuspended";
|
||||
import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
|
||||
import Layout from "~/components/Layout";
|
||||
import RegisterKeyDown from "~/components/RegisterKeyDown";
|
||||
import Sidebar from "~/components/Sidebar";
|
||||
import SidebarRight from "~/components/Sidebar/Right";
|
||||
import SettingsSidebar from "~/components/Sidebar/Settings";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
@@ -48,6 +55,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
const can = usePolicy(ui.activeDocumentId);
|
||||
const canCollection = usePolicy(ui.activeCollectionId);
|
||||
const team = useCurrentTeam();
|
||||
const [spendPostLoginPath] = usePostLoginPath();
|
||||
|
||||
const goToSearch = (ev: KeyboardEvent) => {
|
||||
if (!ev.metaKey && !ev.ctrlKey) {
|
||||
@@ -72,6 +80,11 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
return <ErrorSuspended />;
|
||||
}
|
||||
|
||||
const postLoginPath = spendPostLoginPath();
|
||||
if (postLoginPath) {
|
||||
return <Redirect to={postLoginPath} />;
|
||||
}
|
||||
|
||||
const sidebar = (
|
||||
<Fade>
|
||||
<Switch>
|
||||
|
||||
@@ -13,6 +13,11 @@ export enum AvatarSize {
|
||||
Upload = 64,
|
||||
}
|
||||
|
||||
export enum AvatarVariant {
|
||||
Round = "round",
|
||||
Square = "square",
|
||||
}
|
||||
|
||||
export interface IAvatar {
|
||||
avatarUrl: string | null;
|
||||
color?: string;
|
||||
@@ -23,6 +28,8 @@ export interface IAvatar {
|
||||
type Props = {
|
||||
/** The size of the avatar */
|
||||
size: AvatarSize;
|
||||
/** The variant of the avatar */
|
||||
variant?: AvatarVariant;
|
||||
/** The source of the avatar image, if not passing a model. */
|
||||
src?: string;
|
||||
/** The avatar model, if not passing a source. */
|
||||
@@ -38,14 +45,14 @@ type Props = {
|
||||
};
|
||||
|
||||
function Avatar(props: Props) {
|
||||
const { model, style, ...rest } = props;
|
||||
const { model, style, variant = AvatarVariant.Round, ...rest } = props;
|
||||
const src = props.src || model?.avatarUrl;
|
||||
const [error, handleError] = useBoolean(false);
|
||||
|
||||
return (
|
||||
<Relative style={style}>
|
||||
<Relative style={style} $variant={variant} $size={props.size}>
|
||||
{src && !error ? (
|
||||
<CircleImg onError={handleError} src={src} {...rest} />
|
||||
<Image onError={handleError} src={src} {...rest} />
|
||||
) : model ? (
|
||||
<Initials color={model.color} {...rest}>
|
||||
{model.initial}
|
||||
@@ -61,19 +68,19 @@ Avatar.defaultProps = {
|
||||
size: AvatarSize.Medium,
|
||||
};
|
||||
|
||||
const Relative = styled.div`
|
||||
const Relative = styled.div<{ $variant: AvatarVariant; $size: AvatarSize }>`
|
||||
position: relative;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const CircleImg = styled.img<{ size: number }>`
|
||||
display: block;
|
||||
width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
border-radius: ${(props) =>
|
||||
props.$variant === AvatarVariant.Round ? "50%" : `${props.$size / 8}px`};
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const Image = styled.img<{ size: number }>`
|
||||
display: block;
|
||||
width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
`;
|
||||
|
||||
export default Avatar;
|
||||
|
||||
@@ -7,17 +7,43 @@ import User from "~/models/User";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import Avatar, { AvatarSize } from "./Avatar";
|
||||
|
||||
/**
|
||||
* Props for the AvatarWithPresence component
|
||||
*/
|
||||
type Props = {
|
||||
/** The user to display the avatar for */
|
||||
user: User;
|
||||
/** Whether the user is currently present in the document */
|
||||
isPresent: boolean;
|
||||
/** Whether the user is currently editing the document */
|
||||
isEditing: boolean;
|
||||
/** Whether the user is currently observing the document */
|
||||
isObserving: boolean;
|
||||
/** Whether this avatar represents the current user */
|
||||
isCurrentUser: boolean;
|
||||
/** Optional click handler for the avatar */
|
||||
onClick?: React.MouseEventHandler<HTMLImageElement>;
|
||||
/** Size of the avatar, defaults to AvatarSize.Large */
|
||||
size?: AvatarSize;
|
||||
/** Optional inline styles to apply to the avatar wrapper */
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
/**
|
||||
* AvatarWithPresence component displays a user's avatar with visual indicators
|
||||
* for their current status (present, editing, observing).
|
||||
*
|
||||
* The component shows different visual states:
|
||||
* - Present users have full opacity
|
||||
* - Non-present users have reduced opacity
|
||||
* - Observing users have a colored border matching their user color
|
||||
* - Hovering shows a colored border
|
||||
*
|
||||
* A tooltip displays the user's name and current status.
|
||||
*
|
||||
* @param props - Component properties
|
||||
* @returns React component
|
||||
*/
|
||||
function AvatarWithPresence({
|
||||
onClick,
|
||||
user,
|
||||
@@ -64,16 +90,33 @@ function AvatarWithPresence({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Centered container for tooltip content
|
||||
*/
|
||||
const Centered = styled.div`
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
/**
|
||||
* Props for the AvatarPresence styled component
|
||||
*/
|
||||
type AvatarWrapperProps = {
|
||||
/** Whether the user is currently present */
|
||||
$isPresent: boolean;
|
||||
/** Whether the user is currently observing */
|
||||
$isObserving: boolean;
|
||||
/** The user's color for border highlighting */
|
||||
$color: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Styled component that wraps the Avatar and provides visual indicators
|
||||
* for the user's presence status.
|
||||
*
|
||||
* - Adjusts opacity based on presence
|
||||
* - Adds colored borders for observing users
|
||||
* - Handles hover effects
|
||||
*/
|
||||
const AvatarPresence = styled.div<AvatarWrapperProps>`
|
||||
opacity: ${(props) => (props.$isPresent ? 1 : 0.5)};
|
||||
transition: opacity 250ms ease-in-out;
|
||||
|
||||
@@ -13,7 +13,6 @@ const Initials = styled(Flex)<{
|
||||
}>`
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: ${(props) =>
|
||||
@@ -23,7 +22,6 @@ const Initials = styled(Flex)<{
|
||||
background-color: ${(props) => props.color ?? props.theme.textTertiary};
|
||||
width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
|
||||
// adjust font size down for each additional character
|
||||
|
||||
@@ -80,6 +80,10 @@ const RealButton = styled(ActionButton)<RealProps>`
|
||||
} 0 0 0 1px inset;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
box-shadow: ${`rgba(0, 0, 0, 0.07) 0px 1px 2px, ${props.theme.inputBorderFocused} 0 0 0 1px inset`};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: ${props.theme.textTertiary};
|
||||
background: none;
|
||||
|
||||
@@ -49,7 +49,7 @@ function Collaborators(props: Props) {
|
||||
() =>
|
||||
orderBy(
|
||||
filter(
|
||||
users.orderedData,
|
||||
users.all,
|
||||
(u) =>
|
||||
(presentIds.includes(u.id) ||
|
||||
document.collaboratorIds.includes(u.id)) &&
|
||||
@@ -58,7 +58,7 @@ function Collaborators(props: Props) {
|
||||
[(u) => presentIds.includes(u.id), "id"],
|
||||
["asc", "asc"]
|
||||
),
|
||||
[document.collaboratorIds, users.orderedData, presentIds]
|
||||
[document.collaboratorIds, users.all, presentIds]
|
||||
);
|
||||
|
||||
// load any users we don't yet have in memory
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import uniq from "lodash/uniq";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
@@ -18,6 +19,7 @@ import Switch from "~/components/Switch";
|
||||
import Text from "~/components/Text";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { EmptySelectValue } from "~/types";
|
||||
|
||||
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
|
||||
@@ -30,6 +32,26 @@ export interface FormData {
|
||||
permission: CollectionPermission | undefined;
|
||||
}
|
||||
|
||||
const useIconColor = (collection?: Collection) => {
|
||||
const { collections } = useStores();
|
||||
const hasMultipleCollections = collections.orderedData.length > 1;
|
||||
const collectionColors = uniq(
|
||||
collections.orderedData.map((c) => c.color).filter(Boolean)
|
||||
) as string[];
|
||||
|
||||
const iconColor = React.useMemo(
|
||||
() =>
|
||||
collection?.color ??
|
||||
// If all the existing collections have the same color, use that color,
|
||||
// otherwise pick a random color from the palette
|
||||
(hasMultipleCollections && collectionColors.length === 1
|
||||
? collectionColors[0]
|
||||
: randomElement(colorPalette)),
|
||||
[collection?.color]
|
||||
);
|
||||
return iconColor;
|
||||
};
|
||||
|
||||
export const CollectionForm = observer(function CollectionForm_({
|
||||
handleSubmit,
|
||||
collection,
|
||||
@@ -42,11 +64,7 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
|
||||
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
|
||||
|
||||
const iconColor = React.useMemo(
|
||||
() => collection?.color ?? randomElement(colorPalette),
|
||||
[collection?.color]
|
||||
);
|
||||
|
||||
const iconColor = useIconColor(collection);
|
||||
const fallbackIcon = <Icon value="collection" color={iconColor} />;
|
||||
|
||||
const {
|
||||
|
||||
@@ -4,6 +4,12 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import {
|
||||
AuthenticationFailed,
|
||||
AuthorizationFailed,
|
||||
DocumentTooLarge,
|
||||
TooManyConnections,
|
||||
} from "@shared/collaboration/CloseEvents";
|
||||
import Fade from "~/components/Fade";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
@@ -14,21 +20,21 @@ function ConnectionStatus() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const codeToMessage = {
|
||||
1009: {
|
||||
[DocumentTooLarge.code]: {
|
||||
title: t("Document is too large"),
|
||||
body: t(
|
||||
"This document has reached the maximum size and can no longer be edited"
|
||||
),
|
||||
},
|
||||
4401: {
|
||||
[AuthenticationFailed.code]: {
|
||||
title: t("Authentication failed"),
|
||||
body: t("Please try logging out and back in again"),
|
||||
},
|
||||
4403: {
|
||||
[AuthorizationFailed.code]: {
|
||||
title: t("Authorization failed"),
|
||||
body: t("You may have lost access to this document, try reloading"),
|
||||
},
|
||||
4503: {
|
||||
[TooManyConnections.code]: {
|
||||
title: t("Too many users connected to document"),
|
||||
body: t("Your edits will sync once other users leave the document"),
|
||||
},
|
||||
|
||||
@@ -2,16 +2,11 @@ import { HomeIcon } from "outline-icons";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Optional } from "utility-types";
|
||||
import Flex from "~/components/Flex";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import InputSelect from "~/components/InputSelect";
|
||||
import { IconWrapper } from "~/components/Sidebar/components/SidebarLink";
|
||||
import { InputSelectNew, Option } from "~/components/InputSelectNew";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type DefaultCollectionInputSelectProps = Optional<
|
||||
React.ComponentProps<typeof InputSelect>
|
||||
> & {
|
||||
type DefaultCollectionInputSelectProps = {
|
||||
onSelectCollection: (collection: string) => void;
|
||||
defaultCollectionId: string | null;
|
||||
};
|
||||
@@ -19,7 +14,6 @@ type DefaultCollectionInputSelectProps = Optional<
|
||||
const DefaultCollectionInputSelect = ({
|
||||
onSelectCollection,
|
||||
defaultCollectionId,
|
||||
...rest
|
||||
}: DefaultCollectionInputSelectProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { collections } = useStores();
|
||||
@@ -47,36 +41,26 @@ const DefaultCollectionInputSelect = ({
|
||||
void fetchData();
|
||||
}, [fetchError, t, fetching, collections]);
|
||||
|
||||
const options = React.useMemo(
|
||||
const options: Option[] = React.useMemo(
|
||||
() =>
|
||||
collections.nonPrivate.reduce(
|
||||
(acc, collection) => [
|
||||
...acc,
|
||||
{
|
||||
label: (
|
||||
<Flex align="center">
|
||||
<IconWrapper>
|
||||
<CollectionIcon collection={collection} />
|
||||
</IconWrapper>
|
||||
{collection.name}
|
||||
</Flex>
|
||||
),
|
||||
type: "item",
|
||||
label: collection.name,
|
||||
value: collection.id,
|
||||
icon: <CollectionIcon collection={collection} />,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
label: (
|
||||
<Flex align="center">
|
||||
<IconWrapper>
|
||||
<HomeIcon />
|
||||
</IconWrapper>
|
||||
{t("Home")}
|
||||
</Flex>
|
||||
),
|
||||
type: "item",
|
||||
label: t("Home"),
|
||||
value: "home",
|
||||
icon: <HomeIcon />,
|
||||
},
|
||||
]
|
||||
] satisfies Option[]
|
||||
),
|
||||
[collections.nonPrivate, t]
|
||||
);
|
||||
@@ -86,13 +70,14 @@ const DefaultCollectionInputSelect = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<InputSelect
|
||||
value={defaultCollectionId ?? "home"}
|
||||
<InputSelectNew
|
||||
options={options}
|
||||
value={defaultCollectionId ?? "home"}
|
||||
onChange={onSelectCollection}
|
||||
ariaLabel={t("Default collection")}
|
||||
label={t("Start view")}
|
||||
hideLabel
|
||||
short
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -23,7 +23,10 @@ function Dialogs() {
|
||||
key={id}
|
||||
isOpen={modal.isOpen}
|
||||
fullscreen={modal.fullscreen ?? false}
|
||||
onRequestClose={() => dialogs.closeModal(id)}
|
||||
onRequestClose={() => {
|
||||
modal.onClose?.();
|
||||
dialogs.closeModal(id);
|
||||
}}
|
||||
title={modal.title}
|
||||
style={modal.style}
|
||||
>
|
||||
|
||||
@@ -18,6 +18,13 @@ type Props = {
|
||||
children?: React.ReactNode;
|
||||
document: Document;
|
||||
onlyText?: boolean;
|
||||
reverse?: boolean;
|
||||
/**
|
||||
* Maximum number of items to show in the breadcrumb.
|
||||
* If value is less than or equals to 0, no items will be shown.
|
||||
* If value is undefined, all items will be shown.
|
||||
*/
|
||||
maxDepth?: number;
|
||||
};
|
||||
|
||||
function useCategory(document: Document): MenuInternalLink | null {
|
||||
@@ -54,7 +61,7 @@ function useCategory(document: Document): MenuInternalLink | null {
|
||||
}
|
||||
|
||||
function DocumentBreadcrumb(
|
||||
{ document, children, onlyText }: Props,
|
||||
{ document, children, onlyText, reverse = false, maxDepth }: Props,
|
||||
ref: React.RefObject<HTMLDivElement> | null
|
||||
) {
|
||||
const { collections } = useStores();
|
||||
@@ -65,6 +72,7 @@ function DocumentBreadcrumb(
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const can = usePolicy(collection);
|
||||
const depth = maxDepth === undefined ? undefined : Math.max(0, maxDepth);
|
||||
|
||||
React.useEffect(() => {
|
||||
void document.loadRelations({ withoutPolicies: true });
|
||||
@@ -91,20 +99,23 @@ function DocumentBreadcrumb(
|
||||
};
|
||||
}
|
||||
|
||||
const path = document.pathTo;
|
||||
const path = document.pathTo.slice(0, -1);
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
const output = [];
|
||||
const output: MenuInternalLink[] = [];
|
||||
|
||||
if (depth === 0) {
|
||||
return output;
|
||||
}
|
||||
|
||||
if (category) {
|
||||
output.push(category);
|
||||
}
|
||||
|
||||
if (collectionNode) {
|
||||
output.push(collectionNode);
|
||||
}
|
||||
|
||||
path.slice(0, -1).forEach((node: NavigationNode) => {
|
||||
path.forEach((node: NavigationNode) => {
|
||||
const title = node.title || t("Untitled");
|
||||
output.push({
|
||||
type: "route",
|
||||
@@ -121,21 +132,43 @@ function DocumentBreadcrumb(
|
||||
},
|
||||
});
|
||||
});
|
||||
return output;
|
||||
}, [t, path, category, sidebarContext, collectionNode]);
|
||||
|
||||
return reverse
|
||||
? depth !== undefined
|
||||
? output.slice(-depth)
|
||||
: output
|
||||
: depth !== undefined
|
||||
? output.slice(0, depth)
|
||||
: output;
|
||||
}, [t, path, category, sidebarContext, collectionNode, reverse, depth]);
|
||||
|
||||
if (!collections.isLoaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (onlyText === true) {
|
||||
if (onlyText) {
|
||||
if (depth === 0) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const slicedPath = reverse
|
||||
? path.slice(depth && -depth)
|
||||
: path.slice(0, depth);
|
||||
|
||||
const showCollection =
|
||||
collection &&
|
||||
(!reverse || depth === undefined || slicedPath.length < depth);
|
||||
|
||||
return (
|
||||
<>
|
||||
{collection?.name}
|
||||
{path.slice(0, -1).map((node: NavigationNode) => (
|
||||
{showCollection && collection.name}
|
||||
{slicedPath.map((node: NavigationNode, index: number) => (
|
||||
<React.Fragment key={node.id}>
|
||||
<SmallSlash />
|
||||
{showCollection && <SmallSlash />}
|
||||
{node.title || t("Untitled")}
|
||||
{!showCollection && index !== slicedPath.length - 1 && (
|
||||
<SmallSlash />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -46,10 +46,10 @@ function DocumentViews({ document, isOpen }: Props) {
|
||||
return (
|
||||
<>
|
||||
{isOpen && (
|
||||
<PaginatedList
|
||||
<PaginatedList<User>
|
||||
aria-label={t("Viewers")}
|
||||
items={users}
|
||||
renderItem={(model: User) => {
|
||||
renderItem={(model) => {
|
||||
const view = documentViews.find((v) => v.userId === model.id);
|
||||
const isPresent = presentIds.includes(model.id);
|
||||
const isEditing = editingIds.includes(model.id);
|
||||
|
||||
+39
-13
@@ -6,7 +6,9 @@ import * as React from "react";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import { Optional } from "utility-types";
|
||||
import insertFiles from "@shared/editor/commands/insertFiles";
|
||||
import EditorContainer from "@shared/editor/components/Styles";
|
||||
import { AttachmentPreset } from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { getDataTransferFiles } from "@shared/utils/files";
|
||||
import { AttachmentValidation } from "@shared/validations";
|
||||
import ClickablePadding from "~/components/ClickablePadding";
|
||||
@@ -183,22 +185,46 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
[updateComments]
|
||||
);
|
||||
|
||||
const paragraphs = React.useMemo(() => {
|
||||
if (props.readOnly && typeof props.value === "object") {
|
||||
return ProsemirrorHelper.getPlainParagraphs(props.value);
|
||||
}
|
||||
return undefined;
|
||||
}, [props.readOnly, props.value]);
|
||||
|
||||
return (
|
||||
<ErrorBoundary component="div" reloadOnChunkMissing>
|
||||
<>
|
||||
<LazyLoadedEditor
|
||||
key={props.extensions?.length || 0}
|
||||
ref={mergeRefs([ref, localRef, handleRefChanged])}
|
||||
uploadFile={handleUploadFile}
|
||||
embeds={embeds}
|
||||
userPreferences={preferences}
|
||||
dictionary={dictionary}
|
||||
{...props}
|
||||
onClickLink={handleClickLink}
|
||||
onChange={handleChange}
|
||||
placeholder={props.placeholder || ""}
|
||||
defaultValue={props.defaultValue || ""}
|
||||
/>
|
||||
{paragraphs ? (
|
||||
<EditorContainer
|
||||
rtl={props.dir === "rtl"}
|
||||
grow={props.grow}
|
||||
style={props.style}
|
||||
editorStyle={props.editorStyle}
|
||||
>
|
||||
<div className="ProseMirror">
|
||||
{paragraphs.map((paragraph, index) => (
|
||||
<p key={index} dir="auto">
|
||||
{paragraph.content?.map((content) => content.text)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</EditorContainer>
|
||||
) : (
|
||||
<LazyLoadedEditor
|
||||
key={props.extensions?.length || 0}
|
||||
ref={mergeRefs([ref, localRef, handleRefChanged])}
|
||||
uploadFile={handleUploadFile}
|
||||
embeds={embeds}
|
||||
userPreferences={preferences}
|
||||
dictionary={dictionary}
|
||||
{...props}
|
||||
onClickLink={handleClickLink}
|
||||
onChange={handleChange}
|
||||
placeholder={props.placeholder || ""}
|
||||
defaultValue={props.defaultValue || ""}
|
||||
/>
|
||||
)}
|
||||
{props.editorStyle?.paddingBottom && !props.readOnly && (
|
||||
<ClickablePadding
|
||||
onClick={props.readOnly ? undefined : focusAtEnd}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Text from "~/components/Text";
|
||||
|
||||
const Empty = styled.p`
|
||||
color: ${s("textTertiary")};
|
||||
user-select: none;
|
||||
`;
|
||||
const Empty = styled(Text).attrs({
|
||||
type: "tertiary",
|
||||
selectable: false,
|
||||
})``;
|
||||
|
||||
export default Empty;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { s } from "@shared/styles";
|
||||
import { UrlHelper } from "@shared/utils/UrlHelper";
|
||||
import Button from "~/components/Button";
|
||||
import CenteredContent from "~/components/CenteredContent";
|
||||
import Heading from "~/components/Heading";
|
||||
import PageTitle from "~/components/PageTitle";
|
||||
import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
@@ -77,9 +78,9 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
{showTitle && (
|
||||
<>
|
||||
<PageTitle title={t("Module failed to load")} />
|
||||
<h1>
|
||||
<Heading>
|
||||
<Trans>Loading Failed</Trans>
|
||||
</h1>
|
||||
</Heading>
|
||||
</>
|
||||
)}
|
||||
<Text as="p" type="secondary">
|
||||
@@ -101,9 +102,9 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
{showTitle && (
|
||||
<>
|
||||
<PageTitle title={t("Something Unexpected Happened")} />
|
||||
<h1>
|
||||
<Heading>
|
||||
<Trans>Something Unexpected Happened</Trans>
|
||||
</h1>
|
||||
</Heading>
|
||||
</>
|
||||
)}
|
||||
<Text as="p" type="secondary">
|
||||
|
||||
@@ -234,7 +234,7 @@ const lineStyle = css`
|
||||
width: 1px;
|
||||
height: calc(50% - 14px + 8px);
|
||||
background: ${s("divider")};
|
||||
mix-blend-mode: multiply;
|
||||
mix-blend-mode: ${(props) => (props.theme.isDark ? "lighten" : "multiply")};
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@@ -255,7 +255,7 @@ const lineStyle = css`
|
||||
width: 1px;
|
||||
height: calc(50% - 14px);
|
||||
background: ${s("divider")};
|
||||
mix-blend-mode: multiply;
|
||||
mix-blend-mode: ${(props) => (props.theme.isDark ? "lighten" : "multiply")};
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,11 @@ import React from "react";
|
||||
import styled from "styled-components";
|
||||
import { fadeIn } from "~/styles/animations";
|
||||
|
||||
/**
|
||||
* Fade in animation for a component.
|
||||
*
|
||||
* @param timing - The duration of the fade in animation, default is 250ms.
|
||||
*/
|
||||
const Fade = styled.span<{ timing?: number | string }>`
|
||||
animation: ${fadeIn} ${(props) => props.timing || "250ms"} ease-in-out;
|
||||
`;
|
||||
@@ -17,7 +22,6 @@ type Props = {
|
||||
*/
|
||||
export const ConditionalFade = ({ animate, children }: Props) => {
|
||||
const [isAnimated] = React.useState(animate);
|
||||
|
||||
return isAnimated ? <Fade>{children}</Fade> : <>{children}</>;
|
||||
};
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ const FilterOptions = ({
|
||||
: "";
|
||||
|
||||
const renderItem = React.useCallback(
|
||||
(option: TFilterOption) => (
|
||||
(option) => (
|
||||
<MenuItem
|
||||
key={option.key}
|
||||
onClick={() => {
|
||||
@@ -174,7 +174,7 @@ const FilterOptions = ({
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu aria-label={defaultLabel} minHeight={66} {...menu}>
|
||||
<PaginatedList
|
||||
<PaginatedList<TFilterOption>
|
||||
listRef={listRef}
|
||||
options={{ query, ...fetchQueryOptions }}
|
||||
items={filteredOptions}
|
||||
|
||||
@@ -73,7 +73,7 @@ const Backdrop = styled.div`
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: ${s("backdrop")} !important;
|
||||
z-index: ${depths.modalOverlay};
|
||||
z-index: ${depths.overlay};
|
||||
transition: opacity 200ms ease-in-out;
|
||||
opacity: 0;
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { transparentize } from "polished";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { getTextColor } from "@shared/utils/color";
|
||||
import Text from "~/components/Text";
|
||||
|
||||
export const CARD_MARGIN = 10;
|
||||
@@ -33,7 +32,7 @@ export const Title = styled(Text).attrs({ as: "h2", size: "large" })`
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
gap: 4px;
|
||||
gap: 6px;
|
||||
`;
|
||||
|
||||
export const Info = styled(StyledText).attrs(() => ({
|
||||
@@ -60,15 +59,27 @@ export const Thumbnail = styled.img`
|
||||
export const Label = styled(Text).attrs({ size: "xsmall", weight: "bold" })<{
|
||||
color?: string;
|
||||
}>`
|
||||
background-color: ${(props) =>
|
||||
props.color ?? props.theme.backgroundSecondary};
|
||||
color: ${(props) =>
|
||||
props.color ? getTextColor(props.color) : props.theme.text};
|
||||
border: 1px solid ${(props) => props.theme.divider};
|
||||
width: fit-content;
|
||||
border-radius: 2em;
|
||||
padding: 0 8px;
|
||||
padding: 1px 8px 1px 20px;
|
||||
margin-right: 0.5em;
|
||||
margin-top: 0.5em;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background-color: ${(props) =>
|
||||
props.color || props.theme.backgroundSecondary};
|
||||
}
|
||||
`;
|
||||
|
||||
export const CardContent = styled.div`
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { m } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Portal } from "react-portal";
|
||||
import styled from "styled-components";
|
||||
@@ -8,6 +9,7 @@ import useEventListener from "~/hooks/useEventListener";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import LoadingIndicator from "../LoadingIndicator";
|
||||
import { CARD_MARGIN } from "./Components";
|
||||
import HoverPreviewDocument from "./HoverPreviewDocument";
|
||||
@@ -23,9 +25,9 @@ const POINTER_WIDTH = 22;
|
||||
type Props = {
|
||||
/** The HTML element that is being hovered over, or null if none. */
|
||||
element: HTMLElement | null;
|
||||
/** Data to be previewed */
|
||||
data: Record<string, any> | null;
|
||||
/** Whether the preview data is being loaded */
|
||||
/** ID of the unfurl that will be shown in the hover preview. */
|
||||
unfurlId: string | null;
|
||||
/** Whether the preview data is being loaded. */
|
||||
dataLoading: boolean;
|
||||
/** A callback on close of the hover preview. */
|
||||
onClose: () => void;
|
||||
@@ -36,151 +38,155 @@ enum Direction {
|
||||
DOWN,
|
||||
}
|
||||
|
||||
function HoverPreviewDesktop({ element, data, dataLoading, onClose }: Props) {
|
||||
const [isVisible, setVisible] = React.useState(false);
|
||||
const timerClose = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
const cardRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const { cardLeft, cardTop, pointerLeft, pointerTop, pointerDir } =
|
||||
useHoverPosition({
|
||||
cardRef,
|
||||
element,
|
||||
isVisible,
|
||||
});
|
||||
const HoverPreviewDesktop = observer(
|
||||
({ element, unfurlId, dataLoading, onClose }: Props) => {
|
||||
const { unfurls } = useStores();
|
||||
const [isVisible, setVisible] = React.useState(false);
|
||||
const timerClose = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
const cardRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const { cardLeft, cardTop, pointerLeft, pointerTop, pointerDir } =
|
||||
useHoverPosition({
|
||||
cardRef,
|
||||
element,
|
||||
isVisible,
|
||||
});
|
||||
const data = unfurlId ? unfurls.get(unfurlId)?.data : undefined;
|
||||
|
||||
const closePreview = React.useCallback(() => {
|
||||
setVisible(false);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
const closePreview = React.useCallback(() => {
|
||||
setVisible(false);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const stopCloseTimer = React.useCallback(() => {
|
||||
if (timerClose.current) {
|
||||
clearTimeout(timerClose.current);
|
||||
timerClose.current = undefined;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startCloseTimer = React.useCallback(() => {
|
||||
timerClose.current = setTimeout(closePreview, DELAY_CLOSE);
|
||||
}, [closePreview]);
|
||||
|
||||
// Open and close the preview when the element changes.
|
||||
React.useEffect(() => {
|
||||
if (element && data && !dataLoading) {
|
||||
setVisible(true);
|
||||
} else {
|
||||
startCloseTimer();
|
||||
}
|
||||
}, [startCloseTimer, element, data, dataLoading]);
|
||||
|
||||
// Close the preview on Escape, scroll, or click outside.
|
||||
useOnClickOutside(cardRef, closePreview);
|
||||
useKeyDown("Escape", closePreview);
|
||||
useEventListener("scroll", closePreview, window, { capture: true });
|
||||
|
||||
// Ensure that the preview stays open while the user is hovering over the card.
|
||||
React.useEffect(() => {
|
||||
const card = cardRef.current;
|
||||
|
||||
if (isVisible) {
|
||||
if (card) {
|
||||
card.addEventListener("mouseenter", stopCloseTimer);
|
||||
card.addEventListener("mouseleave", startCloseTimer);
|
||||
const stopCloseTimer = React.useCallback(() => {
|
||||
if (timerClose.current) {
|
||||
clearTimeout(timerClose.current);
|
||||
timerClose.current = undefined;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return () => {
|
||||
if (card) {
|
||||
card.removeEventListener("mouseenter", stopCloseTimer);
|
||||
card.removeEventListener("mouseleave", startCloseTimer);
|
||||
const startCloseTimer = React.useCallback(() => {
|
||||
timerClose.current = setTimeout(closePreview, DELAY_CLOSE);
|
||||
}, [closePreview]);
|
||||
|
||||
// Open and close the preview when the element changes.
|
||||
React.useEffect(() => {
|
||||
if (element && data && !dataLoading) {
|
||||
setVisible(true);
|
||||
} else {
|
||||
startCloseTimer();
|
||||
}
|
||||
}, [startCloseTimer, element, data, dataLoading]);
|
||||
|
||||
// Close the preview on Escape, scroll, or click outside.
|
||||
useOnClickOutside(cardRef, closePreview);
|
||||
useKeyDown("Escape", closePreview);
|
||||
useEventListener("scroll", closePreview, window, { capture: true });
|
||||
|
||||
// Ensure that the preview stays open while the user is hovering over the card.
|
||||
React.useEffect(() => {
|
||||
const card = cardRef.current;
|
||||
|
||||
if (isVisible) {
|
||||
if (card) {
|
||||
card.addEventListener("mouseenter", stopCloseTimer);
|
||||
card.addEventListener("mouseleave", startCloseTimer);
|
||||
}
|
||||
}
|
||||
|
||||
stopCloseTimer();
|
||||
};
|
||||
}, [element, startCloseTimer, isVisible, stopCloseTimer]);
|
||||
return () => {
|
||||
if (card) {
|
||||
card.removeEventListener("mouseenter", stopCloseTimer);
|
||||
card.removeEventListener("mouseleave", startCloseTimer);
|
||||
}
|
||||
|
||||
if (dataLoading) {
|
||||
return <LoadingIndicator />;
|
||||
stopCloseTimer();
|
||||
};
|
||||
}, [element, startCloseTimer, isVisible, stopCloseTimer]);
|
||||
|
||||
if (dataLoading) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Position top={cardTop} left={cardLeft} aria-hidden>
|
||||
{isVisible ? (
|
||||
<Animate
|
||||
initial={{ opacity: 0, y: -20, pointerEvents: "none" }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transitionEnd: { pointerEvents: "auto" },
|
||||
}}
|
||||
>
|
||||
{data.type === UnfurlResourceType.Mention ? (
|
||||
<HoverPreviewMention
|
||||
ref={cardRef}
|
||||
name={data.name}
|
||||
avatarUrl={data.avatarUrl}
|
||||
color={data.color}
|
||||
lastActive={data.lastActive}
|
||||
email={data.email}
|
||||
/>
|
||||
) : data.type === UnfurlResourceType.Document ? (
|
||||
<HoverPreviewDocument
|
||||
ref={cardRef}
|
||||
url={data.url}
|
||||
id={data.id}
|
||||
title={data.title}
|
||||
summary={data.summary}
|
||||
lastActivityByViewer={data.lastActivityByViewer}
|
||||
/>
|
||||
) : data.type === UnfurlResourceType.Issue ? (
|
||||
<HoverPreviewIssue
|
||||
ref={cardRef}
|
||||
url={data.url}
|
||||
id={data.id}
|
||||
title={data.title}
|
||||
description={data.description}
|
||||
author={data.author}
|
||||
labels={data.labels}
|
||||
state={data.state}
|
||||
createdAt={data.createdAt}
|
||||
/>
|
||||
) : data.type === UnfurlResourceType.PR ? (
|
||||
<HoverPreviewPullRequest
|
||||
ref={cardRef}
|
||||
url={data.url}
|
||||
id={data.id}
|
||||
title={data.title}
|
||||
description={data.description}
|
||||
author={data.author}
|
||||
createdAt={data.createdAt}
|
||||
state={data.state}
|
||||
/>
|
||||
) : (
|
||||
<HoverPreviewLink
|
||||
ref={cardRef}
|
||||
url={data.url}
|
||||
thumbnailUrl={data.thumbnailUrl}
|
||||
title={data.title}
|
||||
description={data.description}
|
||||
/>
|
||||
)}
|
||||
<Pointer
|
||||
top={pointerTop}
|
||||
left={pointerLeft}
|
||||
direction={pointerDir}
|
||||
/>
|
||||
</Animate>
|
||||
) : null}
|
||||
</Position>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Position top={cardTop} left={cardLeft} aria-hidden>
|
||||
{isVisible ? (
|
||||
<Animate
|
||||
initial={{ opacity: 0, y: -20, pointerEvents: "none" }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transitionEnd: { pointerEvents: "auto" },
|
||||
}}
|
||||
>
|
||||
{data.type === UnfurlResourceType.Mention ? (
|
||||
<HoverPreviewMention
|
||||
ref={cardRef}
|
||||
name={data.name}
|
||||
avatarUrl={data.avatarUrl}
|
||||
color={data.color}
|
||||
lastActive={data.lastActive}
|
||||
email={data.email}
|
||||
/>
|
||||
) : data.type === UnfurlResourceType.Document ? (
|
||||
<HoverPreviewDocument
|
||||
ref={cardRef}
|
||||
url={data.url}
|
||||
id={data.id}
|
||||
title={data.title}
|
||||
summary={data.summary}
|
||||
lastActivityByViewer={data.lastActivityByViewer}
|
||||
/>
|
||||
) : data.type === UnfurlResourceType.Issue ? (
|
||||
<HoverPreviewIssue
|
||||
ref={cardRef}
|
||||
url={data.url}
|
||||
id={data.id}
|
||||
title={data.title}
|
||||
description={data.description}
|
||||
author={data.author}
|
||||
labels={data.labels}
|
||||
state={data.state}
|
||||
createdAt={data.createdAt}
|
||||
/>
|
||||
) : data.type === UnfurlResourceType.PR ? (
|
||||
<HoverPreviewPullRequest
|
||||
ref={cardRef}
|
||||
url={data.url}
|
||||
id={data.id}
|
||||
title={data.title}
|
||||
description={data.description}
|
||||
author={data.author}
|
||||
createdAt={data.createdAt}
|
||||
state={data.state}
|
||||
/>
|
||||
) : (
|
||||
<HoverPreviewLink
|
||||
ref={cardRef}
|
||||
url={data.url}
|
||||
thumbnailUrl={data.thumbnailUrl}
|
||||
title={data.title}
|
||||
description={data.description}
|
||||
/>
|
||||
)}
|
||||
<Pointer
|
||||
top={pointerTop}
|
||||
left={pointerLeft}
|
||||
direction={pointerDir}
|
||||
/>
|
||||
</Animate>
|
||||
) : null}
|
||||
</Position>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function HoverPreview({ element, data, dataLoading, ...rest }: Props) {
|
||||
function HoverPreview({ element, unfurlId, dataLoading, ...rest }: Props) {
|
||||
const isMobile = useMobile();
|
||||
if (isMobile) {
|
||||
return null;
|
||||
@@ -190,7 +196,7 @@ function HoverPreview({ element, data, dataLoading, ...rest }: Props) {
|
||||
<HoverPreviewDesktop
|
||||
{...rest}
|
||||
element={element}
|
||||
data={data}
|
||||
unfurlId={unfurlId}
|
||||
dataLoading={dataLoading}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import * as React from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import styled from "styled-components";
|
||||
import { Backticks } from "@shared/components/Backticks";
|
||||
import { IssueStatusIcon } from "@shared/components/IssueStatusIcon";
|
||||
import {
|
||||
IntegrationService,
|
||||
UnfurlResourceType,
|
||||
UnfurlResponse,
|
||||
} from "@shared/types";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import { IssueStatusIcon } from "../Icons/IssueStatusIcon";
|
||||
import Text from "../Text";
|
||||
import Time from "../Time";
|
||||
import {
|
||||
@@ -23,6 +29,11 @@ const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
const authorName = author.name;
|
||||
const urlObj = new URL(url);
|
||||
const service =
|
||||
urlObj.hostname === "github.com"
|
||||
? IntegrationService.GitHub
|
||||
: IntegrationService.Linear;
|
||||
|
||||
return (
|
||||
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
|
||||
@@ -31,13 +42,18 @@ const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
|
||||
<CardContent>
|
||||
<Flex gap={2} column>
|
||||
<Title>
|
||||
<IssueStatusIcon status={state.name} color={state.color} />
|
||||
<StyledIssueStatusIcon
|
||||
service={service}
|
||||
state={state}
|
||||
size={18}
|
||||
/>
|
||||
<span>
|
||||
{title} <Text type="tertiary">{id}</Text>
|
||||
<Backticks content={title} />
|
||||
<Text type="tertiary">{id}</Text>
|
||||
</span>
|
||||
</Title>
|
||||
<Flex align="center" gap={4}>
|
||||
<Avatar src={author.avatarUrl} />
|
||||
<Flex align="center" gap={6}>
|
||||
<Avatar src={author.avatarUrl} size={18} />
|
||||
<Info>
|
||||
<Trans>
|
||||
{{ authorName }} created{" "}
|
||||
@@ -62,4 +78,8 @@ const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
|
||||
);
|
||||
});
|
||||
|
||||
const StyledIssueStatusIcon = styled(IssueStatusIcon)`
|
||||
margin-top: 2px;
|
||||
`;
|
||||
|
||||
export default HoverPreviewIssue;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import * as React from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { Backticks } from "@shared/components/Backticks";
|
||||
import { PullRequestIcon } from "@shared/components/PullRequestIcon";
|
||||
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import { PullRequestIcon } from "../Icons/PullRequestIcon";
|
||||
import Text from "../Text";
|
||||
import Time from "../Time";
|
||||
import {
|
||||
@@ -31,13 +33,14 @@ const HoverPreviewPullRequest = React.forwardRef(
|
||||
<CardContent>
|
||||
<Flex gap={2} column>
|
||||
<Title>
|
||||
<PullRequestIcon status={state.name} color={state.color} />
|
||||
<StyledPullRequestIcon size={18} state={state} />
|
||||
<span>
|
||||
{title} <Text type="tertiary">{id}</Text>
|
||||
<Backticks content={title} />
|
||||
<Text type="tertiary">{id}</Text>
|
||||
</span>
|
||||
</Title>
|
||||
<Flex align="center" gap={4}>
|
||||
<Avatar src={author.avatarUrl} />
|
||||
<Flex align="center" gap={6}>
|
||||
<Avatar src={author.avatarUrl} size={18} />
|
||||
<Info>
|
||||
<Trans>
|
||||
{{ authorName }} opened{" "}
|
||||
@@ -55,4 +58,8 @@ const HoverPreviewPullRequest = React.forwardRef(
|
||||
}
|
||||
);
|
||||
|
||||
const StyledPullRequestIcon = styled(PullRequestIcon)`
|
||||
margin-top: 2px;
|
||||
`;
|
||||
|
||||
export default HoverPreviewPullRequest;
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
status: string;
|
||||
color: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Issue status icon based on GitHub issue status, but can be used for any git-style integration.
|
||||
*/
|
||||
export function IssueStatusIcon({ size, ...rest }: Props) {
|
||||
return (
|
||||
<Icon size={size}>
|
||||
<BaseIcon {...rest} />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = styled.span<{ size?: number }>`
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
width: ${(props) => props.size ?? 24}px;
|
||||
height: ${(props) => props.size ?? 24}px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
function BaseIcon(props: Props) {
|
||||
switch (props.status) {
|
||||
case "open":
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
height="16"
|
||||
fill={props.color}
|
||||
className={props.className}
|
||||
>
|
||||
<path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" />
|
||||
<path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z" />
|
||||
</svg>
|
||||
);
|
||||
case "closed":
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
height="16"
|
||||
fill={props.color}
|
||||
className={props.className}
|
||||
>
|
||||
<path d="M11.28 6.78a.75.75 0 0 0-1.06-1.06L7.25 8.69 5.78 7.22a.75.75 0 0 0-1.06 1.06l2 2a.75.75 0 0 0 1.06 0l3.5-3.5Z" />
|
||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0Zm-1.5 0a6.5 6.5 0 1 0-13 0 6.5 6.5 0 0 0 13 0Z" />
|
||||
</svg>
|
||||
);
|
||||
case "canceled":
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
height="16"
|
||||
fill={props.color}
|
||||
className={props.className}
|
||||
>
|
||||
<path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm9.78-2.22-5.5 5.5a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l5.5-5.5a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042Z" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
status: string;
|
||||
color: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Issue status icon based on GitHub pull requests, but can be used for any git-style integration.
|
||||
*/
|
||||
export function PullRequestIcon({ size, ...rest }: Props) {
|
||||
return (
|
||||
<Icon size={size}>
|
||||
<BaseIcon {...rest} />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = styled.span<{ size?: number }>`
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
width: ${(props) => props.size ?? 24}px;
|
||||
height: ${(props) => props.size ?? 24}px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
function BaseIcon(props: Props) {
|
||||
switch (props.status) {
|
||||
case "open":
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
height="16"
|
||||
fill={props.color}
|
||||
className={props.className}
|
||||
>
|
||||
<path d="M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z" />
|
||||
</svg>
|
||||
);
|
||||
case "merged":
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
height="16"
|
||||
fill={props.color}
|
||||
className={props.className}
|
||||
>
|
||||
<path d="M5.45 5.154A4.25 4.25 0 0 0 9.25 7.5h1.378a2.251 2.251 0 1 1 0 1.5H9.25A5.734 5.734 0 0 1 5 7.123v3.505a2.25 2.25 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.95-.218ZM4.25 13.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm8.5-4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5ZM5 3.25a.75.75 0 1 0 0 .005V3.25Z" />
|
||||
</svg>
|
||||
);
|
||||
case "closed":
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
height="16"
|
||||
fill={props.color}
|
||||
className={props.className}
|
||||
>
|
||||
<path d="M3.25 1A2.25 2.25 0 0 1 4 5.372v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.251 2.251 0 0 1 3.25 1Zm9.5 5.5a.75.75 0 0 1 .75.75v3.378a2.251 2.251 0 1 1-1.5 0V7.25a.75.75 0 0 1 .75-.75Zm-2.03-5.273a.75.75 0 0 1 1.06 0l.97.97.97-.97a.748.748 0 0 1 1.265.332.75.75 0 0 1-.205.729l-.97.97.97.97a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018l-.97-.97-.97.97a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l.97-.97-.97-.97a.75.75 0 0 1 0-1.06ZM2.5 3.25a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0ZM3.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm9.5 0a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,10 @@ export const NativeInput = styled.input<{
|
||||
${ellipsis()}
|
||||
${undraggableOnDesktop()}
|
||||
|
||||
&[readOnly] {
|
||||
color: ${s("textSecondary")};
|
||||
}
|
||||
|
||||
&:disabled,
|
||||
&::placeholder {
|
||||
color: ${s("placeholder")};
|
||||
@@ -126,13 +130,14 @@ export interface Props
|
||||
React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>,
|
||||
"prefix"
|
||||
> {
|
||||
type?: "text" | "email" | "checkbox" | "search" | "textarea";
|
||||
type?: "text" | "email" | "checkbox" | "search" | "textarea" | "password";
|
||||
labelHidden?: boolean;
|
||||
label?: string;
|
||||
flex?: boolean;
|
||||
short?: boolean;
|
||||
margin?: string | number;
|
||||
error?: string;
|
||||
rows?: number;
|
||||
/** Optional component that appears inside the input before the textarea and any icon */
|
||||
prefix?: React.ReactNode;
|
||||
/** Optional icon that appears inside the input before the textarea */
|
||||
|
||||
@@ -60,7 +60,8 @@ function InputSearchPage({
|
||||
if (ev.key === "Enter") {
|
||||
ev.preventDefault();
|
||||
history.push(
|
||||
searchPath(ev.currentTarget.value, {
|
||||
searchPath({
|
||||
query: ev.currentTarget.value,
|
||||
collectionId,
|
||||
ref: source,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
|
||||
import { transparentize } from "polished";
|
||||
import React from "react";
|
||||
import styled from "styled-components";
|
||||
import Text from "~/components/Text";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import Separator from "./ContextMenu/Separator";
|
||||
import Flex from "./Flex";
|
||||
import { LabelText } from "./Input";
|
||||
import Scrollable from "./Scrollable";
|
||||
import { IconWrapper } from "./Sidebar/components/SidebarLink";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "./primitives/Drawer";
|
||||
import {
|
||||
InputSelectRoot,
|
||||
InputSelectContent,
|
||||
InputSelectItem,
|
||||
InputSelectSeparator,
|
||||
InputSelectTrigger,
|
||||
type TriggerButtonProps,
|
||||
} from "./primitives/InputSelect";
|
||||
import {
|
||||
SelectItemIndicator,
|
||||
SelectItem as SelectItemWrapper,
|
||||
SelectButton,
|
||||
} from "./primitives/components/InputSelect";
|
||||
|
||||
type Separator = {
|
||||
/* Denotes a horizontal divider line to be rendered in the menu, */
|
||||
type: "separator";
|
||||
};
|
||||
|
||||
export type Item = {
|
||||
/* Denotes a selectable option in the menu. */
|
||||
type: "item";
|
||||
/* Representative text shown in the menu for this option. */
|
||||
label: string;
|
||||
/* Actual value of this option. */
|
||||
value: string;
|
||||
/* Additional info shown alongside the label. */
|
||||
description?: string;
|
||||
/* An icon shown alongside the label. */
|
||||
icon?: React.ReactElement;
|
||||
};
|
||||
|
||||
export type Option = Item | Separator;
|
||||
|
||||
type Props = {
|
||||
/* Options to display in the select menu. */
|
||||
options: Option[];
|
||||
/* Current chosen value. */
|
||||
value?: string;
|
||||
/* Callback when an option is selected. */
|
||||
onChange: (value: string) => void;
|
||||
/* ARIA label for accessibility. */
|
||||
ariaLabel: string;
|
||||
/* Label for the select menu. */
|
||||
label: string;
|
||||
/* When true, label is hidden in an accessible manner. */
|
||||
hideLabel?: boolean;
|
||||
/* When true, menu is disabled. */
|
||||
disabled?: boolean;
|
||||
/* When true, width of the menu trigger is restricted. Otherwise, takes up the full width of parent. */
|
||||
short?: boolean;
|
||||
} & TriggerButtonProps;
|
||||
|
||||
export function InputSelectNew(props: Props) {
|
||||
const {
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
label,
|
||||
hideLabel,
|
||||
disabled,
|
||||
short,
|
||||
...triggerProps
|
||||
} = props;
|
||||
|
||||
const [localValue, setLocalValue] = React.useState(value);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const triggerRef =
|
||||
React.useRef<React.ElementRef<typeof InputSelectTrigger>>(null);
|
||||
const contentRef =
|
||||
React.useRef<React.ElementRef<typeof InputSelectContent>>(null);
|
||||
|
||||
const isMobile = useMobile();
|
||||
|
||||
const placeholder = `Select a ${ariaLabel.toLowerCase()}`;
|
||||
const optionsHaveIcon = options.some(
|
||||
(opt) => opt.type === "item" && !!opt.icon
|
||||
);
|
||||
|
||||
const renderOption = React.useCallback(
|
||||
(option: Option) => {
|
||||
if (option.type === "separator") {
|
||||
return <InputSelectSeparator />;
|
||||
}
|
||||
|
||||
return (
|
||||
<InputSelectItem key={option.value} value={option.value}>
|
||||
<Option option={option} optionsHaveIcon={optionsHaveIcon} />
|
||||
</InputSelectItem>
|
||||
);
|
||||
},
|
||||
[optionsHaveIcon]
|
||||
);
|
||||
|
||||
const onValueChange = React.useCallback(
|
||||
async (val: string) => {
|
||||
setLocalValue(val);
|
||||
onChange(val);
|
||||
},
|
||||
[onChange, setLocalValue]
|
||||
);
|
||||
|
||||
const enablePointerEvents = React.useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.style.pointerEvents = "auto";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const disablePointerEvents = React.useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.style.pointerEvents = "none";
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
setLocalValue(value);
|
||||
}, [value]);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MobileSelect
|
||||
{...props}
|
||||
value={localValue}
|
||||
onChange={onValueChange}
|
||||
placeholder={placeholder}
|
||||
optionsHaveIcon={optionsHaveIcon}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper short={short}>
|
||||
<Label text={label} hidden={hideLabel ?? false} />
|
||||
<InputSelectRoot
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
value={localValue}
|
||||
onValueChange={onValueChange}
|
||||
>
|
||||
<InputSelectTrigger
|
||||
ref={triggerRef}
|
||||
placeholder={placeholder}
|
||||
{...triggerProps}
|
||||
/>
|
||||
<InputSelectContent
|
||||
ref={contentRef}
|
||||
aria-label={ariaLabel}
|
||||
onAnimationStart={disablePointerEvents}
|
||||
onAnimationEnd={enablePointerEvents}
|
||||
>
|
||||
{options.map(renderOption)}
|
||||
</InputSelectContent>
|
||||
</InputSelectRoot>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
type MobileSelectProps = Props & {
|
||||
placeholder: string;
|
||||
optionsHaveIcon: boolean;
|
||||
};
|
||||
|
||||
function MobileSelect(props: MobileSelectProps) {
|
||||
const {
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
label,
|
||||
hideLabel,
|
||||
disabled,
|
||||
short,
|
||||
placeholder,
|
||||
optionsHaveIcon,
|
||||
...triggerProps
|
||||
} = props;
|
||||
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const contentRef = React.useRef<React.ElementRef<typeof DrawerContent>>(null);
|
||||
|
||||
const selectedOption = React.useMemo(
|
||||
() =>
|
||||
value
|
||||
? options.find((opt) => opt.type === "item" && opt.value === value)
|
||||
: undefined,
|
||||
[value, options]
|
||||
);
|
||||
|
||||
const handleSelect = React.useCallback(
|
||||
async (val: string) => {
|
||||
setOpen(false);
|
||||
onChange(val);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const renderOption = React.useCallback(
|
||||
(option: Option) => {
|
||||
if (option.type === "separator") {
|
||||
return <Separator />;
|
||||
}
|
||||
|
||||
const isSelected = option === selectedOption;
|
||||
|
||||
return (
|
||||
<SelectItemWrapper
|
||||
key={option.value}
|
||||
onClick={() => handleSelect(option.value)}
|
||||
data-state={isSelected ? "checked" : "unchecked"}
|
||||
>
|
||||
<Option option={option} optionsHaveIcon={optionsHaveIcon} />
|
||||
{isSelected && <SelectItemIndicator />}
|
||||
</SelectItemWrapper>
|
||||
);
|
||||
},
|
||||
[handleSelect, selectedOption, optionsHaveIcon]
|
||||
);
|
||||
|
||||
const enablePointerEvents = React.useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.style.pointerEvents = "auto";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const disablePointerEvents = React.useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.style.pointerEvents = "none";
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Label text={label} hidden={hideLabel ?? false} />
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>
|
||||
<SelectButton
|
||||
{...triggerProps}
|
||||
neutral
|
||||
disclosure
|
||||
data-placeholder={selectedOption ? false : ""}
|
||||
>
|
||||
{selectedOption ? (
|
||||
<Option
|
||||
option={selectedOption as Item}
|
||||
optionsHaveIcon={optionsHaveIcon}
|
||||
/>
|
||||
) : (
|
||||
<>{placeholder}</>
|
||||
)}
|
||||
</SelectButton>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent
|
||||
ref={contentRef}
|
||||
aria-label={ariaLabel}
|
||||
onAnimationStart={disablePointerEvents}
|
||||
onAnimationEnd={enablePointerEvents}
|
||||
>
|
||||
<DrawerTitle hidden={!label}>{label ?? ariaLabel}</DrawerTitle>
|
||||
<StyledScrollable hiddenScrollbars>
|
||||
{options.map(renderOption)}
|
||||
</StyledScrollable>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
function Label({ text, hidden }: { text: string; hidden: boolean }) {
|
||||
const labelText = <LabelText>{text}</LabelText>;
|
||||
|
||||
return hidden ? (
|
||||
<VisuallyHidden.Root>{labelText}</VisuallyHidden.Root>
|
||||
) : (
|
||||
labelText
|
||||
);
|
||||
}
|
||||
|
||||
function Option({
|
||||
option,
|
||||
optionsHaveIcon,
|
||||
}: {
|
||||
option: Item;
|
||||
optionsHaveIcon: boolean;
|
||||
}) {
|
||||
const icon = optionsHaveIcon ? (
|
||||
option.icon ? (
|
||||
<IconWrapper>{option.icon}</IconWrapper>
|
||||
) : (
|
||||
<IconSpacer />
|
||||
)
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<OptionContainer align="center">
|
||||
{icon}
|
||||
{option.label}
|
||||
{option.description && (
|
||||
<>
|
||||
|
||||
<Description type="tertiary" size="small" ellipsis>
|
||||
– {option.description}
|
||||
</Description>
|
||||
</>
|
||||
)}
|
||||
</OptionContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const Wrapper = styled.label<{ short?: boolean }>`
|
||||
display: block;
|
||||
max-width: ${(props) => (props.short ? "350px" : "100%")};
|
||||
`;
|
||||
|
||||
const OptionContainer = styled(Flex)`
|
||||
min-height: 24px;
|
||||
`;
|
||||
|
||||
const Description = styled(Text)`
|
||||
@media (hover: hover) {
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: ${(props) => transparentize(0.5, props.theme.accentText)};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const IconSpacer = styled.div`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const StyledScrollable = styled(Scrollable)`
|
||||
max-height: 75vh;
|
||||
`;
|
||||
@@ -1,26 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
label: React.ReactNode | string;
|
||||
};
|
||||
|
||||
const Labeled: React.FC<Props> = ({ label, children, ...props }: Props) => (
|
||||
<Flex column {...props}>
|
||||
<Label>{label}</Label>
|
||||
{children}
|
||||
</Flex>
|
||||
);
|
||||
|
||||
export const Label = styled(Flex)`
|
||||
font-weight: 500;
|
||||
padding-bottom: 4px;
|
||||
display: inline-block;
|
||||
color: ${s("text")};
|
||||
`;
|
||||
|
||||
export default observer(Labeled);
|
||||
@@ -223,14 +223,13 @@ const Heading = styled.p<{ $small?: boolean; $ellipsis?: boolean }>`
|
||||
const Content = styled(Flex)<{ $selected: boolean }>`
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
color: ${(props) => (props.$selected ? props.theme.white : props.theme.text)};
|
||||
color: ${s("text")};
|
||||
`;
|
||||
|
||||
const Subtitle = styled.p<{ $small?: boolean; $selected?: boolean }>`
|
||||
margin: 0;
|
||||
font-size: ${(props) => (props.$small ? 13 : 14)}px;
|
||||
color: ${(props) =>
|
||||
props.$selected ? props.theme.white50 : props.theme.textTertiary};
|
||||
color: ${s("textTertiary")};
|
||||
margin-top: -2px;
|
||||
`;
|
||||
|
||||
@@ -238,8 +237,7 @@ export const Actions = styled(Flex)<{ $selected?: boolean }>`
|
||||
align-self: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: ${(props) =>
|
||||
props.$selected ? props.theme.white : props.theme.textSecondary};
|
||||
color: ${s("textSecondary")};
|
||||
`;
|
||||
|
||||
export default React.forwardRef(ListItem);
|
||||
|
||||
@@ -114,6 +114,8 @@ const Modal: React.FC<Props> = ({
|
||||
<Small {...props}>
|
||||
<Centered
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
// maxHeight needed for proper overflow behavior in Safari
|
||||
style={{ maxHeight: "65vh" }}
|
||||
column
|
||||
reverse
|
||||
>
|
||||
@@ -147,7 +149,7 @@ const Backdrop = styled(Flex)<{ $fullscreen?: boolean }>`
|
||||
props.$fullscreen
|
||||
? transparentize(0.25, props.theme.background)
|
||||
: props.theme.modalBackdrop} !important;
|
||||
z-index: ${depths.modalOverlay};
|
||||
z-index: ${depths.overlay};
|
||||
transition: opacity 50ms ease-in-out;
|
||||
opacity: 0;
|
||||
|
||||
@@ -259,6 +261,7 @@ const Small = styled.div`
|
||||
width: 75vw;
|
||||
min-width: 350px;
|
||||
max-width: 450px;
|
||||
max-height: 65vh;
|
||||
z-index: ${depths.modal};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -79,11 +79,11 @@ function Notifications(
|
||||
</Header>
|
||||
<React.Suspense fallback={null}>
|
||||
<Scrollable ref={ref} flex topShadow>
|
||||
<PaginatedList
|
||||
<PaginatedList<Notification>
|
||||
fetch={notifications.fetchPage}
|
||||
options={{ archived: false }}
|
||||
items={isOpen ? notifications.orderedData : undefined}
|
||||
renderItem={(item: Notification) => (
|
||||
renderItem={(item) => (
|
||||
<NotificationListItem
|
||||
key={item.id}
|
||||
notification={item}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { OAuthClientValidation } from "@shared/validations";
|
||||
import OAuthClient from "~/models/oauth/OAuthClient";
|
||||
import ImageInput from "~/scenes/Settings/components/ImageInput";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import Input, { LabelText } from "~/components/Input";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import Switch from "../Switch";
|
||||
|
||||
export interface FormData {
|
||||
name: string;
|
||||
developerName: string;
|
||||
developerUrl: string;
|
||||
description: string;
|
||||
avatarUrl: string;
|
||||
redirectUris: string[];
|
||||
published: boolean;
|
||||
}
|
||||
|
||||
export const OAuthClientForm = observer(function OAuthClientForm_({
|
||||
handleSubmit,
|
||||
oauthClient,
|
||||
}: {
|
||||
handleSubmit: (data: FormData) => void;
|
||||
oauthClient?: OAuthClient;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit: formHandleSubmit,
|
||||
formState,
|
||||
getValues,
|
||||
setFocus,
|
||||
setError,
|
||||
control,
|
||||
} = useForm<FormData>({
|
||||
mode: "all",
|
||||
defaultValues: {
|
||||
name: oauthClient?.name ?? "",
|
||||
description: oauthClient?.description ?? "",
|
||||
avatarUrl: oauthClient?.avatarUrl ?? "",
|
||||
redirectUris: oauthClient?.redirectUris ?? [],
|
||||
published: oauthClient?.published ?? false,
|
||||
},
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
setTimeout(() => setFocus("name", { shouldSelect: true }), 100);
|
||||
}, [setFocus]);
|
||||
|
||||
return (
|
||||
<form onSubmit={formHandleSubmit(handleSubmit)}>
|
||||
<>
|
||||
<label style={{ marginBottom: "1em" }}>
|
||||
<LabelText>{t("Icon")}</LabelText>
|
||||
<Controller
|
||||
control={control}
|
||||
name="avatarUrl"
|
||||
render={({ field }) => (
|
||||
<ImageInput
|
||||
onSuccess={(url) => field.onChange(url)}
|
||||
onError={(err) => setError("avatarUrl", { message: err })}
|
||||
model={{
|
||||
id: oauthClient?.id,
|
||||
avatarUrl: field.value,
|
||||
initial: getValues().name[0],
|
||||
}}
|
||||
borderRadius={0}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
label={t("Name")}
|
||||
placeholder={t("My App")}
|
||||
{...register("name", {
|
||||
required: true,
|
||||
maxLength: OAuthClientValidation.maxNameLength,
|
||||
})}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
flex
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
label={t("Tagline")}
|
||||
placeholder={t("A short description")}
|
||||
{...register("description", {
|
||||
maxLength: OAuthClientValidation.maxDescriptionLength,
|
||||
})}
|
||||
flex
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="redirectUris"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
type="textarea"
|
||||
label={t("Callback URLs")}
|
||||
placeholder="https://example.com/callback"
|
||||
ref={field.ref}
|
||||
value={field.value.join("\n")}
|
||||
rows={Math.max(2, field.value.length + 1)}
|
||||
onChange={(event) => {
|
||||
field.onChange(event.target.value.split("\n"));
|
||||
}}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{isCloudHosted && (
|
||||
<Switch
|
||||
{...register("published")}
|
||||
label={t("Published")}
|
||||
note={t("Allow this app to be installed by other workspaces")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
<Flex justify="flex-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={formState.isSubmitting || !formState.isValid}
|
||||
>
|
||||
{oauthClient
|
||||
? formState.isSubmitting
|
||||
? `${t("Saving")}…`
|
||||
: t("Save")
|
||||
: formState.isSubmitting
|
||||
? `${t("Creating")}…`
|
||||
: t("Create")}
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
import { OAuthClientForm, FormData } from "./OAuthClientForm";
|
||||
|
||||
type Props = {
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
export const OAuthClientNew = observer(function OAuthClientNew_({
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
const { oauthClients } = useStores();
|
||||
const history = useHistory();
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (data: FormData) => {
|
||||
try {
|
||||
const oauthClient = await oauthClients.save(data);
|
||||
onSubmit?.();
|
||||
history.push(settingsPath("applications", oauthClient.id));
|
||||
} catch (error) {
|
||||
toast.error(error.message);
|
||||
}
|
||||
},
|
||||
[oauthClients, history, onSubmit]
|
||||
);
|
||||
|
||||
return <OAuthClientForm handleSubmit={handleSubmit} />;
|
||||
});
|
||||
@@ -10,7 +10,7 @@ type Props = {
|
||||
fetch: (options: any) => Promise<Document[] | undefined>;
|
||||
options?: Record<string, any>;
|
||||
heading?: React.ReactNode;
|
||||
empty?: React.ReactNode;
|
||||
empty?: JSX.Element;
|
||||
showParentDocuments?: boolean;
|
||||
showCollection?: boolean;
|
||||
showPublished?: boolean;
|
||||
@@ -34,7 +34,7 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<PaginatedList
|
||||
<PaginatedList<Document>
|
||||
aria-label={t("Documents")}
|
||||
items={documents}
|
||||
empty={empty}
|
||||
@@ -42,7 +42,7 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
|
||||
fetch={fetch}
|
||||
options={options}
|
||||
renderError={(props) => <Error {...props} />}
|
||||
renderItem={(item: Document, _index) => (
|
||||
renderItem={(item, _index) => (
|
||||
<DocumentListItem
|
||||
key={item.id}
|
||||
document={item}
|
||||
|
||||
@@ -10,7 +10,7 @@ type Props = {
|
||||
fetch: (options: Record<string, any> | undefined) => Promise<Event[]>;
|
||||
options?: Record<string, any>;
|
||||
heading?: React.ReactNode;
|
||||
empty?: React.ReactNode;
|
||||
empty?: JSX.Element;
|
||||
};
|
||||
|
||||
const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import "../stores";
|
||||
import { render } from "@testing-library/react";
|
||||
import { TFunction } from "i18next";
|
||||
import { Provider } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { getI18n } from "react-i18next";
|
||||
import { Pagination } from "@shared/constants";
|
||||
import { Component as PaginatedList } from "./PaginatedList";
|
||||
import PaginatedList from "./PaginatedList";
|
||||
|
||||
describe("PaginatedList", () => {
|
||||
const i18n = getI18n();
|
||||
const authStore = {};
|
||||
|
||||
const props = {
|
||||
i18n,
|
||||
@@ -17,19 +19,23 @@ describe("PaginatedList", () => {
|
||||
|
||||
it("with no items renders nothing", () => {
|
||||
const result = render(
|
||||
<PaginatedList items={[]} renderItem={render} {...props} />
|
||||
<Provider auth={authStore}>
|
||||
<PaginatedList items={[]} renderItem={render} {...props} />
|
||||
</Provider>
|
||||
);
|
||||
expect(result.container.innerHTML).toEqual("");
|
||||
});
|
||||
|
||||
it("with no items renders empty prop", async () => {
|
||||
const result = render(
|
||||
<PaginatedList
|
||||
items={[]}
|
||||
empty={<p>Sorry, no results</p>}
|
||||
renderItem={render}
|
||||
{...props}
|
||||
/>
|
||||
<Provider auth={authStore}>
|
||||
<PaginatedList
|
||||
items={[]}
|
||||
empty={<p>Sorry, no results</p>}
|
||||
renderItem={render}
|
||||
{...props}
|
||||
/>{" "}
|
||||
</Provider>
|
||||
);
|
||||
await expect(
|
||||
result.findAllByText("Sorry, no results")
|
||||
@@ -42,13 +48,15 @@ describe("PaginatedList", () => {
|
||||
id: "one",
|
||||
};
|
||||
render(
|
||||
<PaginatedList
|
||||
items={[]}
|
||||
fetch={fetch}
|
||||
options={options}
|
||||
renderItem={render}
|
||||
{...props}
|
||||
/>
|
||||
<Provider auth={authStore}>
|
||||
<PaginatedList
|
||||
items={[]}
|
||||
fetch={fetch}
|
||||
options={options}
|
||||
renderItem={render}
|
||||
{...props}
|
||||
/>{" "}
|
||||
</Provider>
|
||||
);
|
||||
expect(fetch).toHaveBeenCalledWith({
|
||||
...options,
|
||||
|
||||
+244
-194
@@ -1,265 +1,315 @@
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { observable, action, computed } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { withTranslation, WithTranslation } from "react-i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Waypoint } from "react-waypoint";
|
||||
import { Pagination } from "@shared/constants";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
|
||||
import DelayedMount from "~/components/DelayedMount";
|
||||
import PlaceholderList from "~/components/List/Placeholder";
|
||||
import withStores from "~/components/withStores";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import { dateToHeading } from "~/utils/date";
|
||||
|
||||
/**
|
||||
* Base interface for items that can be paginated
|
||||
* @interface PaginatedItem
|
||||
*/
|
||||
export interface PaginatedItem {
|
||||
/** Unique identifier for the item */
|
||||
id?: string;
|
||||
/** Last update timestamp of the item */
|
||||
updatedAt?: string;
|
||||
/** Creation timestamp of the item */
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
type Props<T> = WithTranslation &
|
||||
RootStore &
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
fetch?: (
|
||||
options: Record<string, any> | undefined
|
||||
) => Promise<T[] | undefined> | undefined;
|
||||
options?: Record<string, any>;
|
||||
heading?: React.ReactNode;
|
||||
empty?: React.ReactNode;
|
||||
loading?: React.ReactElement;
|
||||
items?: T[];
|
||||
className?: string;
|
||||
renderItem: (item: T, index: number) => React.ReactNode;
|
||||
renderError?: (options: {
|
||||
error: Error;
|
||||
retry: () => void;
|
||||
}) => React.ReactNode;
|
||||
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
|
||||
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
listRef?: React.RefObject<HTMLDivElement>;
|
||||
};
|
||||
/**
|
||||
* Props for the PaginatedList component
|
||||
* @template T Type of items in the list, must extend PaginatedItem
|
||||
*/
|
||||
interface Props<T extends PaginatedItem>
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Function to fetch paginated data. Should return a promise resolving to an array of items
|
||||
* @param options Pagination and other query options
|
||||
*/
|
||||
fetch?: (
|
||||
options: Record<string, any> | undefined
|
||||
) => Promise<unknown[] | undefined> | undefined;
|
||||
|
||||
@observer
|
||||
class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
|
||||
Props<T>
|
||||
> {
|
||||
@observable
|
||||
error?: Error;
|
||||
/** Additional options to pass to the fetch function */
|
||||
options?: Record<string, any>;
|
||||
|
||||
@observable
|
||||
isFetchingMore = false;
|
||||
/** Optional header content to display above the list */
|
||||
heading?: React.ReactNode;
|
||||
|
||||
@observable
|
||||
isFetching = false;
|
||||
/** Content to display when the list is empty */
|
||||
empty?: JSX.Element | null;
|
||||
|
||||
@observable
|
||||
isFetchingInitial = !this.props.items?.length;
|
||||
/** Optional loading state content */
|
||||
loading?: JSX.Element | null;
|
||||
|
||||
@observable
|
||||
fetchCounter = 0;
|
||||
/** Array of items to display in the list */
|
||||
items?: T[];
|
||||
|
||||
@observable
|
||||
renderCount = Pagination.defaultLimit;
|
||||
/** CSS class name to apply to the list container */
|
||||
className?: string;
|
||||
|
||||
@observable
|
||||
offset = 0;
|
||||
/**
|
||||
* Function to render each individual item in the list
|
||||
* @param item The item to render
|
||||
* @param index The index of the item in the list
|
||||
*/
|
||||
renderItem: (item: T, index: number) => React.ReactNode;
|
||||
|
||||
@observable
|
||||
allowLoadMore = true;
|
||||
/**
|
||||
* Function to render error state
|
||||
* @param options Object containing error details and retry function
|
||||
*/
|
||||
renderError?: (options: {
|
||||
/** Details of the error */
|
||||
error: Error;
|
||||
/** Function to retry the fetch operation */
|
||||
retry: () => void;
|
||||
}) => JSX.Element;
|
||||
|
||||
componentDidMount() {
|
||||
void this.fetchResults();
|
||||
}
|
||||
/**
|
||||
* Function to render section headings (typically date-based)
|
||||
* @param name The heading text or element to render
|
||||
*/
|
||||
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
|
||||
|
||||
componentDidUpdate(prevProps: Props<T>) {
|
||||
if (
|
||||
prevProps.fetch !== this.props.fetch ||
|
||||
!isEqual(prevProps.options, this.props.options)
|
||||
) {
|
||||
this.reset();
|
||||
void this.fetchResults();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Handler for escape key press
|
||||
* @param ev Keyboard event object
|
||||
*/
|
||||
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
|
||||
reset = () => {
|
||||
this.offset = 0;
|
||||
this.allowLoadMore = true;
|
||||
this.renderCount = Pagination.defaultLimit;
|
||||
this.isFetching = false;
|
||||
this.isFetchingInitial = false;
|
||||
this.isFetchingMore = false;
|
||||
};
|
||||
/** Reference to the list container element */
|
||||
listRef?: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
@action
|
||||
fetchResults = async () => {
|
||||
if (!this.props.fetch) {
|
||||
/**
|
||||
* A reusable component that renders a paginated list with infinite scrolling
|
||||
* and optional date-based section headings.
|
||||
*
|
||||
* @template T Type of the list items, must extend PaginatedItem
|
||||
*/
|
||||
const PaginatedList = <T extends PaginatedItem>({
|
||||
fetch,
|
||||
options,
|
||||
heading,
|
||||
empty = null,
|
||||
loading = null,
|
||||
items = [],
|
||||
className,
|
||||
renderItem,
|
||||
renderError,
|
||||
renderHeading,
|
||||
onEscape,
|
||||
listRef,
|
||||
...rest
|
||||
}: Props<T>): JSX.Element | null => {
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [error, setError] = React.useState<Error | undefined>();
|
||||
const [isFetchingMore, setIsFetchingMore] = React.useState(false);
|
||||
const [isFetching, setIsFetching] = React.useState(false);
|
||||
const [isFetchingInitial, setIsFetchingInitial] = React.useState(
|
||||
!items?.length
|
||||
);
|
||||
const [fetchCounter, setFetchCounter] = React.useState(0);
|
||||
const [renderCount, setRenderCount] = React.useState(Pagination.defaultLimit);
|
||||
const [offset, setOffset] = React.useState(0);
|
||||
const [allowLoadMore, setAllowLoadMore] = React.useState(true);
|
||||
|
||||
const reset = React.useCallback(() => {
|
||||
setOffset(0);
|
||||
setAllowLoadMore(true);
|
||||
setRenderCount(Pagination.defaultLimit);
|
||||
setIsFetching(false);
|
||||
setIsFetchingInitial(false);
|
||||
setIsFetchingMore(false);
|
||||
}, []);
|
||||
|
||||
const fetchResults = React.useCallback(async () => {
|
||||
if (!fetch) {
|
||||
return;
|
||||
}
|
||||
this.isFetching = true;
|
||||
const counter = ++this.fetchCounter;
|
||||
const limit = this.props.options?.limit ?? Pagination.defaultLimit;
|
||||
this.error = undefined;
|
||||
|
||||
setIsFetching(true);
|
||||
const counter = fetchCounter + 1;
|
||||
setFetchCounter(counter);
|
||||
const limit = options?.limit ?? Pagination.defaultLimit;
|
||||
setError(undefined);
|
||||
|
||||
try {
|
||||
const results = await this.props.fetch({
|
||||
const results = await fetch({
|
||||
limit,
|
||||
offset: this.offset,
|
||||
...this.props.options,
|
||||
offset,
|
||||
...options,
|
||||
});
|
||||
|
||||
if (this.offset !== 0) {
|
||||
this.renderCount += limit;
|
||||
if (offset !== 0) {
|
||||
setRenderCount((prevCount) => prevCount + limit);
|
||||
}
|
||||
|
||||
if (results && (results.length === 0 || results.length < limit)) {
|
||||
this.allowLoadMore = false;
|
||||
setAllowLoadMore(false);
|
||||
} else {
|
||||
this.offset += limit;
|
||||
setOffset((prevOffset) => prevOffset + limit);
|
||||
}
|
||||
|
||||
this.isFetchingInitial = false;
|
||||
setIsFetchingInitial(false);
|
||||
} catch (err) {
|
||||
this.error = err;
|
||||
setError(err);
|
||||
} finally {
|
||||
// only the most recent fetch should end the loading state
|
||||
if (counter >= this.fetchCounter) {
|
||||
this.isFetching = false;
|
||||
this.isFetchingMore = false;
|
||||
if (counter >= fetchCounter) {
|
||||
setIsFetching(false);
|
||||
setIsFetchingMore(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [fetch, fetchCounter, offset, options]);
|
||||
|
||||
@action
|
||||
loadMoreResults = async () => {
|
||||
// Don't paginate if there aren't more results or we’re currently fetching
|
||||
if (!this.allowLoadMore || this.isFetching) {
|
||||
const loadMoreResults = React.useCallback(async () => {
|
||||
// Don't paginate if there aren't more results or we're currently fetching
|
||||
if (!allowLoadMore || isFetching) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If there are already cached results that we haven't yet rendered because
|
||||
// of lazy rendering then show another page.
|
||||
const leftToRender = (this.props.items?.length ?? 0) - this.renderCount;
|
||||
const leftToRender = (items?.length ?? 0) - renderCount;
|
||||
|
||||
if (leftToRender > 0) {
|
||||
this.renderCount += Pagination.defaultLimit;
|
||||
setRenderCount((prevCount) => prevCount + Pagination.defaultLimit);
|
||||
}
|
||||
|
||||
// If there are less than a pages results in the cache go ahead and fetch
|
||||
// another page from the server
|
||||
if (leftToRender <= Pagination.defaultLimit) {
|
||||
this.isFetchingMore = true;
|
||||
await this.fetchResults();
|
||||
setIsFetchingMore(true);
|
||||
await fetchResults();
|
||||
}
|
||||
};
|
||||
}, [allowLoadMore, isFetching, items?.length, renderCount, fetchResults]);
|
||||
|
||||
@computed
|
||||
get itemsToRender() {
|
||||
return this.props.items?.slice(0, this.renderCount) ?? [];
|
||||
}
|
||||
const prevFetch = usePrevious(fetch);
|
||||
const prevOptions = usePrevious(options);
|
||||
|
||||
render() {
|
||||
const {
|
||||
items = [],
|
||||
heading,
|
||||
auth,
|
||||
empty = null,
|
||||
renderHeading,
|
||||
renderError,
|
||||
onEscape,
|
||||
} = this.props;
|
||||
// Initial fetch on mount
|
||||
React.useEffect(() => {
|
||||
if (fetch) {
|
||||
void fetchResults();
|
||||
}
|
||||
}, [fetch]);
|
||||
|
||||
const showLoading =
|
||||
this.isFetching &&
|
||||
!this.isFetchingMore &&
|
||||
(!items?.length || (this.fetchCounter <= 1 && this.isFetchingInitial));
|
||||
|
||||
if (showLoading) {
|
||||
return (
|
||||
this.props.loading || (
|
||||
<DelayedMount>
|
||||
<div className={this.props.className}>
|
||||
<PlaceholderList count={5} />
|
||||
</div>
|
||||
</DelayedMount>
|
||||
)
|
||||
);
|
||||
// Handle updates to fetch or options
|
||||
React.useEffect(() => {
|
||||
if (!prevFetch || !prevOptions) {
|
||||
return; // Skip on initial mount since it's handled by the above effect
|
||||
}
|
||||
|
||||
if (items?.length === 0) {
|
||||
if (this.error && renderError) {
|
||||
return renderError({ error: this.error, retry: this.fetchResults });
|
||||
}
|
||||
|
||||
return empty;
|
||||
if (prevFetch !== fetch || !isEqual(prevOptions, options)) {
|
||||
reset();
|
||||
void fetchResults();
|
||||
}
|
||||
}, [fetch, options, reset, fetchResults, prevFetch, prevOptions]);
|
||||
|
||||
// Computed property equivalent
|
||||
const itemsToRender = React.useMemo(
|
||||
() => items?.slice(0, renderCount) ?? [],
|
||||
[items, renderCount]
|
||||
);
|
||||
|
||||
const showLoading =
|
||||
isFetching &&
|
||||
!isFetchingMore &&
|
||||
(!items?.length || (fetchCounter <= 1 && isFetchingInitial));
|
||||
|
||||
if (showLoading) {
|
||||
return (
|
||||
<>
|
||||
{heading}
|
||||
<ArrowKeyNavigation
|
||||
aria-label={this.props["aria-label"]}
|
||||
onEscape={onEscape}
|
||||
className={this.props.className}
|
||||
items={this.itemsToRender}
|
||||
ref={this.props.listRef}
|
||||
>
|
||||
{() => {
|
||||
let previousHeading = "";
|
||||
return this.itemsToRender.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 =
|
||||
"updatedAt" in item && item.updatedAt
|
||||
? item.updatedAt
|
||||
: "createdAt" in item && item.createdAt
|
||||
? 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 (
|
||||
children &&
|
||||
(!previousHeading || currentHeading !== previousHeading)
|
||||
) {
|
||||
previousHeading = currentHeading;
|
||||
return (
|
||||
<React.Fragment
|
||||
key={"id" in item && item.id ? item.id : index}
|
||||
>
|
||||
{renderHeading(currentHeading)}
|
||||
{children}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
});
|
||||
}}
|
||||
</ArrowKeyNavigation>
|
||||
{this.allowLoadMore && (
|
||||
<div style={{ height: "1px" }}>
|
||||
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
|
||||
loading || (
|
||||
<DelayedMount>
|
||||
<div className={className}>
|
||||
<PlaceholderList count={5} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</DelayedMount>
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const Component = PaginatedList;
|
||||
if (items?.length === 0) {
|
||||
if (error && renderError) {
|
||||
return renderError({ error, retry: fetchResults });
|
||||
}
|
||||
|
||||
export default withTranslation()(withStores(PaginatedList));
|
||||
return empty;
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{heading}
|
||||
<ArrowKeyNavigation
|
||||
aria-label={rest["aria-label"]}
|
||||
onEscape={onEscape}
|
||||
className={className}
|
||||
items={itemsToRender}
|
||||
ref={listRef}
|
||||
>
|
||||
{() => {
|
||||
let previousHeading = "";
|
||||
return itemsToRender.map((item, index) => {
|
||||
const children = 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 =
|
||||
"updatedAt" in item && item.updatedAt
|
||||
? item.updatedAt
|
||||
: "createdAt" in item && item.createdAt
|
||||
? item.createdAt
|
||||
: previousHeading;
|
||||
const currentHeading = dateToHeading(
|
||||
currentDate,
|
||||
t,
|
||||
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 (
|
||||
children &&
|
||||
(!previousHeading || currentHeading !== previousHeading)
|
||||
) {
|
||||
previousHeading = currentHeading;
|
||||
return (
|
||||
<React.Fragment key={"id" in item && item.id ? item.id : index}>
|
||||
{renderHeading(currentHeading)}
|
||||
{children}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
});
|
||||
}}
|
||||
</ArrowKeyNavigation>
|
||||
{allowLoadMore && (
|
||||
<div style={{ height: "1px" }}>
|
||||
<Waypoint key={renderCount} onEnter={loadMoreResults} />
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaginatedList;
|
||||
|
||||
@@ -200,7 +200,7 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
style={{ zIndex: depths.sidebar + 1 }}
|
||||
shrink
|
||||
>
|
||||
<PaginatedList
|
||||
<PaginatedList<SearchResult>
|
||||
options={{ query, snippetMinWords: 10, snippetMaxWords: 11 }}
|
||||
items={cachedSearchResults}
|
||||
fetch={performSearch}
|
||||
@@ -209,7 +209,7 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
<NoResults>{t("No results for {{query}}", { query })}</NoResults>
|
||||
}
|
||||
loading={<PlaceholderList count={3} header={{ height: 20 }} />}
|
||||
renderItem={(item: SearchResult, index) => (
|
||||
renderItem={(item, index) => (
|
||||
<SearchListItem
|
||||
key={item.document.id}
|
||||
shareId={shareId}
|
||||
|
||||
@@ -96,7 +96,7 @@ export const Suggestions = observer(
|
||||
? users.notInDocument(document.id, query)
|
||||
: collection
|
||||
? users.notInCollection(collection.id, query)
|
||||
: users.orderedData
|
||||
: users.activeOrInvited
|
||||
).filter((u) => !u.isSuspended && u.id !== user.id);
|
||||
|
||||
if (isEmail(query)) {
|
||||
@@ -114,7 +114,7 @@ export const Suggestions = observer(
|
||||
}, [
|
||||
getSuggestionForEmail,
|
||||
users,
|
||||
users.orderedData,
|
||||
users.activeOrInvited,
|
||||
groups,
|
||||
groups.orderedData,
|
||||
document?.id,
|
||||
|
||||
@@ -12,7 +12,7 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import OrganizationMenu from "~/menus/OrganizationMenu";
|
||||
import TeamMenu from "~/menus/TeamMenu";
|
||||
import { homePath, searchPath } from "~/utils/routeHelpers";
|
||||
import TeamLogo from "../TeamLogo";
|
||||
import Tooltip from "../Tooltip";
|
||||
@@ -62,7 +62,7 @@ function AppSidebar() {
|
||||
<DndProvider backend={HTML5Backend} options={html5Options}>
|
||||
<DragPlaceholder />
|
||||
|
||||
<OrganizationMenu>
|
||||
<TeamMenu>
|
||||
{(props: SidebarButtonProps) => (
|
||||
<SidebarButton
|
||||
{...props}
|
||||
@@ -91,7 +91,7 @@ function AppSidebar() {
|
||||
</Tooltip>
|
||||
</SidebarButton>
|
||||
)}
|
||||
</OrganizationMenu>
|
||||
</TeamMenu>
|
||||
<Overflow>
|
||||
<Section>
|
||||
<SidebarLink
|
||||
|
||||
@@ -54,6 +54,8 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
const [hasPointerMoved, setPointerMoved] = React.useState(false);
|
||||
const isSmallerThanMinimum = width < minWidth;
|
||||
|
||||
const hoverTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const handleDrag = React.useCallback(
|
||||
(event: MouseEvent) => {
|
||||
// suppresses text selection
|
||||
@@ -114,6 +116,10 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
|
||||
const handlePointerActivity = React.useCallback(() => {
|
||||
if (ui.sidebarIsClosed) {
|
||||
// clear the timeout when mouse exits
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
}
|
||||
setHovering(document.hasFocus());
|
||||
setPointerMoved(true);
|
||||
}
|
||||
@@ -122,12 +128,20 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
const handlePointerLeave = React.useCallback(
|
||||
(ev) => {
|
||||
if (hasPointerMoved) {
|
||||
setHovering(
|
||||
document.hasFocus() &&
|
||||
ev.pageX < width &&
|
||||
ev.pageY < window.innerHeight &&
|
||||
ev.pageY > 0
|
||||
);
|
||||
// clear any previous timeout
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
}
|
||||
|
||||
// add a short delay when mouse exits the sidebar before closing
|
||||
hoverTimeoutRef.current = setTimeout(() => {
|
||||
setHovering(
|
||||
document.hasFocus() &&
|
||||
ev.pageX < width &&
|
||||
ev.pageY < window.innerHeight &&
|
||||
ev.pageY > 0
|
||||
);
|
||||
}, 500);
|
||||
}
|
||||
},
|
||||
[width, hasPointerMoved]
|
||||
@@ -307,6 +321,7 @@ const Container = styled(Flex)<ContainerProps>`
|
||||
z-index: ${depths.mobileSidebar};
|
||||
max-width: 80%;
|
||||
min-width: 280px;
|
||||
padding-left: var(--sal);
|
||||
${fadeOnDesktopBackgrounded()}
|
||||
|
||||
@media print {
|
||||
|
||||
@@ -82,12 +82,12 @@ function ArchiveLink() {
|
||||
</div>
|
||||
{expanded === true ? (
|
||||
<Relative>
|
||||
<PaginatedList
|
||||
<PaginatedList<Collection>
|
||||
aria-label={t("Archived collections")}
|
||||
items={collections.archived}
|
||||
loading={<PlaceholderCollections />}
|
||||
renderError={(props) => <StyledError {...props} />}
|
||||
renderItem={(item: Collection) => (
|
||||
renderItem={(item) => (
|
||||
<ArchivedCollectionLink
|
||||
key={item.id}
|
||||
depth={1}
|
||||
|
||||
@@ -54,7 +54,7 @@ function Collections() {
|
||||
<Flex column>
|
||||
<Header id="collections" title={t("Collections")}>
|
||||
<Relative>
|
||||
<PaginatedList
|
||||
<PaginatedList<Collection>
|
||||
options={params}
|
||||
aria-label={t("Collections")}
|
||||
items={collections.allActive}
|
||||
@@ -69,7 +69,7 @@ function Collections() {
|
||||
) : undefined
|
||||
}
|
||||
renderError={(props) => <StyledError {...props} />}
|
||||
renderItem={(item: Collection, index) => (
|
||||
renderItem={(item, index) => (
|
||||
<DraggableCollectionLink
|
||||
key={item.id}
|
||||
collection={item}
|
||||
|
||||
@@ -148,7 +148,12 @@ function InnerDocumentLink(
|
||||
const color = document?.color || node.color;
|
||||
|
||||
// Draggable
|
||||
const [{ isDragging }, drag] = useDragDocument(node, depth, document);
|
||||
const [{ isDragging }, drag] = useDragDocument(
|
||||
node,
|
||||
depth,
|
||||
document,
|
||||
isEditing
|
||||
);
|
||||
|
||||
// Drop to re-parent
|
||||
const parentRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -270,6 +275,8 @@ function InnerDocumentLink(
|
||||
<div ref={dropToReparent}>
|
||||
<DropToImport documentId={node.id} activeClassName="activeDropZone">
|
||||
<SidebarLink
|
||||
// @ts-expect-error react-router type is wrong, string component is fine.
|
||||
component={isEditing ? "div" : undefined}
|
||||
expanded={hasChildren ? isExpanded : undefined}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
onClickIntent={handlePrefetch}
|
||||
@@ -285,6 +292,7 @@ function InnerDocumentLink(
|
||||
<EditableTitle
|
||||
title={title}
|
||||
onSubmit={handleTitleChange}
|
||||
isEditing={isEditing}
|
||||
onEditing={setIsEditing}
|
||||
canUpdate={canUpdate}
|
||||
maxLength={DocumentValidation.maxTitleLength}
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface Props extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||
location?: Location;
|
||||
strict?: boolean;
|
||||
to: LocationDescriptor;
|
||||
component?: React.ComponentType;
|
||||
onBeforeClick?: () => void;
|
||||
}
|
||||
|
||||
@@ -146,17 +147,22 @@ const NavLink = ({
|
||||
setPreActive(undefined);
|
||||
}, [currentLocation]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLAnchorElement>) => {
|
||||
if (["Enter", " "].includes(event.key)) {
|
||||
navigateTo();
|
||||
event.currentTarget?.blur();
|
||||
}
|
||||
},
|
||||
[navigateTo]
|
||||
);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={isActive ? "active" : "inactive"}
|
||||
ref={linkRef}
|
||||
onClick={handleClick}
|
||||
onKeyDown={(event) => {
|
||||
if (["Enter", " "].includes(event.key)) {
|
||||
navigateTo();
|
||||
event.currentTarget?.blur();
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-current={(isActive && ariaCurrent) || undefined}
|
||||
className={className}
|
||||
style={style}
|
||||
|
||||
@@ -38,10 +38,10 @@ function StarredLink({ star }: Props) {
|
||||
const { ui, collections, documents } = useStores();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const { documentId, collectionId } = star;
|
||||
const collection = collections.get(collectionId);
|
||||
const collection = collectionId ? collections.get(collectionId) : undefined;
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
const sidebarContext = starredSidebarContext(
|
||||
star.documentId ?? star.collectionId
|
||||
star.documentId ?? star.collectionId ?? ""
|
||||
);
|
||||
const [expanded, setExpanded] = useState(
|
||||
(star.documentId
|
||||
|
||||
@@ -166,11 +166,13 @@ export function useDropToReorderStar(getIndex?: () => string) {
|
||||
* @param node The NavigationNode model to drag.
|
||||
* @param depth The depth of the node in the sidebar.
|
||||
* @param document The related Document model.
|
||||
* @param isEditing Whether the sidebar item is currently being edited.
|
||||
*/
|
||||
export function useDragDocument(
|
||||
node: NavigationNode,
|
||||
depth: number,
|
||||
document?: Document
|
||||
document?: Document,
|
||||
isEditing?: boolean
|
||||
) {
|
||||
const icon = document?.icon || node.icon || node.emoji;
|
||||
const color = document?.color || node.color;
|
||||
@@ -188,7 +190,7 @@ export function useDragDocument(
|
||||
icon: icon ? <Icon value={icon} color={color} /> : undefined,
|
||||
collectionId: document?.collectionId || "",
|
||||
} as DragObject),
|
||||
canDrag: () => !!document?.isActive,
|
||||
canDrag: () => !!document?.isActive && !isEditing,
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
|
||||
@@ -335,6 +335,7 @@ const TR = styled.div<{ $columns: string }>`
|
||||
grid-template-columns: ${({ $columns }) => `${$columns}`};
|
||||
align-items: center;
|
||||
border-bottom: 1px solid ${s("divider")};
|
||||
overflow: hidden;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
@@ -357,7 +358,8 @@ const TD = styled.span`
|
||||
padding: 10px 6px;
|
||||
font-size: 14px;
|
||||
text-wrap: wrap;
|
||||
word-break: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&:first-child {
|
||||
font-size: 15px;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { Avatar } from "./Avatar";
|
||||
import { AvatarVariant } from "./Avatar/Avatar";
|
||||
|
||||
const TeamLogo = styled(Avatar)`
|
||||
const TeamLogo = styled(Avatar).attrs({
|
||||
variant: AvatarVariant.Square,
|
||||
})`
|
||||
border-radius: 4px;
|
||||
box-shadow: inset 0 0 0 1px ${s("divider")};
|
||||
border: 0;
|
||||
|
||||
+1
-71
@@ -1,73 +1,3 @@
|
||||
import styled, { css } from "styled-components";
|
||||
import { ellipsis } from "@shared/styles";
|
||||
|
||||
type Props = {
|
||||
/** The type of text to render */
|
||||
type?: "secondary" | "tertiary" | "danger";
|
||||
/** The size of the text */
|
||||
size?: "xlarge" | "large" | "medium" | "small" | "xsmall";
|
||||
/** The direction of the text (defaults to ltr) */
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
/** Whether the text should be selectable (defaults to false) */
|
||||
selectable?: boolean;
|
||||
/** The font weight of the text */
|
||||
weight?: "xbold" | "bold" | "normal";
|
||||
/** Whether the text should be italic */
|
||||
italic?: boolean;
|
||||
/** Whether the text should be truncated with an ellipsis */
|
||||
ellipsis?: boolean;
|
||||
/** Whether the text should be monospaced */
|
||||
monospace?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Use this component for all interface text that should not be selectable
|
||||
* by the user, this is the majority of UI text explainers, notes, headings.
|
||||
*/
|
||||
const Text = styled.span<Props>`
|
||||
margin-top: 0;
|
||||
text-align: ${(props) => (props.dir ? props.dir : "inherit")};
|
||||
color: ${(props) =>
|
||||
props.type === "secondary"
|
||||
? props.theme.textSecondary
|
||||
: props.type === "tertiary"
|
||||
? props.theme.textTertiary
|
||||
: props.type === "danger"
|
||||
? props.theme.brand.red
|
||||
: props.theme.text};
|
||||
font-size: ${(props) =>
|
||||
props.size === "xlarge"
|
||||
? "26px"
|
||||
: props.size === "large"
|
||||
? "18px"
|
||||
: props.size === "medium"
|
||||
? "16px"
|
||||
: props.size === "small"
|
||||
? "14px"
|
||||
: props.size === "xsmall"
|
||||
? "13px"
|
||||
: "inherit"};
|
||||
|
||||
${(props) =>
|
||||
props.weight &&
|
||||
css`
|
||||
font-weight: ${props.weight === "xbold"
|
||||
? 600
|
||||
: props.weight === "bold"
|
||||
? 500
|
||||
: props.weight === "normal"
|
||||
? 400
|
||||
: "inherit"};
|
||||
`}
|
||||
|
||||
font-style: ${(props) => (props.italic ? "italic" : "normal")};
|
||||
font-family: ${(props) =>
|
||||
props.monospace ? props.theme.fontFamilyMono : "inherit"};
|
||||
|
||||
white-space: normal;
|
||||
user-select: ${(props) => (props.selectable ? "text" : "none")};
|
||||
|
||||
${(props) => props.ellipsis && ellipsis()}
|
||||
`;
|
||||
import Text from "@shared/components/Text";
|
||||
|
||||
export default Text;
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import invariant from "invariant";
|
||||
import find from "lodash/find";
|
||||
import isObject from "lodash/isObject";
|
||||
import { action, observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { withTranslation, WithTranslation } from "react-i18next";
|
||||
import semver from "semver";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import { toast } from "sonner";
|
||||
import EDITOR_VERSION from "@shared/editor/version";
|
||||
import { FileOperationState, FileOperationType } from "@shared/types";
|
||||
import {
|
||||
FileOperationState,
|
||||
FileOperationType,
|
||||
ImportState,
|
||||
} from "@shared/types";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import Collection from "~/models/Collection";
|
||||
import Comment from "~/models/Comment";
|
||||
@@ -18,6 +19,7 @@ import FileOperation from "~/models/FileOperation";
|
||||
import Group from "~/models/Group";
|
||||
import GroupMembership from "~/models/GroupMembership";
|
||||
import GroupUser from "~/models/GroupUser";
|
||||
import Import from "~/models/Import";
|
||||
import Membership from "~/models/Membership";
|
||||
import Notification from "~/models/Notification";
|
||||
import Pin from "~/models/Pin";
|
||||
@@ -103,6 +105,7 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
subscriptions,
|
||||
fileOperations,
|
||||
notifications,
|
||||
imports,
|
||||
} = this.props;
|
||||
|
||||
const currentUserId = auth?.user?.id;
|
||||
@@ -117,23 +120,10 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on("authenticated", (data) => {
|
||||
this.socket.on("authenticated", () => {
|
||||
if (this.socket) {
|
||||
this.socket.authenticated = true;
|
||||
}
|
||||
if (isObject(data) && "editorVersion" in data) {
|
||||
const parsedClientVersion = semver.parse(EDITOR_VERSION);
|
||||
const parsedCurrentVersion = semver.parse(String(data.editorVersion));
|
||||
|
||||
if (
|
||||
parsedClientVersion &&
|
||||
parsedCurrentVersion &&
|
||||
(parsedClientVersion.major < parsedCurrentVersion.major ||
|
||||
parsedClientVersion.minor < parsedCurrentVersion.minor)
|
||||
) {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on("unauthorized", (err: Error) => {
|
||||
@@ -636,6 +626,23 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on("imports.create", (event: PartialExcept<Import, "id">) => {
|
||||
imports.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("imports.update", (event: PartialExcept<Import, "id">) => {
|
||||
imports.add(event);
|
||||
|
||||
if (
|
||||
event.state === ImportState.Completed &&
|
||||
event.createdBy?.id === auth.user?.id
|
||||
) {
|
||||
toast.success(event.name, {
|
||||
description: this.props.t("Your import completed"),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on(
|
||||
"subscriptions.create",
|
||||
(event: PartialExcept<Subscription, "id">) => {
|
||||
@@ -661,6 +668,10 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on("users.delete", (event: WebsocketEntityDeletedEvent) => {
|
||||
users.remove(event.modelId);
|
||||
});
|
||||
|
||||
this.socket.on(
|
||||
"userMemberships.update",
|
||||
async (event: PartialExcept<UserMembership, "id">) => {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
|
||||
import React from "react";
|
||||
import styled from "styled-components";
|
||||
import { Drawer as DrawerPrimitive } from "vaul";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import Flex from "../Flex";
|
||||
import Text from "../Text";
|
||||
import { Overlay } from "./components/Overlay";
|
||||
|
||||
/** Root Drawer component - all the other components are rendered inside it. */
|
||||
const Drawer = (props: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
|
||||
<DrawerPrimitive.Root {...props} />
|
||||
);
|
||||
Drawer.displayName = "Drawer";
|
||||
|
||||
/** Drawer's trigger. */
|
||||
const DrawerTrigger = DrawerPrimitive.Trigger;
|
||||
|
||||
/** Drawer's content - renders the overlay and the actual content. */
|
||||
const DrawerContent = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
||||
>((props, ref) => {
|
||||
const { children, ...rest } = props;
|
||||
|
||||
return (
|
||||
<DrawerPrimitive.Portal>
|
||||
<DrawerPrimitive.Overlay asChild>
|
||||
<Overlay />
|
||||
</DrawerPrimitive.Overlay>
|
||||
<StyledContent ref={ref} {...rest}>
|
||||
{children}
|
||||
</StyledContent>
|
||||
</DrawerPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
DrawerContent.displayName = DrawerPrimitive.Content.displayName;
|
||||
|
||||
/** Drawer's title shown in the center. */
|
||||
const DrawerTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
|
||||
>((props, ref) => {
|
||||
const { hidden, children, ...rest } = props;
|
||||
|
||||
const title = (
|
||||
<TitleWrapper justify="center">
|
||||
<Text size="medium" weight="bold">
|
||||
{children}
|
||||
</Text>
|
||||
</TitleWrapper>
|
||||
);
|
||||
|
||||
return (
|
||||
<DrawerPrimitive.Title ref={ref} {...rest} asChild>
|
||||
{hidden ? (
|
||||
<VisuallyHidden.Root>{title}</VisuallyHidden.Root>
|
||||
) : (
|
||||
<>{title}</>
|
||||
)}
|
||||
</DrawerPrimitive.Title>
|
||||
);
|
||||
});
|
||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
|
||||
|
||||
/** Styled components. */
|
||||
const StyledContent = styled(DrawerPrimitive.Content)`
|
||||
z-index: ${depths.menu};
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
min-width: 180px;
|
||||
max-width: 100%;
|
||||
min-height: 44px;
|
||||
max-height: 90vh;
|
||||
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
|
||||
background: ${s("menuBackground")};
|
||||
`;
|
||||
|
||||
const TitleWrapper = styled(Flex)`
|
||||
padding: 8px 0;
|
||||
`;
|
||||
|
||||
export { Drawer, DrawerTrigger, DrawerContent, DrawerTitle };
|
||||
@@ -0,0 +1,135 @@
|
||||
import * as InputSelectPrimitive from "@radix-ui/react-select";
|
||||
import React from "react";
|
||||
import styled from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { Props as ButtonProps } from "~/components/Button";
|
||||
import Separator from "~/components/ContextMenu/Separator";
|
||||
import { fadeAndSlideDown, fadeAndSlideUp } from "~/styles/animations";
|
||||
import {
|
||||
SelectItemIndicator,
|
||||
SelectItem as SelectItemWrapper,
|
||||
SelectButton,
|
||||
} from "./components/InputSelect";
|
||||
|
||||
/** Root InputSelect component - all the other components are rendered inside it. */
|
||||
const InputSelectRoot = InputSelectPrimitive.Root;
|
||||
|
||||
/** InputSelect's trigger. */
|
||||
|
||||
export type TriggerButtonProps = {
|
||||
/** When true, "nude" variant of Button is rendered. */
|
||||
nude?: boolean;
|
||||
/** Optional css class names to pass to the trigger. */
|
||||
className?: string;
|
||||
} & Pick<ButtonProps<unknown>, "borderOnHover">;
|
||||
|
||||
type InputSelectTriggerProps = { placeholder: string } & TriggerButtonProps &
|
||||
React.ComponentPropsWithoutRef<typeof InputSelectPrimitive.Trigger>;
|
||||
|
||||
const InputSelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof InputSelectPrimitive.Trigger>,
|
||||
InputSelectTriggerProps
|
||||
>((props, ref) => {
|
||||
const { placeholder, children, ...buttonProps } = props;
|
||||
|
||||
return (
|
||||
<InputSelectPrimitive.Trigger ref={ref} asChild>
|
||||
<SelectButton neutral disclosure {...buttonProps}>
|
||||
<InputSelectPrimitive.Value placeholder={placeholder} />
|
||||
</SelectButton>
|
||||
</InputSelectPrimitive.Trigger>
|
||||
);
|
||||
});
|
||||
InputSelectTrigger.displayName = InputSelectPrimitive.Trigger.displayName;
|
||||
|
||||
/** InputSelect's content - renders the options in a scrollable element. */
|
||||
type ContentProps = Omit<
|
||||
React.ComponentPropsWithoutRef<typeof InputSelectPrimitive.Content>,
|
||||
"position"
|
||||
>;
|
||||
|
||||
const InputSelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof InputSelectPrimitive.Content>,
|
||||
ContentProps
|
||||
>((props, ref) => {
|
||||
const { children, ...rest } = props;
|
||||
|
||||
return (
|
||||
<InputSelectPrimitive.Portal>
|
||||
<StyledContent ref={ref} position={"popper"} {...rest}>
|
||||
<InputSelectPrimitive.Viewport style={{ overscrollBehavior: "none" }}>
|
||||
{children}
|
||||
</InputSelectPrimitive.Viewport>
|
||||
</StyledContent>
|
||||
</InputSelectPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
InputSelectContent.displayName = InputSelectPrimitive.Content.displayName;
|
||||
|
||||
/** Individual InputSelect option rendered in the menu. */
|
||||
const InputSelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof InputSelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof InputSelectPrimitive.Item>
|
||||
>((props, ref) => {
|
||||
const { children, ...rest } = props;
|
||||
|
||||
return (
|
||||
<InputSelectPrimitive.Item ref={ref} {...rest} asChild>
|
||||
<SelectItemWrapper>
|
||||
<InputSelectPrimitive.ItemText>
|
||||
{children}
|
||||
</InputSelectPrimitive.ItemText>
|
||||
<InputSelectPrimitive.ItemIndicator asChild>
|
||||
<SelectItemIndicator />
|
||||
</InputSelectPrimitive.ItemIndicator>
|
||||
</SelectItemWrapper>
|
||||
</InputSelectPrimitive.Item>
|
||||
);
|
||||
});
|
||||
InputSelectItem.displayName = InputSelectPrimitive.Item.displayName;
|
||||
|
||||
/** Horizontal separator rendered between the options. */
|
||||
const InputSelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof InputSelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof InputSelectPrimitive.Separator>
|
||||
>((props, ref) => (
|
||||
<InputSelectPrimitive.Separator ref={ref} asChild>
|
||||
<Separator {...props} />
|
||||
</InputSelectPrimitive.Separator>
|
||||
));
|
||||
InputSelectSeparator.displayName = InputSelectPrimitive.Separator.displayName;
|
||||
|
||||
/** Styled components. */
|
||||
const StyledContent = styled(InputSelectPrimitive.Content)`
|
||||
z-index: ${depths.menu};
|
||||
min-width: var(--radix-select-trigger-width);
|
||||
max-width: 400px;
|
||||
min-height: 44px;
|
||||
max-height: 350px;
|
||||
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
background: ${s("menuBackground")};
|
||||
box-shadow: ${s("menuShadow")};
|
||||
transform-origin: 50% 0;
|
||||
|
||||
&[data-side="bottom"] {
|
||||
animation: ${fadeAndSlideDown} 200ms ease;
|
||||
}
|
||||
|
||||
&[data-side="top"] {
|
||||
animation: ${fadeAndSlideUp} 200ms ease;
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export {
|
||||
InputSelectRoot,
|
||||
InputSelectTrigger,
|
||||
InputSelectContent,
|
||||
InputSelectItem,
|
||||
InputSelectSeparator,
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Reusable components for InputSelect abstraction.
|
||||
*/
|
||||
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import React from "react";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import Button, { Inner } from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
|
||||
export const SelectItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentPropsWithoutRef<"div">
|
||||
>((props, ref) => {
|
||||
const { children, ...rest } = props;
|
||||
|
||||
return (
|
||||
<ItemContainer
|
||||
ref={ref}
|
||||
justify="space-between"
|
||||
align="center"
|
||||
gap={8}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
<IconSpacer />
|
||||
</ItemContainer>
|
||||
);
|
||||
});
|
||||
SelectItem.displayName = "SelectItem";
|
||||
|
||||
export const SelectItemIndicator = React.forwardRef<
|
||||
HTMLSpanElement,
|
||||
React.ComponentPropsWithoutRef<"span">
|
||||
>((props, ref) => (
|
||||
<IndicatorContainer ref={ref} {...props}>
|
||||
<CheckmarkIcon />
|
||||
</IndicatorContainer>
|
||||
));
|
||||
SelectItemIndicator.displayName = "SelectItemIndicator";
|
||||
|
||||
const IconSpacer = styled.div`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export const SelectButton = styled(Button)<{ $nude?: boolean }>`
|
||||
display: block;
|
||||
font-weight: normal;
|
||||
text-transform: none;
|
||||
width: 100%;
|
||||
cursor: var(--pointer);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: ${s("buttonNeutralBackground")};
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.$nude &&
|
||||
css`
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
`}
|
||||
|
||||
${Inner} {
|
||||
line-height: 28px;
|
||||
padding-left: 12px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
svg {
|
||||
justify-self: flex-end;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
&[data-placeholder=""] {
|
||||
color: ${s("placeholder")};
|
||||
}
|
||||
`;
|
||||
|
||||
const ItemContainer = styled(Flex)`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
cursor: var(--pointer);
|
||||
color: ${s("textSecondary")};
|
||||
background: none;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
outline: 0;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: ${s("accentText")};
|
||||
background: ${s("accent")};
|
||||
|
||||
svg {
|
||||
color: ${s("accentText")};
|
||||
fill: ${s("accentText")};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&[data-state="checked"] {
|
||||
${IconSpacer} {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
font-size: 14px;
|
||||
padding: 4px;
|
||||
padding-left: 8px;
|
||||
`}
|
||||
`;
|
||||
|
||||
const IndicatorContainer = styled.span`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
`;
|
||||
@@ -0,0 +1,15 @@
|
||||
import styled from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
|
||||
export const Overlay = styled.div`
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: ${s("backdrop")};
|
||||
z-index: ${depths.overlay};
|
||||
transition: opacity 50ms ease-in-out;
|
||||
opacity: 0;
|
||||
|
||||
&[data-state="open"] {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
@@ -7,6 +7,7 @@ import { isCode } from "@shared/editor/lib/isCode";
|
||||
import { findParentNode } from "@shared/editor/queries/findParentNode";
|
||||
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { getSafeAreaInsets } from "@shared/utils/browser";
|
||||
import { HEADER_HEIGHT } from "~/components/Header";
|
||||
import { Portal } from "~/components/Portal";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
@@ -241,12 +242,16 @@ const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
|
||||
|
||||
if (props.active) {
|
||||
const rect = document.body.getBoundingClientRect();
|
||||
const safeAreaInsets = getSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ReactPortal>
|
||||
<MobileWrapper
|
||||
ref={menuRef}
|
||||
style={{
|
||||
bottom: `calc(100% - ${height - rect.y}px)`,
|
||||
bottom: `calc(100% - ${
|
||||
height - rect.y - safeAreaInsets.bottom
|
||||
}px)`,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
|
||||
@@ -10,6 +10,7 @@ import styled from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { hideScrollbars, s } from "@shared/styles";
|
||||
import { isInternalUrl, sanitizeUrl } from "@shared/utils/urls";
|
||||
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
|
||||
import Flex from "~/components/Flex";
|
||||
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
@@ -253,7 +254,14 @@ const LinkEditor: React.FC<Props> = ({
|
||||
onPointerMove={() => setSelectedIndex(index)}
|
||||
selected={index === selectedIndex}
|
||||
key={doc.id}
|
||||
subtitle={doc.collection?.name}
|
||||
subtitle={
|
||||
<DocumentBreadcrumb
|
||||
document={doc}
|
||||
onlyText
|
||||
reverse
|
||||
maxDepth={2}
|
||||
/>
|
||||
}
|
||||
title={doc.title}
|
||||
icon={
|
||||
doc.icon ? (
|
||||
|
||||
@@ -11,6 +11,7 @@ import { MenuItem } from "@shared/editor/types";
|
||||
import { MentionType } from "@shared/types";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
|
||||
import Flex from "~/components/Flex";
|
||||
import {
|
||||
DocumentsSection,
|
||||
@@ -57,7 +58,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
res.data.documents.map(documents.add);
|
||||
res.data.users.map(users.add);
|
||||
res.data.collections.map(collections.add);
|
||||
}, [search, documents, users])
|
||||
}, [search, documents, users, collections])
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -68,7 +69,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
|
||||
React.useEffect(() => {
|
||||
if (actorId && !loading) {
|
||||
const items = users
|
||||
const items: MentionItem[] = users
|
||||
.findByQuery(search, { maxResults: maxResultsInSection })
|
||||
.map(
|
||||
(user) =>
|
||||
@@ -112,7 +113,14 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
<DocumentIcon />
|
||||
),
|
||||
title: doc.title,
|
||||
subtitle: doc.collection?.name,
|
||||
subtitle: (
|
||||
<DocumentBreadcrumb
|
||||
document={doc}
|
||||
onlyText
|
||||
reverse
|
||||
maxDepth={2}
|
||||
/>
|
||||
),
|
||||
section: DocumentsSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { LinkIcon } from "outline-icons";
|
||||
import { observer } from "mobx-react";
|
||||
import { EmailIcon, LinkIcon } from "outline-icons";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { v4 } from "uuid";
|
||||
import { EmbedDescriptor } from "@shared/editor/embeds";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { MentionType } from "@shared/types";
|
||||
import { isUrl } from "@shared/utils/urls";
|
||||
import Integration from "~/models/Integration";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { determineMentionType, isURLMentionable } from "~/utils/mention";
|
||||
import SuggestionsMenu, {
|
||||
Props as SuggestionsMenuProps,
|
||||
} from "./SuggestionsMenu";
|
||||
@@ -15,34 +24,65 @@ type Props = Omit<
|
||||
embeds: EmbedDescriptor[];
|
||||
};
|
||||
|
||||
const PasteMenu = ({ embeds, ...props }: Props) => {
|
||||
export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { integrations } = useStores();
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
|
||||
let mentionType: MentionType | undefined;
|
||||
|
||||
if (pastedText && isUrl(pastedText)) {
|
||||
const url = new URL(pastedText);
|
||||
const integration = integrations.find((intg: Integration) =>
|
||||
isURLMentionable({ url, integration: intg })
|
||||
);
|
||||
|
||||
mentionType = integration
|
||||
? determineMentionType({ url, integration })
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const embed = React.useMemo(() => {
|
||||
for (const e of embeds) {
|
||||
const matches = e.matcher(props.pastedText);
|
||||
const matches = e.matcher(pastedText);
|
||||
if (matches) {
|
||||
return e;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}, [embeds, props.pastedText]);
|
||||
}, [embeds, pastedText]);
|
||||
|
||||
const items = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
name: "noop",
|
||||
title: t("Keep as link"),
|
||||
icon: <LinkIcon />,
|
||||
},
|
||||
{
|
||||
name: "embed",
|
||||
title: t("Embed"),
|
||||
icon: embed?.icon,
|
||||
keywords: embed?.keywords,
|
||||
},
|
||||
],
|
||||
[embed, t]
|
||||
() =>
|
||||
[
|
||||
{
|
||||
name: "noop",
|
||||
title: t("Keep as link"),
|
||||
icon: <LinkIcon />,
|
||||
},
|
||||
{
|
||||
name: "mention",
|
||||
title: t("Mention"),
|
||||
icon: <EmailIcon />,
|
||||
visible: !!mentionType,
|
||||
attrs: {
|
||||
id: v4(),
|
||||
type: mentionType,
|
||||
label: pastedText,
|
||||
href: pastedText,
|
||||
modelId: v4(),
|
||||
actorId: user?.id,
|
||||
},
|
||||
appendSpace: true,
|
||||
},
|
||||
{
|
||||
name: "embed",
|
||||
title: t("Embed"),
|
||||
icon: embed?.icon,
|
||||
keywords: embed?.keywords,
|
||||
},
|
||||
] satisfies MenuItem[],
|
||||
[t, embed, mentionType, pastedText, user]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -52,9 +92,7 @@ const PasteMenu = ({ embeds, ...props }: Props) => {
|
||||
filterable={false}
|
||||
renderMenuItem={(item, _index, options) => (
|
||||
<SuggestionsMenuItem
|
||||
onClick={() => {
|
||||
props.onSelect?.(item);
|
||||
}}
|
||||
onClick={options.onClick}
|
||||
selected={options.selected}
|
||||
title={item.title}
|
||||
icon={item.icon}
|
||||
@@ -63,6 +101,4 @@ const PasteMenu = ({ embeds, ...props }: Props) => {
|
||||
items={items}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasteMenu;
|
||||
});
|
||||
|
||||
@@ -174,12 +174,12 @@ export default function SelectionToolbar(props: Props) {
|
||||
const { isTemplate, rtl, canComment, canUpdate, ...rest } = props;
|
||||
const { state } = view;
|
||||
const { selection } = state;
|
||||
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
|
||||
|
||||
if ((readOnly && !canComment) || isDragging) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
|
||||
const colIndex = getColumnIndex(state);
|
||||
const rowIndex = getRowIndex(state);
|
||||
const isTableSelection = colIndex !== undefined && rowIndex !== undefined;
|
||||
|
||||
@@ -645,12 +645,11 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
"section" in item ? item.section?.({ t }) : undefined;
|
||||
|
||||
const response = (
|
||||
<>
|
||||
<React.Fragment key={`${index}-${item.name}`}>
|
||||
{currentHeading !== previousHeading && (
|
||||
<Header key={currentHeading}>{currentHeading}</Header>
|
||||
)}
|
||||
<ListItem
|
||||
key={index}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerDown={handlePointerDown}
|
||||
>
|
||||
@@ -659,7 +658,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
onClick: () => handleClickItem(item),
|
||||
})}
|
||||
</ListItem>
|
||||
</>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
previousHeading = currentHeading;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled from "styled-components";
|
||||
@@ -68,12 +69,16 @@ function SuggestionsMenuItem({
|
||||
|
||||
const Subtitle = styled.span<{ $active?: boolean }>`
|
||||
color: ${(props) =>
|
||||
props.$active ? props.theme.white50 : props.theme.textTertiary};
|
||||
props.$active
|
||||
? transparentize(0.35, props.theme.accentText)
|
||||
: props.theme.textTertiary};
|
||||
`;
|
||||
|
||||
const Shortcut = styled.span<{ $active?: boolean }>`
|
||||
color: ${(props) =>
|
||||
props.$active ? props.theme.white50 : props.theme.textTertiary};
|
||||
props.$active
|
||||
? transparentize(0.35, props.theme.accentText)
|
||||
: props.theme.textTertiary};
|
||||
flex-grow: 1;
|
||||
text-align: right;
|
||||
`;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Plugin, PluginKey } from "prosemirror-state";
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import { isList } from "@shared/editor/queries/isList";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
|
||||
/**
|
||||
@@ -18,17 +19,25 @@ export default class ClipboardTextSerializer extends Extension {
|
||||
new Plugin({
|
||||
key: new PluginKey("clipboardTextSerializer"),
|
||||
props: {
|
||||
clipboardTextSerializer: (slice) => {
|
||||
clipboardTextSerializer: (slice, view) => {
|
||||
const isMultiline = slice.content.childCount > 1;
|
||||
|
||||
// This is a cheap way to determine if the content is "complex",
|
||||
// aka it has multiple marks or formatting. In which case we'll use
|
||||
// markdown formatting
|
||||
const hasMultipleListItems = slice.content.content
|
||||
.filter((node) => node.content.content.length > 1)
|
||||
.some((node) => isList(node, view.state.schema));
|
||||
const hasMultipleBlockTypes =
|
||||
[
|
||||
...new Set(
|
||||
slice.content.content
|
||||
.filter((node) => node.content.content.length > 1)
|
||||
.map((node) => node.type.name)
|
||||
),
|
||||
].length > 1;
|
||||
const copyAsMarkdown =
|
||||
isMultiline ||
|
||||
slice.content.content.some(
|
||||
(node) => node.content.content.length > 1
|
||||
);
|
||||
isMultiline || hasMultipleBlockTypes || hasMultipleListItems;
|
||||
|
||||
return copyAsMarkdown
|
||||
? mdSerializer.serialize(slice.content, {
|
||||
|
||||
@@ -4,9 +4,9 @@ import { EditorView } from "prosemirror-view";
|
||||
import * as React from "react";
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import stores from "~/stores";
|
||||
import HoverPreview from "~/components/HoverPreview";
|
||||
import env from "~/env";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
|
||||
interface HoverPreviewsOptions {
|
||||
/** Delay before the target is considered "hovered" and callback is triggered. */
|
||||
@@ -16,11 +16,11 @@ interface HoverPreviewsOptions {
|
||||
export default class HoverPreviews extends Extension {
|
||||
state: {
|
||||
activeLinkElement: HTMLElement | null;
|
||||
data: Record<string, any> | null;
|
||||
unfurlId: string | null;
|
||||
dataLoading: boolean;
|
||||
} = observable({
|
||||
activeLinkElement: null,
|
||||
data: null,
|
||||
unfurlId: null,
|
||||
dataLoading: false,
|
||||
});
|
||||
|
||||
@@ -62,19 +62,25 @@ export default class HoverPreviews extends Extension {
|
||||
);
|
||||
|
||||
if (url) {
|
||||
const transformedUrl = url.startsWith("/")
|
||||
? env.URL + url
|
||||
: url;
|
||||
|
||||
this.state.dataLoading = true;
|
||||
try {
|
||||
const data = await client.post("/urls.unfurl", {
|
||||
url: url.startsWith("/") ? env.URL + url : url,
|
||||
documentId,
|
||||
});
|
||||
|
||||
const unfurl = await stores.unfurls.fetchUnfurl({
|
||||
url: transformedUrl,
|
||||
documentId,
|
||||
});
|
||||
|
||||
if (unfurl) {
|
||||
this.state.activeLinkElement = element;
|
||||
this.state.data = data;
|
||||
} catch (err) {
|
||||
this.state.unfurlId = transformedUrl;
|
||||
} else {
|
||||
this.state.activeLinkElement = null;
|
||||
} finally {
|
||||
this.state.dataLoading = false;
|
||||
}
|
||||
|
||||
this.state.dataLoading = false;
|
||||
}
|
||||
}),
|
||||
this.options.delay
|
||||
@@ -101,10 +107,11 @@ export default class HoverPreviews extends Extension {
|
||||
widget = () => (
|
||||
<HoverPreview
|
||||
element={this.state.activeLinkElement}
|
||||
data={this.state.data}
|
||||
unfurlId={this.state.unfurlId}
|
||||
dataLoading={this.state.dataLoading}
|
||||
onClose={action(() => {
|
||||
this.state.activeLinkElement = null;
|
||||
this.state.unfurlId = null;
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
import { Decoration, DecorationSet } from "prosemirror-view";
|
||||
import * as React from "react";
|
||||
import { v4 } from "uuid";
|
||||
import { LANGUAGES } from "@shared/editor/extensions/Prism";
|
||||
import Extension, { WidgetProps } from "@shared/editor/lib/Extension";
|
||||
import { codeLanguages } from "@shared/editor/lib/code";
|
||||
import isMarkdown from "@shared/editor/lib/isMarkdown";
|
||||
import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize";
|
||||
import { isRemoteTransaction } from "@shared/editor/lib/multiplayer";
|
||||
@@ -24,7 +24,7 @@ import parseCollectionSlug from "@shared/utils/parseCollectionSlug";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { isCollectionUrl, isDocumentUrl, isUrl } from "@shared/utils/urls";
|
||||
import stores from "~/stores";
|
||||
import PasteMenu from "../components/PasteMenu";
|
||||
import { PasteMenu } from "../components/PasteMenu";
|
||||
|
||||
export default class PasteHandler extends Extension {
|
||||
state: {
|
||||
@@ -88,7 +88,7 @@ export default class PasteHandler extends Extension {
|
||||
|
||||
// If the users selection is currently in a code block then paste
|
||||
// as plain text, ignore all formatting and HTML content.
|
||||
if (isInCode(state)) {
|
||||
if (isInCode(state, { inclusive: true })) {
|
||||
event.preventDefault();
|
||||
view.dispatch(state.tr.insertText(text));
|
||||
return true;
|
||||
@@ -122,6 +122,8 @@ export default class PasteHandler extends Extension {
|
||||
}
|
||||
|
||||
// Is the link a link to a document? If so, we can grab the title and insert it.
|
||||
const containsHash = text.includes("#");
|
||||
|
||||
if (isDocumentUrl(text)) {
|
||||
const slug = parseDocumentSlug(text);
|
||||
|
||||
@@ -133,7 +135,7 @@ export default class PasteHandler extends Extension {
|
||||
return;
|
||||
}
|
||||
if (document) {
|
||||
if (state.schema.nodes.mention) {
|
||||
if (state.schema.nodes.mention && !containsHash) {
|
||||
view.dispatch(
|
||||
view.state.tr.replaceWith(
|
||||
state.selection.from,
|
||||
@@ -178,7 +180,7 @@ export default class PasteHandler extends Extension {
|
||||
return;
|
||||
}
|
||||
if (collection) {
|
||||
if (state.schema.nodes.mention) {
|
||||
if (state.schema.nodes.mention && !containsHash) {
|
||||
view.dispatch(
|
||||
view.state.tr.replaceWith(
|
||||
state.selection.from,
|
||||
@@ -226,7 +228,7 @@ export default class PasteHandler extends Extension {
|
||||
state.tr
|
||||
.replaceSelectionWith(
|
||||
state.schema.nodes.code_block.create({
|
||||
language: Object.keys(LANGUAGES).includes(
|
||||
language: Object.keys(codeLanguages).includes(
|
||||
vscodeMeta.mode
|
||||
)
|
||||
? vscodeMeta.mode
|
||||
@@ -413,6 +415,21 @@ export default class PasteHandler extends Extension {
|
||||
});
|
||||
};
|
||||
|
||||
private insertMention = () => {
|
||||
const { view } = this.editor;
|
||||
const { state } = view;
|
||||
const result = this.findPlaceholder(state, this.state.pastedText);
|
||||
|
||||
// Remove just the placeholder here.
|
||||
// Mention node will be created by SuggestionsMenu.
|
||||
if (result) {
|
||||
const tr = state.tr.deleteRange(result[0], result[1]);
|
||||
view.dispatch(
|
||||
tr.setSelection(TextSelection.near(tr.doc.resolve(result[0])))
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
private removePlaceholder = () => {
|
||||
const { view } = this.editor;
|
||||
const { state } = view;
|
||||
@@ -448,6 +465,11 @@ export default class PasteHandler extends Extension {
|
||||
this.insertEmbed();
|
||||
break;
|
||||
}
|
||||
case "mention": {
|
||||
this.hidePasteMenu();
|
||||
this.insertMention();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import Extension from "@shared/editor/lib/Extension";
|
||||
import { InputRule } from "@shared/editor/lib/InputRule";
|
||||
|
||||
const rightArrow = new InputRule(/->$/, "→");
|
||||
const emdash = new InputRule(/--$/, "—");
|
||||
// Note that the suppression of pipe here prevents conflict with table creation rule.
|
||||
const emdash = new InputRule(/(?:^|[^\|])(--)$/, "—");
|
||||
const oneHalf = new InputRule(/(?:^|\s)(1\/2)$/, "½");
|
||||
const threeQuarters = new InputRule(/(?:^|\s)(3\/4)$/, "¾");
|
||||
const copyright = new InputRule(/\(c\)$/, "©️");
|
||||
|
||||
@@ -23,7 +23,7 @@ export default class Suggestion extends Extension {
|
||||
this.options.trigger
|
||||
)}(${`[\\p{L}\/\\p{M}\\d${
|
||||
this.options.allowSpaces ? "\\s{1}" : ""
|
||||
}\\.]+`})${this.options.requireSearchTerm ? "" : "?"}$`,
|
||||
}\\.\\-–_]+`})${this.options.requireSearchTerm ? "" : "?"}$`,
|
||||
"u"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -843,7 +843,7 @@ const EditorContainer = styled(Styles)<{
|
||||
${(props) =>
|
||||
props.userId &&
|
||||
css`
|
||||
.mention[data-id=${props.userId}] {
|
||||
.mention[data-id="${props.userId}"] {
|
||||
color: ${props.theme.textHighlightForeground};
|
||||
background: ${props.theme.textHighlight};
|
||||
|
||||
|
||||
+13
-12
@@ -2,8 +2,11 @@ import { CopyIcon, ExpandedIcon } from "outline-icons";
|
||||
import { Node as ProseMirrorNode } from "prosemirror-model";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import { LANGUAGES } from "@shared/editor/extensions/Prism";
|
||||
import { getFrequentCodeLanguages } from "@shared/editor/lib/code";
|
||||
import {
|
||||
getFrequentCodeLanguages,
|
||||
codeLanguages,
|
||||
getLabelForLanguage,
|
||||
} from "@shared/editor/lib/code";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
|
||||
@@ -14,20 +17,19 @@ export default function codeMenuItems(
|
||||
): MenuItem[] {
|
||||
const node = state.selection.$from.node();
|
||||
|
||||
const allLanguages = Object.entries(LANGUAGES) as [
|
||||
keyof typeof LANGUAGES,
|
||||
string
|
||||
][];
|
||||
const frequentLanguages = getFrequentCodeLanguages();
|
||||
|
||||
const frequentLangMenuItems = frequentLanguages.map((value) => {
|
||||
const label = LANGUAGES[value];
|
||||
const label = codeLanguages[value]?.label;
|
||||
return langToMenuItem({ node, value, label });
|
||||
});
|
||||
|
||||
const remainingLangMenuItems = allLanguages
|
||||
.filter(([value]) => !frequentLanguages.includes(value))
|
||||
.map(([value, label]) => langToMenuItem({ node, value, label }));
|
||||
const remainingLangMenuItems = Object.entries(codeLanguages)
|
||||
.filter(
|
||||
([value]) =>
|
||||
!frequentLanguages.includes(value as keyof typeof codeLanguages)
|
||||
)
|
||||
.map(([value, item]) => langToMenuItem({ node, value, label: item.label }));
|
||||
|
||||
const languageMenuItems = frequentLangMenuItems.length
|
||||
? [
|
||||
@@ -52,8 +54,7 @@ export default function codeMenuItems(
|
||||
visible: !readOnly,
|
||||
name: "code_block",
|
||||
icon: <ExpandedIcon />,
|
||||
// @ts-expect-error We have a fallback for incorrect mapping
|
||||
label: LANGUAGES[node.attrs.language ?? "none"],
|
||||
label: getLabelForLanguage(node.attrs.language ?? "none"),
|
||||
children: languageMenuItems,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -67,7 +67,7 @@ export default function formattingMenuItems(
|
||||
shortcut: `${metaDisplay}+B`,
|
||||
icon: <BoldIcon />,
|
||||
active: isMarkActive(schema.marks.strong),
|
||||
visible: !isCode && (!isMobile || !isEmpty),
|
||||
visible: !isCodeBlock && (!isMobile || !isEmpty),
|
||||
},
|
||||
{
|
||||
name: "em",
|
||||
@@ -75,7 +75,7 @@ export default function formattingMenuItems(
|
||||
shortcut: `${metaDisplay}+I`,
|
||||
icon: <ItalicIcon />,
|
||||
active: isMarkActive(schema.marks.em),
|
||||
visible: !isCode && (!isMobile || !isEmpty),
|
||||
visible: !isCodeBlock && (!isMobile || !isEmpty),
|
||||
},
|
||||
{
|
||||
name: "strikethrough",
|
||||
@@ -83,7 +83,7 @@ export default function formattingMenuItems(
|
||||
shortcut: `${metaDisplay}+D`,
|
||||
icon: <StrikethroughIcon />,
|
||||
active: isMarkActive(schema.marks.strikethrough),
|
||||
visible: !isCode && (!isMobile || !isEmpty),
|
||||
visible: !isCodeBlock && (!isMobile || !isEmpty),
|
||||
},
|
||||
{
|
||||
tooltip: dictionary.mark,
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/**
|
||||
* Hook that provides a dictionary of translated UI strings.
|
||||
*
|
||||
* @returns An object containing all translated UI strings used throughout the application
|
||||
*/
|
||||
export default function useDictionary() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
||||
@@ -1,19 +1,3 @@
|
||||
import * as React from "react";
|
||||
import useIsMounted from "@shared/hooks/useIsMounted";
|
||||
|
||||
/**
|
||||
* 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, []);
|
||||
}
|
||||
export default useIsMounted;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { getCookie } from "tiny-cookie";
|
||||
|
||||
export type Sessions = Record<
|
||||
string,
|
||||
{
|
||||
name: string;
|
||||
logoUrl: string;
|
||||
url: string;
|
||||
}
|
||||
>;
|
||||
|
||||
export function useLoggedInSessions(): Sessions {
|
||||
return JSON.parse(getCookie("sessions") || "{}");
|
||||
}
|
||||
@@ -1,6 +1,15 @@
|
||||
import * as React from "react";
|
||||
import useWindowSize from "./useWindowSize";
|
||||
|
||||
/**
|
||||
* Hook to calculate the maximum height for an element based on its position and viewport size.
|
||||
*
|
||||
* @param options Configuration options
|
||||
* @param options.elementRef A ref pointing to the element to calculate max height for
|
||||
* @param options.maxViewportPercentage The maximum height of the element as a percentage of the viewport
|
||||
* @param options.margin The margin to apply to the positioning
|
||||
* @returns Object containing the calculated maxHeight and a function to recalculate it
|
||||
*/
|
||||
const useMaxHeight = ({
|
||||
elementRef,
|
||||
maxViewportPercentage = 90,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Hook to check if a media query matches the current viewport.
|
||||
*
|
||||
* @param query The CSS media query to check against
|
||||
* @returns boolean indicating whether the media query matches
|
||||
*/
|
||||
export default function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState<boolean>(false);
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { breakpoints } from "@shared/styles";
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
|
||||
/**
|
||||
* Hook to detect if the current viewport is mobile-sized.
|
||||
*
|
||||
* @returns boolean indicating whether the current viewport is mobile-sized
|
||||
*/
|
||||
export default function useMobile(): boolean {
|
||||
return useMediaQuery(`(max-width: ${breakpoints.tablet - 1}px)`);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export default function usePolicy(entity?: string | Model | null) {
|
||||
? entity
|
||||
: entity.id
|
||||
: "";
|
||||
const policy = policies.get(entityId);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
@@ -28,11 +29,11 @@ export default function usePolicy(entity?: string | Model | null) {
|
||||
) {
|
||||
// The policy for this model is missing and we have an authenticated session, attempt to
|
||||
// reload relationships for this model.
|
||||
if (!policies.get(entity.id) && user) {
|
||||
if (!policy && user) {
|
||||
void entity.loadRelations();
|
||||
}
|
||||
}
|
||||
}, [policies, user, entity]);
|
||||
}, [policies, policy, user, entity]);
|
||||
|
||||
return policies.abilities(entityId);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import React from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
/**
|
||||
* Hook to access URL query parameters from the current location.
|
||||
*
|
||||
* @returns URLSearchParams object containing the current URL query parameters
|
||||
*/
|
||||
export default function useQuery() {
|
||||
const location = useLocation();
|
||||
|
||||
|
||||
@@ -24,6 +24,14 @@ export default function useQueryNotices() {
|
||||
);
|
||||
break;
|
||||
}
|
||||
case QueryNotices.UnsubscribeCollection: {
|
||||
toast.success(
|
||||
t("Unsubscribed from collection", {
|
||||
type: "success",
|
||||
})
|
||||
);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
}
|
||||
}, [t, notice]);
|
||||
|
||||
@@ -59,7 +59,7 @@ export default function useRequest<T = unknown>(
|
||||
if (makeRequestOnMount) {
|
||||
void request();
|
||||
}
|
||||
}, [request, makeRequestOnMount]);
|
||||
}, []);
|
||||
|
||||
return { data, loading, loaded, error, request };
|
||||
}
|
||||
|
||||
@@ -8,12 +8,12 @@ import {
|
||||
GlobeIcon,
|
||||
TeamIcon,
|
||||
BeakerIcon,
|
||||
BuildingBlocksIcon,
|
||||
SettingsIcon,
|
||||
ExportIcon,
|
||||
ImportIcon,
|
||||
ShapesIcon,
|
||||
Icon,
|
||||
InternetIcon,
|
||||
} from "outline-icons";
|
||||
import React, { ComponentProps } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -29,7 +29,8 @@ import useCurrentUser from "./useCurrentUser";
|
||||
import usePolicy from "./usePolicy";
|
||||
|
||||
const ApiKeys = lazy(() => import("~/scenes/Settings/ApiKeys"));
|
||||
const PersonalApiKeys = lazy(() => import("~/scenes/Settings/PersonalApiKeys"));
|
||||
const Applications = lazy(() => import("~/scenes/Settings/Applications"));
|
||||
const APIAndApps = lazy(() => import("~/scenes/Settings/APIAndApps"));
|
||||
const Details = lazy(() => import("~/scenes/Settings/Details"));
|
||||
const Export = lazy(() => import("~/scenes/Settings/Export"));
|
||||
const Features = lazy(() => import("~/scenes/Settings/Features"));
|
||||
@@ -40,7 +41,6 @@ const Notifications = lazy(() => import("~/scenes/Settings/Notifications"));
|
||||
const Preferences = lazy(() => import("~/scenes/Settings/Preferences"));
|
||||
const Profile = lazy(() => import("~/scenes/Settings/Profile"));
|
||||
const Security = lazy(() => import("~/scenes/Settings/Security"));
|
||||
const SelfHosted = lazy(() => import("~/scenes/Settings/SelfHosted"));
|
||||
const Shares = lazy(() => import("~/scenes/Settings/Shares"));
|
||||
const Templates = lazy(() => import("~/scenes/Settings/Templates"));
|
||||
const Zapier = lazy(() => import("~/scenes/Settings/Zapier"));
|
||||
@@ -88,12 +88,12 @@ const useSettingsConfig = () => {
|
||||
icon: EmailIcon,
|
||||
},
|
||||
{
|
||||
name: t("API Keys"),
|
||||
path: settingsPath("personal-api-keys"),
|
||||
component: PersonalApiKeys,
|
||||
enabled: can.createApiKey && !can.listApiKeys,
|
||||
name: t("API & Apps"),
|
||||
path: settingsPath("api-and-apps"),
|
||||
component: APIAndApps,
|
||||
enabled: true,
|
||||
group: t("Account"),
|
||||
icon: CodeIcon,
|
||||
icon: PadlockIcon,
|
||||
},
|
||||
// Workspace
|
||||
{
|
||||
@@ -152,6 +152,14 @@ const useSettingsConfig = () => {
|
||||
group: t("Workspace"),
|
||||
icon: CodeIcon,
|
||||
},
|
||||
{
|
||||
name: t("Applications"),
|
||||
path: settingsPath("applications"),
|
||||
component: Applications,
|
||||
enabled: can.listOAuthClients,
|
||||
group: t("Workspace"),
|
||||
icon: InternetIcon,
|
||||
},
|
||||
{
|
||||
name: t("Shared Links"),
|
||||
path: settingsPath("shares"),
|
||||
@@ -177,14 +185,6 @@ const useSettingsConfig = () => {
|
||||
icon: ExportIcon,
|
||||
},
|
||||
// Integrations
|
||||
{
|
||||
name: t("Self Hosted"),
|
||||
path: integrationSettingsPath("self-hosted"),
|
||||
component: SelfHosted,
|
||||
enabled: can.update && !isCloudHosted,
|
||||
group: t("Integrations"),
|
||||
icon: BuildingBlocksIcon,
|
||||
},
|
||||
{
|
||||
name: "Zapier",
|
||||
path: integrationSettingsPath("zapier"),
|
||||
|
||||
@@ -2,6 +2,11 @@ import { MobXProviderContext } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import RootStore from "~/stores";
|
||||
|
||||
/**
|
||||
* Hook to access the MobX stores from the React context.
|
||||
*
|
||||
* @returns The root store containing all application stores
|
||||
*/
|
||||
export default function useStores() {
|
||||
return React.useContext(MobXProviderContext) as typeof RootStore;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* Hook that executes a callback when the component unmounts.
|
||||
*
|
||||
* @param callback Function to be called on component unmount
|
||||
*/
|
||||
const useUnmount = (callback: (...args: Array<any>) => any) => {
|
||||
const ref = React.useRef(callback);
|
||||
ref.current = callback;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user