mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
Merge remote-tracking branch 'origin/main' into tommoor/new-permissions
# Conflicts: # app/components/Sharing/Collection/AccessControlList.tsx # app/scenes/Document/components/Header.tsx # shared/i18n/locales/en_US/translation.json
This commit is contained in:
@@ -24,7 +24,18 @@
|
||||
"include": ["SOURCE_COMMIT", "SOURCE_VERSION"]
|
||||
}
|
||||
],
|
||||
"tsconfig-paths-module-resolver"
|
||||
[
|
||||
"module-resolver",
|
||||
{
|
||||
"root": ["./"],
|
||||
"alias": {
|
||||
"@server": "./server",
|
||||
"@shared": "./shared",
|
||||
"~": "./app",
|
||||
"plugins": "./plugins"
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"env": {
|
||||
"production": {
|
||||
|
||||
@@ -218,6 +218,12 @@ 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,6 +17,8 @@ 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
|
||||
@@ -29,6 +31,15 @@ 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
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
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,6 +31,12 @@ 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,
|
||||
+36
-96
@@ -18,69 +18,13 @@ 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@v2
|
||||
@@ -90,6 +34,7 @@ jobs:
|
||||
config:
|
||||
- '.github/**'
|
||||
- 'vite.config.ts'
|
||||
- 'vitest.config.ts'
|
||||
server:
|
||||
- 'server/**'
|
||||
- 'shared/**'
|
||||
@@ -100,9 +45,36 @@ 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: [setup, changes]
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.app == 'true' || needs.changes.outputs.config == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
@@ -110,21 +82,11 @@ jobs:
|
||||
test-group: [app, shared]
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
- name: Restore node_modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
|
||||
- uses: ./.github/actions/install
|
||||
- run: yarn test:${{ matrix.test-group }}
|
||||
|
||||
test-server:
|
||||
needs: [setup, changes]
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.server == 'true' || needs.changes.outputs.config == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
@@ -148,40 +110,18 @@ jobs:
|
||||
|
||||
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') }}
|
||||
- uses: ./.github/actions/install
|
||||
- run: yarn sequelize db:migrate
|
||||
- name: Run server tests
|
||||
run: |
|
||||
TESTFILES=$(find . -name "*.test.ts" -path "*/server/*" | sort | awk "NR % 4 == (${{ matrix.shard }} - 1)")
|
||||
yarn test --maxWorkers=2 $TESTFILES
|
||||
run: yarn test:server --maxWorkers=2 --shard=${{ matrix.shard }}/4
|
||||
|
||||
bundle-size:
|
||||
needs: [setup, types, changes]
|
||||
needs: changes
|
||||
if: ${{ (needs.changes.outputs.app == 'true' || needs.changes.outputs.config == 'true') && github.repository == 'outline/outline' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
- name: Restore node_modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
|
||||
- uses: ./.github/actions/install
|
||||
- name: Set environment to production
|
||||
run: echo "NODE_ENV=production" >> $GITHUB_ENV
|
||||
- run: yarn vite:build
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
name: Docker Build Check
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "Dockerfile"
|
||||
- "Dockerfile.base"
|
||||
pull_request:
|
||||
paths:
|
||||
- "Dockerfile"
|
||||
- "Dockerfile.base"
|
||||
|
||||
env:
|
||||
BASE_IMAGE_NAME: outline-base
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubicloud-standard-8
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver: docker
|
||||
|
||||
- name: Build base image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.base
|
||||
tags: ${{ env.BASE_IMAGE_NAME }}:latest
|
||||
push: false
|
||||
|
||||
- name: Build main image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
push: false
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}:latest
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Docker
|
||||
name: Publish build
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -17,11 +17,11 @@ jobs:
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Docker base meta
|
||||
id: base_meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
${{ env.BASE_IMAGE_NAME }}
|
||||
@@ -30,14 +30,14 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push base image
|
||||
id: base_build
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.base
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
${{ env.IMAGE_NAME }}
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
@@ -96,11 +96,11 @@ jobs:
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Docker base meta
|
||||
id: base_meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
${{ env.BASE_IMAGE_NAME }}
|
||||
@@ -109,14 +109,14 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push base image
|
||||
id: base_build
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.base
|
||||
@@ -130,7 +130,7 @@ jobs:
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
${{ env.IMAGE_NAME }}
|
||||
@@ -140,7 +140,7 @@ jobs:
|
||||
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
@@ -173,6 +173,7 @@ jobs:
|
||||
needs:
|
||||
- build-amd
|
||||
- build-arm
|
||||
environment: dockerhub
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
@@ -182,17 +183,17 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
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@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
|
||||
@@ -1,63 +0,0 @@
|
||||
{
|
||||
"workerIdleMemoryLimit": "0.75",
|
||||
"maxWorkers": "50%",
|
||||
"transformIgnorePatterns": ["node_modules/(?!(franc|trigram-utils)/)"],
|
||||
"projects": [
|
||||
{
|
||||
"displayName": "server",
|
||||
"roots": ["<rootDir>/server", "<rootDir>/plugins"],
|
||||
"moduleNameMapper": {
|
||||
"^@server/(.*)$": "<rootDir>/server/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1"
|
||||
},
|
||||
"setupFiles": [
|
||||
"<rootDir>/__mocks__/console.js",
|
||||
"<rootDir>/server/test/setupMocks.js"
|
||||
],
|
||||
"setupFilesAfterEnv": ["<rootDir>/server/test/setup.ts"],
|
||||
"globalTeardown": "<rootDir>/server/test/globalTeardown.js",
|
||||
"testEnvironment": "node"
|
||||
},
|
||||
{
|
||||
"displayName": "app",
|
||||
"roots": ["<rootDir>/app"],
|
||||
"moduleNameMapper": {
|
||||
"^~/(.*)$": "<rootDir>/app/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
|
||||
},
|
||||
"modulePaths": ["<rootDir>/app"],
|
||||
"setupFiles": ["<rootDir>/__mocks__/window.js"],
|
||||
"setupFilesAfterEnv": ["<rootDir>/app/test/setup.ts"],
|
||||
"testEnvironment": "jsdom",
|
||||
"testEnvironmentOptions": {
|
||||
"url": "http://localhost"
|
||||
}
|
||||
},
|
||||
{
|
||||
"displayName": "shared-node",
|
||||
"roots": ["<rootDir>/shared"],
|
||||
"moduleNameMapper": {
|
||||
"^@server/(.*)$": "<rootDir>/server/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1"
|
||||
},
|
||||
"setupFiles": ["<rootDir>/__mocks__/console.js"],
|
||||
"setupFilesAfterEnv": ["<rootDir>/shared/test/setup.ts"],
|
||||
"testEnvironment": "node"
|
||||
},
|
||||
{
|
||||
"displayName": "shared-jsdom",
|
||||
"roots": ["<rootDir>/shared"],
|
||||
"moduleNameMapper": {
|
||||
"^~/(.*)$": "<rootDir>/app/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
|
||||
},
|
||||
"setupFiles": ["<rootDir>/__mocks__/window.js"],
|
||||
"testEnvironment": "jsdom",
|
||||
"testEnvironmentOptions": {
|
||||
"url": "http://localhost"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+8
-2
@@ -31,7 +31,7 @@
|
||||
"no-empty-pattern": "error",
|
||||
"no-empty-static-block": "error",
|
||||
"no-ex-assign": "error",
|
||||
"no-explicit-any": "warn",
|
||||
"no-explicit-any": "error",
|
||||
"no-extra-boolean-cast": "error",
|
||||
"no-fallthrough": "error",
|
||||
"no-func-assign": "error",
|
||||
@@ -73,9 +73,13 @@
|
||||
"eqeqeq": "error",
|
||||
"curly": "error",
|
||||
"no-console": "error",
|
||||
"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",
|
||||
{
|
||||
@@ -87,6 +91,8 @@
|
||||
"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",
|
||||
"no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
|
||||
+9
-1
@@ -1,6 +1,14 @@
|
||||
nodeLinker: node-modules
|
||||
|
||||
npmMinimalAgeGate: 86400
|
||||
enableScripts: false
|
||||
|
||||
npmMinimalAgeGate: 4320
|
||||
|
||||
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, Jest, Prettier, Oxlint configurations
|
||||
- **Various config files** - TypeScript, Vite, Vitest, Prettier, Oxlint configurations
|
||||
|
||||
Refer to /docs/ARCHITECTURE.md for detailed architecture documentation.
|
||||
|
||||
@@ -46,6 +46,18 @@ 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.
|
||||
@@ -140,7 +152,7 @@ yarn sequelize migration:create --name=add-field-to-table
|
||||
|
||||
## Testing
|
||||
|
||||
- Run tests with Jest:
|
||||
- Run tests with Vitest:
|
||||
|
||||
```bash
|
||||
# Run a specific test file (preferred)
|
||||
@@ -188,6 +200,7 @@ 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:22.21.0-slim AS runner
|
||||
FROM node:24.15.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:22.21.0 AS deps
|
||||
FROM node:24.15.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.6.1
|
||||
Licensed Work: Outline 1.7.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-03-18
|
||||
Change Date: 2030-05-04
|
||||
|
||||
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).
|
||||
|
||||
# Development
|
||||
# Contributing
|
||||
|
||||
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.
|
||||
> **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.
|
||||
|
||||
## Contributing
|
||||
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.
|
||||
|
||||
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:
|
||||
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 and other issues listed on GitHub
|
||||
- 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.
|
||||
|
||||
## 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 [Jest](https://facebook.github.io/jest/) and add a file with `.test.ts` extension next to the tested code.
|
||||
To add new tests, write your tests with [Vitest](https://vitest.dev/) 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 jest:
|
||||
frontend and backend tests directly with vitest:
|
||||
|
||||
```shell
|
||||
# To run backend tests
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"extends": ["../.oxlintrc.json"],
|
||||
"ignorePatterns": ["**/*.d.ts"],
|
||||
"plugins": ["oxc", "eslint", "typescript", "react"],
|
||||
"overrides": [
|
||||
{
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { PlusIcon, TrashIcon } from "outline-icons";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { CopyIcon, PlusIcon, TrashIcon } from "outline-icons";
|
||||
import { toast } from "sonner";
|
||||
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";
|
||||
@@ -25,6 +28,22 @@ 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 uniqBy from "lodash/uniqBy";
|
||||
import { capitalize, uniqBy } from "es-toolkit/compat";
|
||||
import {
|
||||
DownloadIcon,
|
||||
DuplicateIcon,
|
||||
@@ -39,11 +39,13 @@ 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";
|
||||
@@ -79,7 +81,6 @@ import {
|
||||
trashPath,
|
||||
documentEditPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
import capitalize from "lodash/capitalize";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import type {
|
||||
Action,
|
||||
@@ -127,7 +128,7 @@ export const openDocument = createActionWithChildren({
|
||||
color={item.color ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<DocumentIcon />
|
||||
<DocumentIcon outline={item.isDraft} />
|
||||
),
|
||||
section: DocumentSection,
|
||||
to: item.url,
|
||||
@@ -737,8 +738,6 @@ 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"));
|
||||
}
|
||||
@@ -970,7 +969,11 @@ export const openDocumentInDesktop = createAction({
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
return (
|
||||
isCloudHosted && (isMac || isWindows) && !!document && !document.isDeleted
|
||||
isCloudHosted &&
|
||||
(isMac || isWindows) &&
|
||||
!!document &&
|
||||
!document.isDeleted &&
|
||||
!isMobile()
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
@@ -988,9 +991,14 @@ export const presentDocument = createAction({
|
||||
analyticsName: "Present document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <EmbedIcon />,
|
||||
shortcut: ["Meta+Alt+p"],
|
||||
visible: ({ activeDocumentId }) => !!activeDocumentId,
|
||||
shortcut: ["Control+Alt+KeyP"],
|
||||
visible: ({ activeDocumentId }) => !!activeDocumentId && !isMobile(),
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
if (stores.ui.presentationData) {
|
||||
stores.ui.setPresentingDocument(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
|
||||
@@ -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/EmojiCreateDialog";
|
||||
import { EmojiCreateDialog } from "~/components/EmojiDialog/EmojiCreateDialog";
|
||||
|
||||
export const createEmoji = createAction({
|
||||
name: ({ t }) => `${t("New emoji")}…`,
|
||||
|
||||
@@ -241,7 +241,10 @@ export const logout = createAction({
|
||||
section: NavigationSection,
|
||||
icon: <LogoutIcon />,
|
||||
perform: async () => {
|
||||
await stores.auth.logout({ userInitiated: true });
|
||||
await stores.auth.logout({
|
||||
userInitiated: true,
|
||||
clearCache: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ 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";
|
||||
@@ -200,8 +201,6 @@ 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"));
|
||||
}
|
||||
|
||||
@@ -17,8 +17,12 @@ import Analytics from "~/utils/Analytics";
|
||||
import history from "~/utils/history";
|
||||
import type { Action as KbarAction } from "kbar";
|
||||
|
||||
export function resolve<T>(value: any, context: ActionContext): T {
|
||||
return typeof value === "function" ? value(context) : value;
|
||||
export function resolve<T>(value: unknown, context: ActionContext): T {
|
||||
return (
|
||||
typeof value === "function"
|
||||
? (value as (context: ActionContext) => T)(context)
|
||||
: value
|
||||
) as T;
|
||||
}
|
||||
|
||||
export const ActionSeparator: TActionSeparator = {
|
||||
@@ -132,6 +136,7 @@ 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),
|
||||
};
|
||||
|
||||
@@ -143,6 +148,7 @@ export function actionToMenuItem(
|
||||
icon,
|
||||
visible,
|
||||
disabled,
|
||||
shortcut: action.shortcut,
|
||||
to,
|
||||
};
|
||||
}
|
||||
@@ -154,6 +160,7 @@ export function actionToMenuItem(
|
||||
icon,
|
||||
visible,
|
||||
disabled,
|
||||
shortcut: action.shortcut,
|
||||
href: action.target
|
||||
? { url: action.url, target: action.target }
|
||||
: action.url,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* 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";
|
||||
@@ -85,4 +86,4 @@ const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
|
||||
}
|
||||
);
|
||||
|
||||
export default ActionButton;
|
||||
export default observer(ActionButton);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* oxlint-disable prefer-rest-params */
|
||||
/* global ga */
|
||||
import escape from "lodash/escape";
|
||||
import { escape } from "es-toolkit/compat";
|
||||
import * as React from "react";
|
||||
import type { PublicEnv } from "@shared/types";
|
||||
import { IntegrationService } from "@shared/types";
|
||||
|
||||
@@ -31,7 +31,10 @@ const Authenticated = ({ children }: Props) => {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
void auth.logout({ savePath: true });
|
||||
void auth.logout({
|
||||
savePath: true,
|
||||
clearCache: false,
|
||||
});
|
||||
|
||||
if (auth.logoutRedirectUri) {
|
||||
window.location.href = auth.logoutRedirectUri;
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Switch, Route } from "react-router-dom";
|
||||
import { useLocation } 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";
|
||||
@@ -33,11 +37,18 @@ 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();
|
||||
@@ -60,7 +71,15 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
React.useEffect(() => {
|
||||
const postLoginPath = spendPostLoginPath();
|
||||
if (postLoginPath) {
|
||||
history.replace(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());
|
||||
}
|
||||
}
|
||||
}, [spendPostLoginPath]);
|
||||
|
||||
@@ -68,12 +87,16 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
return <ErrorSuspended />;
|
||||
}
|
||||
|
||||
const isSettings = location.pathname.startsWith(settingsPath());
|
||||
|
||||
const sidebar = (
|
||||
<Fade>
|
||||
<Switch>
|
||||
<Route path={settingsPath()} component={SettingsSidebar} />
|
||||
<Route component={Sidebar} />
|
||||
</Switch>
|
||||
<React.Suspense fallback={null}>
|
||||
{isSettings && <SettingsSidebar />}
|
||||
</React.Suspense>
|
||||
<div style={isSettings ? { display: "none" } : undefined}>
|
||||
<Sidebar />
|
||||
</div>
|
||||
</Fade>
|
||||
);
|
||||
|
||||
|
||||
@@ -113,19 +113,16 @@ 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-left: ${(props) => (props.$withIcon ? "4px" : "0")};
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
margin-inline-start: ${(props) => (props.$withIcon ? "4px" : "0")};
|
||||
max-width: 460px;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
|
||||
@@ -125,7 +125,7 @@ const Label = styled.span<{ hasIcon?: boolean }>`
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
${(props) => props.hasIcon && "padding-left: 4px;"};
|
||||
${(props) => props.hasIcon && "padding-inline-start: 4px;"};
|
||||
`;
|
||||
|
||||
export const Inner = styled.span<{
|
||||
@@ -135,13 +135,13 @@ export const Inner = styled.span<{
|
||||
}>`
|
||||
display: flex;
|
||||
padding: 0 8px;
|
||||
padding-right: ${(props) => (props.disclosure ? 2 : 8)}px;
|
||||
padding-inline-end: ${(props) => (props.disclosure ? 2 : 8)}px;
|
||||
line-height: ${(props) => (props.hasIcon ? 24 : 32)}px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
|
||||
${(props) => props.hasIcon && props.hasText && "padding-left: 4px;"};
|
||||
${(props) => props.hasIcon && props.hasText && "padding-inline-start: 4px;"};
|
||||
${(props) => props.hasIcon && !props.hasText && "padding: 0 4px;"};
|
||||
`;
|
||||
|
||||
|
||||
@@ -22,9 +22,7 @@ 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 = percentage
|
||||
? ((100 - percentage) * circumference) / 100
|
||||
: 0;
|
||||
strokePercentage = ((100 - percentage) * circumference) / 100;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import filter from "lodash/filter";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import orderBy from "lodash/orderBy";
|
||||
import uniq from "lodash/uniq";
|
||||
import { filter, isEqual, orderBy, uniq } from "es-toolkit/compat";
|
||||
import { observer } from "mobx-react";
|
||||
import { useState, useMemo, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -146,7 +143,14 @@ function Collaborators(props: Props) {
|
||||
/>
|
||||
);
|
||||
},
|
||||
[presentIds, editingIds, observingUserId, currentUserId, handleAvatarClick]
|
||||
[
|
||||
presentIds,
|
||||
editingIds,
|
||||
observingUserId,
|
||||
currentUserId,
|
||||
handleAvatarClick,
|
||||
t,
|
||||
]
|
||||
);
|
||||
|
||||
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: 14pxte
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
color: ${s("textSecondary")};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import uniq from "lodash/uniq";
|
||||
import { uniq } from "es-toolkit/compat";
|
||||
import { observer } from "mobx-react";
|
||||
import { useMemo, useEffect, useCallback, Suspense } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
@@ -25,10 +25,11 @@ 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 interface FormData {
|
||||
export type FormData = {
|
||||
name: string;
|
||||
icon: string;
|
||||
color: string | null;
|
||||
@@ -36,7 +37,7 @@ export interface FormData {
|
||||
permission: CollectionPermission | undefined;
|
||||
commenting?: boolean | null;
|
||||
templateManagement: CollectionPermission;
|
||||
}
|
||||
};
|
||||
|
||||
const useIconColor = (collection?: Collection) => {
|
||||
const { collections } = useStores();
|
||||
@@ -67,6 +68,7 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
}) {
|
||||
const team = useCurrentTeam();
|
||||
const { t } = useTranslation();
|
||||
const dialog = useDialogContext();
|
||||
|
||||
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
|
||||
|
||||
@@ -278,7 +280,12 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
{collection ? (
|
||||
options
|
||||
) : (
|
||||
<Collapsible label={t("Advanced options")}>{options}</Collapsible>
|
||||
<Collapsible
|
||||
label={t("Advanced options")}
|
||||
onOpenChange={() => dialog.setAnimating(true)}
|
||||
>
|
||||
{options}
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
<HStack justify="flex-end">
|
||||
|
||||
@@ -3,7 +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 } from "@shared/utils/keyboard";
|
||||
import { normalizeKeyDisplay, shortcutSeparator } from "@shared/utils/keyboard";
|
||||
import Highlight from "~/components/Highlight";
|
||||
import Flex from "~/components/Flex";
|
||||
import Key from "~/components/Key";
|
||||
@@ -30,8 +30,8 @@ function CommandBarItem(
|
||||
) {
|
||||
const theme = useTheme();
|
||||
const ancestors = React.useMemo(() => {
|
||||
if (!currentRootActionId) {
|
||||
return action.ancestors;
|
||||
if (!currentRootActionId || !action.ancestors) {
|
||||
return action.ancestors ?? [];
|
||||
}
|
||||
const index = action.ancestors.findIndex(
|
||||
(ancestor) => ancestor.id === currentRootActionId
|
||||
@@ -90,9 +90,12 @@ function CommandBarItem(
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{sc.split("+").map((key) => (
|
||||
<Key key={key}>{normalizeKeyDisplay(key)}</Key>
|
||||
))}
|
||||
{sc.split("+").flatMap((key, i, arr) => {
|
||||
const el = <Key key={key}>{normalizeKeyDisplay(key)}</Key>;
|
||||
return i < arr.length - 1 && shortcutSeparator
|
||||
? [el, shortcutSeparator]
|
||||
: [el];
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Shortcut>
|
||||
|
||||
@@ -43,7 +43,8 @@ const Container = styled.div`
|
||||
const Header = styled(Text).attrs({ as: "h3" })`
|
||||
letter-spacing: 0.03em;
|
||||
margin: 0;
|
||||
padding: 16px 0 4px 20px;
|
||||
padding-block: 16px 4px;
|
||||
padding-inline: 20px 0;
|
||||
height: 36px;
|
||||
cursor: default;
|
||||
`;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
@@ -25,6 +26,41 @@ interface CacheEntry {
|
||||
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.
|
||||
*/
|
||||
@@ -40,6 +76,9 @@ function SharedSearchActions() {
|
||||
searchQuery: state.searchQuery,
|
||||
}));
|
||||
|
||||
const searchQueryRef = React.useRef(searchQuery);
|
||||
searchQueryRef.current = searchQuery;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!searchQuery || !shareId) {
|
||||
setResults([]);
|
||||
@@ -60,7 +99,9 @@ function SharedSearchActions() {
|
||||
const currentQuery = searchQuery;
|
||||
void documents.search({ query: searchQuery, shareId }).then((res) => {
|
||||
searchCache.current.set(currentQuery, { timestamp: now, results: res });
|
||||
setResults(res);
|
||||
if (searchQueryRef.current === currentQuery) {
|
||||
setResults(res);
|
||||
}
|
||||
});
|
||||
}, [documents, searchQuery, shareId]);
|
||||
|
||||
@@ -92,18 +133,19 @@ function SharedSearchActions() {
|
||||
createAction({
|
||||
id: `shared-search-${result.document.id}`,
|
||||
name: result.document.titleWithDefault,
|
||||
description: result.context,
|
||||
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: searchQuery
|
||||
? `?q=${encodeURIComponent(searchQuery)}`
|
||||
search: currentQuery
|
||||
? `?q=${encodeURIComponent(currentQuery)}`
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ const useRecentDocumentActions = (count = 6) => {
|
||||
color={item.color ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<DocumentIcon />
|
||||
<DocumentIcon outline={item.isDraft} />
|
||||
),
|
||||
to: documentPath(item),
|
||||
})
|
||||
|
||||
@@ -87,22 +87,23 @@ const ContentEditable = React.forwardRef(function ContentEditable_(
|
||||
}));
|
||||
|
||||
const wrappedEvent =
|
||||
(
|
||||
callback:
|
||||
| React.FocusEventHandler<HTMLSpanElement>
|
||||
| React.FormEventHandler<HTMLSpanElement>
|
||||
| React.KeyboardEventHandler<HTMLSpanElement>
|
||||
| undefined
|
||||
<E extends React.SyntheticEvent<HTMLSpanElement>>(
|
||||
callback: ((event: E) => void) | undefined
|
||||
) =>
|
||||
(event: any) => {
|
||||
(event: E) => {
|
||||
if (readOnly) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = event.currentTarget.textContent || "";
|
||||
|
||||
if (maxLength && isPrintableKeyEvent(event) && text.length >= maxLength) {
|
||||
event?.preventDefault();
|
||||
if (
|
||||
maxLength &&
|
||||
event.nativeEvent instanceof KeyboardEvent &&
|
||||
isPrintableKeyEvent(event.nativeEvent) &&
|
||||
text.length >= maxLength
|
||||
) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ const DefaultCollectionInputSelect = observer(
|
||||
value={defaultCollectionId ?? "home"}
|
||||
onChange={onSelectCollection}
|
||||
label={t("Start view")}
|
||||
hideLabel
|
||||
labelHidden
|
||||
short
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
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);
|
||||
+30
-27
@@ -2,6 +2,7 @@ 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"));
|
||||
@@ -12,33 +13,35 @@ function Dialogs() {
|
||||
const modals = [...modalStack];
|
||||
|
||||
return (
|
||||
<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>
|
||||
</DialogProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -101,11 +101,16 @@ function DocumentBreadcrumb(
|
||||
<DocumentName
|
||||
documentId={node.id}
|
||||
collection={collection}
|
||||
icon={node.icon}
|
||||
color={node.color}
|
||||
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,14 +202,10 @@ const CollectionName = observer(function CollectionName_({
|
||||
const DocumentName = observer(function DocumentName_({
|
||||
documentId,
|
||||
collection,
|
||||
icon,
|
||||
color,
|
||||
title,
|
||||
}: {
|
||||
documentId: string;
|
||||
collection: Collection | undefined;
|
||||
icon: string | undefined;
|
||||
color: string | undefined;
|
||||
title: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -212,21 +213,8 @@ const DocumentName = observer(function DocumentName_({
|
||||
const doc = documents.get(documentId);
|
||||
const menuAction = useDocumentMenuAction({ documentId });
|
||||
|
||||
const content = icon ? (
|
||||
<>
|
||||
<StyledIcon
|
||||
value={icon}
|
||||
color={color}
|
||||
initial={title.charAt(0).toUpperCase()}
|
||||
/>{" "}
|
||||
{title}
|
||||
</>
|
||||
) : (
|
||||
title
|
||||
);
|
||||
|
||||
if (!doc) {
|
||||
return <>{content}</>;
|
||||
return <>{title}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -236,16 +224,12 @@ const DocumentName = observer(function DocumentName_({
|
||||
}}
|
||||
>
|
||||
<ContextMenu action={menuAction} ariaLabel={t("Document options")}>
|
||||
<span>{content}</span>
|
||||
<span>{title}</span>
|
||||
</ContextMenu>
|
||||
</ActionContextProvider>
|
||||
);
|
||||
});
|
||||
|
||||
const StyledIcon = styled(Icon)`
|
||||
margin-right: 2px;
|
||||
`;
|
||||
|
||||
const SmallSlash = styled(GoToIcon)`
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
|
||||
@@ -110,7 +110,7 @@ function DocumentCard(props: Props) {
|
||||
dir={document.dir}
|
||||
$isDragging={isDragging}
|
||||
to={{
|
||||
pathname: document.url,
|
||||
pathname: document.path,
|
||||
state: {
|
||||
title: document.titleWithDefault,
|
||||
},
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import FuzzySearch from "fuzzy-search";
|
||||
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 {
|
||||
concat,
|
||||
difference,
|
||||
fill,
|
||||
filter,
|
||||
flatten,
|
||||
includes,
|
||||
map,
|
||||
} from "es-toolkit/compat";
|
||||
import { observer } from "mobx-react";
|
||||
import { StarredIcon, DocumentIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
@@ -22,7 +24,6 @@ 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";
|
||||
@@ -447,10 +448,7 @@ const FlexContainer = styled(Flex)`
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const ListSearch = styled(InputSearch)`
|
||||
${Outline} {
|
||||
border-radius: 16px;
|
||||
}
|
||||
const ListSearch = styled(InputSearch).attrs({ round: true })`
|
||||
margin-bottom: 4px;
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
|
||||
@@ -9,17 +9,27 @@ 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 toggle its selection. */
|
||||
onClick: (ev: React.MouseEvent) => void;
|
||||
};
|
||||
|
||||
@@ -41,7 +51,7 @@ function DocumentExplorerNode(
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const DISCLOSURE = 24;
|
||||
const width = (depth + (hasChildren ? 2 : 1)) * DISCLOSURE;
|
||||
const width = (depth + 2) * DISCLOSURE;
|
||||
|
||||
return (
|
||||
<Node
|
||||
@@ -79,6 +89,10 @@ const StyledDisclosure = styled(Disclosure)`
|
||||
position: relative;
|
||||
left: auto;
|
||||
margin: 2px 0;
|
||||
|
||||
&&[aria-expanded="true"]:not(:hover) {
|
||||
background: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const Spacer = styled(Flex)<{ width: number }>`
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import compact from "lodash/compact";
|
||||
import sortBy from "lodash/sortBy";
|
||||
import { compact, sortBy } from "es-toolkit/compat";
|
||||
import { observer } from "mobx-react";
|
||||
import { useMemo, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
+10
-17
@@ -1,8 +1,9 @@
|
||||
import difference from "lodash/difference";
|
||||
import { difference } from "es-toolkit/compat";
|
||||
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";
|
||||
@@ -16,7 +17,6 @@ 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,12 +28,7 @@ const LazyLoadedEditor = lazyWithRetry(() => import("~/editor"));
|
||||
|
||||
export type Props = Optional<
|
||||
EditorProps,
|
||||
| "placeholder"
|
||||
| "defaultValue"
|
||||
| "onClickLink"
|
||||
| "embeds"
|
||||
| "dictionary"
|
||||
| "extensions"
|
||||
"placeholder" | "defaultValue" | "onClickLink" | "embeds" | "extensions"
|
||||
> & {
|
||||
embedsDisabled?: boolean;
|
||||
onSynced?: () => Promise<void>;
|
||||
@@ -52,7 +47,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
} = props;
|
||||
const { comments } = useStores();
|
||||
const { shareId } = useShare();
|
||||
const dictionary = useDictionary();
|
||||
const { t } = useTranslation();
|
||||
const embeds = useEmbeds(!shareId);
|
||||
const localRef = React.useRef<SharedEditor>();
|
||||
const preferences = useCurrentUser({ rejectOnEmpty: false })?.preferences;
|
||||
@@ -95,11 +90,11 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
const handleFileUploadStart = React.useCallback(() => {
|
||||
uploadState.current.timeoutId = setTimeout(() => {
|
||||
uploadState.current.toastId = toast.loading(
|
||||
dictionary.uploadingWithProgress(0)
|
||||
t("Uploading… {{ progress }}%", { progress: 0 })
|
||||
);
|
||||
}, 2000);
|
||||
onFileUploadStart?.();
|
||||
}, [onFileUploadStart, dictionary.uploadingWithProgress]);
|
||||
}, [onFileUploadStart, t]);
|
||||
|
||||
const handleFileUploadProgress = React.useCallback(
|
||||
(fileId: string, fractionComplete: number) => {
|
||||
@@ -113,12 +108,12 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
|
||||
// Update toast if visible
|
||||
if (uploadState.current.toastId) {
|
||||
toast.loading(dictionary.uploadingWithProgress(percent), {
|
||||
toast.loading(t("Uploading… {{ progress }}%", { progress: percent }), {
|
||||
id: uploadState.current.toastId,
|
||||
});
|
||||
}
|
||||
},
|
||||
[dictionary.uploadingWithProgress]
|
||||
[t]
|
||||
);
|
||||
|
||||
const handleFileUploadStop = React.useCallback(() => {
|
||||
@@ -183,7 +178,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
onFileUploadStart: handleFileUploadStart,
|
||||
onFileUploadStop: handleFileUploadStop,
|
||||
onFileUploadProgress: handleFileUploadProgress,
|
||||
dictionary,
|
||||
isAttachment,
|
||||
});
|
||||
},
|
||||
@@ -192,7 +186,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
handleFileUploadStart,
|
||||
handleFileUploadStop,
|
||||
handleFileUploadProgress,
|
||||
dictionary,
|
||||
handleUploadFile,
|
||||
]
|
||||
);
|
||||
@@ -211,6 +204,7 @@ 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 ?? [],
|
||||
@@ -220,7 +214,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);
|
||||
onCreateCommentMark(mark.id, mark.userId, { focus });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -288,7 +282,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
uploadFile={handleUploadFile}
|
||||
embeds={embeds}
|
||||
userPreferences={preferences}
|
||||
dictionary={dictionary}
|
||||
{...props}
|
||||
onClickLink={handleClickLink}
|
||||
onChange={handleChange}
|
||||
|
||||
@@ -1,233 +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 { 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;
|
||||
`;
|
||||
@@ -0,0 +1,161 @@
|
||||
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;
|
||||
`;
|
||||
@@ -0,0 +1,132 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
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 { WithTranslation } from "react-i18next";
|
||||
import { withTranslation, Trans } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { UrlHelper } from "@shared/utils/UrlHelper";
|
||||
@@ -18,20 +18,26 @@ import Storage from "@shared/utils/Storage";
|
||||
import { deleteAllDatabases } from "~/utils/developer";
|
||||
import Flex from "./Flex";
|
||||
|
||||
type Props = WithTranslation & {
|
||||
interface OwnProps {
|
||||
/** 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 ErrorBoundary extends React.Component<Props> {
|
||||
class ErrorBoundaryClass extends React.Component<Props> {
|
||||
@observable
|
||||
error: Error | null | undefined;
|
||||
|
||||
@@ -223,4 +229,9 @@ const Pre = styled.pre`
|
||||
white-space: pre-wrap;
|
||||
`;
|
||||
|
||||
export default withTranslation()(ErrorBoundary);
|
||||
function ErrorBoundary(props: OwnProps) {
|
||||
const { t } = useTranslation();
|
||||
return <ErrorBoundaryClass t={t} {...props} />;
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
|
||||
@@ -18,7 +18,7 @@ type Props = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps children in a <Fade> if loading is true on mount.
|
||||
* Wraps children in a <Fade> if animate is true on mount.
|
||||
*/
|
||||
export const ConditionalFade = ({ animate, children }: Props) => {
|
||||
const [isAnimated] = useState(animate);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import deburr from "lodash/deburr";
|
||||
import { deburr } from "es-toolkit/compat";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import throttle from "lodash/throttle";
|
||||
import { throttle } from "es-toolkit/compat";
|
||||
import { observer } from "mobx-react";
|
||||
import { MenuIcon } from "outline-icons";
|
||||
import { transparentize } from "polished";
|
||||
@@ -116,17 +116,23 @@ function Header(
|
||||
const Breadcrumbs = styled("div")`
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
padding-right: 8px;
|
||||
padding-inline: 0 8px;
|
||||
display: flex;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
min-width: auto;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Actions = styled(Flex)`
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
min-width: auto;
|
||||
padding-left: 8px;
|
||||
padding-inline: 8px 0;
|
||||
gap: 12px;
|
||||
margin-inline-start: 8px;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
position: unset;
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
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: 6vh; font-weight: 600;")}
|
||||
${(props) => (props.as ? "" : "margin-top: 3vh; 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 "lodash/escapeRegExp";
|
||||
import { escapeRegExp } from "es-toolkit/compat";
|
||||
import * as React from "react";
|
||||
import replace from "string-replace-to-array";
|
||||
import styled from "styled-components";
|
||||
|
||||
@@ -88,6 +88,7 @@ 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;
|
||||
@@ -112,7 +113,6 @@ 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>
|
||||
<Info>{lastActivityByViewer}</Info>
|
||||
{lastActivityByViewer && <Info>{lastActivityByViewer}</Info>}
|
||||
<Description as="div">
|
||||
<React.Suspense fallback={<div />}>
|
||||
<Editor
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import concat from "lodash/concat";
|
||||
import { concat } from "es-toolkit/compat";
|
||||
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/EmojiCreateDialog";
|
||||
import { EmojiCreateDialog } from "~/components/EmojiDialog/EmojiCreateDialog";
|
||||
import { DisplayCategory } from "../utils";
|
||||
import type { DataNode, EmojiNode } from "./GridTemplate";
|
||||
import GridTemplate from "./GridTemplate";
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
import chunk from "lodash/chunk";
|
||||
import compact from "lodash/compact";
|
||||
import { chunk, compact } from "es-toolkit/compat";
|
||||
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";
|
||||
|
||||
/**
|
||||
* icon/emoji size is 24px; and we add 4px padding on all sides,
|
||||
* 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.
|
||||
*/
|
||||
const BUTTON_SIZE = 32;
|
||||
const BUTTON_SIZE_DESKTOP = 32;
|
||||
const BUTTON_SIZE_MOBILE = 40;
|
||||
const ICON_SIZE_DESKTOP = 24;
|
||||
const ICON_SIZE_MOBILE = 32;
|
||||
|
||||
type OutlineNode = {
|
||||
type: IconType.SVG;
|
||||
@@ -53,8 +58,11 @@ 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.floor((width - 24) / BUTTON_SIZE);
|
||||
const itemsPerRow = Math.max(1, Math.floor((width - 24) / buttonSize));
|
||||
|
||||
const gridItems = compact(
|
||||
data.flatMap((node) => {
|
||||
@@ -84,7 +92,11 @@ 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}>
|
||||
<Icon
|
||||
as={IconLibrary.getComponent(item.name)}
|
||||
color={item.color}
|
||||
size={iconSize}
|
||||
>
|
||||
{item.initial}
|
||||
</Icon>
|
||||
</IconButton>
|
||||
@@ -96,7 +108,11 @@ const GridTemplate = (
|
||||
key={item.id}
|
||||
onClick={() => onIconSelect({ id: item.id, value: item.value })}
|
||||
>
|
||||
<Emoji width={24} height={24}>
|
||||
<Emoji
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
size={isMobile ? iconSize : undefined}
|
||||
>
|
||||
{item.type === IconType.Custom ? (
|
||||
<CustomEmoji value={item.value} title={item.name} />
|
||||
) : (
|
||||
@@ -119,7 +135,7 @@ const GridTemplate = (
|
||||
height={height}
|
||||
data={gridItems}
|
||||
columns={itemsPerRow}
|
||||
itemWidth={BUTTON_SIZE}
|
||||
itemWidth={buttonSize}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import styled from "styled-components";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import { breakpoints, s, hover } from "@shared/styles";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
|
||||
export const IconButton = styled(NudeButton)<{ delay?: number }>`
|
||||
@@ -10,4 +10,9 @@ export const IconButton = styled(NudeButton)<{ delay?: number }>`
|
||||
&: ${hover} {
|
||||
background: ${s("listItemHoverBackground")};
|
||||
}
|
||||
|
||||
@media (max-width: ${breakpoints.tablet - 1}px) {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -79,7 +79,9 @@ const IconPicker = ({
|
||||
|
||||
const [activeTab, setActiveTab] = React.useState<TabName>(defaultTab);
|
||||
|
||||
const popoverWidth = isMobile ? windowWidth : POPOVER_WIDTH;
|
||||
// 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 handleTabChange = React.useCallback((value: string) => {
|
||||
setActiveTab(value as TabName);
|
||||
|
||||
+100
-5
@@ -6,11 +6,15 @@ 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;
|
||||
@@ -20,6 +24,10 @@ 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")};
|
||||
@@ -87,7 +95,7 @@ export const Wrapper = styled.div<{
|
||||
|
||||
const IconWrapper = styled.span`
|
||||
position: relative;
|
||||
left: 4px;
|
||||
inset-inline-start: 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
`;
|
||||
@@ -96,7 +104,9 @@ 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"};
|
||||
@@ -109,7 +119,7 @@ export const Outline = styled(Flex)<{
|
||||
: props.$focused
|
||||
? props.theme.inputBorderFocused
|
||||
: props.theme.inputBorder};
|
||||
border-radius: 4px;
|
||||
border-radius: ${(props) => (props.$round ? "16px" : "4px")};
|
||||
font-weight: normal;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
@@ -119,6 +129,24 @@ 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;
|
||||
@@ -141,6 +169,18 @@ 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 */
|
||||
@@ -157,6 +197,21 @@ 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);
|
||||
@@ -174,6 +229,15 @@ 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>
|
||||
) => {
|
||||
@@ -205,14 +269,31 @@ 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 (
|
||||
@@ -224,7 +305,7 @@ function Input(
|
||||
) : (
|
||||
wrappedLabel
|
||||
))}
|
||||
<Outline $focused={focused} margin={margin}>
|
||||
<Outline $focused={focused} $round={round} margin={margin}>
|
||||
{prefix}
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
{type === "textarea" ? (
|
||||
@@ -237,9 +318,14 @@ function Input(
|
||||
onFocus={handleFocus}
|
||||
hasIcon={!!icon}
|
||||
hasPrefix={!!prefix}
|
||||
$autoSize={autoSize}
|
||||
$minHeight={minHeight}
|
||||
$maxHeight={maxHeight}
|
||||
{...rest}
|
||||
// set it after "rest" to override "onKeyDown" from prop.
|
||||
// set it after "rest" to override props from spread.
|
||||
maxLength={maxLength}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
) : (
|
||||
<NativeInput
|
||||
@@ -253,10 +339,19 @@ function Input(
|
||||
hasPrefix={!!prefix}
|
||||
type={type}
|
||||
{...rest}
|
||||
// set it after "rest" to override "onKeyDown" from prop.
|
||||
// set it after "rest" to override "onKeyDown" and "onChange" from prop.
|
||||
maxLength={maxLength}
|
||||
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;
|
||||
right: 6px;
|
||||
inset-inline-end: 6px;
|
||||
`;
|
||||
|
||||
export default InputColor;
|
||||
|
||||
@@ -37,7 +37,7 @@ export default function InputMemberPermissionSelect(
|
||||
value={value || EmptySelectValue}
|
||||
onChange={onChange}
|
||||
label={t("Permissions")}
|
||||
hideLabel
|
||||
labelHidden
|
||||
nude
|
||||
{...rest}
|
||||
/>
|
||||
|
||||
@@ -4,11 +4,17 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import { s } from "@shared/styles";
|
||||
import {
|
||||
isModKey,
|
||||
metaDisplay,
|
||||
shortcutSeparator,
|
||||
} 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, { Outline } from "./Input";
|
||||
import Input from "./Input";
|
||||
|
||||
type Props = {
|
||||
/** A string representing where the search started, for tracking. */
|
||||
@@ -42,6 +48,7 @@ function InputSearchPage({
|
||||
const theme = useTheme();
|
||||
const history = useHistory();
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useMobile();
|
||||
const [isFocused, setFocused, setUnfocused] = useBoolean(false);
|
||||
|
||||
useKeyDown("f", (ev: KeyboardEvent) => {
|
||||
@@ -97,16 +104,29 @@ function InputSearchPage({
|
||||
onBlur={setUnfocused}
|
||||
margin={0}
|
||||
labelHidden
|
||||
/>
|
||||
>
|
||||
{!isMobile && (
|
||||
<Shortcut $visible={!isFocused && !value && !collectionId}>
|
||||
{metaDisplay}
|
||||
{shortcutSeparator}K
|
||||
</Shortcut>
|
||||
)}
|
||||
</InputMaxWidth>
|
||||
);
|
||||
}
|
||||
|
||||
const InputMaxWidth = styled(Input)`
|
||||
max-width: 30vw;
|
||||
const InputMaxWidth = styled(Input).attrs({ round: true })`
|
||||
max-width: min(calc(30vw + 20px), 100%);
|
||||
`;
|
||||
|
||||
${Outline} {
|
||||
border-radius: 16px;
|
||||
}
|
||||
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;
|
||||
`;
|
||||
|
||||
export default observer(InputSearchPage);
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
InputSelectContent,
|
||||
InputSelectItem,
|
||||
InputSelectSeparator,
|
||||
InputSelectHeading,
|
||||
InputSelectTrigger,
|
||||
type TriggerButtonProps,
|
||||
} from "./primitives/InputSelect";
|
||||
@@ -35,6 +36,13 @@ 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";
|
||||
@@ -48,7 +56,7 @@ export type Item = {
|
||||
icon?: React.ReactElement;
|
||||
};
|
||||
|
||||
export type Option = Item | Separator;
|
||||
export type Option = Item | Separator | Heading;
|
||||
|
||||
type Props = Omit<React.HTMLAttributes<HTMLButtonElement>, "onChange"> & {
|
||||
/* Options to display in the select menu. */
|
||||
@@ -60,13 +68,15 @@ type Props = Omit<React.HTMLAttributes<HTMLButtonElement>, "onChange"> & {
|
||||
/* Label for the select menu. */
|
||||
label: string;
|
||||
/* When true, label is hidden in an accessible manner. */
|
||||
hideLabel?: boolean;
|
||||
labelHidden?: 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>(
|
||||
@@ -76,9 +86,10 @@ export const InputSelect = React.forwardRef<HTMLButtonElement, Props>(
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
hideLabel,
|
||||
labelHidden,
|
||||
short,
|
||||
help,
|
||||
displayValue,
|
||||
...triggerProps
|
||||
} = props;
|
||||
|
||||
@@ -95,12 +106,34 @@ 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} />
|
||||
@@ -143,13 +176,14 @@ export const InputSelect = React.forwardRef<HTMLButtonElement, Props>(
|
||||
onChange={onValueChange}
|
||||
placeholder={placeholder}
|
||||
optionsHaveIcon={optionsHaveIcon}
|
||||
resolvedDisplayValue={resolvedDisplayValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper short={short}>
|
||||
<Label text={label} hidden={hideLabel ?? false} help={help} />
|
||||
<Label text={label} hidden={labelHidden ?? false} help={help} />
|
||||
<InputSelectRoot
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
@@ -159,6 +193,7 @@ export const InputSelect = React.forwardRef<HTMLButtonElement, Props>(
|
||||
<InputSelectTrigger
|
||||
ref={ref}
|
||||
placeholder={placeholder}
|
||||
displayValue={resolvedDisplayValue}
|
||||
{...triggerProps}
|
||||
/>
|
||||
<InputSelectContent
|
||||
@@ -179,6 +214,7 @@ InputSelect.displayName = "InputSelect";
|
||||
type MobileSelectProps = Props & {
|
||||
placeholder: string;
|
||||
optionsHaveIcon: boolean;
|
||||
resolvedDisplayValue?: React.ReactNode;
|
||||
};
|
||||
|
||||
const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
|
||||
@@ -188,11 +224,13 @@ const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
hideLabel,
|
||||
labelHidden,
|
||||
disabled,
|
||||
short,
|
||||
placeholder,
|
||||
optionsHaveIcon,
|
||||
displayValue: _displayValue,
|
||||
resolvedDisplayValue,
|
||||
...triggerProps
|
||||
} = props;
|
||||
|
||||
@@ -222,6 +260,14 @@ 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 (
|
||||
@@ -252,7 +298,7 @@ const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Label text={label} hidden={hideLabel ?? false} />
|
||||
<Label text={label} hidden={labelHidden ?? false} />
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>
|
||||
<SelectButton
|
||||
@@ -262,7 +308,9 @@ const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
|
||||
disclosure
|
||||
data-placeholder={selectedOption ? false : ""}
|
||||
>
|
||||
{selectedOption ? (
|
||||
{resolvedDisplayValue !== undefined ? (
|
||||
resolvedDisplayValue
|
||||
) : selectedOption ? (
|
||||
<Option
|
||||
option={selectedOption as Item}
|
||||
optionsHaveIcon={optionsHaveIcon}
|
||||
@@ -365,8 +413,8 @@ const IconWrapper = styled.span`
|
||||
align-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-left: -4px;
|
||||
margin-right: 4px;
|
||||
margin-inline-start: -4px;
|
||||
margin-inline-end: 4px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
@@ -11,7 +11,7 @@ type Props = {
|
||||
shrink?: boolean;
|
||||
} & Pick<
|
||||
React.ComponentProps<typeof InputSelect>,
|
||||
"value" | "onChange" | "disabled" | "hideLabel" | "nude" | "help"
|
||||
"value" | "onChange" | "disabled" | "labelHidden" | "nude" | "help"
|
||||
>;
|
||||
|
||||
export const InputSelectPermission = React.forwardRef<HTMLButtonElement, Props>(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { m } from "framer-motion";
|
||||
import find from "lodash/find";
|
||||
import { find } from "es-toolkit/compat";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { languages, languageOptions } from "@shared/i18n";
|
||||
|
||||
@@ -48,6 +48,7 @@ const Layout = React.forwardRef(function Layout_(
|
||||
<Content
|
||||
auto
|
||||
justify="center"
|
||||
role="main"
|
||||
$isResizing={ui.sidebarIsResizing}
|
||||
$sidebarCollapsed={sidebarCollapsed}
|
||||
$hasSidebar={!!sidebar}
|
||||
@@ -55,7 +56,7 @@ const Layout = React.forwardRef(function Layout_(
|
||||
sidebarCollapsed
|
||||
? undefined
|
||||
: {
|
||||
marginLeft: `${ui.sidebarWidth}px`,
|
||||
marginInlineStart: `${ui.sidebarWidth}px`,
|
||||
}
|
||||
}
|
||||
>
|
||||
@@ -85,21 +86,21 @@ type ContentProps = {
|
||||
const Content = styled(Flex)<ContentProps>`
|
||||
margin: 0;
|
||||
transition: ${(props) =>
|
||||
props.$isResizing ? "none" : `margin-left 100ms ease-out`};
|
||||
props.$isResizing ? "none" : `margin-inline-start 100ms ease-out`};
|
||||
|
||||
@media print {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
${breakpoint("mobile", "tablet")`
|
||||
margin-left: 0 !important;
|
||||
margin-inline-start: 0 !important;
|
||||
`}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
${(props: ContentProps) =>
|
||||
props.$hasSidebar &&
|
||||
props.$sidebarCollapsed &&
|
||||
`margin-left: ${props.theme.sidebarCollapsedWidth}px;`}
|
||||
`margin-inline-start: ${props.theme.sidebarCollapsedWidth}px;`}
|
||||
`};
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 }>;
|
||||
|
||||
@@ -40,7 +40,7 @@ import CopyToClipboard from "./CopyToClipboard";
|
||||
import { Separator } from "./Actions";
|
||||
import useSwipe from "~/hooks/useSwipe";
|
||||
import { toast } from "sonner";
|
||||
import { findIndex } from "lodash";
|
||||
import { findIndex } from "es-toolkit/compat";
|
||||
import type { LightboxImage } from "@shared/editor/lib/Lightbox";
|
||||
import type { ReactZoomPanPinchRef } from "react-zoom-pan-pinch";
|
||||
import {
|
||||
|
||||
@@ -203,7 +203,7 @@ const Wrapper = styled.a<{
|
||||
`;
|
||||
|
||||
const Image = styled(Flex)`
|
||||
padding: 0 8px 0 0;
|
||||
padding-inline-end: 8px;
|
||||
max-height: 32px;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import times from "lodash/times";
|
||||
import { times } from "es-toolkit/compat";
|
||||
import styled from "styled-components";
|
||||
import Fade from "~/components/Fade";
|
||||
import Flex from "~/components/Flex";
|
||||
|
||||
@@ -47,7 +47,7 @@ export const ContextMenu = observer(
|
||||
onClose?.();
|
||||
}
|
||||
},
|
||||
[open, onOpen, onClose]
|
||||
[onOpen, onClose]
|
||||
);
|
||||
|
||||
const enablePointerEvents = React.useCallback(() => {
|
||||
|
||||
@@ -42,13 +42,14 @@ export function toMenuItems(items: MenuItem[]) {
|
||||
case "button":
|
||||
return (
|
||||
<MenuButton
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
key={`${item.type}-${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}
|
||||
/>
|
||||
);
|
||||
@@ -56,10 +57,11 @@ export function toMenuItems(items: MenuItem[]) {
|
||||
case "route":
|
||||
return (
|
||||
<MenuInternalLink
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
key={`${item.type}-${index}`}
|
||||
label={item.title as string}
|
||||
icon={icon}
|
||||
disabled={item.disabled}
|
||||
shortcut={item.shortcut}
|
||||
to={item.to}
|
||||
/>
|
||||
);
|
||||
@@ -67,10 +69,11 @@ export function toMenuItems(items: MenuItem[]) {
|
||||
case "link":
|
||||
return (
|
||||
<MenuExternalLink
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
key={`${item.type}-${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
|
||||
@@ -92,7 +95,7 @@ export function toMenuItems(items: MenuItem[]) {
|
||||
};
|
||||
|
||||
return (
|
||||
<SubMenu key={`${item.type}-${item.title}-${index}`}>
|
||||
<SubMenu key={`${item.type}-${index}`}>
|
||||
<SubMenuTrigger
|
||||
label={item.title as string}
|
||||
icon={icon}
|
||||
@@ -118,7 +121,7 @@ export function toMenuItems(items: MenuItem[]) {
|
||||
|
||||
return (
|
||||
<MenuGroup
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
key={`${item.type}-${index}`}
|
||||
label={item.title as string}
|
||||
items={groupItems}
|
||||
/>
|
||||
@@ -168,7 +171,7 @@ export function toMobileMenuItems(
|
||||
case "button":
|
||||
return (
|
||||
<Components.MenuButton
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
key={`${item.type}-${index}`}
|
||||
disabled={item.disabled}
|
||||
$dangerous={item.dangerous}
|
||||
onClick={(e) => {
|
||||
@@ -189,7 +192,7 @@ export function toMobileMenuItems(
|
||||
case "route":
|
||||
return (
|
||||
<Components.MenuInternalLink
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
key={`${item.type}-${index}`}
|
||||
to={item.to}
|
||||
disabled={item.disabled}
|
||||
onClick={closeMenu}
|
||||
@@ -202,7 +205,7 @@ export function toMobileMenuItems(
|
||||
case "link":
|
||||
return (
|
||||
<Components.MenuExternalLink
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
key={`${item.type}-${index}`}
|
||||
href={typeof item.href === "string" ? item.href : item.href.url}
|
||||
target={
|
||||
typeof item.href === "string" ? undefined : item.href.target
|
||||
@@ -228,7 +231,7 @@ export function toMobileMenuItems(
|
||||
|
||||
return (
|
||||
<Components.MenuButton
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
key={`${item.type}-${index}`}
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
openSubmenu(item.title as string);
|
||||
@@ -253,7 +256,7 @@ export function toMobileMenuItems(
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={`${item.type}-${item.title}-${index}`}>
|
||||
<div key={`${item.type}-${index}`}>
|
||||
<Components.MenuHeader>{item.title}</Components.MenuHeader>
|
||||
{groupItems}
|
||||
</div>
|
||||
|
||||
+27
-19
@@ -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 = "Untitled",
|
||||
title,
|
||||
style,
|
||||
width,
|
||||
height,
|
||||
@@ -40,42 +40,43 @@ 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 && onRequestClose()}
|
||||
>
|
||||
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<Dialog.Portal>
|
||||
<StyledOverlay />
|
||||
<Dialog.Title asChild>
|
||||
<VisuallyHidden.Root>{title}</VisuallyHidden.Root>
|
||||
</Dialog.Title>
|
||||
<StyledContent
|
||||
onEscapeKeyDown={onRequestClose}
|
||||
onPointerDownOutside={onRequestClose}
|
||||
onEscapeKeyDown={onClose}
|
||||
onPointerDownOutside={onClose}
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
{isMobile ? (
|
||||
<Mobile>
|
||||
<MobileContent>
|
||||
<Centered onClick={(ev) => ev.stopPropagation()} column>
|
||||
{title && (
|
||||
<Dialog.Title asChild>
|
||||
<Text size="xlarge" weight="bold">
|
||||
{title}
|
||||
{resolvedTitle}
|
||||
</Text>
|
||||
)}
|
||||
</Dialog.Title>
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
</Centered>
|
||||
</MobileContent>
|
||||
<Close onClick={onRequestClose}>
|
||||
<Close onClick={onClose}>
|
||||
<CloseIcon size={32} />
|
||||
</Close>
|
||||
<Back onClick={onRequestClose}>
|
||||
<Back onClick={onClose}>
|
||||
<BackIcon size={32} />
|
||||
<Text>{t("Back")} </Text>
|
||||
</Back>
|
||||
@@ -89,13 +90,20 @@ const Modal: React.FC<Props> = ({
|
||||
column
|
||||
reverse
|
||||
>
|
||||
<DesktopContent style={style} topShadow>
|
||||
<DesktopContent
|
||||
style={style}
|
||||
topShadow
|
||||
overflow={dialog.animating ? "hidden" : undefined}
|
||||
onAnimationEnd={() => dialog.setAnimating(false)}
|
||||
>
|
||||
<ErrorBoundary component="div">{children}</ErrorBoundary>
|
||||
</DesktopContent>
|
||||
<Header>
|
||||
{title && <Text size="large">{title}</Text>}
|
||||
<Dialog.Title asChild>
|
||||
<Text size="large">{resolvedTitle}</Text>
|
||||
</Dialog.Title>
|
||||
<Tooltip content={t("Close")} shortcut="Esc">
|
||||
<NudeButton onClick={onRequestClose}>
|
||||
<NudeButton onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
|
||||
@@ -117,8 +117,8 @@ const StyledAvatar = styled(Avatar).attrs({
|
||||
|
||||
const Container = styled(Flex)<{ $unread: boolean }>`
|
||||
position: relative;
|
||||
padding: 8px 12px;
|
||||
padding-right: 40px;
|
||||
padding-block: 8px;
|
||||
padding-inline: 12px 40px;
|
||||
border-radius: 4px;
|
||||
|
||||
${StyledLink}[data-state=open] &,
|
||||
|
||||
@@ -6,6 +6,7 @@ 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";
|
||||
@@ -83,6 +84,7 @@ function Notifications(
|
||||
) {
|
||||
const { notifications } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useMobile();
|
||||
const [filter, setFilter] = React.useState<NotificationFilter>("all");
|
||||
|
||||
const filterOptions = React.useMemo<Option[]>(
|
||||
@@ -110,8 +112,9 @@ function Notifications(
|
||||
<Flex
|
||||
style={{
|
||||
width: "100%",
|
||||
height:
|
||||
"min(300px, calc(var(--radix-popover-content-available-height) - 44px))",
|
||||
minHeight: isMobile ? "75vh" : "300px",
|
||||
maxHeight:
|
||||
"min(75vh, calc(var(--radix-popover-content-available-height, 75vh) - 44px))",
|
||||
}}
|
||||
column
|
||||
>
|
||||
@@ -122,7 +125,7 @@ function Notifications(
|
||||
<HStack>
|
||||
<StyledInputSelect
|
||||
label={t("Filter")}
|
||||
hideLabel
|
||||
labelHidden
|
||||
options={filterOptions}
|
||||
value={filter}
|
||||
onChange={(value) => setFilter(value as NotificationFilter)}
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { 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 useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const Notifications = lazyWithRetry(() => import("./Notifications"));
|
||||
import Notifications from "./Notifications";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -19,7 +24,9 @@ const NotificationsPopover: React.FC = ({ children }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { notifications } = useStores();
|
||||
const [open, setOpen] = useState(false);
|
||||
const isMobile = useMobile();
|
||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||
const drawerContentRef = useRef<React.ElementRef<typeof DrawerContent>>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void notifications.fetchPage({ archived: false });
|
||||
@@ -40,6 +47,40 @@ 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>
|
||||
@@ -51,12 +92,7 @@ const NotificationsPopover: React.FC = ({ children }: Props) => {
|
||||
scrollable={false}
|
||||
shrink
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<Notifications
|
||||
onRequestClose={handleRequestClose}
|
||||
ref={scrollableRef}
|
||||
/>
|
||||
</Suspense>
|
||||
{notificationsList}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ import Switch from "../Switch";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import { InputClientType } from "./InputClientType";
|
||||
|
||||
export interface FormData {
|
||||
export type FormData = {
|
||||
name: string;
|
||||
developerName: string;
|
||||
developerUrl: string;
|
||||
@@ -22,7 +22,7 @@ export interface FormData {
|
||||
redirectUris: string[];
|
||||
published: boolean;
|
||||
clientType: "confidential" | "public";
|
||||
}
|
||||
};
|
||||
|
||||
export const OAuthClientForm = observer(function OAuthClientForm_({
|
||||
handleSubmit,
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import env from "~/env";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { useTeamContext } from "./TeamContext";
|
||||
|
||||
type Props = {
|
||||
title: React.ReactNode;
|
||||
title: string;
|
||||
favicon?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ import PaginatedList from "~/components/PaginatedList";
|
||||
|
||||
type Props = {
|
||||
documents: Document[];
|
||||
fetch: (options: any) => Promise<Document[] | undefined>;
|
||||
// oxlint-disable-next-line no-explicit-any
|
||||
fetch: (options: Record<string, any>) => Promise<Document[] | undefined>;
|
||||
// oxlint-disable-next-line no-explicit-any
|
||||
options?: Record<string, any>;
|
||||
heading?: React.ReactNode;
|
||||
empty?: JSX.Element;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { isEqual } from "es-toolkit/compat";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Waypoint } from "react-waypoint";
|
||||
@@ -35,10 +35,12 @@ interface Props<
|
||||
* @param options Pagination and other query options
|
||||
*/
|
||||
fetch?: (
|
||||
// oxlint-disable-next-line no-explicit-any
|
||||
options: Record<string, any> | undefined
|
||||
) => Promise<unknown[] | undefined> | undefined;
|
||||
|
||||
/** Additional options to pass to the fetch function */
|
||||
// oxlint-disable-next-line no-explicit-any
|
||||
options?: Record<string, any>;
|
||||
|
||||
/** Optional header content to display above the list */
|
||||
@@ -78,7 +80,7 @@ interface Props<
|
||||
* Function to render section headings (typically date-based)
|
||||
* @param name The heading text or element to render
|
||||
*/
|
||||
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
|
||||
renderHeading?: (name: React.ReactElement | string) => React.ReactNode;
|
||||
|
||||
/**
|
||||
* Function to determine if an item is a duplicate of the previous item.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import styled from "styled-components";
|
||||
import Logger from "~/utils/Logger";
|
||||
import { Hook, usePluginValue } from "~/utils/PluginManager";
|
||||
import { Hook, PluginManager, usePluginValue } from "~/utils/PluginManager";
|
||||
|
||||
type Props = {
|
||||
/** The ID of the plugin to render an Icon for. */
|
||||
@@ -26,7 +26,9 @@ function PluginIcon({ id, color, size = 24 }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
Logger.warn("No Icon registered for plugin", { id });
|
||||
if (PluginManager.isLoaded) {
|
||||
Logger.warn("No Icon registered for plugin", { id });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -49,13 +49,15 @@ const useTooltipContent = ({
|
||||
);
|
||||
|
||||
// If the emoji is a custom emoji ID, we need to get its short name for display
|
||||
if (isUUID(emoji)) {
|
||||
emojis.fetch(emoji).then((ce) => {
|
||||
if (ce) {
|
||||
setTransformedEmoji(ce.shortName);
|
||||
}
|
||||
});
|
||||
}
|
||||
React.useEffect(() => {
|
||||
if (isUUID(emoji)) {
|
||||
void emojis.fetch(emoji).then((ce) => {
|
||||
if (ce) {
|
||||
setTransformedEmoji(ce.shortName);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [emoji, emojis]);
|
||||
|
||||
if (!reactedUsers.length) {
|
||||
return;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import compact from "lodash/compact";
|
||||
import { compact } from "es-toolkit/compat";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import type Comment from "~/models/Comment";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import compact from "lodash/compact";
|
||||
import { compact } from "es-toolkit/compat";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -34,7 +34,7 @@ const Scene: React.FC<Props> = ({
|
||||
wide,
|
||||
}: Props) => (
|
||||
<FillWidth>
|
||||
<PageTitle title={textTitle || title} />
|
||||
<PageTitle title={textTitle ?? (typeof title === "string" ? title : "")} />
|
||||
<Header
|
||||
hasSidebar
|
||||
title={
|
||||
|
||||
@@ -2,7 +2,6 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled, { css } from "styled-components";
|
||||
import { hideScrollbars } from "@shared/styles";
|
||||
import useWindowSize from "~/hooks/useWindowSize";
|
||||
|
||||
type Props = React.HTMLAttributes<HTMLDivElement> & {
|
||||
/** Whether to show shadows at top and bottom when scrolled */
|
||||
@@ -45,41 +44,37 @@ function Scrollable(
|
||||
const fallbackRef = React.useRef<HTMLDivElement>();
|
||||
const [topShadowVisible, setTopShadow] = React.useState(false);
|
||||
const [bottomShadowVisible, setBottomShadow] = React.useState(false);
|
||||
const { height } = useWindowSize();
|
||||
const updateShadows = React.useCallback(() => {
|
||||
const c = (ref || fallbackRef).current;
|
||||
if (!c) {
|
||||
return;
|
||||
}
|
||||
const scrollTop = c.scrollTop;
|
||||
const tsv = !!((shadow || topShadow || fadeTo) && scrollTop > 0);
|
||||
|
||||
if (tsv !== topShadowVisible) {
|
||||
setTopShadow(tsv);
|
||||
}
|
||||
setTopShadow(!!((shadow || topShadow || fadeTo) && scrollTop > 0));
|
||||
|
||||
const wrapperHeight = c.scrollHeight - c.clientHeight;
|
||||
const bsv = !!(
|
||||
(shadow || bottomShadow || fadeTo) &&
|
||||
wrapperHeight - scrollTop !== 0
|
||||
setBottomShadow(
|
||||
!!((shadow || bottomShadow || fadeTo) && wrapperHeight - scrollTop > 1)
|
||||
);
|
||||
|
||||
if (bsv !== bottomShadowVisible) {
|
||||
setBottomShadow(bsv);
|
||||
}
|
||||
}, [
|
||||
shadow,
|
||||
topShadow,
|
||||
bottomShadow,
|
||||
fadeTo,
|
||||
ref,
|
||||
topShadowVisible,
|
||||
bottomShadowVisible,
|
||||
]);
|
||||
}, [shadow, topShadow, bottomShadow, fadeTo, ref]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const c = (ref || fallbackRef).current;
|
||||
if (!c) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateShadows();
|
||||
}, [height, updateShadows]);
|
||||
|
||||
const observer = new ResizeObserver(updateShadows);
|
||||
observer.observe(c);
|
||||
|
||||
for (const child of Array.from(c.children)) {
|
||||
observer.observe(child);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [ref, updateShadows]);
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
|
||||
@@ -167,7 +167,7 @@ export const AccessControlList = observer(
|
||||
}
|
||||
}}
|
||||
label={t("Access")}
|
||||
hideLabel
|
||||
labelHidden
|
||||
disabled={!can.update}
|
||||
short
|
||||
nude
|
||||
@@ -192,7 +192,7 @@ export const AccessControlList = observer(
|
||||
});
|
||||
}}
|
||||
label={t("Permission")}
|
||||
hideLabel
|
||||
labelHidden
|
||||
disabled={!can.update}
|
||||
nude
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import debounce from "lodash/debounce";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { debounce, isEmpty } from "es-toolkit/compat";
|
||||
import { observer } from "mobx-react";
|
||||
import { CopyIcon, GlobeIcon, QuestionMarkIcon } from "outline-icons";
|
||||
import { CopyIcon, GlobeIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
@@ -20,7 +20,9 @@ import Text from "~/components/Text";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import env from "~/env";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { ListItem } from "../components/ListItem";
|
||||
import ShareSettingsPopover from "../components/ShareSettingsPopover";
|
||||
import { DomainPrefix, ShareLinkInput, StyledInfoIcon } from "../components";
|
||||
|
||||
type Props = {
|
||||
@@ -35,68 +37,46 @@ function InnerPublicAccess(
|
||||
ref: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const { shares } = useStores();
|
||||
const theme = useTheme();
|
||||
const [validationError, setValidationError] = React.useState("");
|
||||
const [urlId, setUrlId] = React.useState(share?.urlId);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const can = usePolicy(share);
|
||||
const collectionAbilities = usePolicy(collection);
|
||||
const canPublish = can.update && collectionAbilities.share;
|
||||
const canPublish = share ? can.update : collectionAbilities.share;
|
||||
const [creating, setCreating] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setUrlId(share?.urlId);
|
||||
}, [share?.urlId]);
|
||||
|
||||
const handleIndexingChanged = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await share?.save({
|
||||
allowIndexing: checked,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[share]
|
||||
);
|
||||
|
||||
const handleShowLastModifiedChanged = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await share?.save({
|
||||
showLastUpdated: checked,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[share]
|
||||
);
|
||||
|
||||
const handleShowTOCChanged = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await share?.save({
|
||||
showTOC: checked,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[share]
|
||||
);
|
||||
|
||||
const handlePublishedChange = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await share?.save({
|
||||
published: checked,
|
||||
});
|
||||
if (checked && !share) {
|
||||
setCreating(true);
|
||||
const newShare = await shares.create({
|
||||
type: "collection",
|
||||
collectionId: collection.id,
|
||||
published: true,
|
||||
});
|
||||
copy(newShare.url);
|
||||
toast.success(t("Public link copied to clipboard"));
|
||||
} else if (share) {
|
||||
await share.save({ published: checked });
|
||||
if (checked) {
|
||||
copy(share.url);
|
||||
toast.success(t("Public link copied to clipboard"));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
},
|
||||
[share]
|
||||
[t, share, shares, collection]
|
||||
);
|
||||
|
||||
const handleUrlChange = React.useMemo(
|
||||
@@ -159,7 +139,7 @@ function InnerPublicAccess(
|
||||
aria-label={t("Publish to internet")}
|
||||
checked={share?.published ?? false}
|
||||
onChange={handlePublishedChange}
|
||||
disabled={!canPublish}
|
||||
disabled={!canPublish || creating}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
@@ -169,96 +149,24 @@ function InnerPublicAccess(
|
||||
<ResizingHeightContainer>
|
||||
{!!share?.published && (
|
||||
<>
|
||||
<ListItem
|
||||
title={
|
||||
<Text type="tertiary" as={Flex}>
|
||||
{t("Search engine indexing")}
|
||||
<Tooltip
|
||||
content={t(
|
||||
"Disable this setting to discourage search engines from indexing the page"
|
||||
)}
|
||||
>
|
||||
<NudeButton size={18}>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</Text>
|
||||
}
|
||||
actions={
|
||||
<Switch
|
||||
aria-label={t("Search engine indexing")}
|
||||
checked={share?.allowIndexing ?? false}
|
||||
onChange={handleIndexingChanged}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
title={
|
||||
<Text type="tertiary" as={Flex}>
|
||||
{t("Show last modified")}
|
||||
<Tooltip
|
||||
content={t(
|
||||
"Display the last modified timestamp on the shared page"
|
||||
)}
|
||||
>
|
||||
<NudeButton size={18}>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</Text>
|
||||
}
|
||||
actions={
|
||||
<Switch
|
||||
aria-label={t("Show last modified")}
|
||||
checked={share?.showLastUpdated ?? false}
|
||||
onChange={handleShowLastModifiedChanged}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
title={
|
||||
<Text type="tertiary" as={Flex}>
|
||||
{t("Show table of contents")}
|
||||
<Tooltip
|
||||
content={t(
|
||||
"Display the table of contents on documents by default"
|
||||
)}
|
||||
>
|
||||
<NudeButton size={18}>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</Text>
|
||||
}
|
||||
actions={
|
||||
<Switch
|
||||
aria-label={t("Show table of contents")}
|
||||
checked={share?.showTOC ?? false}
|
||||
onChange={handleShowTOCChanged}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ShareLinkInput
|
||||
type="text"
|
||||
ref={inputRef}
|
||||
placeholder={share?.id}
|
||||
onChange={handleUrlChange}
|
||||
error={validationError}
|
||||
defaultValue={urlId}
|
||||
prefix={
|
||||
<DomainPrefix onClick={() => inputRef.current?.focus()}>
|
||||
{env.URL.replace(/https?:\/\//, "") + "/s/"}
|
||||
</DomainPrefix>
|
||||
}
|
||||
>
|
||||
{copyButton}
|
||||
</ShareLinkInput>
|
||||
<Flex align="center" gap={2}>
|
||||
<ShareLinkInput
|
||||
type="text"
|
||||
ref={inputRef}
|
||||
placeholder={share?.id}
|
||||
onChange={handleUrlChange}
|
||||
error={validationError}
|
||||
defaultValue={urlId}
|
||||
prefix={
|
||||
<DomainPrefix onClick={() => inputRef.current?.focus()}>
|
||||
{env.URL.replace(/https?:\/\//, "") + "/s/"}
|
||||
</DomainPrefix>
|
||||
}
|
||||
>
|
||||
{copyButton}
|
||||
</ShareLinkInput>
|
||||
<ShareSettingsPopover share={share} />
|
||||
</Flex>
|
||||
<Flex align="flex-start" gap={4}>
|
||||
<StyledInfoIcon color={theme.textTertiary} />
|
||||
<Text type="tertiary" size="xsmall">
|
||||
|
||||
@@ -66,6 +66,7 @@ function SharePopover({
|
||||
const share = shares.getByCollectionId(collection.id);
|
||||
const prevPendingIds = usePrevious(pendingIds);
|
||||
|
||||
const wrapperRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const suggestionsRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const searchInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
|
||||
@@ -89,6 +90,15 @@ function SharePopover({
|
||||
}
|
||||
);
|
||||
|
||||
// Move focus into the popover to account for lazy-loading
|
||||
React.useLayoutEffect(() => {
|
||||
if (!hasRendered) {
|
||||
return;
|
||||
}
|
||||
|
||||
(searchInputRef.current ?? wrapperRef.current)?.focus();
|
||||
}, [hasRendered]);
|
||||
|
||||
// Hide the picker when the popover is closed
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
@@ -351,7 +361,7 @@ function SharePopover({
|
||||
);
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Wrapper ref={wrapperRef} tabIndex={-1}>
|
||||
{can.update && (
|
||||
<SearchInput
|
||||
ref={searchInputRef}
|
||||
|
||||
@@ -154,7 +154,7 @@ export const AccessControlList = observer(
|
||||
value={document.isPrivate ? "private" : "inherited"}
|
||||
onChange={handleAccessChange}
|
||||
label={t("Access")}
|
||||
hideLabel
|
||||
labelHidden
|
||||
short
|
||||
disabled={!can.manageUsers || parentIsPrivate}
|
||||
nude
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import orderBy from "lodash/orderBy";
|
||||
import { orderBy } from "es-toolkit/compat";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import debounce from "lodash/debounce";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { debounce, isEmpty } from "es-toolkit/compat";
|
||||
import { observer } from "mobx-react";
|
||||
import { CopyIcon, GlobeIcon, QuestionMarkIcon } from "outline-icons";
|
||||
import { CopyIcon, GlobeIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
@@ -14,6 +14,7 @@ import type Share from "~/models/Share";
|
||||
import Switch from "~/components/Switch";
|
||||
import env from "~/env";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { AvatarSize } from "../../Avatar";
|
||||
import CopyToClipboard from "../../CopyToClipboard";
|
||||
import NudeButton from "../../NudeButton";
|
||||
@@ -21,6 +22,7 @@ import { ResizingHeightContainer } from "../../ResizingHeightContainer";
|
||||
import Text from "../../Text";
|
||||
import Tooltip from "../../Tooltip";
|
||||
import { ListItem } from "../components/ListItem";
|
||||
import ShareSettingsPopover from "../components/ShareSettingsPopover";
|
||||
import {
|
||||
DomainPrefix,
|
||||
ShareLinkInput,
|
||||
@@ -45,68 +47,46 @@ function PublicAccess(
|
||||
ref: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const { shares } = useStores();
|
||||
const theme = useTheme();
|
||||
const [validationError, setValidationError] = React.useState("");
|
||||
const [urlId, setUrlId] = React.useState(share?.urlId);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const can = usePolicy(share);
|
||||
const documentAbilities = usePolicy(document);
|
||||
const canPublish = can.update && documentAbilities.share;
|
||||
const canPublish = share ? can.update : documentAbilities.share;
|
||||
const [creating, setCreating] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setUrlId(share?.urlId);
|
||||
}, [share?.urlId]);
|
||||
|
||||
const handleIndexingChanged = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await share?.save({
|
||||
allowIndexing: checked,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[share]
|
||||
);
|
||||
|
||||
const handleShowLastModifiedChanged = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await share?.save({
|
||||
showLastUpdated: checked,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[share]
|
||||
);
|
||||
|
||||
const handleShowTOCChanged = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await share?.save({
|
||||
showTOC: checked,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[share]
|
||||
);
|
||||
|
||||
const handlePublishedChange = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await share?.save({
|
||||
published: checked,
|
||||
});
|
||||
if (checked && !share) {
|
||||
setCreating(true);
|
||||
const newShare = await shares.create({
|
||||
type: "document",
|
||||
documentId: document.id,
|
||||
published: true,
|
||||
});
|
||||
copy(newShare.url);
|
||||
toast.success(t("Public link copied to clipboard"));
|
||||
} else if (share) {
|
||||
await share.save({ published: checked });
|
||||
if (checked) {
|
||||
copy(share.url);
|
||||
toast.success(t("Public link copied to clipboard"));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
},
|
||||
[share]
|
||||
[t, share, shares, document]
|
||||
);
|
||||
|
||||
const handleUrlChange = React.useMemo(
|
||||
@@ -202,7 +182,7 @@ function PublicAccess(
|
||||
aria-label={t("Publish to internet")}
|
||||
checked={share?.published ?? false}
|
||||
onChange={handlePublishedChange}
|
||||
disabled={!canPublish}
|
||||
disabled={!canPublish || creating}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
@@ -211,106 +191,29 @@ function PublicAccess(
|
||||
/>
|
||||
|
||||
<ResizingHeightContainer>
|
||||
{share?.published && !sharedParent?.published && (
|
||||
<>
|
||||
<ListItem
|
||||
title={
|
||||
<Text type="tertiary" as={Flex}>
|
||||
{t("Search engine indexing")}
|
||||
<Tooltip
|
||||
content={t(
|
||||
"Disable this setting to discourage search engines from indexing the page"
|
||||
)}
|
||||
>
|
||||
<NudeButton size={18}>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</Text>
|
||||
}
|
||||
actions={
|
||||
<Switch
|
||||
aria-label={t("Search engine indexing")}
|
||||
checked={share?.allowIndexing ?? false}
|
||||
onChange={handleIndexingChanged}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
title={
|
||||
<Text type="tertiary" as={Flex}>
|
||||
{t("Show last modified")}
|
||||
<Tooltip
|
||||
content={t(
|
||||
"Display the last modified timestamp on the shared page"
|
||||
)}
|
||||
>
|
||||
<NudeButton size={18}>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</Text>
|
||||
}
|
||||
actions={
|
||||
<Switch
|
||||
aria-label={t("Show last modified")}
|
||||
checked={share?.showLastUpdated ?? false}
|
||||
onChange={handleShowLastModifiedChanged}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
title={
|
||||
<Text type="tertiary" as={Flex}>
|
||||
{t("Show table of contents")}
|
||||
<Tooltip
|
||||
content={t(
|
||||
"Display the table of contents on documents by default"
|
||||
)}
|
||||
>
|
||||
<NudeButton size={18}>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</Text>
|
||||
}
|
||||
actions={
|
||||
<Switch
|
||||
aria-label={t("Show table of contents")}
|
||||
checked={share?.showTOC ?? false}
|
||||
onChange={handleShowTOCChanged}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{sharedParent?.published && !document.isDraft ? (
|
||||
<ShareLinkInput type="text" disabled defaultValue={shareUrl}>
|
||||
{copyButton}
|
||||
</ShareLinkInput>
|
||||
) : share?.published ? (
|
||||
<ShareLinkInput
|
||||
type="text"
|
||||
ref={inputRef}
|
||||
placeholder={share?.id}
|
||||
onChange={handleUrlChange}
|
||||
error={validationError}
|
||||
defaultValue={urlId}
|
||||
prefix={
|
||||
<DomainPrefix onClick={() => inputRef.current?.focus()}>
|
||||
{env.URL.replace(/https?:\/\//, "") + "/s/"}
|
||||
</DomainPrefix>
|
||||
}
|
||||
>
|
||||
{copyButton}
|
||||
</ShareLinkInput>
|
||||
<Flex align="center" gap={2}>
|
||||
<ShareLinkInput
|
||||
type="text"
|
||||
ref={inputRef}
|
||||
placeholder={share?.id}
|
||||
onChange={handleUrlChange}
|
||||
error={validationError}
|
||||
defaultValue={urlId}
|
||||
prefix={
|
||||
<DomainPrefix onClick={() => inputRef.current?.focus()}>
|
||||
{env.URL.replace(/https?:\/\//, "") + "/s/"}
|
||||
</DomainPrefix>
|
||||
}
|
||||
>
|
||||
{copyButton}
|
||||
</ShareLinkInput>
|
||||
<ShareSettingsPopover share={share} />
|
||||
</Flex>
|
||||
) : null}
|
||||
|
||||
{share?.published && !share.includeChildDocuments ? (
|
||||
|
||||
@@ -68,6 +68,7 @@ function SharePopover({
|
||||
|
||||
const prevPendingIds = usePrevious(pendingIds);
|
||||
|
||||
const wrapperRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const suggestionsRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const searchInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
|
||||
@@ -91,6 +92,15 @@ function SharePopover({
|
||||
}
|
||||
);
|
||||
|
||||
// Move focus into the popover to account for lazy-loading
|
||||
React.useLayoutEffect(() => {
|
||||
if (!hasRendered) {
|
||||
return;
|
||||
}
|
||||
|
||||
(searchInputRef.current ?? wrapperRef.current)?.focus();
|
||||
}, [hasRendered]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
if (externalLoading === undefined) {
|
||||
@@ -358,7 +368,7 @@ function SharePopover({
|
||||
);
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Wrapper ref={wrapperRef} tabIndex={-1}>
|
||||
{can.manageUsers && (
|
||||
<SearchInput
|
||||
ref={searchInputRef}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user