mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c0f375901 | |||
| 4894e92d6b | |||
| 5e6eebd0ec |
@@ -24,18 +24,7 @@
|
||||
"include": ["SOURCE_COMMIT", "SOURCE_VERSION"]
|
||||
}
|
||||
],
|
||||
[
|
||||
"module-resolver",
|
||||
{
|
||||
"root": ["./"],
|
||||
"alias": {
|
||||
"@server": "./server",
|
||||
"@shared": "./shared",
|
||||
"~": "./app",
|
||||
"plugins": "./plugins"
|
||||
}
|
||||
}
|
||||
]
|
||||
"tsconfig-paths-module-resolver"
|
||||
],
|
||||
"env": {
|
||||
"production": {
|
||||
|
||||
-27
@@ -1,21 +1,5 @@
|
||||
NODE_ENV=production
|
||||
|
||||
# –––––––––––––––––––––––––––––––––––––––––
|
||||
# ––––––––––– FILE-BASED SECRETS ––––––––
|
||||
# –––––––––––––––––––––––––––––––––––––––––
|
||||
#
|
||||
# Any environment variable can be loaded from a file by appending _FILE to the
|
||||
# variable name and setting the value to the path of the file. This is useful
|
||||
# for Docker secrets and other file-based secret management systems.
|
||||
#
|
||||
# For example, instead of:
|
||||
# SECRET_KEY=your_secret_key
|
||||
# You can use:
|
||||
# SECRET_KEY_FILE=/run/secrets/outline_secret_key
|
||||
#
|
||||
# The file contents will be trimmed of leading/trailing whitespace. If both the
|
||||
# variable and the _FILE variant are set, the direct variable takes precedence.
|
||||
|
||||
# This URL should point to the fully qualified, publicly accessible, URL. If using a
|
||||
# proxy this will be the proxy's URL.
|
||||
URL=
|
||||
@@ -140,11 +124,6 @@ FORCE_HTTPS=true
|
||||
# and "X-Client-IP".
|
||||
# PROXY_IP_HEADER=
|
||||
|
||||
# Whether to trust the X-Forwarded-* headers (e.g. X-Forwarded-For,
|
||||
# X-Forwarded-Proto) set by an upstream proxy. Set to false if not
|
||||
# running behind a proxy in production.
|
||||
# PROXY_HEADERS_TRUSTED=true
|
||||
|
||||
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
# –––––––––– AUTHENTICATION ––––––––––
|
||||
@@ -223,12 +202,6 @@ RATE_LIMITER_ENABLED=true
|
||||
RATE_LIMITER_REQUESTS=1000
|
||||
RATE_LIMITER_DURATION_WINDOW=60
|
||||
|
||||
# Multiplier applied to the hardcoded per-endpoint API rate limits. Use values
|
||||
# greater than 1 to make the limits more lenient (e.g. 2 doubles the allowed
|
||||
# requests), or less than 1 to make them stricter. Effective limits are rounded
|
||||
# to the nearest integer with a minimum of 1. Defaults to 1.
|
||||
RATE_LIMITER_MULTIPLIER=1
|
||||
|
||||
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
# ––––––––––– INTEGRATIONS –––––––––––
|
||||
|
||||
@@ -17,8 +17,6 @@ SLACK_VERIFICATION_TOKEN=test-token-123
|
||||
GITHUB_CLIENT_ID=123;
|
||||
GITHUB_CLIENT_SECRET=123;
|
||||
GITHUB_APP_NAME=outline-test;
|
||||
GITHUB_APP_ID=123
|
||||
GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA\n-----END RSA PRIVATE KEY-----"
|
||||
|
||||
GITLAB_CLIENT_ID=123
|
||||
GITLAB_CLIENT_SECRET=123
|
||||
@@ -31,15 +29,6 @@ OIDC_USERINFO_URI=http://localhost/userinfo
|
||||
|
||||
IFRAMELY_API_KEY=123
|
||||
|
||||
NOTION_CLIENT_ID=123
|
||||
NOTION_CLIENT_SECRET=123
|
||||
|
||||
LINEAR_CLIENT_ID=123
|
||||
LINEAR_CLIENT_SECRET=123
|
||||
|
||||
FIGMA_CLIENT_ID=123
|
||||
FIGMA_CLIENT_SECRET=123
|
||||
|
||||
RATE_LIMITER_ENABLED=false
|
||||
|
||||
FILE_STORAGE=local
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
name: Install
|
||||
description: Set up Node.js, Corepack, and install dependencies with yarn cache
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Enable Corepack
|
||||
shell: bash
|
||||
run: corepack enable
|
||||
- name: Use Node.js 24.x
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24.x
|
||||
cache: "yarn"
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: yarn install --immutable
|
||||
@@ -31,12 +31,6 @@ jobs:
|
||||
|
||||
if (prAge < TWO_WEEKS) continue;
|
||||
|
||||
const hasSkipLabel = pr.labels.some(label =>
|
||||
label.name === 'pinned'
|
||||
);
|
||||
|
||||
if (hasSkipLabel) continue;
|
||||
|
||||
const comments = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
uses: actions/checkout@v5
|
||||
- name: Compress Images
|
||||
id: calibre
|
||||
uses: calibreapp/image-actions@3d5873ac3e7bf1a38b24d9778d8dc639d5706d8b # main
|
||||
uses: calibreapp/image-actions@main
|
||||
with:
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
# For non-Pull Requests, run in compressOnly mode and we'll PR after.
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
if: |
|
||||
github.event_name != 'pull_request' &&
|
||||
steps.calibre.outputs.markdown != ''
|
||||
uses: peter-evans/create-pull-request@18f7dc018cc2cd597073088f7c7591b9d1c02672 # v3
|
||||
uses: peter-evans/create-pull-request@v3
|
||||
with:
|
||||
title: "chore: Auto Compress Images"
|
||||
branch-suffix: timestamp
|
||||
|
||||
+98
-38
@@ -18,23 +18,78 @@ env:
|
||||
SMTP_USERNAME: localhost
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- name: Use Node.js 22.x
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
- name: Cache node_modules
|
||||
id: cache-node-modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
|
||||
- name: Install dependencies
|
||||
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
||||
run: yarn install --immutable
|
||||
|
||||
lint:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
- name: Restore node_modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
|
||||
- run: yarn lint --quiet
|
||||
|
||||
types:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
- name: Restore node_modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
|
||||
- run: yarn tsc
|
||||
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
config: ${{ steps.filter.outputs.config }}
|
||||
server: ${{ steps.filter.outputs.server }}
|
||||
app: ${{ steps.filter.outputs.app }}
|
||||
deps: ${{ steps.filter.outputs.deps }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dorny/paths-filter@4512585405083f25c027a35db413c2b3b9006d50 # v2
|
||||
- uses: dorny/paths-filter@v2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
config:
|
||||
- '.github/**'
|
||||
- 'vite.config.ts'
|
||||
- 'vitest.config.ts'
|
||||
server:
|
||||
- 'server/**'
|
||||
- 'shared/**'
|
||||
@@ -45,36 +100,9 @@ jobs:
|
||||
- 'shared/**'
|
||||
- 'package.json'
|
||||
- 'yarn.lock'
|
||||
deps:
|
||||
- 'package.json'
|
||||
- 'yarn.lock'
|
||||
- '.yarnrc.yml'
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: ./.github/actions/install
|
||||
- run: yarn lint --quiet
|
||||
|
||||
types:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: ./.github/actions/install
|
||||
- run: yarn tsc
|
||||
|
||||
audit:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.deps == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: ./.github/actions/install
|
||||
- run: yarn npm audit --severity high --recursive --environment production
|
||||
|
||||
test:
|
||||
needs: changes
|
||||
needs: [setup, changes]
|
||||
if: ${{ needs.changes.outputs.app == 'true' || needs.changes.outputs.config == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
@@ -82,11 +110,21 @@ jobs:
|
||||
test-group: [app, shared]
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: ./.github/actions/install
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
- name: Restore node_modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
|
||||
- run: yarn test:${{ matrix.test-group }}
|
||||
|
||||
test-server:
|
||||
needs: changes
|
||||
needs: [setup, changes]
|
||||
if: ${{ needs.changes.outputs.server == 'true' || needs.changes.outputs.config == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
@@ -110,23 +148,45 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: ./.github/actions/install
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
- name: Restore node_modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
|
||||
- run: yarn sequelize db:migrate
|
||||
- name: Run server tests
|
||||
run: yarn test:server --maxWorkers=2 --shard=${{ matrix.shard }}/4
|
||||
run: |
|
||||
TESTFILES=$(find . -name "*.test.ts" -path "*/server/*" | sort | awk "NR % 4 == (${{ matrix.shard }} - 1)")
|
||||
yarn test --maxWorkers=2 $TESTFILES
|
||||
|
||||
bundle-size:
|
||||
needs: changes
|
||||
needs: [setup, types, changes]
|
||||
if: ${{ (needs.changes.outputs.app == 'true' || needs.changes.outputs.config == 'true') && github.repository == 'outline/outline' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: ./.github/actions/install
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
- name: Restore node_modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
|
||||
- name: Set environment to production
|
||||
run: echo "NODE_ENV=production" >> $GITHUB_ENV
|
||||
- run: yarn vite:build
|
||||
- name: Send bundle stats to RelativeCI
|
||||
uses: relative-ci/agent-action@38328454d6a23942175eba485fca4fbb807b1f03 # v2
|
||||
uses: relative-ci/agent-action@v2
|
||||
with:
|
||||
key: ${{ secrets.RELATIVE_CI_KEY }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
name: Publish build
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
IMAGE_NAME: outlinewiki/outline
|
||||
@@ -12,17 +11,17 @@ env:
|
||||
|
||||
jobs:
|
||||
build-arm:
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404-arm
|
||||
runs-on: ubicloud-standard-8-arm
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Blacksmith Builder
|
||||
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Docker base meta
|
||||
id: base_meta
|
||||
uses: docker/metadata-action@v6
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.BASE_IMAGE_NAME }}
|
||||
@@ -31,14 +30,14 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push base image
|
||||
id: base_build
|
||||
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.base
|
||||
@@ -46,11 +45,13 @@ jobs:
|
||||
tags: ${{ env.BASE_IMAGE_NAME }}
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
platforms: linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
pull: false
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.IMAGE_NAME }}
|
||||
@@ -60,7 +61,7 @@ jobs:
|
||||
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
@@ -68,6 +69,8 @@ jobs:
|
||||
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 }}
|
||||
@@ -87,17 +90,17 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
build-amd:
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
runs-on: ubicloud-standard-8
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Blacksmith Builder
|
||||
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Docker base meta
|
||||
id: base_meta
|
||||
uses: docker/metadata-action@v6
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.BASE_IMAGE_NAME }}
|
||||
@@ -106,14 +109,14 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push base image
|
||||
id: base_build
|
||||
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.base
|
||||
@@ -121,11 +124,13 @@ jobs:
|
||||
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@v6
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.IMAGE_NAME }}
|
||||
@@ -135,7 +140,7 @@ jobs:
|
||||
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
@@ -143,6 +148,8 @@ jobs:
|
||||
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 }}
|
||||
@@ -162,11 +169,10 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
runs-on: ubicloud-standard-8
|
||||
needs:
|
||||
- build-amd
|
||||
- build-arm
|
||||
environment: dockerhub
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
@@ -176,17 +182,17 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Setup Blacksmith Builder
|
||||
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
name: Update Node.js LTS
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run every Monday at 9:00 UTC
|
||||
- cron: "0 9 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
update-node:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Check for Node.js LTS update
|
||||
id: check
|
||||
run: |
|
||||
# Get current Node version from Dockerfile
|
||||
CURRENT_VERSION=$(grep -oP 'FROM node:\K[0-9]+\.[0-9]+\.[0-9]+' Dockerfile.base)
|
||||
echo "current=$CURRENT_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Current Node.js version: $CURRENT_VERSION"
|
||||
|
||||
# Fetch the latest LTS release (any major version) from nodejs.org
|
||||
LATEST_VERSION=$(curl -s https://nodejs.org/dist/index.json | \
|
||||
jq -r '[.[] | select(.lts != false)][0].version' | \
|
||||
sed 's/^v//')
|
||||
|
||||
if ! [[ "$LATEST_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "::error::Failed to fetch a valid LTS version (got '$LATEST_VERSION')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "latest=$LATEST_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Latest Node.js LTS version: $LATEST_VERSION"
|
||||
|
||||
if [ "$CURRENT_VERSION" = "$LATEST_VERSION" ]; then
|
||||
echo "updated=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Already up to date."
|
||||
else
|
||||
echo "updated=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Update available: $CURRENT_VERSION -> $LATEST_VERSION"
|
||||
fi
|
||||
|
||||
- name: Update Node.js version references
|
||||
if: steps.check.outputs.updated == 'true'
|
||||
env:
|
||||
CURRENT: ${{ steps.check.outputs.current }}
|
||||
LATEST: ${{ steps.check.outputs.latest }}
|
||||
run: |
|
||||
CURRENT_MAJOR=$(echo "$CURRENT" | cut -d. -f1)
|
||||
LATEST_MAJOR=$(echo "$LATEST" | cut -d. -f1)
|
||||
|
||||
# Update Dockerfiles
|
||||
sed -i "s/node:${CURRENT}-slim/node:${LATEST}-slim/g" Dockerfile
|
||||
sed -i "s/node:${CURRENT} /node:${LATEST} /g" Dockerfile.base
|
||||
|
||||
# Update references that depend on major version
|
||||
if [ "$CURRENT_MAJOR" != "$LATEST_MAJOR" ]; then
|
||||
# .nvmrc
|
||||
echo "$LATEST_MAJOR" > .nvmrc
|
||||
|
||||
# CI workflow: step name, node-version, and cache keys
|
||||
sed -i "s/Use Node.js ${CURRENT_MAJOR}.x/Use Node.js ${LATEST_MAJOR}.x/g" .github/workflows/ci.yml
|
||||
sed -i "s/node-version: ${CURRENT_MAJOR}.x/node-version: ${LATEST_MAJOR}.x/g" .github/workflows/ci.yml
|
||||
# Update cache keys: replace node-modules-[optional old version] with new version
|
||||
sed -i -E "s/node-modules-([0-9]+\.x-)?/node-modules-${LATEST_MAJOR}.x-/g" .github/workflows/ci.yml
|
||||
|
||||
# package.json engines field: append new major version
|
||||
sed -i "s/\"node\": \"\(.*\)\"/\"node\": \"\1 || ${LATEST_MAJOR}\"/" package.json
|
||||
fi
|
||||
|
||||
echo "Updated Node.js from $CURRENT to $LATEST"
|
||||
|
||||
- name: Create pull request
|
||||
if: steps.check.outputs.updated == 'true'
|
||||
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7
|
||||
with:
|
||||
commit-message: "fix: Update Node.js to ${{ steps.check.outputs.latest }}"
|
||||
title: "fix: Update Node.js to ${{ steps.check.outputs.latest }}"
|
||||
body: |
|
||||
Automated update of Node.js in Docker images.
|
||||
|
||||
- **Previous version:** ${{ steps.check.outputs.current }}
|
||||
- **New version:** ${{ steps.check.outputs.latest }}
|
||||
|
||||
[Release notes](https://nodejs.org/en/blog/release/v${{ steps.check.outputs.latest }})
|
||||
branch: automated/update-node-lts
|
||||
delete-branch: true
|
||||
labels: dependencies
|
||||
@@ -14,7 +14,6 @@ data/*
|
||||
*.pem
|
||||
*.key
|
||||
*.cert
|
||||
.history
|
||||
|
||||
# Yarn Berry
|
||||
.yarn/*
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"workerIdleMemoryLimit": "0.75",
|
||||
"maxWorkers": "50%",
|
||||
"transformIgnorePatterns": ["node_modules/(?!(franc|trigram-utils)/)"],
|
||||
"projects": [
|
||||
{
|
||||
"displayName": "server",
|
||||
"roots": ["<rootDir>/server", "<rootDir>/plugins"],
|
||||
"moduleNameMapper": {
|
||||
"^@server/(.*)$": "<rootDir>/server/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1"
|
||||
},
|
||||
"setupFiles": [
|
||||
"<rootDir>/__mocks__/console.js",
|
||||
"<rootDir>/server/test/setupMocks.js"
|
||||
],
|
||||
"setupFilesAfterEnv": ["<rootDir>/server/test/setup.ts"],
|
||||
"globalTeardown": "<rootDir>/server/test/globalTeardown.js",
|
||||
"testEnvironment": "node"
|
||||
},
|
||||
{
|
||||
"displayName": "app",
|
||||
"roots": ["<rootDir>/app"],
|
||||
"moduleNameMapper": {
|
||||
"^~/(.*)$": "<rootDir>/app/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
|
||||
},
|
||||
"modulePaths": ["<rootDir>/app"],
|
||||
"setupFiles": ["<rootDir>/__mocks__/window.js"],
|
||||
"setupFilesAfterEnv": ["<rootDir>/app/test/setup.ts"],
|
||||
"testEnvironment": "jsdom",
|
||||
"testEnvironmentOptions": {
|
||||
"url": "http://localhost"
|
||||
}
|
||||
},
|
||||
{
|
||||
"displayName": "shared-node",
|
||||
"roots": ["<rootDir>/shared"],
|
||||
"moduleNameMapper": {
|
||||
"^@server/(.*)$": "<rootDir>/server/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1"
|
||||
},
|
||||
"setupFiles": ["<rootDir>/__mocks__/console.js"],
|
||||
"setupFilesAfterEnv": ["<rootDir>/shared/test/setup.ts"],
|
||||
"testEnvironment": "node"
|
||||
},
|
||||
{
|
||||
"displayName": "shared-jsdom",
|
||||
"roots": ["<rootDir>/shared"],
|
||||
"moduleNameMapper": {
|
||||
"^~/(.*)$": "<rootDir>/app/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
|
||||
},
|
||||
"setupFiles": ["<rootDir>/__mocks__/window.js"],
|
||||
"testEnvironment": "jsdom",
|
||||
"testEnvironmentOptions": {
|
||||
"url": "http://localhost"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+2
-26
@@ -31,7 +31,7 @@
|
||||
"no-empty-pattern": "error",
|
||||
"no-empty-static-block": "error",
|
||||
"no-ex-assign": "error",
|
||||
"no-explicit-any": "error",
|
||||
"no-explicit-any": "warn",
|
||||
"no-extra-boolean-cast": "error",
|
||||
"no-fallthrough": "error",
|
||||
"no-func-assign": "error",
|
||||
@@ -73,30 +73,9 @@
|
||||
"eqeqeq": "error",
|
||||
"curly": "error",
|
||||
"no-console": "error",
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
"name": "prosemirror-tables",
|
||||
"importNames": [
|
||||
"addRowBefore",
|
||||
"addRowAfter",
|
||||
"addColumnBefore",
|
||||
"addColumnAfter"
|
||||
],
|
||||
"message": "Use the wrappers from shared/editor/commands/table instead, which respect the target index and place the cursor in the inserted cell."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"no-unused-expressions": "error",
|
||||
"arrow-body-style": ["error", "as-needed"],
|
||||
"no-useless-escape": "off",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"typescript/await-thenable": "error",
|
||||
"typescript/no-duplicate-type-constituents": "error",
|
||||
"typescript/no-meaningless-void-operator": "error",
|
||||
"typescript/require-array-sort-compare": "error",
|
||||
"react/self-closing-comp": [
|
||||
"error",
|
||||
{
|
||||
@@ -108,9 +87,6 @@
|
||||
"import/no-named-as-default": "off",
|
||||
"import/no-named-as-default-member": "off",
|
||||
"typescript/consistent-type-imports": "error",
|
||||
"typescript/restrict-template-expressions": "error",
|
||||
"typescript/no-floating-promises": "error",
|
||||
"typescript/no-useless-default-assignment": "error",
|
||||
"no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
|
||||
+1
-9
@@ -1,14 +1,6 @@
|
||||
nodeLinker: node-modules
|
||||
|
||||
enableScripts: false
|
||||
|
||||
npmMinimalAgeGate: 4320
|
||||
npmMinimalAgeGate: 86400
|
||||
|
||||
npmPreapprovedPackages:
|
||||
- outline-icons
|
||||
|
||||
# Build-time advisories that don't affect runtime request handling.
|
||||
# Re-evaluate when bumping the relevant dev/build dep.
|
||||
npmAuditIgnoreAdvisories:
|
||||
- "1113517" # GHSA-mw96-cpmx-2vgc rollup <2.80.0 path traversal (workbox-build, build-time)
|
||||
- "1113686" # GHSA-5c6j-r48x-rmvq serialize-javascript RCE (@rollup/plugin-terser, build-time)
|
||||
|
||||
@@ -9,7 +9,7 @@ There is a web client which is fully responsive and works on mobile devices.
|
||||
- **`shared/`** - Shared TypeScript types, utilities, and editor components
|
||||
- **`plugins/`** - Plugin system for extending functionality
|
||||
- **`public/`** - Static assets served directly
|
||||
- **Various config files** - TypeScript, Vite, Vitest, Prettier, Oxlint configurations
|
||||
- **Various config files** - TypeScript, Vite, Jest, Prettier, Oxlint configurations
|
||||
|
||||
Refer to /docs/ARCHITECTURE.md for detailed architecture documentation.
|
||||
|
||||
@@ -46,18 +46,6 @@ You're an expert in the following areas:
|
||||
yarn install
|
||||
```
|
||||
|
||||
- When adding a `resolutions` entry to address a security advisory in a transitive dependency, target only the specific vulnerable descriptors using the `name@npm:<range>` syntax rather than overriding the package globally. Inspect `yarn.lock` to find the exact ranges requested by upstream packages and add one entry per vulnerable range, e.g.:
|
||||
|
||||
```json
|
||||
"resolutions": {
|
||||
"qs@npm:^6.5.2": "^6.14.2",
|
||||
"qs@npm:^6.11.0": "^6.14.2",
|
||||
"qs@npm:^6.14.0": "^6.14.2"
|
||||
}
|
||||
```
|
||||
|
||||
This keeps overrides scoped to the affected dependents and avoids forcing unrelated consumers onto an incompatible version.
|
||||
|
||||
## TypeScript Usage
|
||||
|
||||
- Use strict mode.
|
||||
@@ -152,7 +140,7 @@ yarn sequelize migration:create --name=add-field-to-table
|
||||
|
||||
## Testing
|
||||
|
||||
- Run tests with Vitest:
|
||||
- Run tests with Jest:
|
||||
|
||||
```bash
|
||||
# Run a specific test file (preferred)
|
||||
@@ -200,7 +188,6 @@ yarn test:shared # All shared code tests
|
||||
## Security
|
||||
|
||||
- Sanitize all user input.
|
||||
- Always use `sanitizeUrl()` when setting `href` or `src` from user-controlled data in ProseMirror `toDOM` methods, regardless of whether it is imported via an alias or a relative path. Unlike React components, `toDOM` writes raw DOM and does not sanitize attribute values.
|
||||
- Use CSRF protection.
|
||||
- Use rateLimiter middleware for sensitive endpoints.
|
||||
- Follow OWASP guidelines.
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
|
||||
# ---
|
||||
FROM node:24.16.0-slim AS runner
|
||||
FROM node:22.21.0-slim AS runner
|
||||
|
||||
LABEL org.opencontainers.image.source="https://github.com/outline/outline"
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
ARG APP_PATH=/opt/outline
|
||||
FROM node:24.16.0 AS deps
|
||||
FROM node:22.21.0 AS deps
|
||||
|
||||
ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 1.8.1
|
||||
Licensed Work: Outline 1.6.1
|
||||
The Licensed Work is (c) 2026 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: 2030-06-06
|
||||
Change Date: 2030-03-18
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -27,23 +27,23 @@ Please see the [documentation](https://docs.getoutline.com/s/hosting/) for runni
|
||||
|
||||
If you have questions or improvements for the docs please create a thread in [GitHub discussions](https://github.com/outline/outline/discussions).
|
||||
|
||||
# Contributing
|
||||
# Development
|
||||
|
||||
> **Note:** Please do not submit AI-generated pull requests. We receive a high volume of mass, low-quality PRs generated by AI tools like Claude, ChatGPT, and Copilot from contributors who are unfamiliar with the codebase. These PRs are almost never mergeable and waste maintainer time reviewing them. If you’d like to contribute, please take the time to understand the codebase and write your changes thoughtfully.
|
||||
There is a short guide for [setting up a development environment](https://docs.getoutline.com/s/hosting/doc/local-development-5hEhFRXow7) if you wish to contribute changes, fixes, and improvements to Outline.
|
||||
|
||||
Before submitting a pull request _you must_ discuss with the core team by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues) – we’d also love to hear from you in the [discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written and that you have read these instructions. This will result in a much higher likelihood of your code being accepted.
|
||||
## Contributing
|
||||
|
||||
If you’re looking for ways to get started, here’s a list of ways to help us improve Outline:
|
||||
Outline is built and maintained by a small team – your help finding and fixing bugs is appreciated, though AI assisted PR's from new contributors are discouraged and unlikely to be merged.
|
||||
|
||||
Before submitting a pull request _you must_ discuss with the core team by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues) – we'd also love to hear from you in the [discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written and that you have read these instructions. This will result in a much higher likelihood of your code being accepted.
|
||||
|
||||
If you’re looking for ways to get started, here's a list of ways to help us improve Outline:
|
||||
|
||||
- [Translation](docs/TRANSLATION.md) into other languages
|
||||
- Issues with [`good first issue`](https://github.com/outline/outline/labels/good%20first%20issue) label
|
||||
- Performance improvements, both on server and frontend
|
||||
- Developer happiness and documentation
|
||||
- Bugs, quality fixes, and other issues listed on GitHub
|
||||
|
||||
# Development
|
||||
|
||||
There is a short guide for [setting up a development environment](https://docs.getoutline.com/s/hosting/doc/local-development-5hEhFRXow7) if you wish to contribute changes, fixes, and improvements to Outline.
|
||||
- Bugs and other issues listed on GitHub
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -61,7 +61,7 @@ can be enabled for all categories by setting `DEBUG=*` or for specific categorie
|
||||
|
||||
We aim to have sufficient test coverage for critical parts of the application and aren't aiming for 100% unit test coverage. All API endpoints and anything authentication related should be thoroughly tested.
|
||||
|
||||
To add new tests, write your tests with [Vitest](https://vitest.dev/) and add a file with `.test.ts` extension next to the tested code.
|
||||
To add new tests, write your tests with [Jest](https://facebook.github.io/jest/) and add a file with `.test.ts` extension next to the tested code.
|
||||
|
||||
```shell
|
||||
# To run all tests
|
||||
@@ -72,7 +72,7 @@ make watch
|
||||
```
|
||||
|
||||
Once the test database is created with `make test` you may individually run
|
||||
frontend and backend tests directly with vitest:
|
||||
frontend and backend tests directly with jest:
|
||||
|
||||
```shell
|
||||
# To run backend tests
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"extends": ["../.oxlintrc.json"],
|
||||
"ignorePatterns": ["**/*.d.ts"],
|
||||
"plugins": ["oxc", "eslint", "typescript", "react"],
|
||||
"overrides": [
|
||||
{
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import { CopyIcon, PlusIcon, TrashIcon } from "outline-icons";
|
||||
import { toast } from "sonner";
|
||||
import { PlusIcon, TrashIcon } from "outline-icons";
|
||||
import stores from "~/stores";
|
||||
import env from "~/env";
|
||||
import type ApiKey from "~/models/ApiKey";
|
||||
import ApiKeyNew from "~/scenes/ApiKeyNew";
|
||||
import ApiKeyRevokeDialog from "~/scenes/Settings/components/ApiKeyRevokeDialog";
|
||||
@@ -28,22 +25,6 @@ export const createApiKey = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const copyApiKeyFactory = ({ apiKey }: { apiKey: ApiKey }) =>
|
||||
createAction({
|
||||
name: ({ t }) => t("Copy"),
|
||||
analyticsName: "Copy API key",
|
||||
section: SettingsSection,
|
||||
icon: <CopyIcon />,
|
||||
visible: () => !!apiKey.value,
|
||||
perform: ({ t }) => {
|
||||
copy(apiKey.value, {
|
||||
debug: env.ENVIRONMENT !== "production",
|
||||
format: "text/plain",
|
||||
});
|
||||
toast.success(t("API key copied"));
|
||||
},
|
||||
});
|
||||
|
||||
export const revokeApiKeyFactory = ({ apiKey }: { apiKey: ApiKey }) =>
|
||||
createAction({
|
||||
name: ({ t, isMenu }) =>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import invariant from "invariant";
|
||||
import { capitalize, uniqBy } from "es-toolkit/compat";
|
||||
import uniqBy from "lodash/uniqBy";
|
||||
import {
|
||||
DownloadIcon,
|
||||
DuplicateIcon,
|
||||
@@ -39,13 +39,11 @@ import { toast } from "sonner";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import type { NavigationNode } from "@shared/types";
|
||||
import { ExportContentType, TeamPreference } from "@shared/types";
|
||||
import { isMobile } from "@shared/utils/browser";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import { Week } from "@shared/utils/time";
|
||||
import type UserMembership from "~/models/UserMembership";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import DocumentDelete from "~/scenes/DocumentDelete";
|
||||
import { ProsemirrorHelper } from "~/models/helpers/ProsemirrorHelper";
|
||||
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
|
||||
import DocumentPublish from "~/scenes/DocumentPublish";
|
||||
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
|
||||
@@ -81,7 +79,7 @@ import {
|
||||
trashPath,
|
||||
documentEditPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
import { documentBreadcrumbText } from "~/components/DocumentBreadcrumb";
|
||||
import capitalize from "lodash/capitalize";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import type {
|
||||
Action,
|
||||
@@ -109,21 +107,19 @@ export const openDocument = createActionWithChildren({
|
||||
shortcut: ["o", "d"],
|
||||
keywords: "go to",
|
||||
icon: <DocumentIcon />,
|
||||
children: ({ stores, t }) => {
|
||||
children: ({ stores }) => {
|
||||
const nodes = stores.collections.navigationNodes.reduce(
|
||||
(acc, node) => [...acc, ...node.children],
|
||||
[] as NavigationNode[]
|
||||
);
|
||||
const documents = stores.documents.orderedData;
|
||||
|
||||
return uniqBy([...documents, ...nodes], "id").map((item) => {
|
||||
const document = stores.documents.get(item.id);
|
||||
return createInternalLinkAction({
|
||||
return uniqBy([...documents, ...nodes], "id").map((item) =>
|
||||
createInternalLinkAction({
|
||||
// Note: using url which includes the slug rather than id here to bust
|
||||
// cache if the document is renamed
|
||||
id: item.url,
|
||||
name: item.title,
|
||||
description: document ? documentBreadcrumbText(document, t) : undefined,
|
||||
icon: item.icon ? (
|
||||
<Icon
|
||||
value={item.icon}
|
||||
@@ -131,12 +127,12 @@ export const openDocument = createActionWithChildren({
|
||||
color={item.color ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<DocumentIcon outline={item.isDraft} />
|
||||
<DocumentIcon />
|
||||
),
|
||||
section: DocumentSection,
|
||||
to: item.url,
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -240,26 +236,6 @@ function findDocumentSiblingIndex(
|
||||
return siblings?.findIndex((node) => node.id === document.id) ?? -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether the user can create a sibling of the given document.
|
||||
* A sibling shares the document's parent, so this mirrors the backend's
|
||||
* create authorization: create permission on the parent document, or on the
|
||||
* collection when the document is at the root.
|
||||
*
|
||||
* @param stores - the root stores.
|
||||
* @param document - the document to create a sibling of.
|
||||
* @returns true if the user can create a sibling.
|
||||
*/
|
||||
function canCreateSiblingDocument(
|
||||
stores: ActionContext["stores"],
|
||||
document: { collectionId?: string | null; parentDocumentId?: string }
|
||||
): boolean {
|
||||
return document.parentDocumentId
|
||||
? stores.policies.abilities(document.parentDocumentId).createChildDocument
|
||||
: !!document.collectionId &&
|
||||
stores.policies.abilities(document.collectionId).createDocument;
|
||||
}
|
||||
|
||||
export const createNestedDocument = createInternalLinkAction({
|
||||
name: ({ t }) => t("Nested document"),
|
||||
analyticsName: "New document",
|
||||
@@ -292,14 +268,10 @@ const createDocumentBefore = createInternalLinkAction({
|
||||
return false;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document?.collectionId) {
|
||||
return false;
|
||||
}
|
||||
const collection = stores.collections.get(document.collectionId);
|
||||
if (collection?.sort.field === "title") {
|
||||
return false;
|
||||
}
|
||||
return canCreateSiblingDocument(stores, document);
|
||||
return (
|
||||
!!document?.collectionId &&
|
||||
stores.policies.abilities(currentTeamId).createDocument
|
||||
);
|
||||
},
|
||||
to: ({ activeDocumentId, stores, sidebarContext }) => {
|
||||
const document = activeDocumentId
|
||||
@@ -334,14 +306,10 @@ const createDocumentAfter = createInternalLinkAction({
|
||||
return false;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document?.collectionId) {
|
||||
return false;
|
||||
}
|
||||
const collection = stores.collections.get(document.collectionId);
|
||||
if (collection?.sort.field === "title") {
|
||||
return false;
|
||||
}
|
||||
return canCreateSiblingDocument(stores, document);
|
||||
return (
|
||||
!!document?.collectionId &&
|
||||
stores.policies.abilities(currentTeamId).createDocument
|
||||
);
|
||||
},
|
||||
to: ({ activeDocumentId, stores, sidebarContext }) => {
|
||||
const document = activeDocumentId
|
||||
@@ -366,18 +334,6 @@ const createDocumentAfter = createInternalLinkAction({
|
||||
},
|
||||
});
|
||||
|
||||
function isAlphabeticallySorted(
|
||||
stores: ActionContext["stores"],
|
||||
activeDocumentId: string
|
||||
): boolean {
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document?.collectionId) {
|
||||
return false;
|
||||
}
|
||||
const collection = stores.collections.get(document.collectionId);
|
||||
return collection?.sort.field === "title";
|
||||
}
|
||||
|
||||
export const createNewDocument = createActionWithChildren({
|
||||
name: ({ t }) => t("New document"),
|
||||
analyticsName: "New document",
|
||||
@@ -385,48 +341,17 @@ export const createNewDocument = createActionWithChildren({
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "create",
|
||||
visible: ({ currentTeamId, activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId || !currentTeamId) {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
if (!stores.policies.abilities(currentTeamId).createDocument) {
|
||||
return false;
|
||||
}
|
||||
return !isAlphabeticallySorted(stores, activeDocumentId);
|
||||
|
||||
return (
|
||||
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument
|
||||
);
|
||||
},
|
||||
children: [createDocumentBefore, createDocumentAfter, createNestedDocument],
|
||||
});
|
||||
|
||||
export const createNewDocumentInAlphabeticalCollection =
|
||||
createInternalLinkAction({
|
||||
name: ({ t }) => t("New document"),
|
||||
analyticsName: "New document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "create",
|
||||
visible: ({ currentTeamId, activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId || !currentTeamId) {
|
||||
return false;
|
||||
}
|
||||
if (!stores.policies.abilities(currentTeamId).createDocument) {
|
||||
return false;
|
||||
}
|
||||
if (!stores.policies.abilities(activeDocumentId).createChildDocument) {
|
||||
return false;
|
||||
}
|
||||
return isAlphabeticallySorted(stores, activeDocumentId);
|
||||
},
|
||||
to: ({ activeDocumentId, sidebarContext }) => {
|
||||
const [pathname, search] =
|
||||
newNestedDocumentPath(activeDocumentId).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const starDocument = createAction({
|
||||
name: ({ t }) => t("Star"),
|
||||
analyticsName: "Star document",
|
||||
@@ -812,6 +737,8 @@ export const copyDocumentAsPlainText = createAction({
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (document) {
|
||||
const { ProsemirrorHelper } =
|
||||
await import("~/models/helpers/ProsemirrorHelper");
|
||||
copy(ProsemirrorHelper.toPlainText(document));
|
||||
toast.success(t("Text copied to clipboard"));
|
||||
}
|
||||
@@ -1043,11 +970,7 @@ export const openDocumentInDesktop = createAction({
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
return (
|
||||
isCloudHosted &&
|
||||
(isMac || isWindows) &&
|
||||
!!document &&
|
||||
!document.isDeleted &&
|
||||
!isMobile()
|
||||
isCloudHosted && (isMac || isWindows) && !!document && !document.isDeleted
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
@@ -1065,14 +988,9 @@ export const presentDocument = createAction({
|
||||
analyticsName: "Present document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <EmbedIcon />,
|
||||
shortcut: ["Control+Alt+KeyP"],
|
||||
visible: ({ activeDocumentId }) => !!activeDocumentId && !isMobile(),
|
||||
shortcut: ["Meta+Alt+p"],
|
||||
visible: ({ activeDocumentId }) => !!activeDocumentId,
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
if (stores.ui.presentationData) {
|
||||
stores.ui.setPresentingDocument(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
@@ -1602,7 +1520,6 @@ export const rootDocumentActions = [
|
||||
createDocument,
|
||||
createDraftDocument,
|
||||
createNewDocument,
|
||||
createNewDocumentInAlphabeticalCollection,
|
||||
createNestedDocument,
|
||||
createTemplateFromDocument,
|
||||
deleteDocument,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { PlusIcon } from "outline-icons";
|
||||
import { createAction } from "~/actions";
|
||||
import { TeamSection } from "../sections";
|
||||
import stores from "~/stores";
|
||||
import { EmojiCreateDialog } from "~/components/EmojiDialog/EmojiCreateDialog";
|
||||
import { EmojiCreateDialog } from "~/components/EmojiCreateDialog";
|
||||
|
||||
export const createEmoji = createAction({
|
||||
name: ({ t }) => `${t("New emoji")}…`,
|
||||
|
||||
@@ -241,10 +241,7 @@ export const logout = createAction({
|
||||
section: NavigationSection,
|
||||
icon: <LogoutIcon />,
|
||||
perform: async () => {
|
||||
await stores.auth.logout({
|
||||
userInitiated: true,
|
||||
clearCache: true,
|
||||
});
|
||||
await stores.auth.logout({ userInitiated: true });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { LinkIcon, RestoreIcon, TrashIcon, DownloadIcon } from "outline-icons";
|
||||
import { matchPath } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { ExportContentType } from "@shared/types";
|
||||
import Revision from "~/models/Revision";
|
||||
import stores from "~/stores";
|
||||
import { createAction, createActionWithChildren } from "~/actions";
|
||||
import { RevisionSection } from "~/actions/sections";
|
||||
@@ -22,7 +21,7 @@ export const restoreRevision = createAction({
|
||||
section: RevisionSection,
|
||||
visible: ({ activeDocumentId }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).update,
|
||||
perform: async ({ event, location, activeDocumentId, getActiveModel }) => {
|
||||
perform: async ({ event, location, activeDocumentId }) => {
|
||||
event?.preventDefault();
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
@@ -31,10 +30,7 @@ export const restoreRevision = createAction({
|
||||
const match = matchPath<{ revisionId: string }>(location.pathname, {
|
||||
path: matchDocumentHistory,
|
||||
});
|
||||
const revisionId = getActiveModel(Revision)?.id ?? match?.params.revisionId;
|
||||
if (!revisionId) {
|
||||
return;
|
||||
}
|
||||
const revisionId = match?.params.revisionId;
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document) {
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
settingsPath,
|
||||
urlify,
|
||||
} from "~/utils/routeHelpers";
|
||||
import { ProsemirrorHelper } from "~/models/helpers/ProsemirrorHelper";
|
||||
import { ActiveTemplateSection, TemplateSection } from "../sections";
|
||||
import Template from "~/models/Template";
|
||||
import { AvatarSize } from "~/components/Avatar";
|
||||
@@ -201,6 +200,8 @@ export const copyTemplateAsPlainText = createAction({
|
||||
perform: async ({ getActiveModel, t }) => {
|
||||
const template = getActiveModel(Template);
|
||||
if (template) {
|
||||
const { ProsemirrorHelper } =
|
||||
await import("~/models/helpers/ProsemirrorHelper");
|
||||
copy(ProsemirrorHelper.toPlainText(template));
|
||||
toast.success(t("Text copied to clipboard"));
|
||||
}
|
||||
|
||||
+2
-12
@@ -17,12 +17,8 @@ import Analytics from "~/utils/Analytics";
|
||||
import history from "~/utils/history";
|
||||
import type { Action as KbarAction } from "kbar";
|
||||
|
||||
export function resolve<T>(value: unknown, context: ActionContext): T {
|
||||
return (
|
||||
typeof value === "function"
|
||||
? (value as (context: ActionContext) => T)(context)
|
||||
: value
|
||||
) as T;
|
||||
export function resolve<T>(value: any, context: ActionContext): T {
|
||||
return typeof value === "function" ? value(context) : value;
|
||||
}
|
||||
|
||||
export const ActionSeparator: TActionSeparator = {
|
||||
@@ -136,7 +132,6 @@ export function actionToMenuItem(
|
||||
tooltip: resolve<React.ReactChild>(action.tooltip, context),
|
||||
selected: resolve<boolean>(action.selected, context),
|
||||
dangerous: action.dangerous,
|
||||
shortcut: action.shortcut,
|
||||
onClick: () => performAction(action, context),
|
||||
};
|
||||
|
||||
@@ -148,7 +143,6 @@ export function actionToMenuItem(
|
||||
icon,
|
||||
visible,
|
||||
disabled,
|
||||
shortcut: action.shortcut,
|
||||
to,
|
||||
};
|
||||
}
|
||||
@@ -160,7 +154,6 @@ export function actionToMenuItem(
|
||||
icon,
|
||||
visible,
|
||||
disabled,
|
||||
shortcut: action.shortcut,
|
||||
href: action.target
|
||||
? { url: action.url, target: action.target }
|
||||
: action.url,
|
||||
@@ -217,7 +210,6 @@ export function actionToKBar(
|
||||
const name = resolve<string>(action.name, context);
|
||||
const icon = resolve<React.ReactElement>(action.icon, context);
|
||||
const section = resolve<string>(action.section, context);
|
||||
const subtitle = resolve<string>(action.description, context);
|
||||
|
||||
const sectionPriority =
|
||||
typeof action.section !== "string" && "priority" in action.section
|
||||
@@ -237,7 +229,6 @@ export function actionToKBar(
|
||||
section,
|
||||
keywords: action.keywords,
|
||||
shortcut: action.shortcut,
|
||||
subtitle,
|
||||
icon,
|
||||
priority,
|
||||
perform: () => performAction(action, context),
|
||||
@@ -263,7 +254,6 @@ export function actionToKBar(
|
||||
keywords: action.keywords,
|
||||
shortcut: action.shortcut,
|
||||
icon,
|
||||
subtitle,
|
||||
priority,
|
||||
},
|
||||
...children.map((child) => ({
|
||||
|
||||
@@ -13,15 +13,8 @@ ActiveCollectionSection.priority = 0.8;
|
||||
|
||||
export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
|
||||
|
||||
export const DateSection = ({ t }: ActionContext) => t("Date");
|
||||
|
||||
DateSection.priority = 1;
|
||||
|
||||
export const DocumentSection = ({ t }: ActionContext) => t("Document");
|
||||
|
||||
export const SearchResultsSection = ({ t }: ActionContext) =>
|
||||
t("Search results");
|
||||
|
||||
export const DocumentsSection = ({ t }: ActionContext) => t("Documents");
|
||||
|
||||
export const ActiveDocumentSection = ({ t, stores }: ActionContext) => {
|
||||
@@ -65,7 +58,7 @@ export const ShareSection = ({ t }: ActionContext) => t("Share");
|
||||
export const TeamSection = ({ t }: ActionContext) => t("Workspace");
|
||||
|
||||
export const RecentSearchesSection = ({ t }: ActionContext) =>
|
||||
t("Recently viewed");
|
||||
t("Recent searches");
|
||||
|
||||
RecentSearchesSection.priority = -0.1;
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
/* oxlint-disable react/prop-types */
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import type { Props as TooltipProps } from "~/components/Tooltip";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
@@ -86,4 +85,4 @@ const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
|
||||
}
|
||||
);
|
||||
|
||||
export default observer(ActionButton);
|
||||
export default ActionButton;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* oxlint-disable prefer-rest-params */
|
||||
/* global ga */
|
||||
import { escape } from "es-toolkit/compat";
|
||||
import escape from "lodash/escape";
|
||||
import * as React from "react";
|
||||
import type { PublicEnv } from "@shared/types";
|
||||
import { IntegrationService } from "@shared/types";
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export default function Arrow() {
|
||||
return (
|
||||
<svg
|
||||
width="13"
|
||||
height="30"
|
||||
viewBox="0 0 13 30"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M7.40242 1.48635C8.23085 0.0650039 10.0656 -0.421985 11.5005 0.39863C12.9354 1.21924 13.427 3.03671 12.5986 4.45806L5.59858 16.4681C4.77015 17.8894 2.93538 18.3764 1.5005 17.5558C0.065623 16.7352 -0.426002 14.9177 0.402425 13.4964L7.40242 1.48635Z" />
|
||||
<path d="M12.5986 25.5419C13.427 26.9633 12.9354 28.7808 11.5005 29.6014C10.0656 30.422 8.23087 29.935 7.40244 28.5136L0.402438 16.5036C-0.425989 15.0823 0.0656365 13.2648 1.50051 12.4442C2.93539 11.6236 4.77016 12.1106 5.59859 13.5319L12.5986 25.5419Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Redirect } from "react-router-dom";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
@@ -16,7 +16,6 @@ const Authenticated = ({ children }: Props) => {
|
||||
const { i18n } = useTranslation();
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
const language = user?.language;
|
||||
const hasLoggedOut = useRef(false);
|
||||
|
||||
// Watching for language changes here as this is the earliest point we might have the user
|
||||
// available and means we can start loading translations faster
|
||||
@@ -24,36 +23,20 @@ const Authenticated = ({ children }: Props) => {
|
||||
void changeLanguage(language, i18n);
|
||||
}, [i18n, language]);
|
||||
|
||||
const shouldLogout = !auth.authenticated && !auth.isFetching;
|
||||
|
||||
// Passive logout when we land here without an authenticated session – note we
|
||||
// intentionally do not revoke the server-side token, as that would clobber
|
||||
// the session in any other tab that may have already re-authenticated.
|
||||
useEffect(() => {
|
||||
if (shouldLogout && !hasLoggedOut.current) {
|
||||
hasLoggedOut.current = true;
|
||||
void auth.logout({
|
||||
savePath: true,
|
||||
clearCache: false,
|
||||
revokeToken: false,
|
||||
});
|
||||
}
|
||||
}, [shouldLogout, auth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (auth.logoutRedirectUri) {
|
||||
window.location.href = auth.logoutRedirectUri;
|
||||
}
|
||||
}, [auth.logoutRedirectUri]);
|
||||
|
||||
if (auth.authenticated) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (auth.isFetching || auth.logoutRedirectUri) {
|
||||
if (auth.isFetching) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
void auth.logout({ savePath: true });
|
||||
|
||||
if (auth.logoutRedirectUri) {
|
||||
window.location.href = auth.logoutRedirectUri;
|
||||
return null;
|
||||
}
|
||||
return <Redirect to="/" />;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,27 +1,21 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { DndProvider } from "react-dnd";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { EditorAwareHTML5Backend } from "~/components/EditorAwareHTML5Backend";
|
||||
import { Switch, Route } from "react-router-dom";
|
||||
import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
|
||||
import Layout from "~/components/Layout";
|
||||
import RegisterKeyDown from "~/components/RegisterKeyDown";
|
||||
import { RightSidebarProvider } from "~/components/RightSidebarContext";
|
||||
import Sidebar from "~/components/Sidebar";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Logger from "~/utils/Logger";
|
||||
import history from "~/utils/history";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import {
|
||||
searchPath,
|
||||
newDocumentPath,
|
||||
settingsPath,
|
||||
homePath,
|
||||
} from "~/utils/routeHelpers";
|
||||
import { DocumentContextProvider } from "./DocumentContext";
|
||||
import Fade from "./Fade";
|
||||
@@ -39,18 +33,11 @@ type Props = {
|
||||
|
||||
const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
const { ui, auth } = useStores();
|
||||
const location = useLocation();
|
||||
const layoutRef = React.useRef<HTMLDivElement>(null);
|
||||
const canCollection = usePolicy(ui.activeCollectionId);
|
||||
const team = useCurrentTeam();
|
||||
const [spendPostLoginPath] = usePostLoginPath();
|
||||
|
||||
useKeyDown(".", (event) => {
|
||||
if (isModKey(event)) {
|
||||
ui.toggleCollapsedSidebar();
|
||||
}
|
||||
});
|
||||
|
||||
const goToSearch = (ev: KeyboardEvent) => {
|
||||
if (!ev.metaKey && !ev.ctrlKey) {
|
||||
ev.preventDefault();
|
||||
@@ -73,15 +60,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
React.useEffect(() => {
|
||||
const postLoginPath = spendPostLoginPath();
|
||||
if (postLoginPath) {
|
||||
try {
|
||||
history.replace(postLoginPath);
|
||||
} catch (err) {
|
||||
Logger.warn("Failed to navigate to post login path, falling back", {
|
||||
path: postLoginPath,
|
||||
error: err,
|
||||
});
|
||||
history.replace(homePath());
|
||||
}
|
||||
history.replace(postLoginPath);
|
||||
}
|
||||
}, [spendPostLoginPath]);
|
||||
|
||||
@@ -89,16 +68,12 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
return <ErrorSuspended />;
|
||||
}
|
||||
|
||||
const isSettings = location.pathname.startsWith(settingsPath());
|
||||
|
||||
const sidebar = (
|
||||
<Fade>
|
||||
<React.Suspense fallback={null}>
|
||||
{isSettings && <SettingsSidebar />}
|
||||
</React.Suspense>
|
||||
<div style={isSettings ? { display: "none" } : undefined}>
|
||||
<Sidebar />
|
||||
</div>
|
||||
<Switch>
|
||||
<Route path={settingsPath()} component={SettingsSidebar} />
|
||||
<Route component={Sidebar} />
|
||||
</Switch>
|
||||
</Fade>
|
||||
);
|
||||
|
||||
@@ -106,16 +81,14 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
<DocumentContextProvider>
|
||||
<RightSidebarProvider>
|
||||
<PortalContext.Provider value={layoutRef.current}>
|
||||
<DndProvider backend={EditorAwareHTML5Backend}>
|
||||
<Layout title={team.name} sidebar={sidebar} ref={layoutRef}>
|
||||
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
|
||||
<RegisterKeyDown trigger="t" handler={goToSearch} />
|
||||
<RegisterKeyDown trigger="/" handler={goToSearch} />
|
||||
{children}
|
||||
<CommandBar />
|
||||
<NotificationBadge />
|
||||
</Layout>
|
||||
</DndProvider>
|
||||
<Layout title={team.name} sidebar={sidebar} ref={layoutRef}>
|
||||
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
|
||||
<RegisterKeyDown trigger="t" handler={goToSearch} />
|
||||
<RegisterKeyDown trigger="/" handler={goToSearch} />
|
||||
{children}
|
||||
<CommandBar />
|
||||
<NotificationBadge />
|
||||
</Layout>
|
||||
</PortalContext.Provider>
|
||||
</RightSidebarProvider>
|
||||
</DocumentContextProvider>
|
||||
|
||||
@@ -55,15 +55,6 @@ function Breadcrumb(
|
||||
});
|
||||
}
|
||||
|
||||
const handleClick = React.useCallback(
|
||||
(event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (event.currentTarget.querySelector('[data-state="open"]')) {
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const toBreadcrumb = React.useCallback(
|
||||
(action: TopLevelAction, index: number) => {
|
||||
if (action.type === "menu") {
|
||||
@@ -77,7 +68,6 @@ function Breadcrumb(
|
||||
{item.icon}
|
||||
<Item
|
||||
to={item.to}
|
||||
onClick={handleClick}
|
||||
$withIcon={!!item.icon}
|
||||
$highlight={!!highlightFirstItem && index === 0}
|
||||
>
|
||||
@@ -86,7 +76,7 @@ function Breadcrumb(
|
||||
</>
|
||||
);
|
||||
},
|
||||
[actionContext, handleClick, highlightFirstItem]
|
||||
[actionContext, highlightFirstItem]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -113,16 +103,19 @@ const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
|
||||
${ellipsis()}
|
||||
${undraggableOnDesktop()}
|
||||
|
||||
display: flex;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
cursor: var(--pointer);
|
||||
color: ${s("text")};
|
||||
font-size: 15px;
|
||||
height: 24px;
|
||||
line-height: 24px;
|
||||
font-weight: ${(props) => (props.$highlight ? "500" : "inherit")};
|
||||
margin-inline-start: ${(props) => (props.$withIcon ? "4px" : "0")};
|
||||
max-width: 460px;
|
||||
margin-left: ${(props) => (props.$withIcon ? "4px" : "0")};
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
|
||||
@@ -125,7 +125,7 @@ const Label = styled.span<{ hasIcon?: boolean }>`
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
${(props) => props.hasIcon && "padding-inline-start: 4px;"};
|
||||
${(props) => props.hasIcon && "padding-left: 4px;"};
|
||||
`;
|
||||
|
||||
export const Inner = styled.span<{
|
||||
@@ -135,13 +135,13 @@ export const Inner = styled.span<{
|
||||
}>`
|
||||
display: flex;
|
||||
padding: 0 8px;
|
||||
padding-inline-end: ${(props) => (props.disclosure ? 2 : 8)}px;
|
||||
padding-right: ${(props) => (props.disclosure ? 2 : 8)}px;
|
||||
line-height: ${(props) => (props.hasIcon ? 24 : 32)}px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
|
||||
${(props) => props.hasIcon && props.hasText && "padding-inline-start: 4px;"};
|
||||
${(props) => props.hasIcon && props.hasText && "padding-left: 4px;"};
|
||||
${(props) => props.hasIcon && !props.hasText && "padding: 0 4px;"};
|
||||
`;
|
||||
|
||||
|
||||
@@ -22,7 +22,9 @@ const Circle = ({
|
||||
if (percentage) {
|
||||
// because the circle is so small, anything greater than 85% appears like 100%
|
||||
percentage = percentage > 85 && percentage < 100 ? 85 : percentage;
|
||||
strokePercentage = ((100 - percentage) * circumference) / 100;
|
||||
strokePercentage = percentage
|
||||
? ((100 - percentage) * circumference) / 100
|
||||
: 0;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { filter, isEqual, orderBy, uniq } from "es-toolkit/compat";
|
||||
import filter from "lodash/filter";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import orderBy from "lodash/orderBy";
|
||||
import uniq from "lodash/uniq";
|
||||
import { observer } from "mobx-react";
|
||||
import { useState, useMemo, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -79,10 +82,10 @@ function Collaborators(props: Props) {
|
||||
// Memoize ids to avoid unnecessary effect executions
|
||||
const missingUserIds = useMemo(
|
||||
() =>
|
||||
uniq([...collaboratorIdsSet, ...presentIds])
|
||||
uniq([...document.collaboratorIds, ...Array.from(presentIds)])
|
||||
.filter((userId) => !users.get(userId))
|
||||
.sort(),
|
||||
[collaboratorIdsSet, presentIds, users]
|
||||
[document.collaboratorIds, presentIds, users]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -143,14 +146,7 @@ function Collaborators(props: Props) {
|
||||
/>
|
||||
);
|
||||
},
|
||||
[
|
||||
presentIds,
|
||||
editingIds,
|
||||
observingUserId,
|
||||
currentUserId,
|
||||
handleAvatarClick,
|
||||
t,
|
||||
]
|
||||
[presentIds, editingIds, observingUserId, currentUserId, handleAvatarClick]
|
||||
);
|
||||
|
||||
if (!document.insightsEnabled) {
|
||||
|
||||
@@ -64,7 +64,7 @@ const StyledTrigger = styled(RadixCollapsible.Trigger)`
|
||||
padding: 0 0 8px 0;
|
||||
cursor: var(--pointer);
|
||||
color: ${s("textTertiary")};
|
||||
font-size: 14px;
|
||||
font-size: 14pxte
|
||||
|
||||
&:hover {
|
||||
color: ${s("textSecondary")};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { uniq } from "es-toolkit/compat";
|
||||
import uniq from "lodash/uniq";
|
||||
import { observer } from "mobx-react";
|
||||
import { useMemo, useEffect, useCallback, Suspense } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
@@ -25,11 +25,10 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { EmptySelectValue } from "~/types";
|
||||
import { HStack } from "../primitives/HStack";
|
||||
import { useDialogContext } from "~/components/DialogContext";
|
||||
|
||||
const IconPicker = createLazyComponent(() => import("~/components/IconPicker"));
|
||||
|
||||
export type FormData = {
|
||||
export interface FormData {
|
||||
name: string;
|
||||
icon: string;
|
||||
color: string | null;
|
||||
@@ -37,7 +36,7 @@ export type FormData = {
|
||||
permission: CollectionPermission | undefined;
|
||||
commenting?: boolean | null;
|
||||
templateManagement: CollectionPermission;
|
||||
};
|
||||
}
|
||||
|
||||
const useIconColor = (collection?: Collection) => {
|
||||
const { collections } = useStores();
|
||||
@@ -68,7 +67,6 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
}) {
|
||||
const team = useCurrentTeam();
|
||||
const { t } = useTranslation();
|
||||
const dialog = useDialogContext();
|
||||
|
||||
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
|
||||
|
||||
@@ -280,12 +278,7 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
{collection ? (
|
||||
options
|
||||
) : (
|
||||
<Collapsible
|
||||
label={t("Advanced options")}
|
||||
onOpenChange={() => dialog.setAnimating(true)}
|
||||
>
|
||||
{options}
|
||||
</Collapsible>
|
||||
<Collapsible label={t("Advanced options")}>{options}</Collapsible>
|
||||
)}
|
||||
|
||||
<HStack justify="flex-end">
|
||||
|
||||
@@ -3,8 +3,7 @@ import { ArrowIcon, BackIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled, { css, useTheme } from "styled-components";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import { normalizeKeyDisplay, shortcutSeparator } from "@shared/utils/keyboard";
|
||||
import Highlight from "~/components/Highlight";
|
||||
import { normalizeKeyDisplay } from "@shared/utils/keyboard";
|
||||
import Flex from "~/components/Flex";
|
||||
import Key from "~/components/Key";
|
||||
import Text from "~/components/Text";
|
||||
@@ -16,22 +15,14 @@ type Props = {
|
||||
currentRootActionId: string | null | undefined;
|
||||
};
|
||||
|
||||
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
|
||||
|
||||
function replaceResultMarks(tag: string) {
|
||||
// don't use SEARCH_RESULT_REGEX here as it causes
|
||||
// an infinite loop to trigger a regex inside it's own callback
|
||||
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
|
||||
}
|
||||
|
||||
function CommandBarItem(
|
||||
{ action, active, currentRootActionId }: Props,
|
||||
ref: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const theme = useTheme();
|
||||
const ancestors = React.useMemo(() => {
|
||||
if (!currentRootActionId || !action.ancestors) {
|
||||
return action.ancestors ?? [];
|
||||
if (!currentRootActionId) {
|
||||
return action.ancestors;
|
||||
}
|
||||
const index = action.ancestors.findIndex(
|
||||
(ancestor) => ancestor.id === currentRootActionId
|
||||
@@ -65,16 +56,6 @@ function CommandBarItem(
|
||||
))}
|
||||
{action.name}
|
||||
{action.children?.length ? "…" : ""}
|
||||
{action.subtitle && (
|
||||
<Text type="secondary" ellipsis>
|
||||
|
||||
<Highlight
|
||||
text={action.subtitle}
|
||||
highlight={SEARCH_RESULT_REGEX}
|
||||
processResult={replaceResultMarks}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
</Content>
|
||||
{action.shortcut?.length ? (
|
||||
<Shortcut>
|
||||
@@ -90,12 +71,9 @@ function CommandBarItem(
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{sc.split("+").flatMap((key, i, arr) => {
|
||||
const el = <Key key={key}>{normalizeKeyDisplay(key)}</Key>;
|
||||
return i < arr.length - 1 && shortcutSeparator
|
||||
? [el, shortcutSeparator]
|
||||
: [el];
|
||||
})}
|
||||
{sc.split("+").map((key) => (
|
||||
<Key key={key}>{normalizeKeyDisplay(key)}</Key>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Shortcut>
|
||||
|
||||
@@ -43,8 +43,7 @@ const Container = styled.div`
|
||||
const Header = styled(Text).attrs({ as: "h3" })`
|
||||
letter-spacing: 0.03em;
|
||||
margin: 0;
|
||||
padding-block: 16px 4px;
|
||||
padding-inline: 20px 0;
|
||||
padding: 16px 0 4px 20px;
|
||||
height: 36px;
|
||||
cursor: default;
|
||||
`;
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import { useKBar, KBarPositioner, KBarAnimator, KBarSearch } from "kbar";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Portal } from "react-portal";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import CommandBarResults from "./CommandBarResults";
|
||||
import SharedSearchActions from "./SharedSearchActions";
|
||||
|
||||
/**
|
||||
* A simplified command bar for public shares that only provides search.
|
||||
*/
|
||||
function SharedCommandBar() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SharedSearchActions />
|
||||
<KBarPortal>
|
||||
<Positioner>
|
||||
<Animator>
|
||||
<SearchInput defaultPlaceholder={`${t("Search")}…`} />
|
||||
<CommandBarResults />
|
||||
</Animator>
|
||||
</Positioner>
|
||||
</KBarPortal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const KBarPortal: React.FC = ({ children }: Props) => {
|
||||
const { showing } = useKBar((state) => ({
|
||||
showing: state.visualState !== "hidden",
|
||||
}));
|
||||
|
||||
if (!showing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Portal>{children}</Portal>;
|
||||
};
|
||||
|
||||
const Positioner = styled(KBarPositioner)`
|
||||
z-index: ${depths.commandBar};
|
||||
`;
|
||||
|
||||
const SearchInput = styled(KBarSearch)`
|
||||
position: relative;
|
||||
padding: 16px 12px;
|
||||
margin: 0 8px;
|
||||
width: calc(100% - 16px);
|
||||
outline: none;
|
||||
border: none;
|
||||
background: ${s("menuBackground")};
|
||||
color: ${s("text")};
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid ${s("inputBorder")};
|
||||
}
|
||||
|
||||
&:disabled,
|
||||
&::placeholder {
|
||||
color: ${s("placeholder")};
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const Animator = styled(KBarAnimator)`
|
||||
max-width: 600px;
|
||||
max-height: 75vh;
|
||||
width: 90vw;
|
||||
background: ${s("menuBackground")};
|
||||
color: ${s("text")};
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: rgb(0 0 0 / 40%) 0px 16px 60px;
|
||||
transition: max-width 0.2s ease-in-out;
|
||||
|
||||
${breakpoint("desktopLarge")`
|
||||
max-width: 740px;
|
||||
`};
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(SharedCommandBar);
|
||||
@@ -1,187 +0,0 @@
|
||||
import { useKBar } from "kbar";
|
||||
import { escapeRegExp } from "es-toolkit/compat";
|
||||
import { observer } from "mobx-react";
|
||||
import { DocumentIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import useShare from "@shared/hooks/useShare";
|
||||
import { Minute } from "@shared/utils/time";
|
||||
import { createAction } from "~/actions";
|
||||
import {
|
||||
RecentSearchesSection,
|
||||
SearchResultsSection,
|
||||
} from "~/actions/sections";
|
||||
import useCommandBarActions from "~/hooks/useCommandBarActions";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import type Document from "~/models/Document";
|
||||
import history from "~/utils/history";
|
||||
import { sharedModelPath } from "~/utils/routeHelpers";
|
||||
import type { SearchResult } from "~/types";
|
||||
|
||||
interface CacheEntry {
|
||||
timestamp: number;
|
||||
results: SearchResult[];
|
||||
}
|
||||
|
||||
const cacheTTL = Minute.ms * 5;
|
||||
const maxRecentDocs = 5;
|
||||
|
||||
/**
|
||||
* Strip server-generated `<b>` highlight tags from context and re-apply them
|
||||
* using the current search query. This prevents stale highlights when the
|
||||
* displayed results are from a previous (in-flight) query.
|
||||
*
|
||||
* @param context the server-generated context string with `<b>` tags.
|
||||
* @param query the current search query to highlight.
|
||||
* @returns the context string with highlights matching the current query.
|
||||
*/
|
||||
function rehighlightContext(
|
||||
context: string | undefined,
|
||||
query: string
|
||||
): string | undefined {
|
||||
if (!context) {
|
||||
return context;
|
||||
}
|
||||
|
||||
const plain = context.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
|
||||
const trimmed = query.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return plain;
|
||||
}
|
||||
|
||||
const terms = trimmed.split(/\s+/).filter(Boolean);
|
||||
const patterns = [escapeRegExp(trimmed)];
|
||||
|
||||
if (terms.length > 1) {
|
||||
patterns.push(...terms.map((t) => `\\b${escapeRegExp(t)}\\b`));
|
||||
}
|
||||
|
||||
const regex = new RegExp(patterns.join("|"), "gi");
|
||||
return plain.replace(regex, "<b>$&</b>");
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers search result actions in the command bar scoped to a public share.
|
||||
*/
|
||||
function SharedSearchActions() {
|
||||
const { documents } = useStores();
|
||||
const { shareId } = useShare();
|
||||
const searchCache = React.useRef<Map<string, CacheEntry>>(new Map());
|
||||
const [results, setResults] = React.useState<SearchResult[]>([]);
|
||||
const recentDocsRef = React.useRef<Document[]>([]);
|
||||
const [recentDocs, setRecentDocs] = React.useState<Document[]>([]);
|
||||
|
||||
const { searchQuery } = useKBar((state) => ({
|
||||
searchQuery: state.searchQuery,
|
||||
}));
|
||||
|
||||
const searchQueryRef = React.useRef(searchQuery);
|
||||
searchQueryRef.current = searchQuery;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!searchQuery || !shareId) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const cachedEntry = searchCache.current.get(searchQuery);
|
||||
const isExpired = cachedEntry
|
||||
? now - cachedEntry.timestamp > cacheTTL
|
||||
: true;
|
||||
|
||||
if (cachedEntry && !isExpired) {
|
||||
setResults(cachedEntry.results);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentQuery = searchQuery;
|
||||
void documents.search({ query: searchQuery, shareId }).then((res) => {
|
||||
searchCache.current.set(currentQuery, { timestamp: now, results: res });
|
||||
if (searchQueryRef.current === currentQuery) {
|
||||
setResults(res);
|
||||
}
|
||||
});
|
||||
}, [documents, searchQuery, shareId]);
|
||||
|
||||
const addRecentDoc = React.useCallback((doc: Document) => {
|
||||
const prev = recentDocsRef.current;
|
||||
const filtered = prev.filter((d) => d.id !== doc.id);
|
||||
const next = [doc, ...filtered].slice(0, maxRecentDocs);
|
||||
recentDocsRef.current = next;
|
||||
setRecentDocs(next);
|
||||
}, []);
|
||||
|
||||
const documentIcon = React.useCallback(
|
||||
(doc: Document) =>
|
||||
doc.icon ? (
|
||||
<Icon
|
||||
value={doc.icon}
|
||||
initial={doc.initial}
|
||||
color={doc.color ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<DocumentIcon />
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const actions = React.useMemo(
|
||||
() =>
|
||||
results.map((result) =>
|
||||
createAction({
|
||||
id: `shared-search-${result.document.id}`,
|
||||
name: result.document.titleWithDefault,
|
||||
description: rehighlightContext(result.context, searchQuery),
|
||||
keywords: searchQuery,
|
||||
analyticsName: "Open shared search result",
|
||||
section: SearchResultsSection,
|
||||
icon: documentIcon(result.document),
|
||||
perform: () => {
|
||||
if (shareId) {
|
||||
const currentQuery = searchQueryRef.current;
|
||||
addRecentDoc(result.document);
|
||||
history.push({
|
||||
pathname: sharedModelPath(shareId, result.document.url),
|
||||
search: currentQuery
|
||||
? `?q=${encodeURIComponent(currentQuery)}`
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
},
|
||||
})
|
||||
),
|
||||
[results, shareId, searchQuery, addRecentDoc, documentIcon]
|
||||
);
|
||||
|
||||
const recentDocActions = React.useMemo(
|
||||
() =>
|
||||
recentDocs.map((doc) =>
|
||||
createAction({
|
||||
id: `shared-recent-doc-${doc.id}`,
|
||||
name: doc.titleWithDefault,
|
||||
analyticsName: "Open recent shared document",
|
||||
section: RecentSearchesSection,
|
||||
icon: documentIcon(doc),
|
||||
perform: () => {
|
||||
if (shareId) {
|
||||
history.push(sharedModelPath(shareId, doc.url));
|
||||
}
|
||||
},
|
||||
})
|
||||
),
|
||||
[recentDocs, shareId, documentIcon]
|
||||
);
|
||||
|
||||
useCommandBarActions(searchQuery ? actions : recentDocActions, [
|
||||
searchQuery
|
||||
? actions.map((a) => a.id).join("")
|
||||
: recentDocActions.map((a) => a.id).join(""),
|
||||
searchQuery,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default observer(SharedSearchActions);
|
||||
@@ -1,16 +1,13 @@
|
||||
import { DocumentIcon } from "outline-icons";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { createInternalLinkAction } from "~/actions";
|
||||
import { RecentSection } from "~/actions/sections";
|
||||
import { documentBreadcrumbText } from "~/components/DocumentBreadcrumb";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
|
||||
const useRecentDocumentActions = (count = 6) => {
|
||||
const { documents, ui } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
@@ -22,7 +19,6 @@ const useRecentDocumentActions = (count = 6) => {
|
||||
name: item.titleWithDefault,
|
||||
analyticsName: "Recently viewed document",
|
||||
section: RecentSection,
|
||||
description: documentBreadcrumbText(item, t),
|
||||
icon: item.icon ? (
|
||||
<Icon
|
||||
value={item.icon}
|
||||
@@ -30,12 +26,12 @@ const useRecentDocumentActions = (count = 6) => {
|
||||
color={item.color ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<DocumentIcon outline={item.isDraft} />
|
||||
<DocumentIcon />
|
||||
),
|
||||
to: documentPath(item),
|
||||
})
|
||||
),
|
||||
[count, ui.activeDocumentId, documents.recentlyViewed, t]
|
||||
[count, ui.activeDocumentId, documents.recentlyViewed]
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -87,23 +87,22 @@ const ContentEditable = React.forwardRef(function ContentEditable_(
|
||||
}));
|
||||
|
||||
const wrappedEvent =
|
||||
<E extends React.SyntheticEvent<HTMLSpanElement>>(
|
||||
callback: ((event: E) => void) | undefined
|
||||
(
|
||||
callback:
|
||||
| React.FocusEventHandler<HTMLSpanElement>
|
||||
| React.FormEventHandler<HTMLSpanElement>
|
||||
| React.KeyboardEventHandler<HTMLSpanElement>
|
||||
| undefined
|
||||
) =>
|
||||
(event: E) => {
|
||||
(event: any) => {
|
||||
if (readOnly) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = event.currentTarget.textContent || "";
|
||||
|
||||
if (
|
||||
maxLength &&
|
||||
event.nativeEvent instanceof KeyboardEvent &&
|
||||
isPrintableKeyEvent(event.nativeEvent) &&
|
||||
text.length >= maxLength
|
||||
) {
|
||||
event.preventDefault();
|
||||
if (maxLength && isPrintableKeyEvent(event) && text.length >= maxLength) {
|
||||
event?.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ const DefaultCollectionInputSelect = observer(
|
||||
value={defaultCollectionId ?? "home"}
|
||||
onChange={onSelectCollection}
|
||||
label={t("Start view")}
|
||||
labelHidden
|
||||
hideLabel
|
||||
short
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function DesktopEventHandler() {
|
||||
const hasDisabledUpdateMessage = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
Desktop.bridge?.redirect((path: string, replace: boolean) => {
|
||||
Desktop.bridge?.redirect((path: string, replace = false) => {
|
||||
if (replace) {
|
||||
history.replace(path);
|
||||
} else {
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { createContext, useContext, useMemo, useState } from "react";
|
||||
|
||||
export type DialogContext = {
|
||||
animating: boolean;
|
||||
setAnimating: (isAnimating: boolean) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Context for the dialogs (Guide/Modal) being rendered.
|
||||
* This helps control the dialog's behavior from within any nested component.
|
||||
*/
|
||||
const DialogContext = createContext<DialogContext>({
|
||||
animating: false,
|
||||
setAnimating: () => {},
|
||||
});
|
||||
|
||||
export function DialogProvider({ children }: { children: React.ReactNode }) {
|
||||
const [animating, setAnimating] = useState(false);
|
||||
const ctx = useMemo<DialogContext>(
|
||||
() => ({
|
||||
animating,
|
||||
setAnimating,
|
||||
}),
|
||||
[animating]
|
||||
);
|
||||
|
||||
return (
|
||||
<DialogContext.Provider value={ctx}>{children}</DialogContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useDialogContext = () => useContext(DialogContext);
|
||||
+27
-30
@@ -2,7 +2,6 @@ import { observer } from "mobx-react";
|
||||
import { Suspense } from "react";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import { DialogProvider } from "./DialogContext";
|
||||
|
||||
const Guide = lazyWithRetry(() => import("~/components/Guide"));
|
||||
const Modal = lazyWithRetry(() => import("~/components/Modal"));
|
||||
@@ -13,35 +12,33 @@ function Dialogs() {
|
||||
const modals = [...modalStack];
|
||||
|
||||
return (
|
||||
<DialogProvider>
|
||||
<Suspense fallback={null}>
|
||||
{guide ? (
|
||||
<Guide
|
||||
isOpen={guide.isOpen}
|
||||
onRequestClose={dialogs.closeGuide}
|
||||
title={guide.title}
|
||||
>
|
||||
{guide.content}
|
||||
</Guide>
|
||||
) : undefined}
|
||||
{modals.map(([id, modal]) => (
|
||||
<Modal
|
||||
key={id}
|
||||
isOpen={modal.isOpen}
|
||||
onRequestClose={() => {
|
||||
modal.onClose?.();
|
||||
dialogs.closeModal(id);
|
||||
}}
|
||||
title={modal.title}
|
||||
style={modal.style}
|
||||
width={modal.width}
|
||||
height={modal.height}
|
||||
>
|
||||
{modal.content}
|
||||
</Modal>
|
||||
))}
|
||||
</Suspense>
|
||||
</DialogProvider>
|
||||
<Suspense fallback={null}>
|
||||
{guide ? (
|
||||
<Guide
|
||||
isOpen={guide.isOpen}
|
||||
onRequestClose={dialogs.closeGuide}
|
||||
title={guide.title}
|
||||
>
|
||||
{guide.content}
|
||||
</Guide>
|
||||
) : undefined}
|
||||
{modals.map(([id, modal]) => (
|
||||
<Modal
|
||||
key={id}
|
||||
isOpen={modal.isOpen}
|
||||
onRequestClose={() => {
|
||||
modal.onClose?.();
|
||||
dialogs.closeModal(id);
|
||||
}}
|
||||
title={modal.title}
|
||||
style={modal.style}
|
||||
width={modal.width}
|
||||
height={modal.height}
|
||||
>
|
||||
{modal.content}
|
||||
</Modal>
|
||||
))}
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
const Divider = styled.hr`
|
||||
border: 0;
|
||||
border-bottom: 1px solid ${s("divider")};
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
`;
|
||||
|
||||
export default Divider;
|
||||
@@ -1,18 +1,13 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { observer } from "mobx-react";
|
||||
import { ArchiveIcon, GoToIcon, TrashIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import type Collection from "~/models/Collection";
|
||||
import type { NavigationNode } from "@shared/types";
|
||||
import type Document from "~/models/Document";
|
||||
import Breadcrumb from "~/components/Breadcrumb";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { ContextMenu } from "~/components/Menu/ContextMenu";
|
||||
import { ActionContextProvider } from "~/hooks/useActionContext";
|
||||
import { useCollectionMenuAction } from "~/hooks/useCollectionMenuAction";
|
||||
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -20,56 +15,6 @@ import { archivePath, trashPath } from "~/utils/routeHelpers";
|
||||
import { createInternalLinkAction } from "~/actions";
|
||||
import { ActiveDocumentSection } from "~/actions/sections";
|
||||
|
||||
/**
|
||||
* Returns the breadcrumb parts leading up to a document, separating the
|
||||
* (possibly deleted) collection label from ancestor document titles. The
|
||||
* document itself is not included.
|
||||
*
|
||||
* @param document - the document to compute the breadcrumb for.
|
||||
* @param t - translation function for fallback titles.
|
||||
* @returns the collection label and ancestor titles.
|
||||
*/
|
||||
export function documentBreadcrumbParts(
|
||||
document: Document,
|
||||
t: TFunction
|
||||
): { collection: string | undefined; ancestors: string[] } {
|
||||
let collectionLabel: string | undefined;
|
||||
if (document.isCollectionDeleted) {
|
||||
collectionLabel = t("Deleted Collection");
|
||||
} else if (document.collection?.name) {
|
||||
collectionLabel = document.collection.name;
|
||||
}
|
||||
|
||||
return {
|
||||
collection: collectionLabel,
|
||||
ancestors: document.pathTo
|
||||
.slice(0, -1)
|
||||
.map((node) => node.title || t("Untitled")),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the breadcrumb path leading up to a document as a plain text
|
||||
* string. Includes the collection name (or "Deleted Collection" fallback)
|
||||
* and any ancestor document titles, slash-separated.
|
||||
*
|
||||
* @param document - the document to compute the breadcrumb for.
|
||||
* @param t - translation function for fallback titles.
|
||||
* @returns the breadcrumb as a slash-separated string, or undefined if the
|
||||
* document has no resolvable parent context.
|
||||
*/
|
||||
export function documentBreadcrumbText(
|
||||
document: Document,
|
||||
t: TFunction
|
||||
): string | undefined {
|
||||
const parts = documentBreadcrumbParts(document, t);
|
||||
const segments = [
|
||||
...(parts.collection ? [parts.collection] : []),
|
||||
...parts.ancestors,
|
||||
];
|
||||
return segments.length ? segments.join(" / ") : undefined;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
document: Document;
|
||||
@@ -123,9 +68,7 @@ function DocumentBreadcrumb(
|
||||
to: archivePath(),
|
||||
}),
|
||||
createInternalLinkAction({
|
||||
name: collection ? (
|
||||
<CollectionName collection={collection} />
|
||||
) : undefined,
|
||||
name: collection?.name,
|
||||
section: ActiveDocumentSection,
|
||||
icon: collection ? (
|
||||
<CollectionIcon collection={collection} expanded />
|
||||
@@ -147,20 +90,18 @@ function DocumentBreadcrumb(
|
||||
...path.map((node) => {
|
||||
const title = node.title || t("Untitled");
|
||||
return createInternalLinkAction({
|
||||
name: (
|
||||
<DocumentName
|
||||
documentId={node.id}
|
||||
collection={collection}
|
||||
title={title}
|
||||
/>
|
||||
name: node.icon ? (
|
||||
<>
|
||||
<StyledIcon
|
||||
value={node.icon}
|
||||
color={node.color}
|
||||
initial={node.title.charAt(0).toUpperCase()}
|
||||
/>{" "}
|
||||
{title}
|
||||
</>
|
||||
) : (
|
||||
title
|
||||
),
|
||||
icon: node.icon ? (
|
||||
<Icon
|
||||
value={node.icon}
|
||||
color={node.color}
|
||||
initial={title.charAt(0).toUpperCase()}
|
||||
/>
|
||||
) : undefined,
|
||||
section: ActiveDocumentSection,
|
||||
to: {
|
||||
pathname: node.url,
|
||||
@@ -197,25 +138,22 @@ function DocumentBreadcrumb(
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const { collection: collectionLabel, ancestors: ancestorLabels } =
|
||||
documentBreadcrumbParts(document, t);
|
||||
|
||||
const slicedAncestors = reverse
|
||||
? ancestorLabels.slice(depth && -depth)
|
||||
: ancestorLabels.slice(0, depth);
|
||||
const slicedPath = reverse
|
||||
? path.slice(depth && -depth)
|
||||
: path.slice(0, depth);
|
||||
|
||||
const showCollection =
|
||||
!!collectionLabel &&
|
||||
(!reverse || depth === undefined || slicedAncestors.length < depth);
|
||||
collection &&
|
||||
(!reverse || depth === undefined || slicedPath.length < depth);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showCollection && collectionLabel}
|
||||
{slicedAncestors.map((label, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{showCollection && collection.name}
|
||||
{slicedPath.map((node: NavigationNode, index: number) => (
|
||||
<React.Fragment key={node.id}>
|
||||
{showCollection && <SmallSlash />}
|
||||
{label}
|
||||
{!showCollection && index !== slicedAncestors.length - 1 && (
|
||||
{node.title || t("Untitled")}
|
||||
{!showCollection && index !== slicedPath.length - 1 && (
|
||||
<SmallSlash />
|
||||
)}
|
||||
</React.Fragment>
|
||||
@@ -231,57 +169,9 @@ function DocumentBreadcrumb(
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders a collection name wrapped in a context menu. */
|
||||
const CollectionName = observer(function CollectionName_({
|
||||
collection,
|
||||
}: {
|
||||
collection: Collection;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const menuAction = useCollectionMenuAction({
|
||||
collectionId: collection.id,
|
||||
});
|
||||
|
||||
return (
|
||||
<ActionContextProvider value={{ activeModels: [collection] }}>
|
||||
<ContextMenu action={menuAction} ariaLabel={t("Collection options")}>
|
||||
<span>{collection.name}</span>
|
||||
</ContextMenu>
|
||||
</ActionContextProvider>
|
||||
);
|
||||
});
|
||||
|
||||
/** Renders a document name wrapped in a context menu. */
|
||||
const DocumentName = observer(function DocumentName_({
|
||||
documentId,
|
||||
collection,
|
||||
title,
|
||||
}: {
|
||||
documentId: string;
|
||||
collection: Collection | undefined;
|
||||
title: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { documents } = useStores();
|
||||
const doc = documents.get(documentId);
|
||||
const menuAction = useDocumentMenuAction({ documentId });
|
||||
|
||||
if (!doc) {
|
||||
return <>{title}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ActionContextProvider
|
||||
value={{
|
||||
activeModels: [doc, ...(collection ? [collection] : [])],
|
||||
}}
|
||||
>
|
||||
<ContextMenu action={menuAction} ariaLabel={t("Document options")}>
|
||||
<span>{title}</span>
|
||||
</ContextMenu>
|
||||
</ActionContextProvider>
|
||||
);
|
||||
});
|
||||
const StyledIcon = styled(Icon)`
|
||||
margin-right: 2px;
|
||||
`;
|
||||
|
||||
const SmallSlash = styled(GoToIcon)`
|
||||
width: 12px;
|
||||
|
||||
@@ -110,7 +110,7 @@ function DocumentCard(props: Props) {
|
||||
dir={document.dir}
|
||||
$isDragging={isDragging}
|
||||
to={{
|
||||
pathname: document.path,
|
||||
pathname: document.url,
|
||||
state: {
|
||||
title: document.titleWithDefault,
|
||||
},
|
||||
|
||||
@@ -40,8 +40,8 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
return nodes;
|
||||
}, [policies, collectionTrees]);
|
||||
|
||||
const copy = async (path = selectedPath) => {
|
||||
if (!path) {
|
||||
const copy = async () => {
|
||||
if (!selectedPath) {
|
||||
toast.message(t("Select a location to copy"));
|
||||
return;
|
||||
}
|
||||
@@ -52,8 +52,10 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
publish,
|
||||
recursive,
|
||||
title: document.title,
|
||||
collectionId: path.collectionId,
|
||||
...(path.type === "document" ? { parentDocumentId: path.id } : {}),
|
||||
collectionId: selectedPath.collectionId,
|
||||
...(selectedPath.type === "document"
|
||||
? { parentDocumentId: selectedPath.id }
|
||||
: {}),
|
||||
});
|
||||
|
||||
toast.success(t("Document copied"));
|
||||
@@ -109,7 +111,7 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
t("Select a location to copy")
|
||||
)}
|
||||
</Text>
|
||||
<Button disabled={!selectedPath || copying} onClick={() => copy()}>
|
||||
<Button disabled={!selectedPath || copying} onClick={copy}>
|
||||
{copying ? `${t("Copying")}…` : t("Copy")}
|
||||
</Button>
|
||||
</Footer>
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import FuzzySearch from "fuzzy-search";
|
||||
import {
|
||||
concat,
|
||||
difference,
|
||||
fill,
|
||||
filter,
|
||||
flatten,
|
||||
includes,
|
||||
map,
|
||||
} from "es-toolkit/compat";
|
||||
import concat from "lodash/concat";
|
||||
import difference from "lodash/difference";
|
||||
import fill from "lodash/fill";
|
||||
import filter from "lodash/filter";
|
||||
import flatten from "lodash/flatten";
|
||||
import includes from "lodash/includes";
|
||||
import map from "lodash/map";
|
||||
import { observer } from "mobx-react";
|
||||
import { StarredIcon, DocumentIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
@@ -24,6 +22,7 @@ import DocumentExplorerNode from "./DocumentExplorerNode";
|
||||
import DocumentExplorerSearchResult from "./DocumentExplorerSearchResult";
|
||||
import Flex from "~/components/Flex";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { Outline } from "~/components/Input";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
import Text from "~/components/Text";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
@@ -31,7 +30,7 @@ import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
/** Action taken upon submission of selected item, could be publish, move etc. */
|
||||
onSubmit: (item: NavigationNode | null) => void;
|
||||
onSubmit: () => void;
|
||||
/** A side-effect of item selection */
|
||||
onSelect: (item: NavigationNode | null) => void;
|
||||
/** Items to be shown in explorer */
|
||||
@@ -255,13 +254,6 @@ function DocumentExplorer({
|
||||
}
|
||||
};
|
||||
|
||||
const submitNode = (node: number) => {
|
||||
const selectedNode = nodes[node];
|
||||
|
||||
selectNode(selectedNode);
|
||||
onSubmit(selectedNode);
|
||||
};
|
||||
|
||||
const ListItem = observer(
|
||||
({
|
||||
index,
|
||||
@@ -318,8 +310,7 @@ function DocumentExplorer({
|
||||
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
|
||||
}}
|
||||
onPointerMove={() => setActiveNode(index)}
|
||||
onClick={() => selectNode(nodes[index])}
|
||||
onDoubleClick={() => submitNode(index)}
|
||||
onClick={() => toggleSelect(index)}
|
||||
icon={renderedIcon}
|
||||
title={title}
|
||||
path={path}
|
||||
@@ -333,8 +324,7 @@ function DocumentExplorer({
|
||||
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
|
||||
}}
|
||||
onPointerMove={() => setActiveNode(index)}
|
||||
onClick={() => selectNode(nodes[index])}
|
||||
onDoubleClick={() => submitNode(index)}
|
||||
onClick={() => toggleSelect(index)}
|
||||
onDisclosureClick={(ev) => {
|
||||
ev.stopPropagation();
|
||||
toggleCollapse(index);
|
||||
@@ -396,7 +386,7 @@ function DocumentExplorer({
|
||||
}
|
||||
case "Enter": {
|
||||
if (isModKey(ev)) {
|
||||
onSubmit(selectedNode);
|
||||
onSubmit();
|
||||
} else {
|
||||
toggleSelect(activeNode);
|
||||
}
|
||||
@@ -457,7 +447,10 @@ const FlexContainer = styled(Flex)`
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const ListSearch = styled(InputSearch).attrs({ round: true })`
|
||||
const ListSearch = styled(InputSearch)`
|
||||
${Outline} {
|
||||
border-radius: 16px;
|
||||
}
|
||||
margin-bottom: 4px;
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
|
||||
@@ -9,30 +9,18 @@ import Disclosure from "~/components/Sidebar/components/Disclosure";
|
||||
import Text from "~/components/Text";
|
||||
|
||||
type Props = {
|
||||
/** Whether this node is the chosen destination (committed pick via click or Enter). */
|
||||
selected: boolean;
|
||||
/** Whether this node is currently highlighted by pointer hover or keyboard navigation. */
|
||||
active: boolean;
|
||||
/** Inline style passed in by the virtualized list for absolute positioning. */
|
||||
style: React.CSSProperties;
|
||||
/** Whether this node's children are currently revealed in the tree. */
|
||||
expanded: boolean;
|
||||
/** Icon rendered before the title (document icon, emoji, or star). */
|
||||
icon?: React.ReactNode;
|
||||
/** Display title for the node. */
|
||||
title: string;
|
||||
/** Zero-based nesting depth, used to indent the node. */
|
||||
depth: number;
|
||||
/** Whether this node has descendants and should render a disclosure chevron. */
|
||||
hasChildren: boolean;
|
||||
/** Fired when the disclosure chevron is clicked to expand or collapse the node. */
|
||||
|
||||
onDisclosureClick: (ev: React.MouseEvent) => void;
|
||||
/** Fired on pointer movement over the node; used to update the active highlight. */
|
||||
onPointerMove: (ev: React.MouseEvent) => void;
|
||||
/** Fired when the node is clicked to select it. */
|
||||
onClick: (ev: React.MouseEvent) => void;
|
||||
/** Fired when the node is double-clicked to submit the current selection. */
|
||||
onDoubleClick: (ev: React.MouseEvent) => void;
|
||||
};
|
||||
|
||||
function DocumentExplorerNode(
|
||||
@@ -48,13 +36,12 @@ function DocumentExplorerNode(
|
||||
onDisclosureClick,
|
||||
onPointerMove,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
}: Props,
|
||||
ref: React.RefObject<HTMLSpanElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const DISCLOSURE = 24;
|
||||
const width = (depth + 2) * DISCLOSURE;
|
||||
const width = (depth + (hasChildren ? 2 : 1)) * DISCLOSURE;
|
||||
|
||||
return (
|
||||
<Node
|
||||
@@ -62,7 +49,6 @@ function DocumentExplorerNode(
|
||||
selected={selected}
|
||||
active={active}
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
style={style}
|
||||
onPointerMove={onPointerMove}
|
||||
role="option"
|
||||
@@ -93,10 +79,6 @@ const StyledDisclosure = styled(Disclosure)`
|
||||
position: relative;
|
||||
left: auto;
|
||||
margin: 2px 0;
|
||||
|
||||
&&[aria-expanded="true"]:not(:hover) {
|
||||
background: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const Spacer = styled(Flex)<{ width: number }>`
|
||||
|
||||
@@ -17,7 +17,6 @@ type Props = {
|
||||
|
||||
onPointerMove: (ev: React.MouseEvent) => void;
|
||||
onClick: (ev: React.MouseEvent) => void;
|
||||
onDoubleClick: (ev: React.MouseEvent) => void;
|
||||
};
|
||||
|
||||
function DocumentExplorerSearchResult({
|
||||
@@ -29,7 +28,6 @@ function DocumentExplorerSearchResult({
|
||||
path,
|
||||
onPointerMove,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -38,7 +36,6 @@ function DocumentExplorerSearchResult({
|
||||
selected={selected}
|
||||
active={active}
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
style={style}
|
||||
onPointerMove={onPointerMove}
|
||||
role="option"
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useState, useMemo } from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import type { NavigationNode } from "@shared/types";
|
||||
import { descendants, flattenTree } from "@shared/utils/tree";
|
||||
import type Document from "~/models/Document";
|
||||
import Button from "~/components/Button";
|
||||
import Text from "~/components/Text";
|
||||
@@ -24,23 +23,13 @@ function DocumentMove({ document }: Props) {
|
||||
const [selectedPath, selectPath] = useState<NavigationNode | null>(null);
|
||||
|
||||
const items = useMemo(() => {
|
||||
// Collect the IDs of the document itself and all of its descendants so they
|
||||
// can be excluded from the move targets (moving to self or a descendant
|
||||
// would create a cycle; moving to the exact same location is a no-op).
|
||||
const allNodes = collectionTrees.flatMap(flattenTree);
|
||||
const sourceNode = allNodes.find((node) => node.id === document.id);
|
||||
const excludedIds = new Set<string>([document.id]);
|
||||
if (sourceNode) {
|
||||
descendants(sourceNode).forEach((n) => excludedIds.add(n.id));
|
||||
}
|
||||
|
||||
// Recursively filter out the document itself and its descendants.
|
||||
// The document's current parent is intentionally kept so that siblings
|
||||
// remain visible as valid move targets.
|
||||
// Recursively filter out the document itself and its existing parent doc, if any.
|
||||
const filterSourceDocument = (node: NavigationNode): NavigationNode => ({
|
||||
...node,
|
||||
children: node.children
|
||||
?.filter((c) => !excludedIds.has(c.id))
|
||||
?.filter(
|
||||
(c) => c.id !== document.id && c.id !== document.parentDocumentId
|
||||
)
|
||||
.map(filterSourceDocument),
|
||||
});
|
||||
|
||||
@@ -54,19 +43,19 @@ function DocumentMove({ document }: Props) {
|
||||
);
|
||||
|
||||
return nodes;
|
||||
}, [policies, collectionTrees, document.id]);
|
||||
}, [policies, collectionTrees, document.id, document.parentDocumentId]);
|
||||
|
||||
const move = async (path = selectedPath) => {
|
||||
if (!path) {
|
||||
const move = async () => {
|
||||
if (!selectedPath) {
|
||||
toast.message(t("Select a location to move"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setMoving(true);
|
||||
const { type, id: parentDocumentId } = path;
|
||||
const { type, id: parentDocumentId } = selectedPath;
|
||||
|
||||
const collectionId = path.collectionId as string;
|
||||
const collectionId = selectedPath.collectionId as string;
|
||||
|
||||
if (type === "document") {
|
||||
await document.move({ collectionId, parentDocumentId });
|
||||
@@ -103,7 +92,7 @@ function DocumentMove({ document }: Props) {
|
||||
t("Select a location to move")
|
||||
)}
|
||||
</Text>
|
||||
<Button disabled={!selectedPath || moving} onClick={() => move()}>
|
||||
<Button disabled={!selectedPath || moving} onClick={move}>
|
||||
{moving ? `${t("Moving")}…` : t("Move")}
|
||||
</Button>
|
||||
</Footer>
|
||||
|
||||
@@ -33,14 +33,15 @@ function TemplateMove({ template }: Props) {
|
||||
[policies, collectionTrees]
|
||||
);
|
||||
|
||||
const move = async (path = selectedPath) => {
|
||||
if (!path) {
|
||||
const move = async () => {
|
||||
if (!selectedPath) {
|
||||
toast.message(t("Select a location to move"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const collectionId = (path.collectionId ?? path.id) as string;
|
||||
const collectionId = (selectedPath.collectionId ??
|
||||
selectedPath.id) as string;
|
||||
await template.save({ collectionId });
|
||||
|
||||
toast.success(t("Template moved"));
|
||||
@@ -75,7 +76,7 @@ function TemplateMove({ template }: Props) {
|
||||
t("Select a location to move")
|
||||
)}
|
||||
</Text>
|
||||
<Button disabled={!selectedPath} onClick={() => move()}>
|
||||
<Button disabled={!selectedPath} onClick={move}>
|
||||
{t("Move")}
|
||||
</Button>
|
||||
</Footer>
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import { Link } from "react-router-dom";
|
||||
import { DocumentIcon } from "outline-icons";
|
||||
import styled, { css, useTheme } from "styled-components";
|
||||
@@ -28,7 +27,6 @@ import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
import { determineSidebarContext } from "./Sidebar/components/SidebarContext";
|
||||
import { useDragDocument } from "./Sidebar/hooks/useDragAndDrop";
|
||||
import { ActionContextProvider } from "~/hooks/useActionContext";
|
||||
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
|
||||
import { ContextMenu } from "./Menu/ContextMenu";
|
||||
@@ -100,23 +98,6 @@ function DocumentListItem(
|
||||
|
||||
const contextMenuAction = useDocumentMenuAction({ documentId: document.id });
|
||||
|
||||
const [{ isDragging }, draggableRef] = useDragDocument(
|
||||
document.asNavigationNode,
|
||||
0,
|
||||
document,
|
||||
false,
|
||||
false
|
||||
);
|
||||
|
||||
const mergedRef = React.useMemo(
|
||||
() =>
|
||||
mergeRefs<HTMLAnchorElement>([
|
||||
itemRef,
|
||||
draggableRef,
|
||||
] as React.Ref<HTMLAnchorElement>[]),
|
||||
[itemRef, draggableRef]
|
||||
);
|
||||
|
||||
return (
|
||||
<ActionContextProvider
|
||||
value={{
|
||||
@@ -133,10 +114,9 @@ function DocumentListItem(
|
||||
onClose={handleMenuClose}
|
||||
>
|
||||
<DocumentLink
|
||||
ref={mergedRef}
|
||||
ref={itemRef}
|
||||
dir={document.dir}
|
||||
$isStarred={document.isStarred}
|
||||
$isDragging={isDragging}
|
||||
$menuOpen={menuOpen}
|
||||
to={{
|
||||
pathname: documentPath(document),
|
||||
@@ -247,7 +227,6 @@ const Actions = styled(EventBoundary)`
|
||||
|
||||
const DocumentLink = styled(Link)<{
|
||||
$isStarred?: boolean;
|
||||
$isDragging?: boolean;
|
||||
$menuOpen?: boolean;
|
||||
}>`
|
||||
display: flex;
|
||||
@@ -258,8 +237,6 @@ const DocumentLink = styled(Link)<{
|
||||
max-height: 50vh;
|
||||
width: calc(100vw - 8px);
|
||||
cursor: var(--pointer);
|
||||
transition: opacity 250ms ease;
|
||||
opacity: ${(props) => (props.$isDragging ? 0.1 : 1)};
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { compact, sortBy } from "es-toolkit/compat";
|
||||
import compact from "lodash/compact";
|
||||
import sortBy from "lodash/sortBy";
|
||||
import { observer } from "mobx-react";
|
||||
import { useMemo, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
+17
-10
@@ -1,9 +1,8 @@
|
||||
import { difference } from "es-toolkit/compat";
|
||||
import difference from "lodash/difference";
|
||||
import { observer } from "mobx-react";
|
||||
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
|
||||
import { TextSelection } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import type { Optional } from "utility-types";
|
||||
@@ -17,6 +16,7 @@ import ClickablePadding from "~/components/ClickablePadding";
|
||||
import ErrorBoundary from "~/components/ErrorBoundary";
|
||||
import type { Props as EditorProps, Editor as SharedEditor } from "~/editor";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useDictionary from "~/hooks/useDictionary";
|
||||
import useEditorClickHandlers from "~/hooks/useEditorClickHandlers";
|
||||
import useEmbeds from "~/hooks/useEmbeds";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -28,7 +28,12 @@ const LazyLoadedEditor = lazyWithRetry(() => import("~/editor"));
|
||||
|
||||
export type Props = Optional<
|
||||
EditorProps,
|
||||
"placeholder" | "defaultValue" | "onClickLink" | "embeds" | "extensions"
|
||||
| "placeholder"
|
||||
| "defaultValue"
|
||||
| "onClickLink"
|
||||
| "embeds"
|
||||
| "dictionary"
|
||||
| "extensions"
|
||||
> & {
|
||||
embedsDisabled?: boolean;
|
||||
onSynced?: () => Promise<void>;
|
||||
@@ -47,7 +52,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
} = props;
|
||||
const { comments } = useStores();
|
||||
const { shareId } = useShare();
|
||||
const { t } = useTranslation();
|
||||
const dictionary = useDictionary();
|
||||
const embeds = useEmbeds(!shareId);
|
||||
const localRef = React.useRef<SharedEditor>();
|
||||
const preferences = useCurrentUser({ rejectOnEmpty: false })?.preferences;
|
||||
@@ -90,11 +95,11 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
const handleFileUploadStart = React.useCallback(() => {
|
||||
uploadState.current.timeoutId = setTimeout(() => {
|
||||
uploadState.current.toastId = toast.loading(
|
||||
t("Uploading… {{ progress }}%", { progress: 0 })
|
||||
dictionary.uploadingWithProgress(0)
|
||||
);
|
||||
}, 2000);
|
||||
onFileUploadStart?.();
|
||||
}, [onFileUploadStart, t]);
|
||||
}, [onFileUploadStart, dictionary.uploadingWithProgress]);
|
||||
|
||||
const handleFileUploadProgress = React.useCallback(
|
||||
(fileId: string, fractionComplete: number) => {
|
||||
@@ -108,12 +113,12 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
|
||||
// Update toast if visible
|
||||
if (uploadState.current.toastId) {
|
||||
toast.loading(t("Uploading… {{ progress }}%", { progress: percent }), {
|
||||
toast.loading(dictionary.uploadingWithProgress(percent), {
|
||||
id: uploadState.current.toastId,
|
||||
});
|
||||
}
|
||||
},
|
||||
[t]
|
||||
[dictionary.uploadingWithProgress]
|
||||
);
|
||||
|
||||
const handleFileUploadStop = React.useCallback(() => {
|
||||
@@ -178,6 +183,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
onFileUploadStart: handleFileUploadStart,
|
||||
onFileUploadStop: handleFileUploadStop,
|
||||
onFileUploadProgress: handleFileUploadProgress,
|
||||
dictionary,
|
||||
isAttachment,
|
||||
});
|
||||
},
|
||||
@@ -186,6 +192,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
handleFileUploadStart,
|
||||
handleFileUploadStop,
|
||||
handleFileUploadProgress,
|
||||
dictionary,
|
||||
handleUploadFile,
|
||||
]
|
||||
);
|
||||
@@ -204,7 +211,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
const commentMarks = localRef.current.getComments();
|
||||
const commentIds = comments.orderedData.map((c) => c.id);
|
||||
const commentMarkIds = commentMarks?.map((c) => c.id);
|
||||
const focus = previousCommentIds.current !== undefined;
|
||||
const newCommentIds = difference(
|
||||
commentMarkIds,
|
||||
previousCommentIds.current ?? [],
|
||||
@@ -214,7 +220,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
newCommentIds.forEach((commentId) => {
|
||||
const mark = commentMarks.find((c) => c.id === commentId);
|
||||
if (mark) {
|
||||
onCreateCommentMark(mark.id, mark.userId, { focus });
|
||||
onCreateCommentMark(mark.id, mark.userId);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -282,6 +288,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
uploadFile={handleUploadFile}
|
||||
embeds={embeds}
|
||||
userPreferences={preferences}
|
||||
dictionary={dictionary}
|
||||
{...props}
|
||||
onClickLink={handleClickLink}
|
||||
onChange={handleChange}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { BackendFactory } from "dnd-core";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
|
||||
/**
|
||||
* react-dnd's HTML5 backend installs global capture-phase listeners on `window`
|
||||
* that call `preventDefault()` on drops whose dataTransfer resembles a native
|
||||
* item – including a dragged `<img>`, which is how ProseMirror serializes an
|
||||
* image drag.
|
||||
*
|
||||
* These handlers run before ProseMirror's, and they live on `window`, so a
|
||||
* propagation-based guard can't stop react-dnd without also starving the editor
|
||||
* of the event. Instead we wrap the backend and make its top-level capture
|
||||
* handlers no-op for events that occur within the editor surface.
|
||||
*/
|
||||
const captureHandlerNames = [
|
||||
"handleTopDragStartCapture",
|
||||
"handleTopDragEnterCapture",
|
||||
"handleTopDragOverCapture",
|
||||
"handleTopDragLeaveCapture",
|
||||
"handleTopDropCapture",
|
||||
"handleTopDragEndCapture",
|
||||
] as const;
|
||||
|
||||
const isWithinEditor = (target: EventTarget | null): boolean =>
|
||||
target instanceof Element && Boolean(target.closest(".ProseMirror"));
|
||||
|
||||
/**
|
||||
* An HTML5 drag-and-drop backend that ignores drag events originating within the
|
||||
* rich text editor so that ProseMirror can handle them itself.
|
||||
*
|
||||
* @param manager The drag-and-drop manager.
|
||||
* @param context The global context.
|
||||
* @param options Backend options.
|
||||
* @returns The wrapped HTML5 backend instance.
|
||||
*/
|
||||
export const EditorAwareHTML5Backend: BackendFactory = (
|
||||
manager,
|
||||
context,
|
||||
options
|
||||
) => {
|
||||
const backend = HTML5Backend(manager, context, options);
|
||||
|
||||
// The capture handlers are private instance fields on the backend, so reach
|
||||
// for them through an index signature view of the instance.
|
||||
const handlers = backend as unknown as Record<
|
||||
string,
|
||||
(event: DragEvent) => void
|
||||
>;
|
||||
|
||||
for (const name of captureHandlerNames) {
|
||||
const original = handlers[name];
|
||||
if (typeof original === "function") {
|
||||
handlers[name] = (event: DragEvent) => {
|
||||
if (isWithinEditor(event.target)) {
|
||||
return;
|
||||
}
|
||||
original.call(backend, event);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return backend;
|
||||
};
|
||||
@@ -0,0 +1,233 @@
|
||||
import * as React from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { AttachmentPreset } from "@shared/types";
|
||||
import { getDataTransferFiles } from "@shared/utils/files";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Input, { LabelText } from "~/components/Input";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { uploadFile } from "~/utils/files";
|
||||
import { compressImage } from "~/utils/compressImage";
|
||||
import { generateEmojiNameFromFilename } from "~/utils/emoji";
|
||||
import { AttachmentValidation, EmojiValidation } from "@shared/validations";
|
||||
import { bytesToHumanReadable } from "@shared/utils/files";
|
||||
import { VStack } from "./primitives/VStack";
|
||||
|
||||
type Props = {
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
export function EmojiCreateDialog({ onSubmit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { emojis } = useStores();
|
||||
const [name, setName] = React.useState("");
|
||||
const [file, setFile] = React.useState<File | null>(null);
|
||||
const [isUploading, setIsUploading] = React.useState(false);
|
||||
|
||||
const handleFileSelection = React.useCallback(
|
||||
(file: File) => {
|
||||
const isValidType = AttachmentValidation.emojiContentTypes.includes(
|
||||
file.type
|
||||
);
|
||||
|
||||
if (!isValidType) {
|
||||
toast.error(
|
||||
t("File type not supported. Please use PNG, JPG, GIF, or WebP.")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (file.size > AttachmentValidation.emojiMaxFileSize) {
|
||||
toast.error(
|
||||
t("File size too large. Maximum size is {{ size }}.", {
|
||||
size: bytesToHumanReadable(AttachmentValidation.emojiMaxFileSize),
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setFile(file);
|
||||
|
||||
// Auto-populate name field if it's empty
|
||||
setName((currentName) => {
|
||||
if (!currentName.trim()) {
|
||||
const generatedName = generateEmojiNameFromFilename(file.name);
|
||||
return generatedName || currentName;
|
||||
}
|
||||
return currentName;
|
||||
});
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const onDrop = React.useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
if (acceptedFiles.length > 0) {
|
||||
handleFileSelection(acceptedFiles[0]);
|
||||
}
|
||||
},
|
||||
[handleFileSelection]
|
||||
);
|
||||
|
||||
// Handle paste events
|
||||
React.useEffect(() => {
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
const files = getDataTransferFiles(event);
|
||||
if (files.length > 0) {
|
||||
event.preventDefault();
|
||||
handleFileSelection(files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("paste", handlePaste);
|
||||
return () => document.removeEventListener("paste", handlePaste);
|
||||
}, [handleFileSelection]);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDropAccepted: onDrop,
|
||||
accept: AttachmentValidation.emojiContentTypes,
|
||||
maxSize: AttachmentValidation.emojiMaxFileSize,
|
||||
maxFiles: 1,
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) {
|
||||
toast.error(t("Please enter a name for the emoji"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file) {
|
||||
toast.error(t("Please select an image file"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
// Skip compression for GIFs to preserve animation
|
||||
const fileToUpload =
|
||||
file.type === "image/gif"
|
||||
? file
|
||||
: await compressImage(file, {
|
||||
maxHeight: 64,
|
||||
maxWidth: 64,
|
||||
});
|
||||
|
||||
const attachment = await uploadFile(fileToUpload, {
|
||||
name: file.name,
|
||||
preset: AttachmentPreset.Emoji,
|
||||
});
|
||||
|
||||
await emojis.create({
|
||||
name: name.trim(),
|
||||
attachmentId: attachment.id,
|
||||
});
|
||||
|
||||
toast.success(t("Emoji created successfully"));
|
||||
onSubmit();
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = event.target;
|
||||
setName(value);
|
||||
};
|
||||
|
||||
const isValidName = EmojiValidation.allowedNameCharacters.test(name);
|
||||
const isValid = name.trim().length > 0 && file && isValidName;
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
disabled={!isValid || isUploading}
|
||||
savingText={isUploading ? `${t("Uploading")}…` : undefined}
|
||||
submitText={t("Add emoji")}
|
||||
>
|
||||
<Text as="p" type="secondary">
|
||||
{t(
|
||||
"Square images with transparent backgrounds work best. If your image is too large, we’ll try to resize it for you."
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<LabelText as="label">{t("Upload an image")}</LabelText>
|
||||
<DropZone {...getRootProps()}>
|
||||
<input {...getInputProps()} />
|
||||
<VStack>
|
||||
{file ? (
|
||||
<>
|
||||
<PreviewImage src={URL.createObjectURL(file)} alt="Preview" />
|
||||
<Text size="medium">{file.name}</Text>
|
||||
<Text size="medium" type="secondary">
|
||||
{t("Click or drag to replace")}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text size="medium">
|
||||
{isDragActive
|
||||
? t("Drop the image here")
|
||||
: t("Click, drop, or paste an image here")}
|
||||
</Text>
|
||||
<Text size="medium" type="secondary">
|
||||
{t("PNG, JPG, GIF, or WebP up to {{ size }}", {
|
||||
size: bytesToHumanReadable(
|
||||
AttachmentValidation.emojiMaxFileSize
|
||||
),
|
||||
})}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</VStack>
|
||||
</DropZone>
|
||||
|
||||
<Input
|
||||
label={t("Choose a name")}
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
placeholder="my_custom_emoji"
|
||||
autoFocus
|
||||
required
|
||||
error={
|
||||
!isValidName
|
||||
? t(
|
||||
"name can only contain lowercase letters, numbers, and underscores."
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{name.trim() && isValidName && (
|
||||
<Text type="secondary" style={{ marginTop: "8px" }}>
|
||||
{t("This emoji will be available as")} <code>:{name}:</code>
|
||||
</Text>
|
||||
)}
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
const DropZone = styled.div`
|
||||
border: 2px dashed ${s("inputBorder")};
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
cursor: var(--pointer);
|
||||
transition: border-color 0.2s;
|
||||
margin-bottom: 1em;
|
||||
|
||||
&:hover {
|
||||
border-color: ${s("inputBorderFocused")};
|
||||
}
|
||||
`;
|
||||
|
||||
const PreviewImage = styled.img`
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
`;
|
||||
@@ -1,161 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { getDataTransferFiles } from "@shared/utils/files";
|
||||
import { bytesToHumanReadable } from "@shared/utils/files";
|
||||
import { AttachmentValidation } from "@shared/validations";
|
||||
import Text from "~/components/Text";
|
||||
import { VStack } from "~/components/primitives/VStack";
|
||||
|
||||
interface UseEmojiFileUploadOptions {
|
||||
/** Optional callback fired after a valid file is selected. */
|
||||
onFileSelected?: (file: File) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that manages emoji image file selection with validation, drag-and-drop,
|
||||
* and paste support.
|
||||
*/
|
||||
export function useEmojiFileUpload(options?: UseEmojiFileUploadOptions) {
|
||||
const { t } = useTranslation();
|
||||
const [file, setFile] = React.useState<File | null>(null);
|
||||
|
||||
const handleFileSelection = React.useCallback(
|
||||
(selected: File) => {
|
||||
const isValidType = AttachmentValidation.emojiContentTypes.includes(
|
||||
selected.type
|
||||
);
|
||||
|
||||
if (!isValidType) {
|
||||
toast.error(
|
||||
t("File type not supported. Please use PNG, JPG, GIF, or WebP.")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selected.size > AttachmentValidation.emojiMaxFileSize) {
|
||||
toast.error(
|
||||
t("File size too large. Maximum size is {{ size }}.", {
|
||||
size: bytesToHumanReadable(AttachmentValidation.emojiMaxFileSize),
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setFile(selected);
|
||||
options?.onFileSelected?.(selected);
|
||||
},
|
||||
[t, options]
|
||||
);
|
||||
|
||||
const handleDrop = React.useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
if (acceptedFiles.length > 0) {
|
||||
handleFileSelection(acceptedFiles[0]);
|
||||
}
|
||||
},
|
||||
[handleFileSelection]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
const files = getDataTransferFiles(event);
|
||||
if (files.length > 0) {
|
||||
event.preventDefault();
|
||||
handleFileSelection(files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("paste", handlePaste);
|
||||
return () => document.removeEventListener("paste", handlePaste);
|
||||
}, [handleFileSelection]);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDropAccepted: handleDrop,
|
||||
accept: AttachmentValidation.emojiContentTypes,
|
||||
maxSize: AttachmentValidation.emojiMaxFileSize,
|
||||
maxFiles: 1,
|
||||
});
|
||||
|
||||
return { file, getRootProps, getInputProps, isDragActive };
|
||||
}
|
||||
|
||||
interface EmojiImageDropZoneProps {
|
||||
/** The currently selected file, if any. */
|
||||
file: File | null;
|
||||
/** Dropzone root props. */
|
||||
getRootProps: ReturnType<typeof useDropzone>["getRootProps"];
|
||||
/** Dropzone input props. */
|
||||
getInputProps: ReturnType<typeof useDropzone>["getInputProps"];
|
||||
/** Whether a drag is currently active. */
|
||||
isDragActive: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared drop zone component for emoji image upload, showing either a file
|
||||
* preview or placeholder text.
|
||||
*/
|
||||
export function EmojiImageDropZone({
|
||||
file,
|
||||
getRootProps,
|
||||
getInputProps,
|
||||
isDragActive,
|
||||
}: EmojiImageDropZoneProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<DropZone {...getRootProps()}>
|
||||
<input {...getInputProps()} />
|
||||
<VStack>
|
||||
{file ? (
|
||||
<>
|
||||
<PreviewImage src={URL.createObjectURL(file)} alt="Preview" />
|
||||
<Text size="medium">{file.name}</Text>
|
||||
<Text size="medium" type="secondary">
|
||||
{t("Click or drag to replace")}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text size="small">
|
||||
{isDragActive
|
||||
? t("Drop the image here")
|
||||
: t("Click, drop, or paste an image here")}
|
||||
</Text>
|
||||
<Text size="small" type="secondary">
|
||||
{t("PNG, JPG, GIF, or WebP up to {{ size }}", {
|
||||
size: bytesToHumanReadable(
|
||||
AttachmentValidation.emojiMaxFileSize
|
||||
),
|
||||
})}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</VStack>
|
||||
</DropZone>
|
||||
);
|
||||
}
|
||||
|
||||
const DropZone = styled.div`
|
||||
border: 2px dashed ${s("inputBorder")};
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
cursor: var(--pointer);
|
||||
transition: border-color 0.2s;
|
||||
margin-bottom: 1em;
|
||||
|
||||
&:hover {
|
||||
border-color: ${s("inputBorderFocused")};
|
||||
}
|
||||
`;
|
||||
|
||||
const PreviewImage = styled.img`
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
`;
|
||||
@@ -1,132 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { AttachmentPreset } from "@shared/types";
|
||||
import { EmojiValidation } from "@shared/validations";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Input, { LabelText } from "~/components/Input";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { uploadFile } from "~/utils/files";
|
||||
import { compressImage } from "~/utils/compressImage";
|
||||
import { generateEmojiNameFromFilename } from "~/utils/emoji";
|
||||
import { useEmojiFileUpload, EmojiImageDropZone } from "./Components";
|
||||
|
||||
interface Props {
|
||||
/** Callback invoked after successful creation. */
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog for creating a new custom emoji with image upload and name input.
|
||||
*/
|
||||
export function EmojiCreateDialog({ onSubmit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { emojis } = useStores();
|
||||
const [name, setName] = React.useState("");
|
||||
const [isUploading, setIsUploading] = React.useState(false);
|
||||
|
||||
const handleFileSelected = React.useCallback((selected: File) => {
|
||||
setName((currentName) => {
|
||||
if (!currentName.trim()) {
|
||||
const generatedName = generateEmojiNameFromFilename(selected.name);
|
||||
return generatedName || currentName;
|
||||
}
|
||||
return currentName;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const { file, getRootProps, getInputProps, isDragActive } =
|
||||
useEmojiFileUpload({ onFileSelected: handleFileSelected });
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) {
|
||||
toast.error(t("Please enter a name for the emoji"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file) {
|
||||
toast.error(t("Please select an image file"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const fileToUpload =
|
||||
file.type === "image/gif"
|
||||
? file
|
||||
: await compressImage(file, {
|
||||
maxHeight: 64,
|
||||
maxWidth: 64,
|
||||
});
|
||||
|
||||
const attachment = await uploadFile(fileToUpload, {
|
||||
name: file.name,
|
||||
preset: AttachmentPreset.Emoji,
|
||||
});
|
||||
|
||||
await emojis.create({
|
||||
name: name.trim(),
|
||||
attachmentId: attachment.id,
|
||||
});
|
||||
|
||||
toast.success(t("Emoji created successfully"));
|
||||
onSubmit();
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = event.target;
|
||||
setName(value);
|
||||
};
|
||||
|
||||
const isValidName = EmojiValidation.allowedNameCharacters.test(name);
|
||||
const isValid = name.trim().length > 0 && file && isValidName;
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
disabled={!isValid || isUploading}
|
||||
savingText={isUploading ? `${t("Uploading")}…` : undefined}
|
||||
submitText={t("Add emoji")}
|
||||
>
|
||||
<Text as="p" type="secondary">
|
||||
{t(
|
||||
"Square images with transparent backgrounds work best. If your image is too large, we'll try to resize it for you."
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<LabelText as="label">{t("Upload an image")}</LabelText>
|
||||
<EmojiImageDropZone
|
||||
file={file}
|
||||
getRootProps={getRootProps}
|
||||
getInputProps={getInputProps}
|
||||
isDragActive={isDragActive}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label={t("Choose a name")}
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
placeholder="my_custom_emoji"
|
||||
autoFocus
|
||||
required
|
||||
error={
|
||||
!isValidName
|
||||
? t(
|
||||
"name can only contain lowercase letters, numbers, and underscores."
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{name.trim() && isValidName && (
|
||||
<Text type="secondary" style={{ marginTop: "8px" }}>
|
||||
{t("This emoji will be available as")} <code>:{name}:</code>
|
||||
</Text>
|
||||
)}
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { AttachmentPreset } from "@shared/types";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Text from "~/components/Text";
|
||||
import type Emoji from "~/models/Emoji";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { uploadFile } from "~/utils/files";
|
||||
import { compressImage } from "~/utils/compressImage";
|
||||
import { useEmojiFileUpload, EmojiImageDropZone } from "./Components";
|
||||
|
||||
interface Props {
|
||||
/** The emoji whose image is being replaced. */
|
||||
emoji: Emoji;
|
||||
/** Callback invoked after a successful replacement. */
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog for replacing the image of an existing custom emoji.
|
||||
*/
|
||||
export function EmojiReplaceDialog({ emoji, onSubmit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { emojis } = useStores();
|
||||
const [isUploading, setIsUploading] = React.useState(false);
|
||||
const { file, getRootProps, getInputProps, isDragActive } =
|
||||
useEmojiFileUpload();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!file) {
|
||||
toast.error(t("Please select an image file"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const fileToUpload =
|
||||
file.type === "image/gif"
|
||||
? file
|
||||
: await compressImage(file, {
|
||||
maxHeight: 64,
|
||||
maxWidth: 64,
|
||||
});
|
||||
|
||||
const attachment = await uploadFile(fileToUpload, {
|
||||
name: file.name,
|
||||
preset: AttachmentPreset.Emoji,
|
||||
});
|
||||
|
||||
await emojis.update({
|
||||
id: emoji.id,
|
||||
attachmentId: attachment.id,
|
||||
});
|
||||
|
||||
toast.success(t("Emoji replaced"));
|
||||
onSubmit();
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
disabled={!file || isUploading}
|
||||
savingText={isUploading ? `${t("Uploading")}…` : undefined}
|
||||
submitText={t("Save")}
|
||||
>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans
|
||||
defaults="Upload a new image to replace the current one for <em>{{emojiName}}</em>. All existing uses of this emoji will be updated automatically."
|
||||
values={{ emojiName: `:${emoji.name}:` }}
|
||||
components={{ em: <code /> }}
|
||||
/>
|
||||
</Text>
|
||||
|
||||
<EmojiImageDropZone
|
||||
file={file}
|
||||
getRootProps={getRootProps}
|
||||
getInputProps={getInputProps}
|
||||
isDragActive={isDragActive}
|
||||
/>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import type { WithTranslation } from "react-i18next";
|
||||
import { withTranslation, Trans } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { UrlHelper } from "@shared/utils/UrlHelper";
|
||||
@@ -18,26 +18,20 @@ import Storage from "@shared/utils/Storage";
|
||||
import { deleteAllDatabases } from "~/utils/developer";
|
||||
import Flex from "./Flex";
|
||||
|
||||
interface OwnProps {
|
||||
type Props = WithTranslation & {
|
||||
/** Whether to reload the page if a chunk fails to load. */
|
||||
reloadOnChunkMissing?: boolean;
|
||||
/** Whether to show a title heading. */
|
||||
showTitle?: boolean;
|
||||
/** The wrapping component to use. */
|
||||
component?: React.ComponentType | string;
|
||||
/** Children rendered when no error is present. */
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
type Props = OwnProps & {
|
||||
t: TFunction;
|
||||
};
|
||||
|
||||
const ERROR_TRACKING_KEY = "error-boundary-tracking";
|
||||
const ERROR_TRACKING_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
@observer
|
||||
class ErrorBoundaryClass extends React.Component<Props> {
|
||||
class ErrorBoundary extends React.Component<Props> {
|
||||
@observable
|
||||
error: Error | null | undefined;
|
||||
|
||||
@@ -229,9 +223,4 @@ const Pre = styled.pre`
|
||||
white-space: pre-wrap;
|
||||
`;
|
||||
|
||||
function ErrorBoundary(props: OwnProps) {
|
||||
const { t } = useTranslation();
|
||||
return <ErrorBoundaryClass t={t} {...props} />;
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
export default withTranslation()(ErrorBoundary);
|
||||
|
||||
+1
-1
@@ -12,11 +12,11 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Text from "@shared/components/Text";
|
||||
import type Document from "~/models/Document";
|
||||
import type Event from "~/models/Event";
|
||||
import Time from "~/components/Time";
|
||||
import Logger from "~/utils/Logger";
|
||||
import Text from "./Text";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
@@ -18,7 +18,7 @@ type Props = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps children in a <Fade> if animate is true on mount.
|
||||
* Wraps children in a <Fade> if loading is true on mount.
|
||||
*/
|
||||
export const ConditionalFade = ({ animate, children }: Props) => {
|
||||
const [isAnimated] = useState(animate);
|
||||
|
||||
@@ -1,26 +1,16 @@
|
||||
import { deburr } from "es-toolkit/compat";
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import deburr from "lodash/deburr";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import type { FetchPageParams } from "~/stores/base/Store";
|
||||
import Button, { Inner } from "~/components/Button";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import Text from "~/components/Text";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import Input, { NativeInput, Outline } from "./Input";
|
||||
import type { PaginatedItem } from "./PaginatedList";
|
||||
import PaginatedList from "./PaginatedList";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "./primitives/Drawer";
|
||||
import { MenuProvider } from "./primitives/Menu/MenuContext";
|
||||
import { Menu, MenuContent, MenuTrigger, MenuButton } from "./primitives/Menu";
|
||||
import * as MenuComponents from "./primitives/components/Menu";
|
||||
import { MenuIconWrapper } from "./primitives/components/Menu";
|
||||
|
||||
interface TFilterOption extends PaginatedItem {
|
||||
@@ -44,7 +34,7 @@ type Props = {
|
||||
|
||||
const FilterOptions = ({
|
||||
options,
|
||||
selectedKeys,
|
||||
selectedKeys = [],
|
||||
className,
|
||||
onSelect,
|
||||
showFilter,
|
||||
@@ -55,7 +45,6 @@ const FilterOptions = ({
|
||||
...rest
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useMobile();
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const listRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
@@ -69,45 +58,23 @@ const FilterOptions = ({
|
||||
: "";
|
||||
|
||||
const renderItem = React.useCallback(
|
||||
(option) => {
|
||||
const handleClick = () => {
|
||||
onSelect(option.key);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const icon =
|
||||
option.icon && showIcons ? (
|
||||
<MenuIconWrapper aria-hidden>{option.icon}</MenuIconWrapper>
|
||||
) : undefined;
|
||||
|
||||
// On mobile the options render inside a Drawer (bottom sheet) rather than
|
||||
// a Radix dropdown menu, so use the raw menu components directly instead
|
||||
// of the dropdown-bound MenuButton which expects a menu root context.
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MenuComponents.MenuButton key={option.key} onClick={handleClick}>
|
||||
{icon}
|
||||
<MenuComponents.MenuLabel>{option.label}</MenuComponents.MenuLabel>
|
||||
<MenuComponents.SelectedIconWrapper aria-hidden>
|
||||
{selectedKeys.includes(option.key) ? (
|
||||
<CheckmarkIcon size={18} />
|
||||
) : null}
|
||||
</MenuComponents.SelectedIconWrapper>
|
||||
</MenuComponents.MenuButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuButton
|
||||
key={option.key}
|
||||
icon={icon}
|
||||
label={option.label}
|
||||
onClick={handleClick}
|
||||
selected={selectedKeys.includes(option.key)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[onSelect, showIcons, selectedKeys, isMobile]
|
||||
(option) => (
|
||||
<MenuButton
|
||||
key={option.key}
|
||||
icon={
|
||||
option.icon && showIcons ? (
|
||||
<MenuIconWrapper aria-hidden>{option.icon}</MenuIconWrapper>
|
||||
) : undefined
|
||||
}
|
||||
label={option.label}
|
||||
onClick={() => {
|
||||
onSelect(option.key);
|
||||
setOpen(false);
|
||||
}}
|
||||
selected={selectedKeys.includes(option.key)}
|
||||
/>
|
||||
),
|
||||
[onSelect, showIcons, selectedKeys]
|
||||
);
|
||||
|
||||
const handleFilter = React.useCallback(
|
||||
@@ -202,73 +169,39 @@ const FilterOptions = ({
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
// Avoid auto-focusing on mobile as it immediately pops the on-screen
|
||||
// keyboard over the drawer.
|
||||
if (!isMobile) {
|
||||
searchInputRef.current?.focus();
|
||||
}
|
||||
searchInputRef.current?.focus();
|
||||
} else {
|
||||
setQuery("");
|
||||
}
|
||||
}, [open, isMobile]);
|
||||
}, [open]);
|
||||
|
||||
const showFilterInput = showFilter || options.length > 10;
|
||||
const defaultLabel = rest.defaultLabel || t("Filter options");
|
||||
|
||||
const trigger = (
|
||||
<StyledButton
|
||||
className={className}
|
||||
icon={selectedItems[0]?.key && selectedItems[0]?.icon}
|
||||
disclosure={disclosure}
|
||||
neutral
|
||||
>
|
||||
{selectedItems.length ? selectedLabel : defaultLabel}
|
||||
</StyledButton>
|
||||
);
|
||||
|
||||
const list = (
|
||||
<PaginatedList<TFilterOption>
|
||||
listRef={listRef}
|
||||
options={{ query, ...fetchQueryOptions }}
|
||||
items={filteredOptions}
|
||||
fetch={fetchQuery}
|
||||
renderItem={renderItem}
|
||||
onEscape={handleEscapeFromList}
|
||||
heading={showFilterInput && !isMobile ? <Spacer /> : undefined}
|
||||
empty={<Empty />}
|
||||
/>
|
||||
);
|
||||
|
||||
// On mobile render the options inside a Drawer (bottom sheet) to match the
|
||||
// popover style used by context menus across the app.
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
||||
<DrawerContent aria-label={defaultLabel} aria-describedby={undefined}>
|
||||
<DrawerTitle>{defaultLabel}</DrawerTitle>
|
||||
{showFilterInput && (
|
||||
<MobileSearchInput
|
||||
ref={searchInputRef}
|
||||
value={query}
|
||||
onChange={handleFilter}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={`${t("Filter")}…`}
|
||||
margin={0}
|
||||
/>
|
||||
)}
|
||||
<StyledScrollable hiddenScrollbars>{list}</StyledScrollable>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuProvider variant="dropdown">
|
||||
<Menu open={open} onOpenChange={setOpen}>
|
||||
<MenuTrigger>{trigger}</MenuTrigger>
|
||||
<MenuTrigger>
|
||||
<StyledButton
|
||||
className={className}
|
||||
icon={selectedItems[0]?.key && selectedItems[0]?.icon}
|
||||
disclosure={disclosure}
|
||||
neutral
|
||||
>
|
||||
{selectedItems.length ? selectedLabel : defaultLabel}
|
||||
</StyledButton>
|
||||
</MenuTrigger>
|
||||
<MenuContent aria-label={defaultLabel} align="start">
|
||||
{list}
|
||||
<PaginatedList<TFilterOption>
|
||||
listRef={listRef}
|
||||
options={{ query, ...fetchQueryOptions }}
|
||||
items={filteredOptions}
|
||||
fetch={fetchQuery}
|
||||
renderItem={renderItem}
|
||||
onEscape={handleEscapeFromList}
|
||||
heading={showFilterInput ? <Spacer /> : undefined}
|
||||
empty={<Empty />}
|
||||
/>
|
||||
{showFilterInput && (
|
||||
<SearchInput
|
||||
ref={searchInputRef}
|
||||
@@ -327,22 +260,6 @@ const SearchInput = styled(Input)`
|
||||
}
|
||||
`;
|
||||
|
||||
const MobileSearchInput = styled(Input)`
|
||||
/* "none" keeps an auto basis so the input retains its natural height; a
|
||||
flexible/0% basis would collapse it and overlap the list below. */
|
||||
flex: none;
|
||||
margin: 0 6px 6px;
|
||||
|
||||
${NativeInput} {
|
||||
/* 16px avoids iOS zooming the viewport when the input is focused. */
|
||||
font-size: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledScrollable = styled(Scrollable)`
|
||||
max-height: 75vh;
|
||||
`;
|
||||
|
||||
export const StyledButton = styled(Button)`
|
||||
box-shadow: none;
|
||||
text-transform: none;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { throttle } from "es-toolkit/compat";
|
||||
import throttle from "lodash/throttle";
|
||||
import { observer } from "mobx-react";
|
||||
import { MenuIcon } from "outline-icons";
|
||||
import { transparentize } from "polished";
|
||||
@@ -116,23 +116,17 @@ function Header(
|
||||
const Breadcrumbs = styled("div")`
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
padding-inline: 0 8px;
|
||||
padding-right: 8px;
|
||||
display: flex;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
min-width: auto;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Actions = styled(Flex)`
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
min-width: auto;
|
||||
padding-inline: 8px 0;
|
||||
padding-left: 8px;
|
||||
gap: 12px;
|
||||
margin-inline-start: 8px;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
position: unset;
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
|
||||
const Heading = styled.h1<{ as?: string; centered?: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
${(props) => (props.as ? "" : "margin-top: 3vh; font-weight: 600;")}
|
||||
${(props) => (props.as ? "" : "margin-top: 6vh; font-weight: 600;")}
|
||||
${(props) => (props.centered ? "text-align: center;" : "")}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
${(props: { as?: string }) => (props.as ? "" : "margin-top: 6vh;")}
|
||||
`};
|
||||
`;
|
||||
|
||||
export default Heading;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { escapeRegExp } from "es-toolkit/compat";
|
||||
import escapeRegExp from "lodash/escapeRegExp";
|
||||
import * as React from "react";
|
||||
import replace from "string-replace-to-array";
|
||||
import styled from "styled-components";
|
||||
|
||||
@@ -88,7 +88,6 @@ export const CardContent = styled.div`
|
||||
|
||||
// &:after — gradient mask for overflow text
|
||||
export const Card = styled.div<{ fadeOut?: boolean; $borderRadius?: string }>`
|
||||
${sharedVars}
|
||||
backdrop-filter: blur(10px);
|
||||
background: ${s("menuBackground")};
|
||||
padding: 16px;
|
||||
@@ -113,6 +112,7 @@ export const Card = styled.div<{ fadeOut?: boolean; $borderRadius?: string }>`
|
||||
${(props) =>
|
||||
props.fadeOut !== false
|
||||
? `&:after {
|
||||
${sharedVars}
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
|
||||
@@ -26,7 +26,7 @@ const HoverPreviewDocument = React.forwardRef(function HoverPreviewDocument_(
|
||||
<ErrorBoundary showTitle={false} reloadOnChunkMissing={false}>
|
||||
<Flex column gap={2}>
|
||||
<Title>{title}</Title>
|
||||
{lastActivityByViewer && <Info>{lastActivityByViewer}</Info>}
|
||||
<Info>{lastActivityByViewer}</Info>
|
||||
<Description as="div">
|
||||
<React.Suspense fallback={<div />}>
|
||||
<Editor
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { concat } from "es-toolkit/compat";
|
||||
import concat from "lodash/concat";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -6,7 +6,7 @@ import type { EmojiSkinTone } from "@shared/types";
|
||||
import { EmojiCategory, IconType } from "@shared/types";
|
||||
import { getEmojis, getEmojisWithCategory, search } from "@shared/utils/emoji";
|
||||
import Flex from "~/components/Flex";
|
||||
import { EmojiCreateDialog } from "~/components/EmojiDialog/EmojiCreateDialog";
|
||||
import { EmojiCreateDialog } from "~/components/EmojiCreateDialog";
|
||||
import { DisplayCategory } from "../utils";
|
||||
import type { DataNode, EmojiNode } from "./GridTemplate";
|
||||
import GridTemplate from "./GridTemplate";
|
||||
|
||||
@@ -1,25 +1,20 @@
|
||||
import { chunk, compact } from "es-toolkit/compat";
|
||||
import chunk from "lodash/chunk";
|
||||
import compact from "lodash/compact";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { IconType } from "@shared/types";
|
||||
import { IconLibrary } from "@shared/utils/IconLibrary";
|
||||
import { Emoji } from "~/components/Emoji";
|
||||
import Text from "~/components/Text";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { TRANSLATED_CATEGORIES } from "../utils";
|
||||
import Grid from "./Grid";
|
||||
import { IconButton } from "./IconButton";
|
||||
import { CustomEmoji } from "@shared/components/CustomEmoji";
|
||||
|
||||
/**
|
||||
* Desktop: 24px icon/emoji + 4px padding on all sides = 32px button.
|
||||
* Mobile: 32px icon/emoji + 4px padding on all sides = 40px button, so
|
||||
* roughly 8 emojis fit across a typical phone screen.
|
||||
* icon/emoji size is 24px; and we add 4px padding on all sides,
|
||||
*/
|
||||
const BUTTON_SIZE_DESKTOP = 32;
|
||||
const BUTTON_SIZE_MOBILE = 40;
|
||||
const ICON_SIZE_DESKTOP = 24;
|
||||
const ICON_SIZE_MOBILE = 32;
|
||||
const BUTTON_SIZE = 32;
|
||||
|
||||
type OutlineNode = {
|
||||
type: IconType.SVG;
|
||||
@@ -58,11 +53,8 @@ const GridTemplate = (
|
||||
{ width, height, data, empty, onIconSelect }: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) => {
|
||||
const isMobile = useMobile();
|
||||
const buttonSize = isMobile ? BUTTON_SIZE_MOBILE : BUTTON_SIZE_DESKTOP;
|
||||
const iconSize = isMobile ? ICON_SIZE_MOBILE : ICON_SIZE_DESKTOP;
|
||||
// 24px padding for the Grid Container
|
||||
const itemsPerRow = Math.max(1, Math.floor((width - 24) / buttonSize));
|
||||
const itemsPerRow = Math.floor((width - 24) / BUTTON_SIZE);
|
||||
|
||||
const gridItems = compact(
|
||||
data.flatMap((node) => {
|
||||
@@ -92,11 +84,7 @@ const GridTemplate = (
|
||||
onClick={() => onIconSelect({ id: item.name, value: item.name })}
|
||||
style={{ "--delay": `${item.delay}ms` } as React.CSSProperties}
|
||||
>
|
||||
<Icon
|
||||
as={IconLibrary.getComponent(item.name)}
|
||||
color={item.color}
|
||||
size={iconSize}
|
||||
>
|
||||
<Icon as={IconLibrary.getComponent(item.name)} color={item.color}>
|
||||
{item.initial}
|
||||
</Icon>
|
||||
</IconButton>
|
||||
@@ -108,11 +96,7 @@ const GridTemplate = (
|
||||
key={item.id}
|
||||
onClick={() => onIconSelect({ id: item.id, value: item.value })}
|
||||
>
|
||||
<Emoji
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
size={isMobile ? iconSize : undefined}
|
||||
>
|
||||
<Emoji width={24} height={24}>
|
||||
{item.type === IconType.Custom ? (
|
||||
<CustomEmoji value={item.value} title={item.name} />
|
||||
) : (
|
||||
@@ -135,7 +119,7 @@ const GridTemplate = (
|
||||
height={height}
|
||||
data={gridItems}
|
||||
columns={itemsPerRow}
|
||||
itemWidth={buttonSize}
|
||||
itemWidth={BUTTON_SIZE}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import styled from "styled-components";
|
||||
import { breakpoints, s, hover } from "@shared/styles";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
|
||||
export const IconButton = styled(NudeButton)<{ delay?: number }>`
|
||||
@@ -10,9 +10,4 @@ export const IconButton = styled(NudeButton)<{ delay?: number }>`
|
||||
&: ${hover} {
|
||||
background: ${s("listItemHoverBackground")};
|
||||
}
|
||||
|
||||
@media (max-width: ${breakpoints.tablet - 1}px) {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -79,9 +79,7 @@ const IconPicker = ({
|
||||
|
||||
const [activeTab, setActiveTab] = React.useState<TabName>(defaultTab);
|
||||
|
||||
// The Drawer's inner content has 6px padding on each side; subtract it
|
||||
// so the panel doesn't overflow horizontally and itemsPerRow is correct.
|
||||
const popoverWidth = isMobile ? windowWidth - 12 : POPOVER_WIDTH;
|
||||
const popoverWidth = isMobile ? windowWidth : POPOVER_WIDTH;
|
||||
|
||||
const handleTabChange = React.useCallback((value: string) => {
|
||||
setActiveTab(value as TabName);
|
||||
@@ -107,6 +105,7 @@ const IconPicker = ({
|
||||
|
||||
const handleIconChange = React.useCallback(
|
||||
(ic: string) => {
|
||||
setOpen(false);
|
||||
const icType = determineIconType(ic);
|
||||
const finalColor = icType === IconType.SVG ? chosenColor : null;
|
||||
onChange(ic, finalColor);
|
||||
|
||||
+5
-100
@@ -6,15 +6,11 @@ import breakpoint from "styled-components-breakpoint";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
import Fade from "~/components/Fade";
|
||||
import { undraggableOnDesktop } from "~/styles";
|
||||
|
||||
export const NativeTextarea = styled.textarea<{
|
||||
hasIcon?: boolean;
|
||||
hasPrefix?: boolean;
|
||||
$autoSize?: boolean;
|
||||
$minHeight?: string;
|
||||
$maxHeight?: string;
|
||||
}>`
|
||||
border: 0;
|
||||
flex: 1;
|
||||
@@ -24,10 +20,6 @@ export const NativeTextarea = styled.textarea<{
|
||||
background: none;
|
||||
color: ${s("text")};
|
||||
|
||||
${(props) => props.$autoSize && `field-sizing: content;`}
|
||||
${(props) => props.$minHeight && `min-height: ${props.$minHeight};`}
|
||||
${(props) => props.$maxHeight && `max-height: ${props.$maxHeight};`}
|
||||
|
||||
&:disabled,
|
||||
&::placeholder {
|
||||
color: ${s("placeholder")};
|
||||
@@ -95,7 +87,7 @@ export const Wrapper = styled.div<{
|
||||
|
||||
const IconWrapper = styled.span`
|
||||
position: relative;
|
||||
inset-inline-start: 4px;
|
||||
left: 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
`;
|
||||
@@ -104,9 +96,7 @@ export const Outline = styled(Flex)<{
|
||||
margin?: string | number;
|
||||
hasError?: boolean;
|
||||
$focused?: boolean;
|
||||
$round?: boolean;
|
||||
}>`
|
||||
position: relative;
|
||||
flex: 1;
|
||||
margin: ${(props) =>
|
||||
props.margin !== undefined ? props.margin : "0 0 16px"};
|
||||
@@ -119,7 +109,7 @@ export const Outline = styled(Flex)<{
|
||||
: props.$focused
|
||||
? props.theme.inputBorderFocused
|
||||
: props.theme.inputBorder};
|
||||
border-radius: ${(props) => (props.$round ? "16px" : "4px")};
|
||||
border-radius: 4px;
|
||||
font-weight: normal;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
@@ -129,24 +119,6 @@ export const Outline = styled(Flex)<{
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
const CharacterCount = styled.span<{ $warning?: boolean }>`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
inset-inline-end: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
padding: 2px 4px;
|
||||
border-start-start-radius: 0;
|
||||
border-start-end-radius: 0;
|
||||
border-end-end-radius: 0;
|
||||
border-end-start-radius: 2px;
|
||||
background: ${(props) =>
|
||||
props.$warning ? props.theme.warning : props.theme.inputBorder};
|
||||
color: ${(props) =>
|
||||
props.$warning ? props.theme.white : props.theme.textTertiary};
|
||||
pointer-events: none;
|
||||
`;
|
||||
|
||||
export const LabelText = styled.div`
|
||||
font-weight: 500;
|
||||
padding-bottom: 4px;
|
||||
@@ -169,18 +141,6 @@ export interface Props extends Omit<
|
||||
prefix?: React.ReactNode;
|
||||
/** Optional icon that appears inside the input before the textarea */
|
||||
icon?: React.ReactNode;
|
||||
/** Show a character count near the maxLength limit. Always shown for textareas, opt-in for other types. */
|
||||
showCharacterCount?: boolean;
|
||||
/** An optional soft limit below maxLength. When the value exceeds this, the character count is shown in a warning color. */
|
||||
warningLimit?: number;
|
||||
/** For textareas, grow the height to fit content. Use with `maxHeight` to cap the growth. */
|
||||
autoSize?: boolean;
|
||||
/** Minimum height of the textarea as a CSS length value (e.g. "3lh", "80px"). */
|
||||
minHeight?: string;
|
||||
/** Maximum height of the textarea as a CSS length value (e.g. "20lh", "400px"). */
|
||||
maxHeight?: string;
|
||||
/** Whether to use a round border-radius (16px) instead of the default (4px). */
|
||||
round?: boolean;
|
||||
/** Like autoFocus, but also select any text in the input */
|
||||
autoSelect?: boolean;
|
||||
/** Callback is triggered with the CMD+Enter keyboard combo */
|
||||
@@ -197,21 +157,6 @@ function Input(
|
||||
) {
|
||||
const internalRef = React.useRef<HTMLInputElement | HTMLTextAreaElement>();
|
||||
const [focused, setFocused] = React.useState(false);
|
||||
const [charCount, setCharCount] = React.useState(() => {
|
||||
if (typeof props.value === "string") {
|
||||
return props.value.length;
|
||||
}
|
||||
if (typeof props.defaultValue === "string") {
|
||||
return props.defaultValue.length;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof props.value === "string") {
|
||||
setCharCount(props.value.length);
|
||||
}
|
||||
}, [props.value]);
|
||||
|
||||
const handleBlur = (ev: React.SyntheticEvent) => {
|
||||
setFocused(false);
|
||||
@@ -229,15 +174,6 @@ function Input(
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (
|
||||
ev: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => {
|
||||
setCharCount(ev.target.value.length);
|
||||
if (props.onChange) {
|
||||
props.onChange(ev);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (
|
||||
ev: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => {
|
||||
@@ -269,31 +205,14 @@ function Input(
|
||||
short,
|
||||
flex,
|
||||
prefix,
|
||||
round,
|
||||
labelHidden,
|
||||
maxLength,
|
||||
showCharacterCount,
|
||||
warningLimit,
|
||||
autoSize,
|
||||
minHeight,
|
||||
maxHeight,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onChange,
|
||||
onRequestSubmit,
|
||||
children,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const showCharCount =
|
||||
(type === "textarea" || showCharacterCount) &&
|
||||
maxLength !== undefined &&
|
||||
(charCount >= maxLength * 0.9 ||
|
||||
(warningLimit !== undefined && charCount >= warningLimit));
|
||||
|
||||
const overWarningLimit =
|
||||
warningLimit !== undefined && charCount > warningLimit;
|
||||
|
||||
const wrappedLabel = <LabelText>{label}</LabelText>;
|
||||
|
||||
return (
|
||||
@@ -305,7 +224,7 @@ function Input(
|
||||
) : (
|
||||
wrappedLabel
|
||||
))}
|
||||
<Outline $focused={focused} $round={round} margin={margin}>
|
||||
<Outline $focused={focused} margin={margin}>
|
||||
{prefix}
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
{type === "textarea" ? (
|
||||
@@ -318,14 +237,9 @@ function Input(
|
||||
onFocus={handleFocus}
|
||||
hasIcon={!!icon}
|
||||
hasPrefix={!!prefix}
|
||||
$autoSize={autoSize}
|
||||
$minHeight={minHeight}
|
||||
$maxHeight={maxHeight}
|
||||
{...rest}
|
||||
// set it after "rest" to override props from spread.
|
||||
maxLength={maxLength}
|
||||
// set it after "rest" to override "onKeyDown" from prop.
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
) : (
|
||||
<NativeInput
|
||||
@@ -339,19 +253,10 @@ function Input(
|
||||
hasPrefix={!!prefix}
|
||||
type={type}
|
||||
{...rest}
|
||||
// set it after "rest" to override "onKeyDown" and "onChange" from prop.
|
||||
maxLength={maxLength}
|
||||
// set it after "rest" to override "onKeyDown" from prop.
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)}
|
||||
{showCharCount && (
|
||||
<Fade>
|
||||
<CharacterCount $warning={overWarningLimit}>
|
||||
{charCount}/{maxLength}
|
||||
</CharacterCount>
|
||||
</Fade>
|
||||
)}
|
||||
{children}
|
||||
</Outline>
|
||||
</label>
|
||||
|
||||
@@ -36,7 +36,7 @@ const PositionedSwatchButton = styled(SwatchButton)`
|
||||
border: 1px solid ${(props) => props.theme.inputBorder};
|
||||
position: absolute;
|
||||
bottom: 21px;
|
||||
inset-inline-end: 6px;
|
||||
right: 6px;
|
||||
`;
|
||||
|
||||
export default InputColor;
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function InputMemberPermissionSelect(
|
||||
value={value || EmptySelectValue}
|
||||
onChange={onChange}
|
||||
label={t("Permissions")}
|
||||
labelHidden
|
||||
hideLabel
|
||||
nude
|
||||
{...rest}
|
||||
/>
|
||||
|
||||
@@ -4,18 +4,11 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import {
|
||||
isModKey,
|
||||
metaDisplay,
|
||||
shortcutSeparator,
|
||||
} from "@shared/utils/keyboard";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { searchPath } from "~/utils/routeHelpers";
|
||||
import Input from "./Input";
|
||||
import Input, { Outline } from "./Input";
|
||||
|
||||
type Props = {
|
||||
/** A string representing where the search started, for tracking. */
|
||||
@@ -49,7 +42,6 @@ function InputSearchPage({
|
||||
const theme = useTheme();
|
||||
const history = useHistory();
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useMobile();
|
||||
const [isFocused, setFocused, setUnfocused] = useBoolean(false);
|
||||
|
||||
useKeyDown("f", (ev: KeyboardEvent) => {
|
||||
@@ -105,35 +97,16 @@ function InputSearchPage({
|
||||
onBlur={setUnfocused}
|
||||
margin={0}
|
||||
labelHidden
|
||||
>
|
||||
{!isMobile && (
|
||||
<Shortcut $visible={!isFocused && !value && !collectionId}>
|
||||
{metaDisplay}
|
||||
{shortcutSeparator}K
|
||||
</Shortcut>
|
||||
)}
|
||||
</InputMaxWidth>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const InputMaxWidth = styled(Input).attrs({ round: true })`
|
||||
max-width: min(calc(30vw + 20px), 100%);
|
||||
const InputMaxWidth = styled(Input)`
|
||||
max-width: 30vw;
|
||||
|
||||
/* On mobile the input grows to fill the header, so add a gap before the
|
||||
* adjacent action button (e.g. "New doc"). */
|
||||
${breakpoint("mobile", "tablet")`
|
||||
margin-inline-end: 8px;
|
||||
`}
|
||||
`;
|
||||
|
||||
const Shortcut = styled.span<{ $visible: boolean }>`
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
color: ${s("textTertiary")};
|
||||
padding-inline: 0 10px;
|
||||
pointer-events: none;
|
||||
opacity: ${(props) => (props.$visible ? 1 : 0)};
|
||||
transition: opacity 100ms ease-in-out;
|
||||
${Outline} {
|
||||
border-radius: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(InputSearchPage);
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
InputSelectContent,
|
||||
InputSelectItem,
|
||||
InputSelectSeparator,
|
||||
InputSelectHeading,
|
||||
InputSelectTrigger,
|
||||
type TriggerButtonProps,
|
||||
} from "./primitives/InputSelect";
|
||||
@@ -36,13 +35,6 @@ type Separator = {
|
||||
type: "separator";
|
||||
};
|
||||
|
||||
type Heading = {
|
||||
/* Denotes a non-selectable heading rendered above a group of options. */
|
||||
type: "heading";
|
||||
/* Text shown as the heading label. */
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type Item = {
|
||||
/* Denotes a selectable option in the menu. */
|
||||
type: "item";
|
||||
@@ -56,7 +48,7 @@ export type Item = {
|
||||
icon?: React.ReactElement;
|
||||
};
|
||||
|
||||
export type Option = Item | Separator | Heading;
|
||||
export type Option = Item | Separator;
|
||||
|
||||
type Props = Omit<React.HTMLAttributes<HTMLButtonElement>, "onChange"> & {
|
||||
/* Options to display in the select menu. */
|
||||
@@ -68,15 +60,13 @@ type Props = Omit<React.HTMLAttributes<HTMLButtonElement>, "onChange"> & {
|
||||
/* Label for the select menu. */
|
||||
label: string;
|
||||
/* When true, label is hidden in an accessible manner. */
|
||||
labelHidden?: boolean;
|
||||
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;
|
||||
/** Display a tooltip with the descriptive help text about the select menu. */
|
||||
help?: string;
|
||||
/** Render function to override the selected value shown in the trigger. Receives the currently selected option, or undefined when none is selected. */
|
||||
displayValue?: (selectedOption: Item | undefined) => React.ReactNode;
|
||||
} & TriggerButtonProps;
|
||||
|
||||
export const InputSelect = React.forwardRef<HTMLButtonElement, Props>(
|
||||
@@ -86,10 +76,9 @@ export const InputSelect = React.forwardRef<HTMLButtonElement, Props>(
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
labelHidden,
|
||||
hideLabel,
|
||||
short,
|
||||
help,
|
||||
displayValue,
|
||||
...triggerProps
|
||||
} = props;
|
||||
|
||||
@@ -106,34 +95,12 @@ export const InputSelect = React.forwardRef<HTMLButtonElement, Props>(
|
||||
(opt) => opt.type === "item" && !!opt.icon
|
||||
);
|
||||
|
||||
const selectedOption = React.useMemo(
|
||||
() =>
|
||||
localValue
|
||||
? (options.find(
|
||||
(opt) => opt.type === "item" && opt.value === localValue
|
||||
) as Item | undefined)
|
||||
: undefined,
|
||||
[localValue, options]
|
||||
);
|
||||
|
||||
const resolvedDisplayValue = displayValue
|
||||
? displayValue(selectedOption)
|
||||
: undefined;
|
||||
|
||||
const renderOption = React.useCallback(
|
||||
(option: Option, idx: number) => {
|
||||
if (option.type === "separator") {
|
||||
return <InputSelectSeparator key={`separator-${idx}`} />;
|
||||
}
|
||||
|
||||
if (option.type === "heading") {
|
||||
return (
|
||||
<InputSelectHeading key={`heading-${option.label}`}>
|
||||
{option.label}
|
||||
</InputSelectHeading>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<InputSelectItem key={option.value} value={option.value}>
|
||||
<Option option={option} optionsHaveIcon={optionsHaveIcon} />
|
||||
@@ -176,14 +143,13 @@ export const InputSelect = React.forwardRef<HTMLButtonElement, Props>(
|
||||
onChange={onValueChange}
|
||||
placeholder={placeholder}
|
||||
optionsHaveIcon={optionsHaveIcon}
|
||||
resolvedDisplayValue={resolvedDisplayValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper short={short}>
|
||||
<Label text={label} hidden={labelHidden ?? false} help={help} />
|
||||
<Label text={label} hidden={hideLabel ?? false} help={help} />
|
||||
<InputSelectRoot
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
@@ -193,7 +159,6 @@ export const InputSelect = React.forwardRef<HTMLButtonElement, Props>(
|
||||
<InputSelectTrigger
|
||||
ref={ref}
|
||||
placeholder={placeholder}
|
||||
displayValue={resolvedDisplayValue}
|
||||
{...triggerProps}
|
||||
/>
|
||||
<InputSelectContent
|
||||
@@ -214,7 +179,6 @@ InputSelect.displayName = "InputSelect";
|
||||
type MobileSelectProps = Props & {
|
||||
placeholder: string;
|
||||
optionsHaveIcon: boolean;
|
||||
resolvedDisplayValue?: React.ReactNode;
|
||||
};
|
||||
|
||||
const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
|
||||
@@ -224,13 +188,11 @@ const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
labelHidden,
|
||||
hideLabel,
|
||||
disabled,
|
||||
short,
|
||||
placeholder,
|
||||
optionsHaveIcon,
|
||||
displayValue: _displayValue,
|
||||
resolvedDisplayValue,
|
||||
...triggerProps
|
||||
} = props;
|
||||
|
||||
@@ -260,14 +222,6 @@ const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
|
||||
return <InputSelectSeparator key={`separator-${idx}`} />;
|
||||
}
|
||||
|
||||
if (option.type === "heading") {
|
||||
return (
|
||||
<InputSelectHeading key={`heading-${option.label}`}>
|
||||
{option.label}
|
||||
</InputSelectHeading>
|
||||
);
|
||||
}
|
||||
|
||||
const isSelected = option === selectedOption;
|
||||
|
||||
return (
|
||||
@@ -298,7 +252,7 @@ const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Label text={label} hidden={labelHidden ?? false} />
|
||||
<Label text={label} hidden={hideLabel ?? false} />
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>
|
||||
<SelectButton
|
||||
@@ -308,9 +262,7 @@ const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
|
||||
disclosure
|
||||
data-placeholder={selectedOption ? false : ""}
|
||||
>
|
||||
{resolvedDisplayValue !== undefined ? (
|
||||
resolvedDisplayValue
|
||||
) : selectedOption ? (
|
||||
{selectedOption ? (
|
||||
<Option
|
||||
option={selectedOption as Item}
|
||||
optionsHaveIcon={optionsHaveIcon}
|
||||
@@ -413,8 +365,8 @@ const IconWrapper = styled.span`
|
||||
align-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-inline-start: -4px;
|
||||
margin-inline-end: 4px;
|
||||
margin-left: -4px;
|
||||
margin-right: 4px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
@@ -11,7 +11,7 @@ type Props = {
|
||||
shrink?: boolean;
|
||||
} & Pick<
|
||||
React.ComponentProps<typeof InputSelect>,
|
||||
"value" | "onChange" | "disabled" | "labelHidden" | "nude" | "help"
|
||||
"value" | "onChange" | "disabled" | "hideLabel" | "nude" | "help"
|
||||
>;
|
||||
|
||||
export const InputSelectPermission = React.forwardRef<HTMLButtonElement, Props>(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { m } from "framer-motion";
|
||||
import { find } from "es-toolkit/compat";
|
||||
import find from "lodash/find";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { languages, languageOptions } from "@shared/i18n";
|
||||
|
||||
@@ -48,7 +48,6 @@ const Layout = React.forwardRef(function Layout_(
|
||||
<Content
|
||||
auto
|
||||
justify="center"
|
||||
role="main"
|
||||
$isResizing={ui.sidebarIsResizing}
|
||||
$sidebarCollapsed={sidebarCollapsed}
|
||||
$hasSidebar={!!sidebar}
|
||||
@@ -56,7 +55,7 @@ const Layout = React.forwardRef(function Layout_(
|
||||
sidebarCollapsed
|
||||
? undefined
|
||||
: {
|
||||
marginInlineStart: `${ui.sidebarWidth}px`,
|
||||
marginLeft: `${ui.sidebarWidth}px`,
|
||||
}
|
||||
}
|
||||
>
|
||||
@@ -86,21 +85,21 @@ type ContentProps = {
|
||||
const Content = styled(Flex)<ContentProps>`
|
||||
margin: 0;
|
||||
transition: ${(props) =>
|
||||
props.$isResizing ? "none" : `margin-inline-start 100ms ease-out`};
|
||||
props.$isResizing ? "none" : `margin-left 100ms ease-out`};
|
||||
|
||||
@media print {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
${breakpoint("mobile", "tablet")`
|
||||
margin-inline-start: 0 !important;
|
||||
margin-left: 0 !important;
|
||||
`}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
${(props: ContentProps) =>
|
||||
props.$hasSidebar &&
|
||||
props.$sidebarCollapsed &&
|
||||
`margin-inline-start: ${props.theme.sidebarCollapsedWidth}px;`}
|
||||
`margin-left: ${props.theme.sidebarCollapsedWidth}px;`}
|
||||
`};
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as React from "react";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
// oxlint-disable no-explicit-any -- ComponentType<any> is the standard React pattern for generic component constraints
|
||||
export interface LazyComponent<T extends React.ComponentType<any>> {
|
||||
Component: React.LazyExoticComponent<T>;
|
||||
preload: () => Promise<{ default: T }>;
|
||||
|
||||
+12
-174
@@ -2,12 +2,7 @@ import { observer } from "mobx-react";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import type { Keyframes } from "styled-components";
|
||||
import styled, { css, keyframes } from "styled-components";
|
||||
import type {
|
||||
ComponentProps,
|
||||
HTMLAttributes,
|
||||
ReactNode,
|
||||
SyntheticEvent,
|
||||
} from "react";
|
||||
import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
forwardRef,
|
||||
@@ -23,7 +18,6 @@ import { Error as ImageError } from "@shared/editor/components/Image";
|
||||
import {
|
||||
BackIcon,
|
||||
CloseIcon,
|
||||
CommentIcon,
|
||||
CrossIcon,
|
||||
DownloadIcon,
|
||||
LinkIcon,
|
||||
@@ -46,7 +40,7 @@ import CopyToClipboard from "./CopyToClipboard";
|
||||
import { Separator } from "./Actions";
|
||||
import useSwipe from "~/hooks/useSwipe";
|
||||
import { toast } from "sonner";
|
||||
import { findIndex } from "es-toolkit/compat";
|
||||
import { findIndex } from "lodash";
|
||||
import type { LightboxImage } from "@shared/editor/lib/Lightbox";
|
||||
import type { ReactZoomPanPinchRef } from "react-zoom-pan-pinch";
|
||||
import {
|
||||
@@ -61,9 +55,6 @@ import { NodeSelection } from "prosemirror-state";
|
||||
import { ImageSource } from "@shared/editor/lib/FileHelper";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import { HStack } from "./primitives/HStack";
|
||||
import { useDocumentContext } from "./DocumentContext";
|
||||
import LightboxComments from "~/scenes/Document/components/Comments/LightboxComments";
|
||||
import { PortalContext } from "./Portal";
|
||||
|
||||
export enum LightboxStatus {
|
||||
READY_TO_OPEN,
|
||||
@@ -97,15 +88,6 @@ type Animation = {
|
||||
|
||||
const ANIMATION_DURATION = 0.3 * Second.ms;
|
||||
|
||||
/**
|
||||
* Stops a React synthetic event from propagating to ancestor handlers, including
|
||||
* Radix Dialog's outside-interaction detection and the editor's own click
|
||||
* handlers, so the comments sidebar can manage its own focus.
|
||||
*/
|
||||
const stopPropagation = (event: SyntheticEvent) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
type Props = {
|
||||
/** List of allowed images */
|
||||
images: LightboxImage[];
|
||||
@@ -243,11 +225,6 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
|
||||
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const [status, setStatus] = useState<Status>({ lightbox: null, image: null });
|
||||
const [commentsOpen, setCommentsOpen] = useState(false);
|
||||
const [commentsRendered, setCommentsRendered] = useState(false);
|
||||
const [commentsVisible, setCommentsVisible] = useState(false);
|
||||
const [commentsPortalEl, setCommentsPortalEl] =
|
||||
useState<HTMLDivElement | null>(null);
|
||||
const animation = useRef<Animation | null>(null);
|
||||
const finalImage = useRef<{
|
||||
center: { x: number; y: number };
|
||||
@@ -256,10 +233,6 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
|
||||
} | null>(null);
|
||||
const zoomPanPinchRef = useRef<ReactZoomPanPinchRef>(null);
|
||||
const editor = useEditor();
|
||||
const { document: contextDocument } = useDocumentContext();
|
||||
const activeNode = editor?.view?.state?.doc?.nodeAt(activeImage.pos);
|
||||
const canShowComments =
|
||||
!!contextDocument && activeNode?.type.name === "image";
|
||||
|
||||
const currentImageIndex = findIndex(
|
||||
images,
|
||||
@@ -339,19 +312,6 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
|
||||
}
|
||||
}, [status.lightbox]);
|
||||
|
||||
useEffect(() => {
|
||||
if (commentsOpen) {
|
||||
setCommentsRendered(true);
|
||||
const frame = window.requestAnimationFrame(() =>
|
||||
setCommentsVisible(true)
|
||||
);
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}
|
||||
setCommentsVisible(false);
|
||||
const timer = window.setTimeout(() => setCommentsRendered(false), 200);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [commentsOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status.image === ImageStatus.MIN_ZOOM) {
|
||||
// It was observed that focus went to `body` as the zoom out button was disabled
|
||||
@@ -481,10 +441,6 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
|
||||
status.image === ImageStatus.MAX_ZOOM
|
||||
)
|
||||
) {
|
||||
// Refresh the cached natural image position to account for any layout
|
||||
// changes (e.g., the comments sidebar opening) since the image loaded.
|
||||
rememberImagePosition();
|
||||
|
||||
// in lightbox
|
||||
const lightboxImgDOMRect = imgRef.current.getBoundingClientRect();
|
||||
const {
|
||||
@@ -676,30 +632,17 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
|
||||
}, [activeImage, status.lightbox]);
|
||||
|
||||
const handleKeyDown = (ev: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
// Don't intercept keys while typing into an input, textarea, or editor.
|
||||
const target = ev.target as HTMLElement | null;
|
||||
if (
|
||||
target &&
|
||||
target !== ev.currentTarget &&
|
||||
(target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.isContentEditable)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
switch (ev.key) {
|
||||
case "ArrowLeft": {
|
||||
ev.preventDefault();
|
||||
prev();
|
||||
break;
|
||||
}
|
||||
case "ArrowRight": {
|
||||
ev.preventDefault();
|
||||
next();
|
||||
break;
|
||||
}
|
||||
case "Escape": {
|
||||
ev.preventDefault();
|
||||
close();
|
||||
break;
|
||||
}
|
||||
@@ -755,21 +698,14 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
|
||||
onAnimationStart={handleFadeStart}
|
||||
onAnimationEnd={handleFadeEnd}
|
||||
/>
|
||||
<StyledContent
|
||||
onKeyDown={handleKeyDown}
|
||||
ref={contentRef}
|
||||
$commentsOpen={canShowComments && commentsOpen}
|
||||
>
|
||||
<StyledContent onKeyDown={handleKeyDown} ref={contentRef}>
|
||||
<VisuallyHidden.Root>
|
||||
<Dialog.Title>{t("Lightbox")}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{t("View, navigate, or download images in the document")}
|
||||
</Dialog.Description>
|
||||
</VisuallyHidden.Root>
|
||||
<Actions
|
||||
animation={animation.current}
|
||||
$commentsOpen={canShowComments && commentsOpen}
|
||||
>
|
||||
<Actions animation={animation.current}>
|
||||
<Tooltip content={t("Zoom in")} placement="bottom">
|
||||
<ActionButton
|
||||
tabIndex={-1}
|
||||
@@ -852,22 +788,7 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canShowComments && (
|
||||
<Tooltip content={t("Comments")} placement="bottom">
|
||||
<ActionButton
|
||||
tabIndex={-1}
|
||||
onClick={() => setCommentsOpen((open) => !open)}
|
||||
aria-label={t("Comments")}
|
||||
aria-pressed={commentsOpen}
|
||||
size={32}
|
||||
icon={<CommentIcon />}
|
||||
borderOnHover
|
||||
neutral
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Actions>
|
||||
<CloseAction animation={animation.current}>
|
||||
<Separator />
|
||||
<Dialog.Close asChild>
|
||||
<Tooltip content={t("Close")} shortcut="Esc" placement="bottom">
|
||||
<ActionButton
|
||||
@@ -881,7 +802,7 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
|
||||
/>
|
||||
</Tooltip>
|
||||
</Dialog.Close>
|
||||
</CloseAction>
|
||||
</Actions>
|
||||
{currentImageIndex > 0 &&
|
||||
!(
|
||||
status.image === ImageStatus.ZOOMED ||
|
||||
@@ -957,36 +878,12 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
|
||||
status.image === ImageStatus.ZOOMED ||
|
||||
status.image === ImageStatus.MAX_ZOOM
|
||||
) && (
|
||||
<Nav
|
||||
dir="right"
|
||||
$hidden={isIdle}
|
||||
animation={animation.current}
|
||||
$commentsOpen={canShowComments && commentsOpen}
|
||||
>
|
||||
<Nav dir="right" $hidden={isIdle} animation={animation.current}>
|
||||
<NavButton onClick={next} size={32} aria-label={t("Next")}>
|
||||
<NextIcon size={32} />
|
||||
</NavButton>
|
||||
</Nav>
|
||||
)}
|
||||
{canShowComments && commentsRendered && contextDocument && (
|
||||
<CommentsSidebar
|
||||
ref={setCommentsPortalEl}
|
||||
animation={animation.current}
|
||||
$open={commentsVisible}
|
||||
onPointerDown={stopPropagation}
|
||||
onPointerUp={stopPropagation}
|
||||
onMouseDown={stopPropagation}
|
||||
onMouseUp={stopPropagation}
|
||||
onClick={stopPropagation}
|
||||
>
|
||||
<PortalContext.Provider value={commentsPortalEl}>
|
||||
<LightboxComments
|
||||
document={contextDocument}
|
||||
pos={activeImage.pos}
|
||||
/>
|
||||
</PortalContext.Provider>
|
||||
</CommentsSidebar>
|
||||
)}
|
||||
</StyledContent>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
@@ -1193,7 +1090,7 @@ const StyledImg = styled.img<{
|
||||
: ""}
|
||||
`;
|
||||
|
||||
const StyledContent = styled(Dialog.Content)<{ $commentsOpen: boolean }>`
|
||||
const StyledContent = styled(Dialog.Content)`
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: ${depths.modal};
|
||||
@@ -1201,8 +1098,6 @@ const StyledContent = styled(Dialog.Content)<{ $commentsOpen: boolean }>`
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
outline: none;
|
||||
padding-inline-end: ${(props) => (props.$commentsOpen ? "360px" : "0")};
|
||||
transition: padding-inline-end 200ms ease-out;
|
||||
`;
|
||||
|
||||
const ActionButton = styled(Button)`
|
||||
@@ -1211,42 +1106,12 @@ const ActionButton = styled(Button)`
|
||||
|
||||
const Actions = styled(HStack)<{
|
||||
animation: Animation | null;
|
||||
$commentsOpen: boolean;
|
||||
}>`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: ${(props) => (props.$commentsOpen ? "360px" : "44px")};
|
||||
margin: 16px 12px;
|
||||
z-index: ${depths.modal};
|
||||
background: ${(props) => transparentize(0.2, props.theme.background)};
|
||||
backdrop-filter: blur(4px);
|
||||
border-radius: 6px;
|
||||
transition: right 200ms ease-out;
|
||||
|
||||
${(props) =>
|
||||
props.animation === null
|
||||
? css`
|
||||
opacity: 0;
|
||||
`
|
||||
: props.animation.fadeIn
|
||||
? css`
|
||||
animation: ${props.animation.fadeIn.apply()}
|
||||
${props.animation.fadeIn.duration}ms;
|
||||
`
|
||||
: props.animation.fadeOut
|
||||
? css`
|
||||
animation: ${props.animation.fadeOut.apply()}
|
||||
${props.animation.fadeOut.duration}ms;
|
||||
`
|
||||
: ""}
|
||||
`;
|
||||
|
||||
const CloseAction = styled.div<{ animation: Animation | null }>`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
margin: 16px 12px;
|
||||
z-index: ${depths.modal + 1};
|
||||
z-index: ${depths.modal};
|
||||
background: ${(props) => transparentize(0.2, props.theme.background)};
|
||||
backdrop-filter: blur(4px);
|
||||
border-radius: 6px;
|
||||
@@ -1273,16 +1138,10 @@ const Nav = styled.div<{
|
||||
$hidden: boolean;
|
||||
dir: "left" | "right";
|
||||
animation: Animation | null;
|
||||
$commentsOpen?: boolean;
|
||||
}>`
|
||||
position: absolute;
|
||||
${(props) =>
|
||||
props.dir === "left"
|
||||
? "left: 0;"
|
||||
: `right: ${props.$commentsOpen ? "360px" : "0"};`}
|
||||
transition:
|
||||
opacity 500ms ease-in-out,
|
||||
right 200ms ease-out;
|
||||
${(props) => (props.dir === "left" ? "left: 0;" : "right: 0;")}
|
||||
transition: opacity 500ms ease-in-out;
|
||||
z-index: ${depths.modal};
|
||||
${(props) => props.$hidden && "opacity: 0;"}
|
||||
${(props) =>
|
||||
@@ -1324,27 +1183,6 @@ const StyledError = styled(ImageError)<{
|
||||
: ""}
|
||||
`;
|
||||
|
||||
const CommentsSidebar = styled.div<{
|
||||
animation: Animation | null;
|
||||
$open: boolean;
|
||||
}>`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: ${depths.modal};
|
||||
display: flex;
|
||||
transform: translateX(${(props) => (props.$open ? "0" : "100%")});
|
||||
transition: transform 200ms ease-out;
|
||||
${(props) =>
|
||||
props.animation?.fadeOut
|
||||
? css`
|
||||
animation: ${props.animation.fadeOut.apply()}
|
||||
${props.animation.fadeOut.duration}ms;
|
||||
`
|
||||
: ""}
|
||||
`;
|
||||
|
||||
const NavButton = styled(NudeButton)`
|
||||
margin: 16px;
|
||||
opacity: 0.75;
|
||||
|
||||
@@ -203,7 +203,7 @@ const Wrapper = styled.a<{
|
||||
`;
|
||||
|
||||
const Image = styled(Flex)`
|
||||
padding-inline-end: 8px;
|
||||
padding: 0 8px 0 0;
|
||||
max-height: 32px;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { times } from "es-toolkit/compat";
|
||||
import times from "lodash/times";
|
||||
import styled from "styled-components";
|
||||
import Fade from "~/components/Fade";
|
||||
import Flex from "~/components/Flex";
|
||||
|
||||
@@ -47,7 +47,7 @@ export const ContextMenu = observer(
|
||||
onClose?.();
|
||||
}
|
||||
},
|
||||
[onOpen, onClose]
|
||||
[open, onOpen, onClose]
|
||||
);
|
||||
|
||||
const enablePointerEvents = React.useCallback(() => {
|
||||
|
||||
@@ -33,12 +33,6 @@ type Props = {
|
||||
align?: "start" | "end";
|
||||
/** ARIA label for the menu */
|
||||
ariaLabel: string;
|
||||
/**
|
||||
* Whether the menu should lock page scroll and trap focus while open.
|
||||
* Defaults to true. Set to false to avoid the scrollbar-removal layout
|
||||
* shift when the menu lives inside a scrollable container.
|
||||
*/
|
||||
modal?: boolean;
|
||||
/** Additional component to display at the bottom of the top-level menu */
|
||||
append?: React.ReactNode;
|
||||
/** Callback when menu is opened */
|
||||
@@ -56,7 +50,6 @@ export const DropdownMenu = observer(
|
||||
children,
|
||||
align = "start",
|
||||
ariaLabel,
|
||||
modal = true,
|
||||
append,
|
||||
onOpen,
|
||||
onClose,
|
||||
@@ -123,7 +116,7 @@ export const DropdownMenu = observer(
|
||||
|
||||
return (
|
||||
<MenuProvider variant="dropdown">
|
||||
<Menu open={open} onOpenChange={handleOpenChange} modal={modal}>
|
||||
<Menu open={open} onOpenChange={handleOpenChange}>
|
||||
<MenuTrigger ref={ref} aria-label={ariaLabel} {...rest}>
|
||||
{children}
|
||||
</MenuTrigger>
|
||||
|
||||
@@ -42,14 +42,13 @@ export function toMenuItems(items: MenuItem[]) {
|
||||
case "button":
|
||||
return (
|
||||
<MenuButton
|
||||
key={`${item.type}-${index}`}
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
label={item.title as string}
|
||||
icon={icon}
|
||||
disabled={item.disabled}
|
||||
tooltip={item.tooltip}
|
||||
selected={item.selected}
|
||||
dangerous={item.dangerous}
|
||||
shortcut={item.shortcut}
|
||||
onClick={item.onClick}
|
||||
/>
|
||||
);
|
||||
@@ -57,11 +56,10 @@ export function toMenuItems(items: MenuItem[]) {
|
||||
case "route":
|
||||
return (
|
||||
<MenuInternalLink
|
||||
key={`${item.type}-${index}`}
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
label={item.title as string}
|
||||
icon={icon}
|
||||
disabled={item.disabled}
|
||||
shortcut={item.shortcut}
|
||||
to={item.to}
|
||||
/>
|
||||
);
|
||||
@@ -69,11 +67,10 @@ export function toMenuItems(items: MenuItem[]) {
|
||||
case "link":
|
||||
return (
|
||||
<MenuExternalLink
|
||||
key={`${item.type}-${index}`}
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
label={item.title as string}
|
||||
icon={icon}
|
||||
disabled={item.disabled}
|
||||
shortcut={item.shortcut}
|
||||
href={typeof item.href === "string" ? item.href : item.href.url}
|
||||
target={
|
||||
typeof item.href === "string" ? undefined : item.href.target
|
||||
@@ -95,7 +92,7 @@ export function toMenuItems(items: MenuItem[]) {
|
||||
};
|
||||
|
||||
return (
|
||||
<SubMenu key={`${item.type}-${index}`}>
|
||||
<SubMenu key={`${item.type}-${item.title}-${index}`}>
|
||||
<SubMenuTrigger
|
||||
label={item.title as string}
|
||||
icon={icon}
|
||||
@@ -121,7 +118,7 @@ export function toMenuItems(items: MenuItem[]) {
|
||||
|
||||
return (
|
||||
<MenuGroup
|
||||
key={`${item.type}-${index}`}
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
label={item.title as string}
|
||||
items={groupItems}
|
||||
/>
|
||||
@@ -171,7 +168,7 @@ export function toMobileMenuItems(
|
||||
case "button":
|
||||
return (
|
||||
<Components.MenuButton
|
||||
key={`${item.type}-${index}`}
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
disabled={item.disabled}
|
||||
$dangerous={item.dangerous}
|
||||
onClick={(e) => {
|
||||
@@ -192,7 +189,7 @@ export function toMobileMenuItems(
|
||||
case "route":
|
||||
return (
|
||||
<Components.MenuInternalLink
|
||||
key={`${item.type}-${index}`}
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
to={item.to}
|
||||
disabled={item.disabled}
|
||||
onClick={closeMenu}
|
||||
@@ -205,7 +202,7 @@ export function toMobileMenuItems(
|
||||
case "link":
|
||||
return (
|
||||
<Components.MenuExternalLink
|
||||
key={`${item.type}-${index}`}
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
href={typeof item.href === "string" ? item.href : item.href.url}
|
||||
target={
|
||||
typeof item.href === "string" ? undefined : item.href.target
|
||||
@@ -231,7 +228,7 @@ export function toMobileMenuItems(
|
||||
|
||||
return (
|
||||
<Components.MenuButton
|
||||
key={`${item.type}-${index}`}
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
openSubmenu(item.title as string);
|
||||
@@ -256,7 +253,7 @@ export function toMobileMenuItems(
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={`${item.type}-${index}`}>
|
||||
<div key={`${item.type}-${item.title}-${index}`}>
|
||||
<Components.MenuHeader>{item.title}</Components.MenuHeader>
|
||||
{groupItems}
|
||||
</div>
|
||||
|
||||
+19
-27
@@ -15,8 +15,8 @@ import usePrevious from "~/hooks/usePrevious";
|
||||
import { fadeAndScaleIn, fadeIn } from "~/styles/animations";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import ErrorBoundary from "./ErrorBoundary";
|
||||
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
|
||||
import Tooltip from "./Tooltip";
|
||||
import { useDialogContext } from "~/components/DialogContext";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -31,7 +31,7 @@ type Props = {
|
||||
const Modal: React.FC<Props> = ({
|
||||
children,
|
||||
isOpen,
|
||||
title,
|
||||
title = "Untitled",
|
||||
style,
|
||||
width,
|
||||
height,
|
||||
@@ -40,43 +40,42 @@ const Modal: React.FC<Props> = ({
|
||||
const wasOpen = usePrevious(isOpen);
|
||||
const isMobile = useMobile();
|
||||
const { t } = useTranslation();
|
||||
const resolvedTitle = title ?? t("Untitled");
|
||||
const dialog = useDialogContext();
|
||||
|
||||
const onClose = React.useCallback(() => {
|
||||
dialog.setAnimating(false); // Reset
|
||||
onRequestClose();
|
||||
}, [dialog, onRequestClose]);
|
||||
|
||||
if (!isOpen && !wasOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<Dialog.Root
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && onRequestClose()}
|
||||
>
|
||||
<Dialog.Portal>
|
||||
<StyledOverlay />
|
||||
<Dialog.Title asChild>
|
||||
<VisuallyHidden.Root>{title}</VisuallyHidden.Root>
|
||||
</Dialog.Title>
|
||||
<StyledContent
|
||||
onEscapeKeyDown={onClose}
|
||||
onPointerDownOutside={onClose}
|
||||
onEscapeKeyDown={onRequestClose}
|
||||
onPointerDownOutside={onRequestClose}
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
{isMobile ? (
|
||||
<Mobile>
|
||||
<MobileContent>
|
||||
<Centered onClick={(ev) => ev.stopPropagation()} column>
|
||||
<Dialog.Title asChild>
|
||||
{title && (
|
||||
<Text size="xlarge" weight="bold">
|
||||
{resolvedTitle}
|
||||
{title}
|
||||
</Text>
|
||||
</Dialog.Title>
|
||||
)}
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
</Centered>
|
||||
</MobileContent>
|
||||
<Close onClick={onClose}>
|
||||
<Close onClick={onRequestClose}>
|
||||
<CloseIcon size={32} />
|
||||
</Close>
|
||||
<Back onClick={onClose}>
|
||||
<Back onClick={onRequestClose}>
|
||||
<BackIcon size={32} />
|
||||
<Text>{t("Back")} </Text>
|
||||
</Back>
|
||||
@@ -90,20 +89,13 @@ const Modal: React.FC<Props> = ({
|
||||
column
|
||||
reverse
|
||||
>
|
||||
<DesktopContent
|
||||
style={style}
|
||||
topShadow
|
||||
overflow={dialog.animating ? "hidden" : undefined}
|
||||
onAnimationEnd={() => dialog.setAnimating(false)}
|
||||
>
|
||||
<DesktopContent style={style} topShadow>
|
||||
<ErrorBoundary component="div">{children}</ErrorBoundary>
|
||||
</DesktopContent>
|
||||
<Header>
|
||||
<Dialog.Title asChild>
|
||||
<Text size="large">{resolvedTitle}</Text>
|
||||
</Dialog.Title>
|
||||
{title && <Text size="large">{title}</Text>}
|
||||
<Tooltip content={t("Close")} shortcut="Esc">
|
||||
<NudeButton onClick={onClose}>
|
||||
<NudeButton onClick={onRequestClose}>
|
||||
<CloseIcon />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { DocumentPermission } from "@shared/types";
|
||||
import type Notification from "~/models/Notification";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import Button from "../Button";
|
||||
import Flex from "../Flex";
|
||||
import { SplitButton } from "../primitives/SplitButton";
|
||||
|
||||
type Props = {
|
||||
notification: Notification;
|
||||
};
|
||||
|
||||
function AccessRequestActions({ notification }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [processing, setProcessing] = React.useState(false);
|
||||
const [selectedPermission, setSelectedPermission] =
|
||||
React.useState<DocumentPermission>(DocumentPermission.Read);
|
||||
|
||||
const permissionOptions = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
label: t("View only"),
|
||||
value: DocumentPermission.Read,
|
||||
description: t("Can view the document"),
|
||||
},
|
||||
{
|
||||
label: t("Can edit"),
|
||||
value: DocumentPermission.ReadWrite,
|
||||
description: t("Can view and edit the document"),
|
||||
},
|
||||
{
|
||||
label: t("Manage"),
|
||||
value: DocumentPermission.Admin,
|
||||
description: t("Full access including sharing"),
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const selectedLabel = permissionOptions.find(
|
||||
(o) => o.value === selectedPermission
|
||||
)?.label;
|
||||
|
||||
const handleApprove = React.useCallback(async () => {
|
||||
if (!notification.actor || processing) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessing(true);
|
||||
try {
|
||||
await client.post("/accessRequests.approve", {
|
||||
id: notification.accessRequestId,
|
||||
permission: selectedPermission,
|
||||
});
|
||||
notification.accessRequestStatus = "approved";
|
||||
toast.success(
|
||||
t(`Permissions for {{ userName }} updated`, {
|
||||
userName: notification.actor?.name,
|
||||
})
|
||||
);
|
||||
void notification.markAsRead();
|
||||
} catch {
|
||||
toast.error(t("Failed to approve access request"));
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
}, [notification, processing, selectedPermission, t]);
|
||||
|
||||
const handleDismiss = React.useCallback(async () => {
|
||||
if (processing) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessing(true);
|
||||
try {
|
||||
await client.post("/accessRequests.dismiss", {
|
||||
id: notification.accessRequestId,
|
||||
});
|
||||
notification.accessRequestStatus = "dismissed";
|
||||
toast.success(t("Access request dismissed"));
|
||||
void notification.markAsRead();
|
||||
} catch {
|
||||
toast.error(t("Failed to dismiss access request"));
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
}, [notification, processing, t]);
|
||||
|
||||
const handleContainerClick = React.useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Container gap={8} align="center" onClick={handleContainerClick}>
|
||||
<Button onClick={handleDismiss} disabled={processing} neutral>
|
||||
{t("Dismiss")}
|
||||
</Button>
|
||||
<SplitButton
|
||||
options={permissionOptions}
|
||||
selectedValue={selectedPermission}
|
||||
onSelect={(value) => setSelectedPermission(value as DocumentPermission)}
|
||||
onClick={handleApprove}
|
||||
disabled={processing}
|
||||
>
|
||||
{t("Approve")} · {selectedLabel}
|
||||
</SplitButton>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const Container = styled(Flex)`
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
export default observer(AccessRequestActions);
|
||||
@@ -5,7 +5,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { s, hover, truncateMultiline } from "@shared/styles";
|
||||
import { NotificationEventType } from "@shared/types";
|
||||
import type Notification from "~/models/Notification";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { Avatar, AvatarSize, AvatarVariant } from "../Avatar";
|
||||
@@ -22,7 +21,6 @@ import {
|
||||
notificationArchive,
|
||||
} from "~/actions/definitions/notifications";
|
||||
import { NotificationSection } from "~/actions/sections";
|
||||
import AccessRequestActions from "./AccessRequestActions";
|
||||
|
||||
const CommentEditor = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/Comments/CommentEditor")
|
||||
@@ -39,10 +37,6 @@ function NotificationListItem({ notification, onNavigate }: Props) {
|
||||
const collectionId = notification.document?.collectionId;
|
||||
const collection = collectionId ? collections.get(collectionId) : undefined;
|
||||
|
||||
const isAccessRequestPending =
|
||||
notification.event === NotificationEventType.RequestDocumentAccess &&
|
||||
notification.accessRequestStatus === "pending";
|
||||
|
||||
const handleClick: React.MouseEventHandler<HTMLAnchorElement> = (event) => {
|
||||
if (event.altKey) {
|
||||
event.preventDefault();
|
||||
@@ -92,9 +86,6 @@ function NotificationListItem({ notification, onNavigate }: Props) {
|
||||
defaultValue={toJS(notification.comment.data)}
|
||||
/>
|
||||
)}
|
||||
{isAccessRequestPending && (
|
||||
<AccessRequestActions notification={notification} />
|
||||
)}
|
||||
</Flex>
|
||||
{notification.viewedAt ? null : <UnreadBadge />}
|
||||
</Container>
|
||||
@@ -126,12 +117,11 @@ const StyledAvatar = styled(Avatar).attrs({
|
||||
|
||||
const Container = styled(Flex)<{ $unread: boolean }>`
|
||||
position: relative;
|
||||
padding-block: 8px;
|
||||
padding-inline: 12px 40px;
|
||||
padding: 8px 12px;
|
||||
padding-right: 40px;
|
||||
border-radius: 4px;
|
||||
|
||||
${StyledLink}[data-state=open] &,
|
||||
&:has([data-state="open"]),
|
||||
&:${hover},
|
||||
&:active {
|
||||
background: ${s("listItemHoverBackground")};
|
||||
|
||||
@@ -6,7 +6,6 @@ import styled from "styled-components";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import Notification, { type NotificationFilter } from "~/models/Notification";
|
||||
import { markNotificationsAsRead } from "~/actions/definitions/notifications";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import NotificationMenu from "~/menus/NotificationMenu";
|
||||
import Empty from "../Empty";
|
||||
@@ -84,7 +83,6 @@ function Notifications(
|
||||
) {
|
||||
const { notifications } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useMobile();
|
||||
const [filter, setFilter] = React.useState<NotificationFilter>("all");
|
||||
|
||||
const filterOptions = React.useMemo<Option[]>(
|
||||
@@ -112,9 +110,8 @@ function Notifications(
|
||||
<Flex
|
||||
style={{
|
||||
width: "100%",
|
||||
minHeight: isMobile ? "75vh" : "300px",
|
||||
maxHeight:
|
||||
"min(75vh, calc(var(--radix-popover-content-available-height, 75vh) - 44px))",
|
||||
height:
|
||||
"min(300px, calc(var(--radix-popover-content-available-height) - 44px))",
|
||||
}}
|
||||
column
|
||||
>
|
||||
@@ -125,7 +122,7 @@ function Notifications(
|
||||
<HStack>
|
||||
<StyledInputSelect
|
||||
label={t("Filter")}
|
||||
labelHidden
|
||||
hideLabel
|
||||
options={filterOptions}
|
||||
value={filter}
|
||||
onChange={(value) => setFilter(value as NotificationFilter)}
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "~/components/primitives/Drawer";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "~/components/primitives/Popover";
|
||||
import useConsumeQueryParam from "~/hooks/useConsumeQueryParam";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Notifications from "./Notifications";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const Notifications = lazyWithRetry(() => import("./Notifications"));
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -24,11 +18,8 @@ type Props = {
|
||||
const NotificationsPopover: React.FC = ({ children }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { notifications } = useStores();
|
||||
const shouldOpen = useConsumeQueryParam("notifications") === "true";
|
||||
const [open, setOpen] = useState(shouldOpen);
|
||||
const isMobile = useMobile();
|
||||
const [open, setOpen] = useState(false);
|
||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||
const drawerContentRef = useRef<React.ElementRef<typeof DrawerContent>>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void notifications.fetchPage({ archived: false });
|
||||
@@ -49,40 +40,6 @@ const NotificationsPopover: React.FC = ({ children }: Props) => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const enablePointerEvents = useCallback(() => {
|
||||
if (drawerContentRef.current) {
|
||||
drawerContentRef.current.style.pointerEvents = "auto";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const disablePointerEvents = useCallback(() => {
|
||||
if (drawerContentRef.current) {
|
||||
drawerContentRef.current.style.pointerEvents = "none";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const notificationsList = (
|
||||
<Notifications onRequestClose={handleRequestClose} ref={scrollableRef} />
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>{children}</DrawerTrigger>
|
||||
<DrawerContent
|
||||
ref={drawerContentRef}
|
||||
aria-label={t("Notifications")}
|
||||
aria-describedby={undefined}
|
||||
onAnimationStart={disablePointerEvents}
|
||||
onAnimationEnd={enablePointerEvents}
|
||||
>
|
||||
<DrawerTitle hidden>{t("Notifications")}</DrawerTitle>
|
||||
{notificationsList}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger>{children}</PopoverTrigger>
|
||||
@@ -94,7 +51,12 @@ const NotificationsPopover: React.FC = ({ children }: Props) => {
|
||||
scrollable={false}
|
||||
shrink
|
||||
>
|
||||
{notificationsList}
|
||||
<Suspense fallback={null}>
|
||||
<Notifications
|
||||
onRequestClose={handleRequestClose}
|
||||
ref={scrollableRef}
|
||||
/>
|
||||
</Suspense>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ import Switch from "../Switch";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import { InputClientType } from "./InputClientType";
|
||||
|
||||
export type FormData = {
|
||||
export interface FormData {
|
||||
name: string;
|
||||
developerName: string;
|
||||
developerUrl: string;
|
||||
@@ -22,7 +22,7 @@ export type FormData = {
|
||||
redirectUris: string[];
|
||||
published: boolean;
|
||||
clientType: "confidential" | "public";
|
||||
};
|
||||
}
|
||||
|
||||
export const OAuthClientForm = observer(function OAuthClientForm_({
|
||||
handleSubmit,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user