mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e47eb3e41 | |||
| 39cb446748 | |||
| 4c465dcabd | |||
| 067a376ada |
@@ -0,0 +1,183 @@
|
||||
version: 2.1
|
||||
|
||||
defaults: &defaults
|
||||
working_directory: ~/outline
|
||||
docker:
|
||||
- image: cimg/node:20.10
|
||||
resource_class: large
|
||||
environment:
|
||||
NODE_ENV: test
|
||||
DATABASE_URL: postgres://postgres:password@localhost:5432/circle_test
|
||||
URL: http://localhost:3000
|
||||
NODE_OPTIONS: --max-old-space-size=8000
|
||||
|
||||
executors:
|
||||
docker-publisher:
|
||||
environment:
|
||||
IMAGE_NAME: outlinewiki/outline
|
||||
BASE_IMAGE_NAME: outlinewiki/outline-base
|
||||
docker:
|
||||
- image: circleci/buildpack-deps:stretch
|
||||
|
||||
jobs:
|
||||
build:
|
||||
<<: *defaults
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: dependency-cache-v1-{{ checksum "package.json" }}
|
||||
- run:
|
||||
name: install-deps
|
||||
command: yarn install --frozen-lockfile
|
||||
- save_cache:
|
||||
key: dependency-cache-v1-{{ checksum "package.json" }}
|
||||
paths:
|
||||
- ./node_modules
|
||||
lint:
|
||||
<<: *defaults
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: dependency-cache-v1-{{ checksum "package.json" }}
|
||||
- run:
|
||||
name: lint
|
||||
command: yarn lint
|
||||
types:
|
||||
<<: *defaults
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: dependency-cache-v1-{{ checksum "package.json" }}
|
||||
- run:
|
||||
name: typescript
|
||||
command: yarn tsc
|
||||
test-app:
|
||||
<<: *defaults
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: dependency-cache-v1-{{ checksum "package.json" }}
|
||||
- run:
|
||||
name: test
|
||||
command: yarn test:app
|
||||
test-shared:
|
||||
<<: *defaults
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: dependency-cache-v1-{{ checksum "package.json" }}
|
||||
- run:
|
||||
name: test
|
||||
command: yarn test:shared
|
||||
test-server:
|
||||
<<: *defaults
|
||||
parallelism: 3
|
||||
docker:
|
||||
- image: cimg/node:20.10
|
||||
- image: cimg/redis:5.0
|
||||
- image: cimg/postgres:14.2
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_DB: circle_test
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: dependency-cache-v1-{{ checksum "package.json" }}
|
||||
- run:
|
||||
name: migrate
|
||||
command: ./node_modules/.bin/sequelize db:migrate
|
||||
- run:
|
||||
name: test
|
||||
command: |
|
||||
TESTFILES=$(circleci tests glob "**/server/**/*.test.ts" | circleci tests split)
|
||||
yarn test --maxWorkers=2 $TESTFILES
|
||||
bundle-size:
|
||||
<<: *defaults
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: dependency-cache-v1-{{ checksum "package.json" }}
|
||||
- run:
|
||||
name: build-vite
|
||||
command: yarn vite:build
|
||||
- run:
|
||||
name: Send bundle stats to RelativeCI
|
||||
command: npx relative-ci-agent
|
||||
build-image:
|
||||
executor: docker-publisher
|
||||
steps:
|
||||
- checkout
|
||||
- setup_remote_docker
|
||||
- run:
|
||||
name: Install Docker buildx
|
||||
command: |
|
||||
mkdir -p ~/.docker/cli-plugins
|
||||
url="https://github.com/docker/buildx/releases/download/v0.8.0/buildx-v0.8.0.linux-amd64"
|
||||
curl -sSL -o ~/.docker/cli-plugins/docker-buildx $url
|
||||
chmod a+x ~/.docker/cli-plugins/docker-buildx
|
||||
- run:
|
||||
name: Enable Docker buildx
|
||||
command: export DOCKER_CLI_EXPERIMENTAL=enabled
|
||||
- run:
|
||||
name: Initialize Docker buildx
|
||||
command: |
|
||||
docker buildx install
|
||||
docker context create docker-multiarch
|
||||
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
|
||||
docker buildx create --name docker-multiarch --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x docker-multiarch
|
||||
docker buildx inspect --builder docker-multiarch --bootstrap
|
||||
docker buildx use docker-multiarch
|
||||
- run:
|
||||
name: Build base image
|
||||
command: docker build -f Dockerfile.base -t $BASE_IMAGE_NAME:latest --load .
|
||||
- run:
|
||||
name: Login to Docker Hub
|
||||
command: echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
|
||||
- run:
|
||||
name: Publish base Docker Image to Docker Hub
|
||||
command: docker push $BASE_IMAGE_NAME:latest
|
||||
- run:
|
||||
name: Build and push Docker image
|
||||
command: |
|
||||
if [[ "$CIRCLE_TAG" == *"-"* ]]; then
|
||||
docker buildx build -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
|
||||
else
|
||||
docker buildx build -t $IMAGE_NAME:latest -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
|
||||
fi
|
||||
|
||||
workflows:
|
||||
version: 2
|
||||
all:
|
||||
jobs:
|
||||
- build
|
||||
- lint:
|
||||
requires:
|
||||
- build
|
||||
- test-server:
|
||||
requires:
|
||||
- build
|
||||
- test-shared:
|
||||
requires:
|
||||
- build
|
||||
- test-app:
|
||||
requires:
|
||||
- build
|
||||
- types:
|
||||
requires:
|
||||
- build
|
||||
- bundle-size:
|
||||
requires:
|
||||
- build
|
||||
- types
|
||||
|
||||
build-docker:
|
||||
jobs:
|
||||
- build-image:
|
||||
filters:
|
||||
tags:
|
||||
only: /^v.*/
|
||||
branches:
|
||||
ignore: /.*/
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
curl --user ${CIRCLE_TOKEN}: \
|
||||
--request POST \
|
||||
--form revision=<ENTER COMMIT SHA HERE>\
|
||||
--form config=@config.yml \
|
||||
--form notify=false \
|
||||
https://circleci.com/api/v1.1/project/github/outline/outline/tree/master
|
||||
@@ -1,8 +1,5 @@
|
||||
URL=https://local.outline.dev:3000
|
||||
|
||||
DATABASE_URL=postgres://user:pass@127.0.0.1:5432/outline
|
||||
REDIS_URL=redis://127.0.0.1:6379
|
||||
|
||||
SMTP_FROM_EMAIL=hello@example.com
|
||||
|
||||
# Enable unsafe-inline in script-src CSP directive
|
||||
|
||||
+2
-2
@@ -12,14 +12,14 @@ UTILS_SECRET=generate_a_new_key
|
||||
|
||||
# For production point these at your databases, in development the default
|
||||
# should work out of the box.
|
||||
DATABASE_URL=postgres://user:pass@postgres:5432/outline
|
||||
DATABASE_URL=postgres://user:pass@localhost:5432/outline
|
||||
DATABASE_CONNECTION_POOL_MIN=
|
||||
DATABASE_CONNECTION_POOL_MAX=
|
||||
# Uncomment this to disable SSL for connecting to Postgres
|
||||
# PGSSLMODE=disable
|
||||
|
||||
# For redis you can either specify an ioredis compatible url like this
|
||||
REDIS_URL=redis://redis:6379
|
||||
REDIS_URL=redis://localhost:6379
|
||||
# or alternatively, if you would like to provide additional connection options,
|
||||
# use a base64 encoded JSON connection option object. Refer to the ioredis documentation
|
||||
# for a list of available options.
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots or videos to help explain your problem.
|
||||
|
||||
**Outline (please complete the following information):**
|
||||
- Install: [getoutline.com or self hosted]
|
||||
- Version: [commit sha if self hosted]
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Mobile (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
@@ -1,63 +0,0 @@
|
||||
name: Bug report
|
||||
description: File a bug to help us improve
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Is there an existing issue for this?
|
||||
description: Please search to see if an issue already exists for the bug you encountered.
|
||||
options:
|
||||
- label: I have searched the existing issues
|
||||
required: true
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: This is not related to configuring Outline
|
||||
description: I understand that questions related to configuring self-hosted Outline should be asked in the [community forum](https://github.com/outline/outline/discussions/categories/self-hosting).
|
||||
options:
|
||||
- label: The issue is not related to self-hosting config
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Current Behavior
|
||||
description: A concise description of what you're experiencing.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: A concise description of what you expected to happen.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Steps To Reproduce
|
||||
description: Steps to reproduce the behavior.
|
||||
placeholder: |
|
||||
1. In this environment...
|
||||
1. With this config...
|
||||
1. Run '...'
|
||||
1. See error...
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Environment
|
||||
description: |
|
||||
examples:
|
||||
- **Outline**: Outline 0.80.0
|
||||
- **Browser**: Safari
|
||||
value: |
|
||||
- Outline:
|
||||
- Browser:
|
||||
render: markdown
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Anything else?
|
||||
description: |
|
||||
Links? References? Anything that will give us more context about the issue you are encountering!
|
||||
|
||||
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
|
||||
validations:
|
||||
required: false
|
||||
@@ -13,16 +13,3 @@ updates:
|
||||
update-types: ["version-update:semver-major"]
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
groups:
|
||||
babel:
|
||||
patterns:
|
||||
- "@babel/*"
|
||||
sentry:
|
||||
patterns:
|
||||
- "@sentry/*"
|
||||
fortawesome:
|
||||
patterns:
|
||||
- "@fortawesome/*"
|
||||
aws:
|
||||
patterns:
|
||||
- "@aws-sdk/*"
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
env:
|
||||
NODE_ENV: test
|
||||
DATABASE_URL: postgres://postgres:password@localhost:5432/outline_test
|
||||
REDIS_URL: redis://127.0.0.1:6379
|
||||
URL: http://localhost:3000
|
||||
NODE_OPTIONS: --max-old-space-size=8000
|
||||
SECRET_KEY: F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
|
||||
UTILS_SECRET: 123456
|
||||
SLACK_VERIFICATION_TOKEN: 123456
|
||||
SMTP_USERNAME: localhost
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
lint:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: 'yarn'
|
||||
- run: yarn install --frozen-lockfile
|
||||
- run: yarn lint
|
||||
|
||||
types:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: 'yarn'
|
||||
- run: yarn install --frozen-lockfile
|
||||
- run: yarn tsc
|
||||
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
server: ${{ steps.filter.outputs.server }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dorny/paths-filter@v2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
server:
|
||||
- 'server/**'
|
||||
- 'shared/**'
|
||||
- 'package.json'
|
||||
- 'yarn.lock'
|
||||
app:
|
||||
- 'app/**'
|
||||
- 'shared/**'
|
||||
- 'package.json'
|
||||
- 'yarn.lock'
|
||||
|
||||
test:
|
||||
needs: build
|
||||
if: ${{ needs.changes.outputs.app == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
test-group: [app, shared]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: 'yarn'
|
||||
- run: yarn install --frozen-lockfile
|
||||
- run: yarn test:${{ matrix.test-group }}
|
||||
|
||||
test-server:
|
||||
needs: [build, changes]
|
||||
if: ${{ needs.changes.outputs.server == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:14.2
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_DB: outline_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
redis:
|
||||
image: redis:5.0
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
shard: [1, 2, 3]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: 'yarn'
|
||||
- run: yarn install --frozen-lockfile
|
||||
- run: yarn sequelize db:migrate
|
||||
- name: Run server tests
|
||||
run: |
|
||||
TESTFILES=$(find . -name "*.test.ts" -path "*/server/*" | sort | split -n -d -l $(($(find . -name "*.test.ts" -path "*/server/*" | wc -l)/${{ matrix.shard }})) - | sed -n "${{ matrix.shard }}p")
|
||||
yarn test --maxWorkers=2 $TESTFILES
|
||||
|
||||
bundle-size:
|
||||
needs: [build, types]
|
||||
if: ${{ needs.changes.outputs.app == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: 'yarn'
|
||||
- run: yarn install --frozen-lockfile
|
||||
- name: Set environment to production
|
||||
run: echo "NODE_ENV=production" >> $GITHUB_ENV
|
||||
- run: yarn vite:build
|
||||
- name: Send bundle stats to RelativeCI
|
||||
uses: relative-ci/agent-action@v2
|
||||
with:
|
||||
key: ${{ secrets.RELATIVE_CI_KEY }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -1,52 +0,0 @@
|
||||
name: Docker build
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
env:
|
||||
IMAGE_NAME: outlinewiki/outline
|
||||
BASE_IMAGE_NAME: outlinewiki/outline-base
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Build and push base image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.base
|
||||
push: true
|
||||
tags: ${{ env.BASE_IMAGE_NAME }}:latest
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x
|
||||
|
||||
- name: Extract version
|
||||
id: version
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push main image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x
|
||||
tags: |
|
||||
${{ env.IMAGE_NAME }}:latest
|
||||
${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
|
||||
@@ -24,6 +24,6 @@ jobs:
|
||||
operations-per-run: 60
|
||||
stale-issue-label: stale
|
||||
stale-pr-label: stale
|
||||
exempt-issue-labels: "security,pinned,A1"
|
||||
exempt-issue-labels: "security,pinned"
|
||||
- name: Print outputs
|
||||
run: echo ${{ join(steps.stale.outputs.*, ',') }}
|
||||
|
||||
@@ -3,8 +3,8 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.82.1-3
|
||||
The Licensed Work is (c) 2025 General Outline, Inc.
|
||||
Licensed Work: Outline 0.81.0
|
||||
The Licensed Work is (c) 2024 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
|
||||
Service.
|
||||
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
Licensed Work by creating teams and documents
|
||||
controlled by such third parties.
|
||||
|
||||
Change Date: 2029-02-22
|
||||
Change Date: 2028-11-11
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -8,10 +8,8 @@ import {
|
||||
SearchIcon,
|
||||
ShapesIcon,
|
||||
StarredIcon,
|
||||
SubscribeIcon,
|
||||
TrashIcon,
|
||||
UnstarredIcon,
|
||||
UnsubscribeIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -207,66 +205,6 @@ export const unstarCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const subscribeCollection = createAction({
|
||||
name: ({ t }) => t("Subscribe"),
|
||||
analyticsName: "Subscribe to collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <SubscribeIcon />,
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
return (
|
||||
!collection?.isSubscribed &&
|
||||
stores.policies.abilities(activeCollectionId).subscribe
|
||||
);
|
||||
},
|
||||
perform: async ({ activeCollectionId, stores, t }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
await collection?.subscribe();
|
||||
|
||||
toast.success(t("Subscribed to document notifications"));
|
||||
},
|
||||
});
|
||||
|
||||
export const unsubscribeCollection = createAction({
|
||||
name: ({ t }) => t("Unsubscribe"),
|
||||
analyticsName: "Unsubscribe from collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <UnsubscribeIcon />,
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
return (
|
||||
!!collection?.isSubscribed &&
|
||||
stores.policies.abilities(activeCollectionId).unsubscribe
|
||||
);
|
||||
},
|
||||
perform: async ({ activeCollectionId, currentUserId, stores, t }) => {
|
||||
if (!activeCollectionId || !currentUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
await collection?.unsubscribe();
|
||||
|
||||
toast.success(t("Unsubscribed from document notifications"));
|
||||
},
|
||||
});
|
||||
|
||||
export const archiveCollection = createAction({
|
||||
name: ({ t }) => `${t("Archive")}…`,
|
||||
analyticsName: "Archive collection",
|
||||
@@ -393,7 +331,5 @@ export const rootCollectionActions = [
|
||||
createCollection,
|
||||
starCollection,
|
||||
unstarCollection,
|
||||
subscribeCollection,
|
||||
unsubscribeCollection,
|
||||
deleteCollection,
|
||||
];
|
||||
|
||||
@@ -53,13 +53,9 @@ export const resolveCommentFactory = ({
|
||||
perform: async ({ t }) => {
|
||||
await comment.resolve();
|
||||
|
||||
const locationState = history.location.state as Record<string, unknown>;
|
||||
history.replace({
|
||||
...history.location,
|
||||
state: {
|
||||
sidebarContext: locationState["sidebarContext"],
|
||||
commentId: undefined,
|
||||
},
|
||||
state: null,
|
||||
});
|
||||
|
||||
onResolve();
|
||||
@@ -85,13 +81,9 @@ export const unresolveCommentFactory = ({
|
||||
perform: async () => {
|
||||
await comment.unresolve();
|
||||
|
||||
const locationState = history.location.state as Record<string, unknown>;
|
||||
history.replace({
|
||||
...history.location,
|
||||
state: {
|
||||
sidebarContext: locationState["sidebarContext"],
|
||||
commentId: undefined,
|
||||
},
|
||||
state: null,
|
||||
});
|
||||
|
||||
onUnresolve();
|
||||
|
||||
@@ -2,7 +2,6 @@ import copy from "copy-to-clipboard";
|
||||
import {
|
||||
BeakerIcon,
|
||||
CopyIcon,
|
||||
EditIcon,
|
||||
ToolsIcon,
|
||||
TrashIcon,
|
||||
UserIcon,
|
||||
@@ -84,38 +83,6 @@ export const copyId = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
function generateRandomText() {
|
||||
const characters =
|
||||
"abcdefghijklmno pqrstuvwxyzABCDEFGHIJKL MNOPQRSTUVWXYZ 0123456789\n";
|
||||
let text = "";
|
||||
for (let i = 0; i < Math.floor(Math.random() * 10) + 1; i++) {
|
||||
text += characters.charAt(Math.floor(Math.random() * characters.length));
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export const startTyping = createAction({
|
||||
name: "Start automatic typing",
|
||||
icon: <EditIcon />,
|
||||
section: DeveloperSection,
|
||||
visible: ({ activeDocumentId }) =>
|
||||
!!activeDocumentId && env.ENVIRONMENT === "development",
|
||||
perform: () => {
|
||||
const intervalId = setInterval(() => {
|
||||
const text = generateRandomText();
|
||||
document.execCommand("insertText", false, text);
|
||||
}, 250);
|
||||
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
intervalId && clearInterval(intervalId);
|
||||
}
|
||||
});
|
||||
|
||||
toast.info("Automatic typing started, press Escape to stop");
|
||||
},
|
||||
});
|
||||
|
||||
export const clearIndexedDB = createAction({
|
||||
name: ({ t }) => t("Clear IndexedDB cache"),
|
||||
icon: <TrashIcon />,
|
||||
@@ -202,7 +169,6 @@ export const developer = createAction({
|
||||
createToast,
|
||||
createTestUsers,
|
||||
clearIndexedDB,
|
||||
startTyping,
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import {
|
||||
ExportContentType,
|
||||
TeamPreference,
|
||||
@@ -47,6 +46,7 @@ import DocumentPublish from "~/scenes/DocumentPublish";
|
||||
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import DocumentCopy from "~/components/DocumentCopy";
|
||||
import Icon from "~/components/Icon";
|
||||
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
|
||||
import SharePopover from "~/components/Sharing/Document";
|
||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||
@@ -125,20 +125,6 @@ export const createDocument = createAction({
|
||||
}),
|
||||
});
|
||||
|
||||
export const createDraftDocument = createAction({
|
||||
name: ({ t }) => t("New draft"),
|
||||
analyticsName: "New document",
|
||||
section: DocumentSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "create document",
|
||||
visible: ({ currentTeamId, stores }) =>
|
||||
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument,
|
||||
perform: ({ sidebarContext }) =>
|
||||
history.push(newDocumentPath(), {
|
||||
sidebarContext,
|
||||
}),
|
||||
});
|
||||
|
||||
export const createDocumentFromTemplate = createAction({
|
||||
name: ({ t }) => t("New from template"),
|
||||
analyticsName: "New document",
|
||||
@@ -333,7 +319,6 @@ export const subscribeDocument = createAction({
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
return (
|
||||
!document?.collection?.isSubscribed &&
|
||||
!document?.isSubscribed &&
|
||||
stores.policies.abilities(activeDocumentId).subscribe
|
||||
);
|
||||
@@ -362,9 +347,8 @@ export const unsubscribeDocument = createAction({
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
return (
|
||||
!!document?.collection?.isSubscribed ||
|
||||
(!!document?.isSubscribed &&
|
||||
stores.policies.abilities(activeDocumentId).unsubscribe)
|
||||
!!document?.isSubscribed &&
|
||||
stores.policies.abilities(activeDocumentId).unsubscribe
|
||||
);
|
||||
},
|
||||
perform: async ({ activeDocumentId, stores, currentUserId, t }) => {
|
||||
@@ -374,7 +358,7 @@ export const unsubscribeDocument = createAction({
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
await document?.unsubscribe();
|
||||
await document?.unsubscribe(currentUserId);
|
||||
|
||||
toast.success(t("Unsubscribed from document notifications"));
|
||||
},
|
||||
@@ -748,6 +732,7 @@ export const importDocument = createAction({
|
||||
history.push(document.url);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1195,8 +1180,6 @@ export const rootDocumentActions = [
|
||||
openDocument,
|
||||
archiveDocument,
|
||||
createDocument,
|
||||
createDraftDocument,
|
||||
createNestedDocument,
|
||||
createTemplateFromDocument,
|
||||
deleteDocument,
|
||||
importDocument,
|
||||
|
||||
@@ -13,8 +13,6 @@ export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
|
||||
|
||||
export const DocumentSection = ({ t }: ActionContext) => t("Document");
|
||||
|
||||
export const DocumentsSection = ({ t }: ActionContext) => t("Documents");
|
||||
|
||||
export const ActiveDocumentSection = ({ t, stores }: ActionContext) => {
|
||||
const activeDocument = stores.documents.active;
|
||||
return `${t("Document")} · ${activeDocument?.titleWithDefault}`;
|
||||
@@ -36,8 +34,6 @@ export const NotificationSection = ({ t }: ActionContext) => t("Notification");
|
||||
|
||||
export const UserSection = ({ t }: ActionContext) => t("People");
|
||||
|
||||
UserSection.priority = 0.5;
|
||||
|
||||
export const TeamSection = ({ t }: ActionContext) => t("Workspace");
|
||||
|
||||
export const RecentSearchesSection = ({ t }: ActionContext) =>
|
||||
|
||||
@@ -7,10 +7,9 @@ export enum AvatarSize {
|
||||
Small = 16,
|
||||
Toast = 18,
|
||||
Medium = 24,
|
||||
Large = 28,
|
||||
XLarge = 32,
|
||||
XXLarge = 48,
|
||||
Upload = 64,
|
||||
Large = 32,
|
||||
XLarge = 48,
|
||||
XXLarge = 64,
|
||||
}
|
||||
|
||||
export interface IAvatar {
|
||||
@@ -21,37 +20,36 @@ export interface IAvatar {
|
||||
}
|
||||
|
||||
type Props = {
|
||||
/** The size of the avatar */
|
||||
size: AvatarSize;
|
||||
/** The source of the avatar image, if not passing a model. */
|
||||
src?: string;
|
||||
/** The avatar model, if not passing a source. */
|
||||
model?: IAvatar;
|
||||
/** The alt text for the image */
|
||||
alt?: string;
|
||||
/** Optional click handler */
|
||||
showBorder?: boolean;
|
||||
onClick?: React.MouseEventHandler<HTMLImageElement>;
|
||||
/** Optional class name */
|
||||
className?: string;
|
||||
/** Optional style */
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
function Avatar(props: Props) {
|
||||
const { model, style, ...rest } = props;
|
||||
const { showBorder, model, style, ...rest } = props;
|
||||
const src = props.src || model?.avatarUrl;
|
||||
const [error, handleError] = useBoolean(false);
|
||||
|
||||
return (
|
||||
<Relative style={style}>
|
||||
{src && !error ? (
|
||||
<CircleImg onError={handleError} src={src} {...rest} />
|
||||
<CircleImg
|
||||
onError={handleError}
|
||||
src={src}
|
||||
$showBorder={showBorder}
|
||||
{...rest}
|
||||
/>
|
||||
) : model ? (
|
||||
<Initials color={model.color} {...rest}>
|
||||
<Initials color={model.color} $showBorder={showBorder} {...rest}>
|
||||
{model.initial}
|
||||
</Initials>
|
||||
) : (
|
||||
<Initials {...rest} />
|
||||
<Initials $showBorder={showBorder} {...rest} />
|
||||
)}
|
||||
</Relative>
|
||||
);
|
||||
@@ -67,11 +65,15 @@ const Relative = styled.div`
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const CircleImg = styled.img<{ size: number }>`
|
||||
const CircleImg = styled.img<{ size: number; $showBorder?: boolean }>`
|
||||
display: block;
|
||||
width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
border-radius: 50%;
|
||||
border: ${(props) =>
|
||||
props.$showBorder === false
|
||||
? "none"
|
||||
: `2px solid ${props.theme.background}`};
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
@@ -5,7 +5,7 @@ import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import User from "~/models/User";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import Avatar, { AvatarSize } from "./Avatar";
|
||||
import Avatar from "./Avatar";
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
@@ -14,8 +14,6 @@ type Props = {
|
||||
isObserving: boolean;
|
||||
isCurrentUser: boolean;
|
||||
onClick?: React.MouseEventHandler<HTMLImageElement>;
|
||||
size?: AvatarSize;
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
function AvatarWithPresence({
|
||||
@@ -25,8 +23,6 @@ function AvatarWithPresence({
|
||||
isEditing,
|
||||
isObserving,
|
||||
isCurrentUser,
|
||||
size = AvatarSize.Large,
|
||||
style,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const status = isPresent
|
||||
@@ -51,14 +47,13 @@ function AvatarWithPresence({
|
||||
}
|
||||
placement="bottom"
|
||||
>
|
||||
<AvatarPresence
|
||||
<AvatarWrapper
|
||||
$isPresent={isPresent}
|
||||
$isObserving={isObserving}
|
||||
$color={user.color}
|
||||
style={style}
|
||||
>
|
||||
<Avatar model={user} onClick={onClick} size={size} />
|
||||
</AvatarPresence>
|
||||
<Avatar model={user} onClick={onClick} size={32} />
|
||||
</AvatarWrapper>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
@@ -74,7 +69,7 @@ type AvatarWrapperProps = {
|
||||
$color: string;
|
||||
};
|
||||
|
||||
const AvatarPresence = styled.div<AvatarWrapperProps>`
|
||||
const AvatarWrapper = styled.div<AvatarWrapperProps>`
|
||||
opacity: ${(props) => (props.$isPresent ? 1 : 0.5)};
|
||||
transition: opacity 250ms ease-in-out;
|
||||
border-radius: 50%;
|
||||
|
||||
@@ -1,33 +1,27 @@
|
||||
import { getLuminance } from "polished";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
|
||||
const Initials = styled(Flex)<{
|
||||
/** The color of the background, defaults to textTertiary. */
|
||||
color?: string;
|
||||
/** Content is only used to calculate font size, use children to render. */
|
||||
content?: string;
|
||||
/** The size of the avatar */
|
||||
size: number;
|
||||
$showBorder?: boolean;
|
||||
}>`
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: ${(props) =>
|
||||
getLuminance(props.color ?? props.theme.textTertiary) > 0.5
|
||||
? s("black50")
|
||||
: s("white75")};
|
||||
background-color: ${(props) => props.color ?? props.theme.textTertiary};
|
||||
color: ${s("white75")};
|
||||
background-color: ${(props) => props.color};
|
||||
width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid
|
||||
${(props) =>
|
||||
props.$showBorder === false ? "transparent" : props.theme.background};
|
||||
flex-shrink: 0;
|
||||
|
||||
// adjust font size down for each additional character
|
||||
font-size: ${(props) => props.size / 2 - (props.content?.length ?? 0)}px;
|
||||
font-size: ${(props) => props.size / 2}px;
|
||||
font-weight: 500;
|
||||
`;
|
||||
|
||||
|
||||
@@ -37,11 +37,7 @@ function Breadcrumb(
|
||||
return (
|
||||
<Flex justify="flex-start" align="center" ref={ref}>
|
||||
{topLevelItems.map((item, index) => (
|
||||
<React.Fragment
|
||||
key={
|
||||
(typeof item.to === "string" ? item.to : item.to.pathname) || index
|
||||
}
|
||||
>
|
||||
<React.Fragment key={String(item.to) || index}>
|
||||
{item.icon}
|
||||
{item.to ? (
|
||||
<Item
|
||||
|
||||
@@ -7,7 +7,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
||||
import Document from "~/models/Document";
|
||||
import { AvatarSize, AvatarWithPresence } from "~/components/Avatar";
|
||||
import { AvatarWithPresence } from "~/components/Avatar";
|
||||
import DocumentViews from "~/components/DocumentViews";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
@@ -78,56 +78,49 @@ function Collaborators(props: Props) {
|
||||
placement: "bottom-end",
|
||||
});
|
||||
|
||||
const renderAvatar = React.useCallback(
|
||||
({ model: collaborator, ...rest }) => {
|
||||
const isPresent = presentIds.includes(collaborator.id);
|
||||
const isEditing = editingIds.includes(collaborator.id);
|
||||
const isObserving = ui.observingUserId === collaborator.id;
|
||||
const isObservable = collaborator.id !== currentUserId;
|
||||
|
||||
return (
|
||||
<AvatarWithPresence
|
||||
{...rest}
|
||||
key={collaborator.id}
|
||||
user={collaborator}
|
||||
isPresent={isPresent}
|
||||
isEditing={isEditing}
|
||||
isObserving={isObserving}
|
||||
isCurrentUser={currentUserId === collaborator.id}
|
||||
onClick={
|
||||
isObservable
|
||||
? (ev) => {
|
||||
if (isPresent) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
ui.setObservingUser(
|
||||
isObserving ? undefined : collaborator.id
|
||||
);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[presentIds, ui, currentUserId, editingIds]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PopoverDisclosure {...popover}>
|
||||
{(popoverProps) => (
|
||||
<NudeButton
|
||||
width={Math.min(collaborators.length, limit) * AvatarSize.Large}
|
||||
height={AvatarSize.Large}
|
||||
width={Math.min(collaborators.length, limit) * 32}
|
||||
height={32}
|
||||
{...popoverProps}
|
||||
>
|
||||
<Facepile
|
||||
size={AvatarSize.Large}
|
||||
limit={limit}
|
||||
overflow={Math.max(0, collaborators.length - limit)}
|
||||
overflow={collaborators.length - limit}
|
||||
users={collaborators}
|
||||
renderAvatar={renderAvatar}
|
||||
renderAvatar={(collaborator) => {
|
||||
const isPresent = presentIds.includes(collaborator.id);
|
||||
const isEditing = editingIds.includes(collaborator.id);
|
||||
const isObserving = ui.observingUserId === collaborator.id;
|
||||
const isObservable = collaborator.id !== user.id;
|
||||
|
||||
return (
|
||||
<AvatarWithPresence
|
||||
key={collaborator.id}
|
||||
user={collaborator}
|
||||
isPresent={isPresent}
|
||||
isEditing={isEditing}
|
||||
isObserving={isObserving}
|
||||
isCurrentUser={currentUserId === collaborator.id}
|
||||
onClick={
|
||||
isObservable
|
||||
? (ev) => {
|
||||
if (isPresent) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
ui.setObservingUser(
|
||||
isObserving ? undefined : collaborator.id
|
||||
);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</NudeButton>
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,6 @@ import * as React from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { randomElement } from "@shared/random";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import { IconLibrary } from "@shared/utils/IconLibrary";
|
||||
@@ -12,6 +11,7 @@ import { CollectionValidation } from "@shared/validations";
|
||||
import Collection from "~/models/Collection";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import Icon from "~/components/Icon";
|
||||
import Input from "~/components/Input";
|
||||
import InputSelectPermission from "~/components/InputSelectPermission";
|
||||
import Switch from "~/components/Switch";
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
import debounce from "lodash/debounce";
|
||||
import { observer } from "mobx-react";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { richExtensions } from "@shared/editor/nodes";
|
||||
import { s } from "@shared/styles";
|
||||
import { CollectionValidation } from "@shared/validations";
|
||||
import Collection from "~/models/Collection";
|
||||
import Arrow from "~/components/Arrow";
|
||||
import ButtonLink from "~/components/ButtonLink";
|
||||
import Editor from "~/components/Editor";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import { withUIExtensions } from "~/editor/extensions";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import BlockMenuExtension from "~/editor/extensions/BlockMenu";
|
||||
import EmojiMenuExtension from "~/editor/extensions/EmojiMenu";
|
||||
import HoverPreviewsExtension from "~/editor/extensions/HoverPreviews";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Text from "./Text";
|
||||
|
||||
const extensions = withUIExtensions(richExtensions);
|
||||
const extensions = [
|
||||
...richExtensions,
|
||||
BlockMenuExtension,
|
||||
EmojiMenuExtension,
|
||||
HoverPreviewsExtension,
|
||||
];
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
@@ -24,8 +33,33 @@ type Props = {
|
||||
function CollectionDescription({ collection }: Props) {
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const [isExpanded, setExpanded] = React.useState(false);
|
||||
const [isEditing, setEditing] = React.useState(false);
|
||||
const [isDirty, setDirty] = React.useState(false);
|
||||
const can = usePolicy(collection);
|
||||
|
||||
const handleStartEditing = React.useCallback(() => {
|
||||
setEditing(true);
|
||||
}, []);
|
||||
|
||||
const handleStopEditing = React.useCallback(() => {
|
||||
setEditing(false);
|
||||
}, []);
|
||||
|
||||
const handleClickDisclosure = React.useCallback(
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (isExpanded && document.activeElement) {
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'blur' does not exist on type 'Element'.
|
||||
document.activeElement.blur();
|
||||
}
|
||||
|
||||
setExpanded(!isExpanded);
|
||||
},
|
||||
[isExpanded]
|
||||
);
|
||||
|
||||
const handleSave = React.useMemo(
|
||||
() =>
|
||||
debounce(async (getValue) => {
|
||||
@@ -33,6 +67,7 @@ function CollectionDescription({ collection }: Props) {
|
||||
await collection.save({
|
||||
data: getValue(false),
|
||||
});
|
||||
setDirty(false);
|
||||
} catch (err) {
|
||||
toast.error(t("Sorry, an error occurred saving the collection"));
|
||||
throw err;
|
||||
@@ -41,44 +76,162 @@ function CollectionDescription({ collection }: Props) {
|
||||
[collection, t]
|
||||
);
|
||||
|
||||
const childRef = React.useRef<HTMLDivElement>(null);
|
||||
const childOffsetHeight = childRef.current?.offsetHeight || 0;
|
||||
const editorStyle = React.useMemo(
|
||||
() => ({
|
||||
padding: "0 32px",
|
||||
margin: "0 -32px",
|
||||
paddingBottom: `calc(50vh - ${childOffsetHeight}px)`,
|
||||
}),
|
||||
[childOffsetHeight]
|
||||
const handleChange = React.useCallback(
|
||||
async (getValue) => {
|
||||
setDirty(true);
|
||||
await handleSave(getValue);
|
||||
},
|
||||
[handleSave]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
setEditing(false);
|
||||
}, [collection.id]);
|
||||
const placeholder = `${t("Add a description")}…`;
|
||||
const key = isEditing || isDirty ? "draft" : collection.updatedAt;
|
||||
|
||||
return (
|
||||
<>
|
||||
{collections.isSaving && <LoadingIndicator />}
|
||||
{(collection.hasDescription || can.update) && (
|
||||
<React.Suspense fallback={<Placeholder>Loading…</Placeholder>}>
|
||||
<Editor
|
||||
defaultValue={collection.data}
|
||||
onChange={handleSave}
|
||||
placeholder={`${t("Add a description")}…`}
|
||||
extensions={extensions}
|
||||
maxLength={CollectionValidation.maxDescriptionLength}
|
||||
canUpdate={can.update}
|
||||
readOnly={!can.update}
|
||||
editorStyle={editorStyle}
|
||||
embedsDisabled
|
||||
/>
|
||||
<div ref={childRef} />
|
||||
</React.Suspense>
|
||||
<MaxHeight data-editing={isEditing} data-expanded={isExpanded}>
|
||||
<Input data-editing={isEditing} data-expanded={isExpanded}>
|
||||
<span onClick={can.update ? handleStartEditing : undefined}>
|
||||
{collections.isSaving && <LoadingIndicator />}
|
||||
{collection.hasDescription || isEditing || isDirty ? (
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<Placeholder
|
||||
onClick={() => {
|
||||
//
|
||||
}}
|
||||
>
|
||||
Loading…
|
||||
</Placeholder>
|
||||
}
|
||||
>
|
||||
<Editor
|
||||
key={key}
|
||||
defaultValue={collection.data}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
readOnly={!isEditing}
|
||||
autoFocus={isEditing}
|
||||
onBlur={handleStopEditing}
|
||||
extensions={extensions}
|
||||
maxLength={1000}
|
||||
embedsDisabled
|
||||
canUpdate
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : (
|
||||
can.update && (
|
||||
<Placeholder
|
||||
onClick={() => {
|
||||
//
|
||||
}}
|
||||
>
|
||||
{placeholder}
|
||||
</Placeholder>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
</Input>
|
||||
{!isEditing && (
|
||||
<Disclosure
|
||||
onClick={handleClickDisclosure}
|
||||
aria-label={isExpanded ? t("Collapse") : t("Expand")}
|
||||
size={30}
|
||||
>
|
||||
<Arrow />
|
||||
</Disclosure>
|
||||
)}
|
||||
</>
|
||||
</MaxHeight>
|
||||
);
|
||||
}
|
||||
|
||||
const Placeholder = styled(Text)`
|
||||
const Disclosure = styled(NudeButton)`
|
||||
opacity: 0;
|
||||
color: ${s("divider")};
|
||||
position: absolute;
|
||||
top: calc(25vh - 50px);
|
||||
left: 50%;
|
||||
z-index: 1;
|
||||
transform: rotate(-90deg) translateX(-50%);
|
||||
transition: opacity 100ms ease-in-out;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: ${s("sidebarText")};
|
||||
}
|
||||
`;
|
||||
|
||||
const Placeholder = styled(ButtonLink)`
|
||||
color: ${s("placeholder")};
|
||||
cursor: text;
|
||||
min-height: 27px;
|
||||
`;
|
||||
|
||||
const MaxHeight = styled.div`
|
||||
position: relative;
|
||||
max-height: 25vh;
|
||||
overflow: hidden;
|
||||
margin: 8px -8px -8px;
|
||||
padding: 8px;
|
||||
|
||||
&[data-editing="true"],
|
||||
&[data-expanded="true"] {
|
||||
max-height: initial;
|
||||
overflow: initial;
|
||||
|
||||
${Disclosure} {
|
||||
top: initial;
|
||||
bottom: 0;
|
||||
transform: rotate(90deg) translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover ${Disclosure} {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const Input = styled.div`
|
||||
margin: -8px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: calc(25vh - 50px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 50px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
${(props) => transparentize(1, props.theme.background)} 0%,
|
||||
${s("background")} 100%
|
||||
);
|
||||
}
|
||||
|
||||
&[data-editing="true"],
|
||||
&[data-expanded="true"] {
|
||||
&:after {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-editing="true"] {
|
||||
background: ${s("backgroundSecondary")};
|
||||
}
|
||||
|
||||
.block-menu-trigger,
|
||||
.heading-anchor {
|
||||
display: none !important;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(CollectionDescription);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DocumentIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import Icon from "~/components/Icon";
|
||||
import { createAction } from "~/actions";
|
||||
import { RecentSection } from "~/actions/sections";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NewDocumentIcon, ShapesIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import Icon from "~/components/Icon";
|
||||
import { createAction } from "~/actions";
|
||||
import {
|
||||
ActiveCollectionSection,
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { CollectionPermission, NavigationNode } from "@shared/types";
|
||||
import type Collection from "~/models/Collection";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { AuthorizationError } from "~/utils/errors";
|
||||
|
||||
type Props = {
|
||||
/** The navigation node to move, must represent a document. */
|
||||
@@ -32,29 +30,12 @@ function ConfirmMoveDialog({ collection, item, ...rest }: Props) {
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await documents.move({
|
||||
documentId: item.id,
|
||||
collectionId: collection.id,
|
||||
...rest,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof AuthorizationError) {
|
||||
toast.error(
|
||||
t(
|
||||
"You do not have permission to move {{ documentName }} to the {{ collectionName }} collection",
|
||||
{
|
||||
documentName: item.title,
|
||||
collectionName: collection.name,
|
||||
}
|
||||
)
|
||||
);
|
||||
} else {
|
||||
toast.error(err.message);
|
||||
}
|
||||
} finally {
|
||||
dialogs.closeAllModals();
|
||||
}
|
||||
await documents.move({
|
||||
documentId: item.id,
|
||||
collectionId: collection.id,
|
||||
...rest,
|
||||
});
|
||||
dialogs.closeAllModals();
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -8,8 +8,8 @@ import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
/** Callback when the dialog is submitted. Return false to prevent closing. */
|
||||
onSubmit: () => Promise<void | boolean> | void;
|
||||
/** Callback when the dialog is submitted */
|
||||
onSubmit: () => Promise<void> | void;
|
||||
/** Text to display on the submit button */
|
||||
submitText?: string;
|
||||
/** Text to display while the form is saving */
|
||||
@@ -38,10 +38,7 @@ const ConfirmationDialog: React.FC<Props> = ({
|
||||
ev.preventDefault();
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const res = await onSubmit();
|
||||
if (res === false) {
|
||||
return;
|
||||
}
|
||||
await onSubmit();
|
||||
dialogs.closeAllModals();
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
|
||||
@@ -23,7 +23,7 @@ type Props = {
|
||||
as?: string | React.ComponentType<any>;
|
||||
hide?: () => void;
|
||||
level?: number;
|
||||
icon?: React.ReactNode;
|
||||
icon?: React.ReactElement;
|
||||
children?: React.ReactNode;
|
||||
ref?: React.LegacyRef<HTMLButtonElement> | undefined;
|
||||
};
|
||||
@@ -109,8 +109,6 @@ const Title = styled.div`
|
||||
${ellipsis()}
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
type MenuAnchorProps = {
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
MenuHeading,
|
||||
MenuItem as TMenuItem,
|
||||
} from "~/types";
|
||||
import Tooltip from "../Tooltip";
|
||||
import Header from "./Header";
|
||||
import MenuItem, { MenuAnchor } from "./MenuItem";
|
||||
import MouseSafeArea from "./MouseSafeArea";
|
||||
@@ -168,7 +167,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
|
||||
}
|
||||
|
||||
if (item.type === "button") {
|
||||
const menuItem = (
|
||||
return (
|
||||
<MenuItem
|
||||
as="button"
|
||||
id={`${item.title}-${index}`}
|
||||
@@ -183,14 +182,6 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
|
||||
{item.title}
|
||||
</MenuItem>
|
||||
);
|
||||
|
||||
return item.tooltip ? (
|
||||
<Tooltip content={item.tooltip} placement={"bottom"}>
|
||||
<div>{menuItem}</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<>{menuItem}</>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "submenu") {
|
||||
|
||||
@@ -3,16 +3,20 @@ import { ArchiveIcon, GoToIcon, ShapesIcon, TrashIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import type { NavigationNode } from "@shared/types";
|
||||
import Document from "~/models/Document";
|
||||
import Breadcrumb from "~/components/Breadcrumb";
|
||||
import Icon from "~/components/Icon";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { MenuInternalLink } from "~/types";
|
||||
import { archivePath, settingsPath, trashPath } from "~/utils/routeHelpers";
|
||||
import {
|
||||
archivePath,
|
||||
collectionPath,
|
||||
settingsPath,
|
||||
trashPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -60,7 +64,6 @@ function DocumentBreadcrumb(
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const category = useCategory(document);
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
@@ -77,10 +80,7 @@ function DocumentBreadcrumb(
|
||||
type: "route",
|
||||
title: collection.name,
|
||||
icon: <CollectionIcon collection={collection} expanded />,
|
||||
to: {
|
||||
pathname: collection.path,
|
||||
state: { sidebarContext },
|
||||
},
|
||||
to: collectionPath(collection.path),
|
||||
};
|
||||
} else if (document.isCollectionDeleted) {
|
||||
collectionNode = {
|
||||
@@ -105,24 +105,20 @@ function DocumentBreadcrumb(
|
||||
}
|
||||
|
||||
path.slice(0, -1).forEach((node: NavigationNode) => {
|
||||
const title = node.title || t("Untitled");
|
||||
output.push({
|
||||
type: "route",
|
||||
title: node.icon ? (
|
||||
<>
|
||||
<StyledIcon value={node.icon} color={node.color} /> {title}
|
||||
<StyledIcon value={node.icon} color={node.color} /> {node.title}
|
||||
</>
|
||||
) : (
|
||||
title
|
||||
node.title
|
||||
),
|
||||
to: {
|
||||
pathname: node.url,
|
||||
state: { sidebarContext },
|
||||
},
|
||||
to: node.url,
|
||||
});
|
||||
});
|
||||
return output;
|
||||
}, [t, path, category, sidebarContext, collectionNode]);
|
||||
}, [path, category, collectionNode]);
|
||||
|
||||
if (!collections.isLoaded) {
|
||||
return null;
|
||||
@@ -135,7 +131,7 @@ function DocumentBreadcrumb(
|
||||
{path.slice(0, -1).map((node: NavigationNode) => (
|
||||
<React.Fragment key={node.id}>
|
||||
<SmallSlash />
|
||||
{node.title || t("Untitled")}
|
||||
{node.title}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { subDays } from "date-fns";
|
||||
import { m } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
import { CloseIcon, DocumentIcon, ClockIcon, EyeIcon } from "outline-icons";
|
||||
import { CloseIcon, DocumentIcon, ClockIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import Squircle from "@shared/components/Squircle";
|
||||
import { s, hover, ellipsis } from "@shared/styles";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import { IconType } from "@shared/types";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
import Document from "~/models/Document";
|
||||
import Pin from "~/models/Pin";
|
||||
import Flex from "~/components/Flex";
|
||||
import Icon from "~/components/Icon";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Time from "~/components/Time";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { useTextStats } from "~/hooks/useTextStats";
|
||||
import { hover } from "~/styles";
|
||||
import CollectionIcon from "./Icons/CollectionIcon";
|
||||
import Text from "./Text";
|
||||
import Tooltip from "./Tooltip";
|
||||
@@ -72,10 +71,6 @@ function DocumentCard(props: Props) {
|
||||
[pin]
|
||||
);
|
||||
|
||||
// If the document was updated within the last 7 days, show a timestamp instead of reading time
|
||||
const isRecentlyUpdated =
|
||||
new Date(document.updatedAt) > subDays(new Date(), 7);
|
||||
|
||||
return (
|
||||
<Reorderable
|
||||
ref={setNodeRef}
|
||||
@@ -148,14 +143,8 @@ function DocumentCard(props: Props) {
|
||||
: document.titleWithDefault}
|
||||
</Heading>
|
||||
<DocumentMeta size="xsmall">
|
||||
{isRecentlyUpdated ? (
|
||||
<>
|
||||
<Clock size={18} />
|
||||
<Time dateTime={document.updatedAt} addSuffix shorten />
|
||||
</>
|
||||
) : (
|
||||
<ReadingTime document={document} />
|
||||
)}
|
||||
<Clock size={18} />
|
||||
<Time dateTime={document.updatedAt} addSuffix shorten />
|
||||
</DocumentMeta>
|
||||
</div>
|
||||
</Content>
|
||||
@@ -176,21 +165,6 @@ function DocumentCard(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const ReadingTime = ({ document }: { document: Document }) => {
|
||||
const { t } = useTranslation();
|
||||
const markdown = React.useMemo(() => document.toMarkdown(), [document]);
|
||||
const stats = useTextStats(markdown);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EyeIcon size={18} />
|
||||
{t(`{{ minutes }}m read`, {
|
||||
minutes: stats.total.readingTime,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const DocumentSquircle = ({
|
||||
icon,
|
||||
color,
|
||||
|
||||
@@ -14,18 +14,18 @@ import { FixedSizeList as List } from "react-window";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
|
||||
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
|
||||
import Flex from "~/components/Flex";
|
||||
import Icon from "~/components/Icon";
|
||||
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";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
import { ancestors, descendants } from "~/utils/tree";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -9,22 +9,21 @@ import { Link } from "react-router-dom";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import { s } from "@shared/styles";
|
||||
import Document from "~/models/Document";
|
||||
import Badge from "~/components/Badge";
|
||||
import DocumentMeta from "~/components/DocumentMeta";
|
||||
import Flex from "~/components/Flex";
|
||||
import Highlight from "~/components/Highlight";
|
||||
import Icon from "~/components/Icon";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import StarButton, { AnimatedStar } from "~/components/Star";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import { hover } from "~/styles";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
import { determineSidebarContext } from "./Sidebar/components/SidebarContext";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
@@ -51,7 +50,6 @@ function DocumentListItem(
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
|
||||
let itemRef: React.Ref<HTMLAnchorElement> =
|
||||
@@ -80,12 +78,6 @@ function DocumentListItem(
|
||||
!!document.title.toLowerCase().includes(highlight.toLowerCase());
|
||||
const canStar = !document.isArchived && !document.isTemplate;
|
||||
|
||||
const sidebarContext = determineSidebarContext({
|
||||
document,
|
||||
user,
|
||||
currentContext: locationSidebarContext,
|
||||
});
|
||||
|
||||
return (
|
||||
<DocumentLink
|
||||
ref={itemRef}
|
||||
@@ -97,7 +89,6 @@ function DocumentListItem(
|
||||
pathname: documentPath(document),
|
||||
state: {
|
||||
title: document.titleWithDefault,
|
||||
sidebarContext,
|
||||
},
|
||||
}}
|
||||
{...rest}
|
||||
|
||||
@@ -185,9 +185,9 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
{showCollection && collection && (
|
||||
<span>
|
||||
{t("in")}
|
||||
<Strong>
|
||||
<strong>
|
||||
<DocumentBreadcrumb document={document} onlyText />
|
||||
</Strong>
|
||||
</strong>
|
||||
</span>
|
||||
)}
|
||||
{showParentDocuments && nestedDocumentsCount > 0 && (
|
||||
@@ -210,10 +210,6 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const Strong = styled.strong`
|
||||
font-weight: 550;
|
||||
`;
|
||||
|
||||
const Container = styled(Flex)<{ rtl?: boolean }>`
|
||||
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||
color: ${s("textTertiary")};
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { dateLocale, dateToRelative } from "@shared/utils/date";
|
||||
import Document from "~/models/Document";
|
||||
import User from "~/models/User";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
@@ -71,13 +71,7 @@ function DocumentViews({ document, isOpen }: Props) {
|
||||
key={model.id}
|
||||
title={model.name}
|
||||
subtitle={subtitle}
|
||||
image={
|
||||
<Avatar
|
||||
key={model.id}
|
||||
model={model}
|
||||
size={AvatarSize.Large}
|
||||
/>
|
||||
}
|
||||
image={<Avatar key={model.id} model={model} size={32} />}
|
||||
border={false}
|
||||
small
|
||||
/>
|
||||
|
||||
+87
-17
@@ -1,4 +1,6 @@
|
||||
import deburr from "lodash/deburr";
|
||||
import difference from "lodash/difference";
|
||||
import sortBy from "lodash/sortBy";
|
||||
import { observer } from "mobx-react";
|
||||
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
|
||||
import { TextSelection } from "prosemirror-state";
|
||||
@@ -7,7 +9,10 @@ import { mergeRefs } from "react-merge-refs";
|
||||
import { Optional } from "utility-types";
|
||||
import insertFiles from "@shared/editor/commands/insertFiles";
|
||||
import { AttachmentPreset } from "@shared/types";
|
||||
import { dateLocale, dateToRelative } from "@shared/utils/date";
|
||||
import { getDataTransferFiles } from "@shared/utils/files";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { isInternalUrl } from "@shared/utils/urls";
|
||||
import { AttachmentValidation } from "@shared/validations";
|
||||
import ClickablePadding from "~/components/ClickablePadding";
|
||||
import ErrorBoundary from "~/components/ErrorBoundary";
|
||||
@@ -17,8 +22,12 @@ import useDictionary from "~/hooks/useDictionary";
|
||||
import useEditorClickHandlers from "~/hooks/useEditorClickHandlers";
|
||||
import useEmbeds from "~/hooks/useEmbeds";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { uploadFile, uploadFileFromUrl } from "~/utils/files";
|
||||
import useUserLocale from "~/hooks/useUserLocale";
|
||||
import { NotFoundError } from "~/utils/errors";
|
||||
import { uploadFile } from "~/utils/files";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import DocumentBreadcrumb from "./DocumentBreadcrumb";
|
||||
import Icon from "./Icon";
|
||||
|
||||
const LazyLoadedEditor = lazyWithRetry(() => import("~/editor"));
|
||||
|
||||
@@ -41,23 +50,82 @@ export type Props = Optional<
|
||||
function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
const { id, shareId, onChange, onCreateCommentMark, onDeleteCommentMark } =
|
||||
props;
|
||||
const { comments } = useStores();
|
||||
const userLocale = useUserLocale();
|
||||
const locale = dateLocale(userLocale);
|
||||
const { comments, documents } = useStores();
|
||||
const dictionary = useDictionary();
|
||||
const embeds = useEmbeds(!shareId);
|
||||
const localRef = React.useRef<SharedEditor>();
|
||||
const preferences = useCurrentUser({ rejectOnEmpty: false })?.preferences;
|
||||
const previousCommentIds = React.useRef<string[]>();
|
||||
|
||||
const handleSearchLink = React.useCallback(
|
||||
async (term: string) => {
|
||||
if (isInternalUrl(term)) {
|
||||
// search for exact internal document
|
||||
const slug = parseDocumentSlug(term);
|
||||
if (!slug) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const document = await documents.fetch(slug);
|
||||
const time = dateToRelative(Date.parse(document.updatedAt), {
|
||||
addSuffix: true,
|
||||
shorten: true,
|
||||
locale,
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
title: document.title,
|
||||
subtitle: `Updated ${time}`,
|
||||
url: document.url,
|
||||
icon: document.icon ? (
|
||||
<Icon
|
||||
value={document.icon}
|
||||
color={document.color ?? undefined}
|
||||
/>
|
||||
) : undefined,
|
||||
},
|
||||
];
|
||||
} catch (error) {
|
||||
// NotFoundError could not find document for slug
|
||||
if (!(error instanceof NotFoundError)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// default search for anything that doesn't look like a URL
|
||||
const results = await documents.searchTitles({ query: term });
|
||||
|
||||
return sortBy(
|
||||
results.map(({ document }) => ({
|
||||
title: document.title,
|
||||
subtitle: <DocumentBreadcrumb document={document} onlyText />,
|
||||
url: document.url,
|
||||
icon: document.icon ? (
|
||||
<Icon value={document.icon} color={document.color ?? undefined} />
|
||||
) : undefined,
|
||||
})),
|
||||
(document) =>
|
||||
deburr(document.title)
|
||||
.toLowerCase()
|
||||
.startsWith(deburr(term).toLowerCase())
|
||||
? -1
|
||||
: 1
|
||||
);
|
||||
},
|
||||
[locale, documents]
|
||||
);
|
||||
|
||||
const handleUploadFile = React.useCallback(
|
||||
async (file: File | string) => {
|
||||
const options = {
|
||||
async (file: File) => {
|
||||
const result = await uploadFile(file, {
|
||||
documentId: id,
|
||||
preset: AttachmentPreset.DocumentAttachment,
|
||||
};
|
||||
const result =
|
||||
file instanceof File
|
||||
? await uploadFile(file, options)
|
||||
: await uploadFileFromUrl(file, options);
|
||||
});
|
||||
return result.url;
|
||||
},
|
||||
[id]
|
||||
@@ -195,18 +263,20 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
dictionary={dictionary}
|
||||
{...props}
|
||||
onClickLink={handleClickLink}
|
||||
onSearchLink={handleSearchLink}
|
||||
onChange={handleChange}
|
||||
placeholder={props.placeholder || ""}
|
||||
defaultValue={props.defaultValue || ""}
|
||||
/>
|
||||
{props.editorStyle?.paddingBottom && !props.readOnly && (
|
||||
<ClickablePadding
|
||||
onClick={props.readOnly ? undefined : focusAtEnd}
|
||||
onDrop={props.readOnly ? undefined : handleDrop}
|
||||
onDragOver={props.readOnly ? undefined : handleDragOver}
|
||||
minHeight={props.editorStyle.paddingBottom}
|
||||
/>
|
||||
)}
|
||||
{props.editorStyle?.paddingBottom &&
|
||||
(!props.readOnly || props.shareId) && (
|
||||
<ClickablePadding
|
||||
onClick={props.readOnly ? undefined : focusAtEnd}
|
||||
onDrop={props.readOnly ? undefined : handleDrop}
|
||||
onDragOver={props.readOnly ? undefined : handleDragOver}
|
||||
minHeight={props.editorStyle.paddingBottom}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -7,71 +7,38 @@ import {
|
||||
PublishIcon,
|
||||
MoveIcon,
|
||||
UnpublishIcon,
|
||||
RestoreIcon,
|
||||
UserIcon,
|
||||
CrossIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import styled, { css } from "styled-components";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import { RevisionHelper } from "@shared/utils/RevisionHelper";
|
||||
import { s } from "@shared/styles";
|
||||
import Document from "~/models/Document";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Item, { Actions } from "~/components/List/Item";
|
||||
import Event from "~/models/Event";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Item, { Actions, Props as ItemProps } from "~/components/List/Item";
|
||||
import Time from "~/components/Time";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import RevisionMenu from "~/menus/RevisionMenu";
|
||||
import { hover } from "~/styles";
|
||||
import Logger from "~/utils/Logger";
|
||||
import { documentHistoryPath } from "~/utils/routeHelpers";
|
||||
import Text from "./Text";
|
||||
|
||||
export type RevisionEvent = {
|
||||
name: "revisions.create";
|
||||
latest: boolean;
|
||||
};
|
||||
|
||||
export type DocumentEvent = {
|
||||
name:
|
||||
| "documents.publish"
|
||||
| "documents.unpublish"
|
||||
| "documents.archive"
|
||||
| "documents.unarchive"
|
||||
| "documents.delete"
|
||||
| "documents.restore"
|
||||
| "documents.add_user"
|
||||
| "documents.remove_user"
|
||||
| "documents.move";
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type Event = { id: string; actorId: string; createdAt: string } & (
|
||||
| RevisionEvent
|
||||
| DocumentEvent
|
||||
);
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
event: Event;
|
||||
event: Event<Document>;
|
||||
latest?: boolean;
|
||||
};
|
||||
|
||||
const EventListItem = ({ event, document, ...rest }: Props) => {
|
||||
const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { revisions, users } = useStores();
|
||||
const actor = "actorId" in event ? users.get(event.actorId) : undefined;
|
||||
const user = "userId" in event ? users.get(event.userId) : undefined;
|
||||
const { revisions } = useStores();
|
||||
const location = useLocation();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const revisionLoadedRef = React.useRef(false);
|
||||
const opts = {
|
||||
userName: actor?.name,
|
||||
userName: event.actor.name,
|
||||
};
|
||||
const isRevision = event.name === "revisions.create";
|
||||
const isDerivedFromDocument =
|
||||
event.id === RevisionHelper.latestId(document.id);
|
||||
let meta, icon, to: LocationDescriptor | undefined;
|
||||
|
||||
const ref = React.useRef<HTMLAnchorElement>(null);
|
||||
@@ -82,85 +49,69 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
|
||||
};
|
||||
|
||||
const prefetchRevision = async () => {
|
||||
if (
|
||||
!document.isDeleted &&
|
||||
event.name === "revisions.create" &&
|
||||
!isDerivedFromDocument &&
|
||||
!revisionLoadedRef.current
|
||||
) {
|
||||
await revisions.fetch(event.id, { force: true });
|
||||
revisionLoadedRef.current = true;
|
||||
if (event.name === "revisions.create" && event.modelId) {
|
||||
await revisions.fetch(event.modelId);
|
||||
}
|
||||
};
|
||||
|
||||
switch (event.name) {
|
||||
case "revisions.create":
|
||||
icon = <EditIcon size={16} />;
|
||||
meta = event.latest ? (
|
||||
meta = latest ? (
|
||||
<>
|
||||
{t("Current version")} · {actor?.name}
|
||||
{t("Current version")} · {event.actor.name}
|
||||
</>
|
||||
) : (
|
||||
t("{{userName}} edited", opts)
|
||||
);
|
||||
to = {
|
||||
pathname: documentHistoryPath(
|
||||
document,
|
||||
isDerivedFromDocument ? "latest" : event.id
|
||||
),
|
||||
state: {
|
||||
sidebarContext,
|
||||
retainScrollPosition: true,
|
||||
},
|
||||
pathname: documentHistoryPath(document, event.modelId || "latest"),
|
||||
state: { retainScrollPosition: true },
|
||||
};
|
||||
break;
|
||||
|
||||
case "documents.archive":
|
||||
icon = <ArchiveIcon />;
|
||||
icon = <ArchiveIcon size={16} />;
|
||||
meta = t("{{userName}} archived", opts);
|
||||
break;
|
||||
|
||||
case "documents.unarchive":
|
||||
icon = <RestoreIcon />;
|
||||
meta = t("{{userName}} restored", opts);
|
||||
break;
|
||||
|
||||
case "documents.delete":
|
||||
icon = <TrashIcon />;
|
||||
icon = <TrashIcon size={16} />;
|
||||
meta = t("{{userName}} deleted", opts);
|
||||
break;
|
||||
case "documents.add_user":
|
||||
icon = <UserIcon />;
|
||||
meta = t("{{userName}} added {{addedUserName}}", {
|
||||
...opts,
|
||||
addedUserName: user?.name ?? t("a user"),
|
||||
addedUserName: event.user?.name ?? t("a user"),
|
||||
});
|
||||
break;
|
||||
case "documents.remove_user":
|
||||
icon = <CrossIcon />;
|
||||
meta = t("{{userName}} removed {{removedUserName}}", {
|
||||
...opts,
|
||||
removedUserName: user?.name ?? t("a user"),
|
||||
removedUserName: event.user?.name ?? t("a user"),
|
||||
});
|
||||
break;
|
||||
|
||||
case "documents.restore":
|
||||
icon = <RestoreIcon />;
|
||||
meta = t("{{userName}} moved from trash", opts);
|
||||
break;
|
||||
|
||||
case "documents.publish":
|
||||
icon = <PublishIcon />;
|
||||
icon = <PublishIcon size={16} />;
|
||||
meta = t("{{userName}} published", opts);
|
||||
break;
|
||||
|
||||
case "documents.unpublish":
|
||||
icon = <UnpublishIcon />;
|
||||
icon = <UnpublishIcon size={16} />;
|
||||
meta = t("{{userName}} unpublished", opts);
|
||||
break;
|
||||
|
||||
case "documents.move":
|
||||
icon = <MoveIcon />;
|
||||
icon = <MoveIcon size={16} />;
|
||||
meta = t("{{userName}} moved", opts);
|
||||
break;
|
||||
|
||||
@@ -181,8 +132,8 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
|
||||
to = undefined;
|
||||
}
|
||||
|
||||
return event.name === "revisions.create" ? (
|
||||
<RevisionItem
|
||||
return (
|
||||
<BaseItem
|
||||
small
|
||||
exact
|
||||
to={to}
|
||||
@@ -198,12 +149,17 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
|
||||
onClick={handleTimeClick}
|
||||
/>
|
||||
}
|
||||
image={<Avatar model={actor} size={AvatarSize.Large} />}
|
||||
subtitle={meta}
|
||||
image={<Avatar model={event.actor} size={32} />}
|
||||
subtitle={
|
||||
<Subtitle>
|
||||
{icon}
|
||||
{meta}
|
||||
</Subtitle>
|
||||
}
|
||||
actions={
|
||||
isRevision && isActive && !event.latest ? (
|
||||
isRevision && isActive && event.modelId && !latest ? (
|
||||
<StyledEventBoundary>
|
||||
<RevisionMenu document={document} revisionId={event.id} />
|
||||
<RevisionMenu document={document} revisionId={event.modelId} />
|
||||
</StyledEventBoundary>
|
||||
) : undefined
|
||||
}
|
||||
@@ -211,100 +167,63 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
|
||||
ref={ref}
|
||||
{...rest}
|
||||
/>
|
||||
) : (
|
||||
<EventItem>
|
||||
<IconWrapper size="xsmall" type="secondary">
|
||||
{icon}
|
||||
</IconWrapper>
|
||||
<Text size="xsmall" type="secondary">
|
||||
{meta} ·{" "}
|
||||
<Time dateTime={event.createdAt} relative shorten addSuffix />
|
||||
</Text>
|
||||
</EventItem>
|
||||
);
|
||||
};
|
||||
|
||||
const lineStyle = css`
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: 22px;
|
||||
width: 1px;
|
||||
height: calc(50% - 14px + 8px);
|
||||
background: ${s("divider")};
|
||||
mix-blend-mode: multiply;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&:first-child::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:nth-child(2)::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: calc(50% + 14px);
|
||||
left: 22px;
|
||||
width: 1px;
|
||||
height: calc(50% - 14px);
|
||||
background: ${s("divider")};
|
||||
mix-blend-mode: multiply;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&:last-child::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
h3 + &::before {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const IconWrapper = styled(Text)`
|
||||
height: 24px;
|
||||
`;
|
||||
|
||||
const EventItem = styled.li`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
list-style: none;
|
||||
margin: 8px 0;
|
||||
padding: 4px 10px;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
|
||||
time {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
${lineStyle}
|
||||
`;
|
||||
const BaseItem = React.forwardRef(function _BaseItem(
|
||||
{ to, ...rest }: ItemProps,
|
||||
ref?: React.Ref<HTMLAnchorElement>
|
||||
) {
|
||||
return <ListItem to={to} ref={ref} {...rest} />;
|
||||
});
|
||||
|
||||
const StyledEventBoundary = styled(EventBoundary)`
|
||||
height: 24px;
|
||||
`;
|
||||
|
||||
const RevisionItem = styled(Item)`
|
||||
const Subtitle = styled.span`
|
||||
svg {
|
||||
margin: -3px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
`;
|
||||
|
||||
const ItemStyle = css`
|
||||
border: 0;
|
||||
position: relative;
|
||||
margin: 8px 0;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
|
||||
${lineStyle}
|
||||
img {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: 23px;
|
||||
width: 2px;
|
||||
height: calc(100% + 8px);
|
||||
background: ${s("textSecondary")};
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
&:nth-child(2)::before {
|
||||
height: 50%;
|
||||
top: auto;
|
||||
bottom: -4px;
|
||||
}
|
||||
|
||||
&:last-child::before {
|
||||
height: 50%;
|
||||
}
|
||||
|
||||
&:first-child:last-child::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
${Actions} {
|
||||
opacity: 0.5;
|
||||
@@ -315,4 +234,8 @@ const RevisionItem = styled(Item)`
|
||||
}
|
||||
`;
|
||||
|
||||
const ListItem = styled(Item)`
|
||||
${ItemStyle}
|
||||
`;
|
||||
|
||||
export default observer(EventListItem);
|
||||
|
||||
+38
-62
@@ -1,26 +1,17 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import User from "~/models/User";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import Initials from "./Avatar/Initials";
|
||||
|
||||
type Props = {
|
||||
/** The users to display */
|
||||
users: User[];
|
||||
/** The size of the avatars, defaults to AvatarSize.Large */
|
||||
size?: number;
|
||||
/** A number to show as the number of additional users */
|
||||
overflow?: number;
|
||||
/** The maximum number of users to display, defaults to 8 */
|
||||
limit?: number;
|
||||
/** A component to render the avatar, defaults to Avatar. */
|
||||
renderAvatar?: React.ComponentType<
|
||||
React.ComponentProps<typeof Avatar> & {
|
||||
model: User;
|
||||
}
|
||||
>;
|
||||
renderAvatar?: (user: User) => React.ReactNode;
|
||||
};
|
||||
|
||||
function Facepile({
|
||||
@@ -28,70 +19,55 @@ function Facepile({
|
||||
overflow = 0,
|
||||
size = AvatarSize.Large,
|
||||
limit = 8,
|
||||
renderAvatar = Avatar,
|
||||
renderAvatar = DefaultAvatar,
|
||||
...rest
|
||||
}: Props) {
|
||||
const filtered = users.filter(Boolean).slice(-limit);
|
||||
const Component = renderAvatar;
|
||||
|
||||
return (
|
||||
<Avatars {...rest}>
|
||||
{overflow > 0 && (
|
||||
<Initials size={size} content={String(overflow)}>
|
||||
{users.length ? "+" : ""}
|
||||
{overflow}
|
||||
</Initials>
|
||||
<More size={size}>
|
||||
<span>
|
||||
{users.length ? "+" : ""}
|
||||
{overflow}
|
||||
</span>
|
||||
</More>
|
||||
)}
|
||||
{filtered.map((model, index) => {
|
||||
const lastChild = index === 0 && overflow <= 0;
|
||||
return (
|
||||
<Component
|
||||
key={model.id}
|
||||
{...{
|
||||
model,
|
||||
size,
|
||||
style: {
|
||||
marginRight: lastChild ? 0 : -4,
|
||||
...(lastChild || filtered.length === 1
|
||||
? {}
|
||||
: { clipPath: `url(#${clipPathId(size)})` }),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<FacepileClip size={size} />
|
||||
{users
|
||||
.filter(Boolean)
|
||||
.slice(0, limit)
|
||||
.map((user) => (
|
||||
<AvatarWrapper key={user.id}>{renderAvatar(user)}</AvatarWrapper>
|
||||
))}
|
||||
</Avatars>
|
||||
);
|
||||
}
|
||||
|
||||
function FacepileClip({ size }: { size: number }) {
|
||||
return (
|
||||
<SVG
|
||||
width="25"
|
||||
height="28"
|
||||
viewBox="0 0 25 28"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<clipPath id={clipPathId(size)}>
|
||||
<path
|
||||
transform={size !== 28 ? `scale(${size / 28})` : ""}
|
||||
d="M14.0633 0.5C18.1978 0.5 21.8994 2.34071 24.3876 5.24462C22.8709 7.81315 22.0012 10.8061 22.0012 14C22.0012 17.1939 22.8709 20.1868 24.3876 22.7554C21.8994 25.6593 18.1978 27.5 14.0633 27.5C6.57035 27.5 0.5 21.4537 0.5 14C0.5 6.54628 6.57035 0.5 14.0633 0.5Z"
|
||||
/>
|
||||
</clipPath>
|
||||
</SVG>
|
||||
);
|
||||
function DefaultAvatar(user: User) {
|
||||
return <Avatar model={user} size={AvatarSize.Large} />;
|
||||
}
|
||||
|
||||
function clipPathId(size: number) {
|
||||
return `facepile-${size}`;
|
||||
}
|
||||
const AvatarWrapper = styled.div`
|
||||
margin-right: -8px;
|
||||
|
||||
const SVG = styled.svg`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
&:first-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const More = styled.div<{ size: number }>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
border-radius: 100%;
|
||||
background: ${(props) => props.theme.textTertiary};
|
||||
color: ${s("white")};
|
||||
border: 2px solid ${s("background")};
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
`;
|
||||
|
||||
const Avatars = styled(Flex)`
|
||||
|
||||
@@ -46,7 +46,7 @@ const FilterOptions = ({
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const listRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const menu = useMenuState({
|
||||
modal: false,
|
||||
modal: true,
|
||||
});
|
||||
const selectedItems = options.filter((option) =>
|
||||
selectedKeys.includes(option.key)
|
||||
@@ -229,7 +229,7 @@ const SearchInput = styled(Input)`
|
||||
${Outline} {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
border-bottom: 1px solid ${s("divider")};
|
||||
border-bottom: 1px solid rgb(34 40 52);
|
||||
background: ${s("menuBackground")};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { GroupIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { MAX_AVATAR_DISPLAY } from "@shared/constants";
|
||||
import { s } from "@shared/styles";
|
||||
import Group from "~/models/Group";
|
||||
import GroupMembership from "~/models/GroupMembership";
|
||||
import GroupMembers from "~/scenes/GroupMembers";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import Flex from "~/components/Flex";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Modal from "~/components/Modal";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import { hover } from "~/styles";
|
||||
import NudeButton from "./NudeButton";
|
||||
|
||||
type Props = {
|
||||
group: Group;
|
||||
membership?: GroupMembership;
|
||||
showFacepile?: boolean;
|
||||
showAvatar?: boolean;
|
||||
renderActions: (params: { openMembersModal: () => void }) => React.ReactNode;
|
||||
};
|
||||
|
||||
function GroupListItem({ group, showFacepile, renderActions }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [membersModalOpen, setMembersModalOpen, setMembersModalClosed] =
|
||||
useBoolean();
|
||||
const memberCount = group.memberCount;
|
||||
const users = group.users.slice(0, MAX_AVATAR_DISPLAY);
|
||||
const overflow = memberCount - users.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListItem
|
||||
image={
|
||||
<Image>
|
||||
<GroupIcon size={24} />
|
||||
</Image>
|
||||
}
|
||||
title={<Title onClick={setMembersModalOpen}>{group.name}</Title>}
|
||||
subtitle={t("{{ count }} member", { count: memberCount })}
|
||||
actions={
|
||||
<Flex align="center" gap={8}>
|
||||
{showFacepile && (
|
||||
<NudeButton
|
||||
width="auto"
|
||||
height="auto"
|
||||
onClick={setMembersModalOpen}
|
||||
>
|
||||
<Facepile users={users} overflow={overflow} />
|
||||
</NudeButton>
|
||||
)}
|
||||
{renderActions({
|
||||
openMembersModal: setMembersModalOpen,
|
||||
})}
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
<Modal
|
||||
title={t("Group members")}
|
||||
onRequestClose={setMembersModalClosed}
|
||||
isOpen={membersModalOpen}
|
||||
>
|
||||
<GroupMembers group={group} />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const Image = styled(Flex)`
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: ${s("backgroundSecondary")};
|
||||
border-radius: 32px;
|
||||
`;
|
||||
|
||||
const Title = styled.span`
|
||||
&: ${hover} {
|
||||
text-decoration: underline;
|
||||
cursor: var(--pointer);
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(GroupListItem);
|
||||
+32
-35
@@ -6,18 +6,17 @@ import * as React from "react";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { useComponentSize } from "@shared/hooks/useComponentSize";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { supportsPassiveListener } from "@shared/utils/browser";
|
||||
import Button from "~/components/Button";
|
||||
import Fade from "~/components/Fade";
|
||||
import Flex from "~/components/Flex";
|
||||
import useComponentSize from "~/hooks/useComponentSize";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { draggableOnDesktop, fadeOnDesktopBackgrounded } from "~/styles";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import { TooltipProvider } from "./TooltipContext";
|
||||
|
||||
export const HEADER_HEIGHT = 64;
|
||||
|
||||
@@ -72,40 +71,38 @@ function Header(
|
||||
const isCompact = size.width < 1000 || breadcrumbMakesCompact;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Wrapper
|
||||
ref={mergeRefs([ref, internalRef])}
|
||||
align="center"
|
||||
shrink={false}
|
||||
className={className}
|
||||
$passThrough={passThrough}
|
||||
$insetTitleAdjust={ui.sidebarIsClosed && Desktop.hasInsetTitlebar()}
|
||||
>
|
||||
{left || hasMobileSidebar ? (
|
||||
<Breadcrumbs ref={setBreadcrumbRef}>
|
||||
{hasMobileSidebar && (
|
||||
<MobileMenuButton
|
||||
onClick={ui.toggleMobileSidebar}
|
||||
icon={<MenuIcon />}
|
||||
neutral
|
||||
/>
|
||||
)}
|
||||
{left}
|
||||
</Breadcrumbs>
|
||||
) : null}
|
||||
<Wrapper
|
||||
ref={mergeRefs([ref, internalRef])}
|
||||
align="center"
|
||||
shrink={false}
|
||||
className={className}
|
||||
$passThrough={passThrough}
|
||||
$insetTitleAdjust={ui.sidebarIsClosed && Desktop.hasInsetTitlebar()}
|
||||
>
|
||||
{left || hasMobileSidebar ? (
|
||||
<Breadcrumbs ref={setBreadcrumbRef}>
|
||||
{hasMobileSidebar && (
|
||||
<MobileMenuButton
|
||||
onClick={ui.toggleMobileSidebar}
|
||||
icon={<MenuIcon />}
|
||||
neutral
|
||||
/>
|
||||
)}
|
||||
{left}
|
||||
</Breadcrumbs>
|
||||
) : null}
|
||||
|
||||
{isScrolled && !isCompact ? (
|
||||
<Title onClick={handleClickTitle}>
|
||||
<Fade>{title}</Fade>
|
||||
</Title>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<Actions align="center" justify="flex-end">
|
||||
{typeof actions === "function" ? actions({ isCompact }) : actions}
|
||||
</Actions>
|
||||
</Wrapper>
|
||||
</TooltipProvider>
|
||||
{isScrolled && !isCompact ? (
|
||||
<Title onClick={handleClickTitle}>
|
||||
<Fade>{title}</Fade>
|
||||
</Title>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<Actions align="center" justify="flex-end">
|
||||
{typeof actions === "function" ? actions({ isCompact }) : actions}
|
||||
</Actions>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ const HoverPreviewLink = React.forwardRef(function _HoverPreviewLink(
|
||||
return (
|
||||
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
|
||||
<Flex column ref={ref}>
|
||||
{thumbnailUrl ? <Thumbnail src={thumbnailUrl} alt="" /> : null}
|
||||
{thumbnailUrl ? <Thumbnail src={thumbnailUrl} alt={""} /> : null}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Flex column>
|
||||
|
||||
@@ -2,13 +2,13 @@ import { observer } from "mobx-react";
|
||||
import { getLuminance } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import useStores from "../hooks/useStores";
|
||||
import { IconType } from "../types";
|
||||
import { IconLibrary } from "../utils/IconLibrary";
|
||||
import { colorPalette } from "../utils/collections";
|
||||
import { determineIconType } from "../utils/icon";
|
||||
import EmojiIcon from "./EmojiIcon";
|
||||
// import Logger from "~/utils/Logger";
|
||||
import { IconType } from "@shared/types";
|
||||
import { IconLibrary } from "@shared/utils/IconLibrary";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
import EmojiIcon from "~/components/Icons/EmojiIcon";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Logger from "~/utils/Logger";
|
||||
import Flex from "./Flex";
|
||||
|
||||
export type Props = {
|
||||
@@ -40,9 +40,9 @@ const Icon = ({
|
||||
const iconType = determineIconType(icon);
|
||||
|
||||
if (!iconType) {
|
||||
// Logger.warn("Failed to determine icon type", {
|
||||
// icon,
|
||||
// });
|
||||
Logger.warn("Failed to determine icon type", {
|
||||
icon,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -62,9 +62,9 @@ const Icon = ({
|
||||
|
||||
return <EmojiIcon emoji={icon} size={size} className={className} />;
|
||||
} catch (err) {
|
||||
// Logger.warn("Failed to render icon", {
|
||||
// icon,
|
||||
// });
|
||||
Logger.warn("Failed to render icon", {
|
||||
icon,
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -80,6 +80,7 @@ const SVGIcon = observer(
|
||||
forceColor,
|
||||
}: Props) => {
|
||||
const { ui } = useStores();
|
||||
|
||||
let color = inputColor ?? colorPalette[0];
|
||||
|
||||
// If the chosen icon color is very dark then we invert it in dark mode
|
||||
@@ -1,12 +1,13 @@
|
||||
import { BackIcon } from "outline-icons";
|
||||
import React from "react";
|
||||
import styled from "styled-components";
|
||||
import { breakpoints, s, hover } from "@shared/styles";
|
||||
import { breakpoints, s } from "@shared/styles";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import { validateColorHex } from "@shared/utils/color";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Text from "~/components/Text";
|
||||
import { hover } from "~/styles";
|
||||
|
||||
enum Panel {
|
||||
Builtin,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import styled from "styled-components";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import { s } from "@shared/styles";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { hover } from "~/styles";
|
||||
|
||||
export const IconButton = styled(NudeButton)<{ delay?: number }>`
|
||||
width: 32px;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import styled, { css } from "styled-components";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import { s } from "@shared/styles";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { hover } from "~/styles";
|
||||
|
||||
export const PopoverButton = styled(NudeButton)<{ $borderOnHover?: boolean }>`
|
||||
&: ${hover},
|
||||
|
||||
@@ -2,12 +2,13 @@ import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Menu, MenuButton, MenuItem, useMenuState } from "reakit";
|
||||
import styled from "styled-components";
|
||||
import { depths, s, hover } from "@shared/styles";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { EmojiSkinTone } from "@shared/types";
|
||||
import { getEmojiVariants } from "@shared/utils/emoji";
|
||||
import { Emoji } from "~/components/Emoji";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { hover } from "~/styles";
|
||||
import { IconButton } from "./IconButton";
|
||||
|
||||
const SkinTonePicker = ({
|
||||
|
||||
@@ -10,18 +10,19 @@ import {
|
||||
useTabState,
|
||||
} from "reakit";
|
||||
import styled, { css } from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import { s } from "@shared/styles";
|
||||
import theme from "@shared/styles/theme";
|
||||
import { IconType } from "@shared/types";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
import Flex from "~/components/Flex";
|
||||
import Icon from "~/components/Icon";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Popover from "~/components/Popover";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useWindowSize from "~/hooks/useWindowSize";
|
||||
import { hover } from "~/styles";
|
||||
import EmojiPanel from "./components/EmojiPanel";
|
||||
import IconPanel from "./components/IconPanel";
|
||||
import { PopoverButton } from "./components/PopoverButton";
|
||||
|
||||
@@ -2,9 +2,9 @@ import { observer } from "mobx-react";
|
||||
import { CollectionIcon, PrivateCollectionIcon } from "outline-icons";
|
||||
import { getLuminance } from "polished";
|
||||
import * as React from "react";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import Collection from "~/models/Collection";
|
||||
import Icon from "~/components/Icon";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "../styles";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
type Props = {
|
||||
/** The emoji to render */
|
||||
@@ -33,7 +33,7 @@ const SVG = ({ size, emoji }: { size: number; emoji: string }) => (
|
||||
<svg width={size} height={size} xmlns="http://www.w3.org/2000/svg">
|
||||
<text
|
||||
x="50%"
|
||||
y="55%"
|
||||
y={"55%"}
|
||||
dominantBaseline="middle"
|
||||
textAnchor="middle"
|
||||
fontSize={size * 0.7}
|
||||
@@ -4,9 +4,9 @@ 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 useBoolean from "~/hooks/useBoolean";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
import { searchPath } from "~/utils/routeHelpers";
|
||||
import Input, { Outline } from "./Input";
|
||||
|
||||
|
||||
@@ -48,8 +48,7 @@ export type Props = Omit<ButtonProps<any>, "onChange"> & {
|
||||
options: Option[];
|
||||
/** @deprecated Removing soon, do not use. */
|
||||
note?: React.ReactNode;
|
||||
/** Callback function that is called when the value changes. Return false to cancel the change. */
|
||||
onChange?: (value: string | null) => void | Promise<boolean | void>;
|
||||
onChange?: (value: string | null) => void;
|
||||
style?: React.CSSProperties;
|
||||
/**
|
||||
* Set to true if this component is rendered inside a Modal.
|
||||
@@ -166,18 +165,9 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
|
||||
if (previousValue.current === select.selectedValue) {
|
||||
return;
|
||||
}
|
||||
const previous = previousValue.current;
|
||||
previousValue.current = select.selectedValue;
|
||||
|
||||
const response = onChange?.(select.selectedValue);
|
||||
if (response && response instanceof Promise) {
|
||||
void response.then((success) => {
|
||||
if (success === false) {
|
||||
select.selectedValue = previous;
|
||||
select.setSelectedValue(previous);
|
||||
}
|
||||
});
|
||||
}
|
||||
onChange?.(select.selectedValue);
|
||||
}, [onChange, select.selectedValue]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Helmet } from "react-helmet-async";
|
||||
import styled, { DefaultTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import Flex from "~/components/Flex";
|
||||
import { LoadingIndicatorBar } from "~/components/LoadingIndicator";
|
||||
import SkipNavContent from "~/components/SkipNavContent";
|
||||
@@ -14,6 +13,7 @@ import useAutoRefresh from "~/hooks/useAutoRefresh";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import { MenuProvider } from "~/hooks/useMenuContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
|
||||
@@ -6,9 +6,10 @@ import { LocationDescriptor } from "history";
|
||||
import * as React from "react";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { s, hover, ellipsis } from "@shared/styles";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
import NavLink from "~/components/NavLink";
|
||||
import { hover } from "~/styles";
|
||||
|
||||
export type Props = Omit<React.HTMLAttributes<HTMLAnchorElement>, "title"> & {
|
||||
/** An icon or image to display to the left of the list item */
|
||||
|
||||
@@ -4,10 +4,11 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { s, hover, truncateMultiline } from "@shared/styles";
|
||||
import { s } from "@shared/styles";
|
||||
import Notification from "~/models/Notification";
|
||||
import CommentEditor from "~/scenes/Document/components/CommentEditor";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { hover, truncateMultiline } from "~/styles";
|
||||
import { Avatar, AvatarSize } from "../Avatar";
|
||||
import Flex from "../Flex";
|
||||
import Text from "../Text";
|
||||
|
||||
@@ -3,12 +3,13 @@ import { MarkAsReadIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import { s } from "@shared/styles";
|
||||
import Notification from "~/models/Notification";
|
||||
import { markNotificationsAsRead } from "~/actions/definitions/notifications";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import NotificationMenu from "~/menus/NotificationMenu";
|
||||
import { hover } from "~/styles";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import Empty from "../Empty";
|
||||
import ErrorBoundary from "../ErrorBoundary";
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Document from "~/models/Document";
|
||||
import Event from "~/models/Event";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import EventListItem, { type Event } from "./EventListItem";
|
||||
import EventListItem from "./EventListItem";
|
||||
|
||||
type Props = {
|
||||
events: Event[];
|
||||
events: Event<Document>[];
|
||||
document: Document;
|
||||
fetch: (options: Record<string, any> | undefined) => Promise<Event[]>;
|
||||
fetch: (
|
||||
options: Record<string, any> | undefined
|
||||
) => Promise<Event<Document>[]>;
|
||||
options?: Record<string, any>;
|
||||
heading?: React.ReactNode;
|
||||
empty?: React.ReactNode;
|
||||
@@ -29,8 +32,13 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
|
||||
heading={heading}
|
||||
fetch={fetch}
|
||||
options={options}
|
||||
renderItem={(item: Event) => (
|
||||
<EventListItem key={item.id} event={item} document={document} />
|
||||
renderItem={(item: Event<Document>, index) => (
|
||||
<EventListItem
|
||||
key={item.id}
|
||||
event={item}
|
||||
document={document}
|
||||
latest={index === 0}
|
||||
/>
|
||||
)}
|
||||
renderHeading={(name) => <Heading>{name}</Heading>}
|
||||
{...rest}
|
||||
|
||||
@@ -60,7 +60,7 @@ class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
|
||||
fetchCounter = 0;
|
||||
|
||||
@observable
|
||||
renderCount = Pagination.defaultLimit;
|
||||
renderCount = 15;
|
||||
|
||||
@observable
|
||||
offset = 0;
|
||||
@@ -108,16 +108,13 @@ class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
|
||||
...this.props.options,
|
||||
});
|
||||
|
||||
if (this.offset !== 0) {
|
||||
this.renderCount += limit;
|
||||
}
|
||||
|
||||
if (results && (results.length === 0 || results.length < limit)) {
|
||||
this.allowLoadMore = false;
|
||||
} else {
|
||||
this.offset += limit;
|
||||
}
|
||||
|
||||
this.renderCount += limit;
|
||||
this.isFetchingInitial = false;
|
||||
} catch (err) {
|
||||
this.error = err;
|
||||
@@ -251,9 +248,7 @@ class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
|
||||
}}
|
||||
</ArrowKeyNavigation>
|
||||
{this.allowLoadMore && (
|
||||
<div style={{ height: "1px" }}>
|
||||
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
|
||||
</div>
|
||||
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -10,23 +10,13 @@ import { fadeAndScaleIn } from "~/styles/animations";
|
||||
|
||||
type Props = PopoverProps & {
|
||||
children: React.ReactNode;
|
||||
/** The width of the popover, defaults to 380px. */
|
||||
width?: number;
|
||||
/** The minimum width of the popover, use instead of width if contents adjusts size. */
|
||||
minWidth?: number;
|
||||
/** Shrink the padding of the popover */
|
||||
shrink?: boolean;
|
||||
/** Make the popover flex */
|
||||
flex?: boolean;
|
||||
/** The tab index of the popover */
|
||||
tabIndex?: number;
|
||||
/** Whether the popover should be scrollable, defaults to true. */
|
||||
scrollable?: boolean;
|
||||
/** The position of the popover on mobile, defaults to "top". */
|
||||
mobilePosition?: "top" | "bottom";
|
||||
/** Function to show the popover */
|
||||
show: () => void;
|
||||
/** Function to hide the popover */
|
||||
hide: () => void;
|
||||
};
|
||||
|
||||
@@ -35,7 +25,6 @@ const Popover = (
|
||||
children,
|
||||
shrink,
|
||||
width = 380,
|
||||
minWidth,
|
||||
scrollable = true,
|
||||
flex,
|
||||
mobilePosition,
|
||||
@@ -82,7 +71,6 @@ const Popover = (
|
||||
ref={ref}
|
||||
$shrink={shrink}
|
||||
$width={width}
|
||||
$minWidth={minWidth}
|
||||
$scrollable={scrollable}
|
||||
$flex={flex}
|
||||
>
|
||||
@@ -95,7 +83,6 @@ const Popover = (
|
||||
type ContentsProps = {
|
||||
$shrink?: boolean;
|
||||
$width?: number;
|
||||
$minWidth?: number;
|
||||
$flex?: boolean;
|
||||
$scrollable: boolean;
|
||||
$mobilePosition?: "top" | "bottom";
|
||||
@@ -114,8 +101,7 @@ const Contents = styled.div<ContentsProps>`
|
||||
padding: ${(props) => (props.$shrink ? "6px 0" : "12px 24px")};
|
||||
max-height: 75vh;
|
||||
box-shadow: ${s("menuShadow")};
|
||||
${(props) => props.$width && `width: ${props.$width}px`};
|
||||
${(props) => props.$minWidth && `min-width: ${props.$minWidth}px`};
|
||||
width: ${(props) => props.$width}px;
|
||||
|
||||
${(props) =>
|
||||
props.$scrollable
|
||||
|
||||
@@ -3,7 +3,7 @@ import { transparentize } from "polished";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import { s } from "@shared/styles";
|
||||
import type { ReactionSummary } from "@shared/types";
|
||||
import { getEmojiId } from "@shared/utils/emoji";
|
||||
import User from "~/models/User";
|
||||
@@ -13,6 +13,7 @@ import NudeButton from "~/components/NudeButton";
|
||||
import Text from "~/components/Text";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import { hover } from "~/styles";
|
||||
|
||||
type Props = {
|
||||
/** Thin reaction data - contains the emoji & active user ids for this reaction. */
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Tab, TabPanel, useTabState } from "reakit";
|
||||
import { toast } from "sonner";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import { s } from "@shared/styles";
|
||||
import Comment from "~/models/Comment";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import { Emoji } from "~/components/Emoji";
|
||||
@@ -13,6 +13,7 @@ import Flex from "~/components/Flex";
|
||||
import PlaceholderText from "~/components/PlaceholderText";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { hover } from "~/styles";
|
||||
|
||||
type Props = {
|
||||
/** Model for which to show the reactions. */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { m, TargetAndTransition } from "framer-motion";
|
||||
import * as React from "react";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import { useComponentSize } from "@shared/hooks/useComponentSize";
|
||||
import useComponentSize from "~/hooks/useComponentSize";
|
||||
|
||||
type Props = {
|
||||
/** The children to render */
|
||||
|
||||
@@ -7,9 +7,10 @@ import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s, hover, ellipsis } from "@shared/styles";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import Document from "~/models/Document";
|
||||
import Highlight, { Mark } from "~/components/Highlight";
|
||||
import { hover } from "~/styles";
|
||||
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { observer } from "mobx-react";
|
||||
import { UserIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import Squircle from "@shared/components/Squircle";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
@@ -168,24 +167,18 @@ export const AccessControlList = observer(
|
||||
| CollectionPermission
|
||||
| typeof EmptySelectValue
|
||||
) => {
|
||||
try {
|
||||
if (permission === EmptySelectValue) {
|
||||
await groupMemberships.delete({
|
||||
collectionId: collection.id,
|
||||
groupId: membership.groupId,
|
||||
});
|
||||
} else {
|
||||
await groupMemberships.create({
|
||||
collectionId: collection.id,
|
||||
groupId: membership.groupId,
|
||||
permission,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
return false;
|
||||
if (permission === EmptySelectValue) {
|
||||
await groupMemberships.delete({
|
||||
collectionId: collection.id,
|
||||
groupId: membership.groupId,
|
||||
});
|
||||
} else {
|
||||
await groupMemberships.create({
|
||||
collectionId: collection.id,
|
||||
groupId: membership.groupId,
|
||||
permission,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
disabled={!can.update}
|
||||
value={membership.permission}
|
||||
@@ -208,7 +201,11 @@ export const AccessControlList = observer(
|
||||
<ListItem
|
||||
key={membership.id}
|
||||
image={
|
||||
<Avatar model={membership.user} size={AvatarSize.Medium} />
|
||||
<Avatar
|
||||
model={membership.user}
|
||||
size={AvatarSize.Medium}
|
||||
showBorder={false}
|
||||
/>
|
||||
}
|
||||
title={membership.user.name}
|
||||
subtitle={membership.user.email}
|
||||
@@ -222,24 +219,18 @@ export const AccessControlList = observer(
|
||||
| CollectionPermission
|
||||
| typeof EmptySelectValue
|
||||
) => {
|
||||
try {
|
||||
if (permission === EmptySelectValue) {
|
||||
await memberships.delete({
|
||||
collectionId: collection.id,
|
||||
userId: membership.userId,
|
||||
});
|
||||
} else {
|
||||
await memberships.create({
|
||||
collectionId: collection.id,
|
||||
userId: membership.userId,
|
||||
permission,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
return false;
|
||||
if (permission === EmptySelectValue) {
|
||||
await memberships.delete({
|
||||
collectionId: collection.id,
|
||||
userId: membership.userId,
|
||||
});
|
||||
} else {
|
||||
await memberships.create({
|
||||
collectionId: collection.id,
|
||||
userId: membership.userId,
|
||||
permission,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
disabled={!can.update}
|
||||
value={membership.permission}
|
||||
|
||||
@@ -146,7 +146,7 @@ export const AccessControlList = observer(
|
||||
/>
|
||||
) : (
|
||||
<ListItem
|
||||
image={<Avatar model={user} />}
|
||||
image={<Avatar model={user} showBorder={false} />}
|
||||
title={user.name}
|
||||
subtitle={t("You have full access")}
|
||||
actions={<AccessTooltip>{t("Can edit")}</AccessTooltip>}
|
||||
@@ -160,7 +160,9 @@ export const AccessControlList = observer(
|
||||
) : document.isDraft ? (
|
||||
<>
|
||||
<ListItem
|
||||
image={<Avatar model={document.createdBy} />}
|
||||
image={
|
||||
<Avatar model={document.createdBy} showBorder={false} />
|
||||
}
|
||||
title={document.createdBy?.name}
|
||||
actions={
|
||||
<AccessTooltip content={t("Created the document")}>
|
||||
|
||||
@@ -73,7 +73,9 @@ const DocumentMemberListItem = ({
|
||||
return (
|
||||
<ListItem
|
||||
title={user.name}
|
||||
image={<Avatar model={user} size={AvatarSize.Medium} />}
|
||||
image={
|
||||
<Avatar model={user} size={AvatarSize.Medium} showBorder={false} />
|
||||
}
|
||||
subtitle={
|
||||
membership?.sourceId ? (
|
||||
<Trans>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import styled from "styled-components";
|
||||
import { hover } from "@shared/styles";
|
||||
import BaseListItem from "~/components/List/Item";
|
||||
import { hover } from "~/styles";
|
||||
|
||||
export const InviteIcon = styled(PlusIcon)`
|
||||
opacity: 0;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { CheckmarkIcon, CloseIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import { s } from "@shared/styles";
|
||||
import { stringToColor } from "@shared/utils/color";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
@@ -20,6 +20,7 @@ import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useMaxHeight from "~/hooks/useMaxHeight";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useThrottledCallback from "~/hooks/useThrottledCallback";
|
||||
import { hover } from "~/styles";
|
||||
import { InviteIcon, ListItem } from "./ListItem";
|
||||
|
||||
type Suggestion = IAvatar & {
|
||||
@@ -158,7 +159,13 @@ export const Suggestions = observer(
|
||||
: suggestion.isViewer
|
||||
? t("Viewer")
|
||||
: t("Editor"),
|
||||
image: <Avatar model={suggestion} size={AvatarSize.Medium} />,
|
||||
image: (
|
||||
<Avatar
|
||||
model={suggestion}
|
||||
size={AvatarSize.Medium}
|
||||
showBorder={false}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { darken } from "polished";
|
||||
import styled from "styled-components";
|
||||
import Flex from "@shared/components/Flex";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import { s } from "@shared/styles";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { hover } from "~/styles";
|
||||
|
||||
// TODO: Temp until Button/NudeButton styles are normalized
|
||||
export const Wrapper = styled.div`
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { SearchIcon, HomeIcon, SidebarIcon } from "outline-icons";
|
||||
import { DraftsIcon, SearchIcon, HomeIcon, SidebarIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { DndProvider } from "react-dnd";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { metaDisplay } from "@shared/utils/keyboard";
|
||||
import Flex from "~/components/Flex";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import Text from "~/components/Text";
|
||||
import { inviteUser } from "~/actions/definitions/users";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import OrganizationMenu from "~/menus/OrganizationMenu";
|
||||
import { homePath, searchPath } from "~/utils/routeHelpers";
|
||||
import { metaDisplay } from "~/utils/keyboard";
|
||||
import { homePath, draftsPath, searchPath } from "~/utils/routeHelpers";
|
||||
import TeamLogo from "../TeamLogo";
|
||||
import Tooltip from "../Tooltip";
|
||||
import Sidebar from "./Sidebar";
|
||||
import ArchiveLink from "./components/ArchiveLink";
|
||||
import Collections from "./components/Collections";
|
||||
import { DraftsLink } from "./components/DraftsLink";
|
||||
import DragPlaceholder from "./components/DragPlaceholder";
|
||||
import HistoryNavigation from "./components/HistoryNavigation";
|
||||
import Section from "./components/Section";
|
||||
@@ -106,7 +107,24 @@ function AppSidebar() {
|
||||
label={t("Search")}
|
||||
exact={false}
|
||||
/>
|
||||
{can.createDocument && <DraftsLink />}
|
||||
{can.createDocument && (
|
||||
<SidebarLink
|
||||
to={draftsPath()}
|
||||
icon={<DraftsIcon />}
|
||||
label={
|
||||
<Flex align="center" justify="space-between">
|
||||
{t("Drafts")}
|
||||
{documents.totalDrafts > 0 ? (
|
||||
<Drafts size="xsmall" type="tertiary">
|
||||
{documents.totalDrafts > 25
|
||||
? "25+"
|
||||
: documents.totalDrafts}
|
||||
</Drafts>
|
||||
) : null}
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
</Overflow>
|
||||
<Scrollable flex shadow>
|
||||
@@ -140,4 +158,8 @@ const Overflow = styled.div`
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const Drafts = styled(Text)`
|
||||
margin: 0 4px;
|
||||
`;
|
||||
|
||||
export default observer(AppSidebar);
|
||||
|
||||
@@ -5,12 +5,12 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { metaDisplay } from "@shared/utils/keyboard";
|
||||
import Flex from "~/components/Flex";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import useSettingsConfig from "~/hooks/useSettingsConfig";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import { metaDisplay } from "~/utils/keyboard";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
import Tooltip from "../Tooltip";
|
||||
import Sidebar from "./Sidebar";
|
||||
|
||||
@@ -3,18 +3,17 @@ import { SidebarIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { hover } from "@shared/styles";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import { metaDisplay } from "@shared/utils/keyboard";
|
||||
import Flex from "~/components/Flex";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import SearchPopover from "~/components/SearchPopover";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { hover } from "~/styles";
|
||||
import history from "~/utils/history";
|
||||
import { metaDisplay } from "~/utils/keyboard";
|
||||
import { homePath, sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
import { AvatarSize } from "../Avatar";
|
||||
import { useTeamContext } from "../TeamContext";
|
||||
import TeamLogo from "../TeamLogo";
|
||||
import Sidebar from "./Sidebar";
|
||||
@@ -41,9 +40,7 @@ function SharedSidebar({ rootNode, shareId }: Props) {
|
||||
{teamAvailable && (
|
||||
<SidebarButton
|
||||
title={team.name}
|
||||
image={
|
||||
<TeamLogo model={team} size={AvatarSize.XLarge} alt={t("Logo")} />
|
||||
}
|
||||
image={<TeamLogo model={team} size={32} alt={t("Logo")} />}
|
||||
onClick={() =>
|
||||
history.push(
|
||||
user ? homePath() : sharedDocumentPath(shareId, rootNode.url)
|
||||
@@ -70,7 +67,6 @@ function SharedSidebar({ rootNode, shareId }: Props) {
|
||||
depth={0}
|
||||
shareId={shareId}
|
||||
node={rootNode}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
activeDocumentId={ui.activeDocumentId}
|
||||
activeDocument={documents.active}
|
||||
/>
|
||||
|
||||
@@ -17,7 +17,6 @@ import { fadeIn } from "~/styles/animations";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import NotificationIcon from "../Notifications/NotificationIcon";
|
||||
import NotificationsPopover from "../Notifications/NotificationsPopover";
|
||||
import { TooltipProvider } from "../TooltipContext";
|
||||
import ResizeBorder from "./components/ResizeBorder";
|
||||
import SidebarButton, { SidebarButtonProps } from "./components/SidebarButton";
|
||||
import ToggleButton from "./components/ToggleButton";
|
||||
@@ -195,9 +194,8 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<>
|
||||
<Container
|
||||
id="sidebar"
|
||||
ref={ref}
|
||||
style={style}
|
||||
$hidden={hidden}
|
||||
@@ -228,6 +226,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
alt={user.name}
|
||||
model={user}
|
||||
size={24}
|
||||
showBorder={false}
|
||||
style={{ marginLeft: 4 }}
|
||||
/>
|
||||
}
|
||||
@@ -243,7 +242,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
/>
|
||||
</Container>
|
||||
{ui.mobileSidebarVisible && <Backdrop onClick={ui.toggleMobileSidebar} />}
|
||||
</TooltipProvider>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import { ArchivedCollectionLink } from "./ArchivedCollectionLink";
|
||||
import { StyledError } from "./Collections";
|
||||
import PlaceholderCollections from "./PlaceholderCollections";
|
||||
import Relative from "./Relative";
|
||||
import SidebarContext from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
function ArchiveLink() {
|
||||
@@ -65,40 +64,38 @@ function ArchiveLink() {
|
||||
useDropToArchive();
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value="archive">
|
||||
<Flex column>
|
||||
<div ref={dropToArchiveRef}>
|
||||
<SidebarLink
|
||||
to={archivePath()}
|
||||
icon={<ArchiveIcon open={isOverArchiveSection && isDragging} />}
|
||||
exact={false}
|
||||
label={t("Archive")}
|
||||
isActiveDrop={isOverArchiveSection && isDragging}
|
||||
depth={0}
|
||||
expanded={disclosure ? expanded : undefined}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
onClick={handleClick}
|
||||
<Flex column>
|
||||
<div ref={dropToArchiveRef}>
|
||||
<SidebarLink
|
||||
to={archivePath()}
|
||||
icon={<ArchiveIcon open={isOverArchiveSection && isDragging} />}
|
||||
exact={false}
|
||||
label={t("Archive")}
|
||||
isActiveDrop={isOverArchiveSection && isDragging}
|
||||
depth={0}
|
||||
expanded={disclosure ? expanded : undefined}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
</div>
|
||||
{expanded === true ? (
|
||||
<Relative>
|
||||
<PaginatedList
|
||||
aria-label={t("Archived collections")}
|
||||
items={collections.archived}
|
||||
loading={<PlaceholderCollections />}
|
||||
renderError={(props) => <StyledError {...props} />}
|
||||
renderItem={(item: Collection) => (
|
||||
<ArchivedCollectionLink
|
||||
key={item.id}
|
||||
depth={1}
|
||||
collection={item}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{expanded === true ? (
|
||||
<Relative>
|
||||
<PaginatedList
|
||||
aria-label={t("Archived collections")}
|
||||
items={collections.archived}
|
||||
loading={<PlaceholderCollections />}
|
||||
renderError={(props) => <StyledError {...props} />}
|
||||
renderItem={(item: Collection) => (
|
||||
<ArchivedCollectionLink
|
||||
key={item.id}
|
||||
depth={1}
|
||||
collection={item}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Relative>
|
||||
) : null}
|
||||
</Flex>
|
||||
</SidebarContext.Provider>
|
||||
</Relative>
|
||||
) : null}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,29 +2,26 @@ import { Location } from "history";
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { UserPreference } from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { CollectionValidation, DocumentValidation } from "@shared/validations";
|
||||
import { CollectionValidation } from "@shared/validations";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import ConfirmMoveDialog from "~/components/ConfirmMoveDialog";
|
||||
import Fade from "~/components/Fade";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { createDocument } from "~/actions/definitions/documents";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import CollectionMenu from "~/menus/CollectionMenu";
|
||||
import { documentEditPath } from "~/utils/routeHelpers";
|
||||
import { useDropToChangeCollection } from "../hooks/useDragAndDrop";
|
||||
import DropToImport from "./DropToImport";
|
||||
import EditableTitle, { RefHandle } from "./EditableTitle";
|
||||
import Relative from "./Relative";
|
||||
import { SidebarContextType, useSidebarContext } from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import SidebarLink, { DragObject } from "./SidebarLink";
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
@@ -44,14 +41,12 @@ const CollectionLink: React.FC<Props> = ({
|
||||
depth,
|
||||
onClick,
|
||||
}: Props) => {
|
||||
const { dialogs, documents, collections } = useStores();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const { documents } = useStores();
|
||||
const history = useHistory();
|
||||
const can = usePolicy(collection);
|
||||
const { t } = useTranslation();
|
||||
const sidebarContext = useSidebarContext();
|
||||
const user = useCurrentUser();
|
||||
const editableTitleRef = React.useRef<RefHandle>(null);
|
||||
|
||||
const handleTitleChange = React.useCallback(
|
||||
@@ -63,132 +58,119 @@ const CollectionLink: React.FC<Props> = ({
|
||||
[collection]
|
||||
);
|
||||
|
||||
const handleExpand = React.useCallback(() => {
|
||||
if (!expanded) {
|
||||
onDisclosureClick();
|
||||
}
|
||||
}, [expanded, onDisclosureClick]);
|
||||
// Drop to re-parent document
|
||||
const [{ isOver, canDrop }, drop] = useDrop({
|
||||
accept: "document",
|
||||
drop: async (item: DragObject, monitor) => {
|
||||
const { id, collectionId } = item;
|
||||
if (monitor.didDrop()) {
|
||||
return;
|
||||
}
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parentRef = React.useRef<HTMLDivElement>(null);
|
||||
const [{ isOver, canDrop }, dropRef] = useDropToChangeCollection(
|
||||
collection,
|
||||
handleExpand,
|
||||
parentRef
|
||||
);
|
||||
const document = documents.get(id);
|
||||
if (collection.id === collectionId && !document?.parentDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prevCollection = collections.get(collectionId);
|
||||
|
||||
if (
|
||||
prevCollection &&
|
||||
prevCollection.permission !== collection.permission &&
|
||||
!document?.isDraft
|
||||
) {
|
||||
dialogs.openModal({
|
||||
title: t("Change permissions?"),
|
||||
content: <ConfirmMoveDialog item={item} collection={collection} />,
|
||||
});
|
||||
} else {
|
||||
await documents.move({ documentId: id, collectionId: collection.id });
|
||||
|
||||
if (!expanded) {
|
||||
onDisclosureClick();
|
||||
}
|
||||
}
|
||||
},
|
||||
canDrop: () => can.createDocument,
|
||||
collect: (monitor) => ({
|
||||
isOver: !!monitor.isOver({
|
||||
shallow: true,
|
||||
}),
|
||||
canDrop: monitor.canDrop(),
|
||||
}),
|
||||
});
|
||||
|
||||
const handlePrefetch = React.useCallback(() => {
|
||||
void collection.fetchDocuments();
|
||||
}, [collection]);
|
||||
|
||||
const context = useActionContext({
|
||||
activeCollectionId: collection.id,
|
||||
sidebarContext,
|
||||
});
|
||||
|
||||
const handleRename = React.useCallback(() => {
|
||||
editableTitleRef.current?.setIsEditing(true);
|
||||
}, [editableTitleRef]);
|
||||
|
||||
const [isAddingNewChild, setIsAddingNewChild, closeAddingNewChild] =
|
||||
useBoolean();
|
||||
|
||||
const handleNewDoc = React.useCallback(
|
||||
async (input) => {
|
||||
const newDocument = await documents.create(
|
||||
{
|
||||
collectionId: collection.id,
|
||||
title: input,
|
||||
fullWidth: user.getPreference(UserPreference.FullWidthDocuments),
|
||||
data: ProsemirrorHelper.getEmptyDocument(),
|
||||
},
|
||||
{ publish: true }
|
||||
);
|
||||
collection?.addDocument(newDocument);
|
||||
|
||||
closeAddingNewChild();
|
||||
history.push({
|
||||
pathname: documentEditPath(newDocument),
|
||||
state: { sidebarContext },
|
||||
});
|
||||
},
|
||||
[user, sidebarContext, closeAddingNewChild, history, collection, documents]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Relative ref={mergeRefs([parentRef, dropRef])}>
|
||||
<DropToImport collectionId={collection.id}>
|
||||
<SidebarLink
|
||||
onClick={onClick}
|
||||
to={{
|
||||
pathname: collection.path,
|
||||
state: { sidebarContext },
|
||||
}}
|
||||
expanded={expanded}
|
||||
onDisclosureClick={onDisclosureClick}
|
||||
onClickIntent={handlePrefetch}
|
||||
icon={
|
||||
<CollectionIcon collection={collection} expanded={expanded} />
|
||||
}
|
||||
showActions={menuOpen}
|
||||
isActiveDrop={isOver && canDrop}
|
||||
isActive={(
|
||||
match,
|
||||
location: Location<{ sidebarContext?: SidebarContextType }>
|
||||
) => !!match && location.state?.sidebarContext === sidebarContext}
|
||||
label={
|
||||
<EditableTitle
|
||||
title={collection.name}
|
||||
onSubmit={handleTitleChange}
|
||||
onEditing={setIsEditing}
|
||||
canUpdate={can.update}
|
||||
maxLength={CollectionValidation.maxNameLength}
|
||||
ref={editableTitleRef}
|
||||
/>
|
||||
}
|
||||
exact={false}
|
||||
depth={depth ? depth : 0}
|
||||
menu={
|
||||
!isEditing &&
|
||||
!isDraggingAnyCollection && (
|
||||
<Fade>
|
||||
{can.createDocument && (
|
||||
<NudeButton
|
||||
tooltip={{ content: t("New doc"), delay: 500 }}
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
setIsAddingNewChild();
|
||||
handleExpand();
|
||||
}}
|
||||
>
|
||||
<PlusIcon />
|
||||
</NudeButton>
|
||||
)}
|
||||
<CollectionMenu
|
||||
collection={collection}
|
||||
onRename={handleRename}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
</Fade>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</DropToImport>
|
||||
</Relative>
|
||||
{isAddingNewChild && (
|
||||
<Relative ref={drop}>
|
||||
<DropToImport collectionId={collection.id}>
|
||||
<SidebarLink
|
||||
depth={2}
|
||||
isActive={() => true}
|
||||
onClick={onClick}
|
||||
to={{
|
||||
pathname: collection.path,
|
||||
state: { sidebarContext },
|
||||
}}
|
||||
expanded={expanded}
|
||||
onDisclosureClick={onDisclosureClick}
|
||||
onClickIntent={handlePrefetch}
|
||||
icon={<CollectionIcon collection={collection} expanded={expanded} />}
|
||||
showActions={menuOpen}
|
||||
isActiveDrop={isOver && canDrop}
|
||||
isActive={(
|
||||
match,
|
||||
location: Location<{ sidebarContext?: SidebarContextType }>
|
||||
) => !!match && location.state?.sidebarContext === sidebarContext}
|
||||
label={
|
||||
<EditableTitle
|
||||
title=""
|
||||
canUpdate
|
||||
isEditing
|
||||
placeholder={`${t("New doc")}…`}
|
||||
onCancel={closeAddingNewChild}
|
||||
onSubmit={handleNewDoc}
|
||||
maxLength={DocumentValidation.maxTitleLength}
|
||||
title={collection.name}
|
||||
onSubmit={handleTitleChange}
|
||||
onEditing={setIsEditing}
|
||||
canUpdate={can.update}
|
||||
maxLength={CollectionValidation.maxNameLength}
|
||||
ref={editableTitleRef}
|
||||
/>
|
||||
}
|
||||
exact={false}
|
||||
depth={depth ? depth : 0}
|
||||
menu={
|
||||
!isEditing &&
|
||||
!isDraggingAnyCollection && (
|
||||
<Fade>
|
||||
<NudeButton
|
||||
tooltip={{ content: t("New doc"), delay: 500 }}
|
||||
action={createDocument}
|
||||
context={context}
|
||||
hideOnActionDisabled
|
||||
>
|
||||
<PlusIcon />
|
||||
</NudeButton>
|
||||
<CollectionMenu
|
||||
collection={collection}
|
||||
onRename={handleRename}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
</Fade>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</DropToImport>
|
||||
</Relative>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import noop from "lodash/noop";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Waypoint } from "react-waypoint";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import ConfirmMoveDialog from "~/components/ConfirmMoveDialog";
|
||||
import DocumentsLoader from "~/components/DocumentsLoader";
|
||||
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
|
||||
import Text from "~/components/Text";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
import useCollectionDocuments from "../hooks/useCollectionDocuments";
|
||||
import { useDropToChangeCollection } from "../hooks/useDragAndDrop";
|
||||
import DocumentLink from "./DocumentLink";
|
||||
import DropCursor from "./DropCursor";
|
||||
import Folder from "./Folder";
|
||||
import PlaceholderCollections from "./PlaceholderCollections";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import SidebarLink, { DragObject } from "./SidebarLink";
|
||||
|
||||
type Props = {
|
||||
/** The collection to render the children of. */
|
||||
@@ -34,17 +36,55 @@ function CollectionLinkChildren({
|
||||
prefetchDocument,
|
||||
}: Props) {
|
||||
const pageSize = 250;
|
||||
const { documents } = useStores();
|
||||
const can = usePolicy(collection);
|
||||
const manualSort = collection.sort.field === "index";
|
||||
const { documents, dialogs, collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const childDocuments = useCollectionDocuments(collection, documents.active);
|
||||
const [showing, setShowing] = React.useState(pageSize);
|
||||
const dummyRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const [{ isOver, canDrop }, dropRef] = useDropToChangeCollection(
|
||||
collection,
|
||||
noop,
|
||||
dummyRef
|
||||
);
|
||||
// Drop to reorder document
|
||||
const [{ isOverReorder, isDraggingAnyDocument }, dropToReorder] = useDrop({
|
||||
accept: "document",
|
||||
drop: (item: DragObject) => {
|
||||
if (!manualSort && item.collectionId === collection?.id) {
|
||||
toast.message(
|
||||
t(
|
||||
"You can't reorder documents in an alphabetically sorted collection"
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prevCollection = collections.get(item.collectionId);
|
||||
|
||||
if (
|
||||
prevCollection &&
|
||||
prevCollection.permission !== collection.permission
|
||||
) {
|
||||
dialogs.openModal({
|
||||
title: t("Change permissions?"),
|
||||
content: (
|
||||
<ConfirmMoveDialog item={item} collection={collection} index={0} />
|
||||
),
|
||||
});
|
||||
} else {
|
||||
void documents.move({
|
||||
documentId: item.id,
|
||||
collectionId: collection.id,
|
||||
index: 0,
|
||||
});
|
||||
}
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOverReorder: !!monitor.isOver(),
|
||||
isDraggingAnyDocument: !!monitor.canDrop(),
|
||||
}),
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!expanded) {
|
||||
@@ -60,8 +100,12 @@ function CollectionLinkChildren({
|
||||
|
||||
return (
|
||||
<Folder expanded={expanded}>
|
||||
{canDrop && collection.isManualSort && (
|
||||
<DropCursor isActiveDrop={isOver} innerRef={dropRef} position="top" />
|
||||
{isDraggingAnyDocument && can.createDocument && manualSort && (
|
||||
<DropCursor
|
||||
isActiveDrop={isOverReorder}
|
||||
innerRef={dropToReorder}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
<DocumentsLoader collection={collection} enabled={expanded}>
|
||||
{!childDocuments && (
|
||||
|
||||
@@ -10,14 +10,13 @@ import Error from "~/components/List/Error";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import { createCollection } from "~/actions/definitions/collections";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { DragObject } from "../hooks/useDragAndDrop";
|
||||
import DraggableCollectionLink from "./DraggableCollectionLink";
|
||||
import DropCursor from "./DropCursor";
|
||||
import Header from "./Header";
|
||||
import PlaceholderCollections from "./PlaceholderCollections";
|
||||
import Relative from "./Relative";
|
||||
import SidebarAction from "./SidebarAction";
|
||||
import SidebarContext from "./SidebarContext";
|
||||
import { DragObject } from "./SidebarLink";
|
||||
|
||||
function Collections() {
|
||||
const { documents, collections } = useStores();
|
||||
@@ -50,40 +49,38 @@ function Collections() {
|
||||
});
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value="collections">
|
||||
<Flex column>
|
||||
<Header id="collections" title={t("Collections")}>
|
||||
<Relative>
|
||||
<PaginatedList
|
||||
options={params}
|
||||
aria-label={t("Collections")}
|
||||
items={collections.allActive}
|
||||
loading={<PlaceholderCollections />}
|
||||
heading={
|
||||
isDraggingAnyCollection ? (
|
||||
<DropCursor
|
||||
isActiveDrop={isCollectionDropping}
|
||||
innerRef={dropToReorderCollection}
|
||||
position="top"
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
renderError={(props) => <StyledError {...props} />}
|
||||
renderItem={(item: Collection, index) => (
|
||||
<DraggableCollectionLink
|
||||
key={item.id}
|
||||
collection={item}
|
||||
activeDocument={documents.active}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
belowCollection={orderedCollections[index + 1]}
|
||||
<Flex column>
|
||||
<Header id="collections" title={t("Collections")}>
|
||||
<Relative>
|
||||
<PaginatedList
|
||||
options={params}
|
||||
aria-label={t("Collections")}
|
||||
items={collections.allActive}
|
||||
loading={<PlaceholderCollections />}
|
||||
heading={
|
||||
isDraggingAnyCollection ? (
|
||||
<DropCursor
|
||||
isActiveDrop={isCollectionDropping}
|
||||
innerRef={dropToReorderCollection}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SidebarAction action={createCollection} depth={0} />
|
||||
</Relative>
|
||||
</Header>
|
||||
</Flex>
|
||||
</SidebarContext.Provider>
|
||||
) : undefined
|
||||
}
|
||||
renderError={(props) => <StyledError {...props} />}
|
||||
renderItem={(item: Collection, index) => (
|
||||
<DraggableCollectionLink
|
||||
key={item.id}
|
||||
collection={item}
|
||||
activeDocument={documents.active}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
belowCollection={orderedCollections[index + 1]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SidebarAction action={createCollection} depth={0} />
|
||||
</Relative>
|
||||
</Header>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,24 +3,22 @@ import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { NavigationNode, UserPreference } from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import { sortNavigationNodes } from "@shared/utils/collections";
|
||||
import { DocumentValidation } from "@shared/validations";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import Fade from "~/components/Fade";
|
||||
import Icon from "~/components/Icon";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import { documentEditPath } from "~/utils/routeHelpers";
|
||||
import { newNestedDocumentPath } from "~/utils/routeHelpers";
|
||||
import {
|
||||
useDragDocument,
|
||||
useDropToReorderDocument,
|
||||
@@ -60,7 +58,6 @@ function InnerDocumentLink(
|
||||
) {
|
||||
const { documents, policies } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const canUpdate = usePolicy(node.id).update;
|
||||
const isActiveDocument = activeDocument && activeDocument.id === node.id;
|
||||
const hasChildDocuments =
|
||||
@@ -70,7 +67,6 @@ function InnerDocumentLink(
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const editableTitleRef = React.useRef<RefHandle>(null);
|
||||
const sidebarContext = useSidebarContext();
|
||||
const user = useCurrentUser();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
@@ -220,43 +216,6 @@ function InnerDocumentLink(
|
||||
[setExpanded, setCollapsed, hasChildren, expanded]
|
||||
);
|
||||
|
||||
const [isAddingNewChild, setIsAddingNewChild, closeAddingNewChild] =
|
||||
useBoolean();
|
||||
|
||||
const handleNewDoc = React.useCallback(
|
||||
async (input) => {
|
||||
const newDocument = await documents.create(
|
||||
{
|
||||
collectionId: collection?.id,
|
||||
parentDocumentId: node.id,
|
||||
fullWidth:
|
||||
doc?.fullWidth ??
|
||||
user.getPreference(UserPreference.FullWidthDocuments),
|
||||
title: input,
|
||||
data: ProsemirrorHelper.getEmptyDocument(),
|
||||
},
|
||||
{ publish: true }
|
||||
);
|
||||
collection?.addDocument(newDocument, node.id);
|
||||
|
||||
closeAddingNewChild();
|
||||
history.push({
|
||||
pathname: documentEditPath(newDocument),
|
||||
state: { sidebarContext },
|
||||
});
|
||||
},
|
||||
[
|
||||
documents,
|
||||
collection,
|
||||
sidebarContext,
|
||||
user,
|
||||
node,
|
||||
doc,
|
||||
history,
|
||||
closeAddingNewChild,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Relative ref={parentRef}>
|
||||
@@ -323,11 +282,8 @@ function InnerDocumentLink(
|
||||
<NudeButton
|
||||
type={undefined}
|
||||
aria-label={t("New nested document")}
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
setIsAddingNewChild();
|
||||
setExpanded();
|
||||
}}
|
||||
as={Link}
|
||||
to={newNestedDocumentPath(document.id)}
|
||||
>
|
||||
<PlusIcon />
|
||||
</NudeButton>
|
||||
@@ -352,25 +308,8 @@ function InnerDocumentLink(
|
||||
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
|
||||
)}
|
||||
</Relative>
|
||||
{isAddingNewChild && (
|
||||
<SidebarLink
|
||||
isActive={() => true}
|
||||
depth={depth + 1}
|
||||
label={
|
||||
<EditableTitle
|
||||
title=""
|
||||
canUpdate
|
||||
isEditing
|
||||
placeholder={`${t("New doc")}…`}
|
||||
onCancel={closeAddingNewChild}
|
||||
onSubmit={handleNewDoc}
|
||||
maxLength={DocumentValidation.maxTitleLength}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Folder expanded={expanded && !isDragging}>
|
||||
{nodeChildren.map((childNode, childIndex) => (
|
||||
{nodeChildren.map((childNode, index) => (
|
||||
<DocumentLink
|
||||
key={childNode.id}
|
||||
collection={collection}
|
||||
@@ -379,7 +318,7 @@ function InnerDocumentLink(
|
||||
prefetchDocument={prefetchDocument}
|
||||
isDraft={childNode.isDraft}
|
||||
depth={depth + 1}
|
||||
index={childIndex}
|
||||
index={index}
|
||||
parentId={node.id}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { DraftsIcon } from "outline-icons";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { draftsPath } from "~/utils/routeHelpers";
|
||||
import { useDropToUnpublish } from "../hooks/useDragAndDrop";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
export const DraftsLink = observer(() => {
|
||||
const { t } = useTranslation();
|
||||
const { documents } = useStores();
|
||||
const [{ isOver, canDrop }, dropRef] = useDropToUnpublish();
|
||||
|
||||
return (
|
||||
<div ref={dropRef}>
|
||||
<SidebarLink
|
||||
to={draftsPath()}
|
||||
icon={<DraftsIcon />}
|
||||
label={
|
||||
<Flex align="center" justify="space-between">
|
||||
{t("Drafts")}
|
||||
{documents.totalDrafts > 0 ? (
|
||||
<Drafts size="xsmall" type="tertiary">
|
||||
{documents.totalDrafts > 25 ? "25+" : documents.totalDrafts}
|
||||
</Drafts>
|
||||
) : null}
|
||||
</Flex>
|
||||
}
|
||||
isActiveDrop={isOver && canDrop}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const Drafts = styled(Text)`
|
||||
margin: 0 4px;
|
||||
`;
|
||||
@@ -7,14 +7,14 @@ import styled from "styled-components";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { DragObject } from "../hooks/useDragAndDrop";
|
||||
import { useLocationState } from "../hooks/useLocationState";
|
||||
import CollectionLink from "./CollectionLink";
|
||||
import CollectionLinkChildren from "./CollectionLinkChildren";
|
||||
import DropCursor from "./DropCursor";
|
||||
import Relative from "./Relative";
|
||||
import { useSidebarContext } from "./SidebarContext";
|
||||
import { DragObject } from "./SidebarLink";
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
@@ -29,7 +29,7 @@ function DraggableCollectionLink({
|
||||
prefetchDocument,
|
||||
belowCollection,
|
||||
}: Props) {
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
const locationSidebarContext = useLocationState();
|
||||
const sidebarContext = useSidebarContext();
|
||||
const { ui, policies, collections } = useStores();
|
||||
const [expanded, setExpanded] = React.useState(
|
||||
|
||||
@@ -3,33 +3,23 @@ import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
type Props = Omit<React.HTMLAttributes<HTMLInputElement>, "onSubmit"> & {
|
||||
/** A callback when the title is submitted. */
|
||||
onSubmit: (title: string) => Promise<void> | void;
|
||||
/** A callback when the editing status changes. */
|
||||
type Props = {
|
||||
onSubmit: (title: string) => Promise<void>;
|
||||
onEditing?: (isEditing: boolean) => void;
|
||||
/** A callback when editing is canceled. */
|
||||
onCancel?: () => void;
|
||||
/** The default title. */
|
||||
title: string;
|
||||
/** Whether the user can update the title. */
|
||||
canUpdate: boolean;
|
||||
/** The maximum length of the title. */
|
||||
maxLength?: number;
|
||||
/** The default editing state. */
|
||||
isEditing?: boolean;
|
||||
};
|
||||
|
||||
export type RefHandle = {
|
||||
/** A function to set the editing state. */
|
||||
setIsEditing: (isEditing: boolean) => void;
|
||||
};
|
||||
|
||||
function EditableTitle(
|
||||
{ title, onSubmit, canUpdate, onEditing, onCancel, ...rest }: Props,
|
||||
{ title, onSubmit, canUpdate, onEditing, ...rest }: Props,
|
||||
ref: React.RefObject<RefHandle>
|
||||
) {
|
||||
const [isEditing, setIsEditing] = React.useState(rest.isEditing || false);
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const [originalValue, setOriginalValue] = React.useState(title);
|
||||
const [value, setValue] = React.useState(title);
|
||||
|
||||
@@ -69,7 +59,6 @@ function EditableTitle(
|
||||
|
||||
if (trimmedValue === originalValue || trimmedValue.length === 0) {
|
||||
setValue(originalValue);
|
||||
onCancel?.();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -84,7 +73,7 @@ function EditableTitle(
|
||||
}
|
||||
}
|
||||
},
|
||||
[originalValue, value, onCancel, onSubmit]
|
||||
[originalValue, value, onSubmit]
|
||||
);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
@@ -94,14 +83,13 @@ function EditableTitle(
|
||||
}
|
||||
if (ev.key === "Escape") {
|
||||
setIsEditing(false);
|
||||
onCancel?.();
|
||||
setValue(originalValue);
|
||||
}
|
||||
if (ev.key === "Enter") {
|
||||
await handleSave(ev);
|
||||
}
|
||||
},
|
||||
[handleSave, onCancel, originalValue]
|
||||
[handleSave, originalValue]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -2,11 +2,10 @@ import { observer } from "mobx-react";
|
||||
import { GroupIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import Group from "~/models/Group";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import Folder from "./Folder";
|
||||
import Relative from "./Relative";
|
||||
import SharedWithMeLink from "./SharedWithMeLink";
|
||||
import SidebarContext, { groupSidebarContext } from "./SidebarContext";
|
||||
import SidebarContext from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
type Props = {
|
||||
@@ -15,23 +14,13 @@ type Props = {
|
||||
};
|
||||
|
||||
const GroupLink: React.FC<Props> = ({ group }) => {
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
const sidebarContext = groupSidebarContext(group.id);
|
||||
const [expanded, setExpanded] = React.useState(
|
||||
locationSidebarContext === sidebarContext
|
||||
);
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
|
||||
const handleDisclosureClick = React.useCallback((ev) => {
|
||||
ev?.preventDefault();
|
||||
setExpanded((e) => !e);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (locationSidebarContext === sidebarContext) {
|
||||
setExpanded(true);
|
||||
}
|
||||
}, [sidebarContext, locationSidebarContext, setExpanded]);
|
||||
|
||||
return (
|
||||
<Relative>
|
||||
<SidebarLink
|
||||
@@ -41,7 +30,7 @@ const GroupLink: React.FC<Props> = ({ group }) => {
|
||||
onClick={handleDisclosureClick}
|
||||
depth={0}
|
||||
/>
|
||||
<SidebarContext.Provider value={sidebarContext}>
|
||||
<SidebarContext.Provider value={group.id}>
|
||||
<Folder expanded={expanded}>
|
||||
{group.documentMemberships.map((membership) => (
|
||||
<SharedWithMeLink
|
||||
|
||||
@@ -93,11 +93,15 @@ const NavLink = ({
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (isActive && linkRef.current && scrollIntoViewIfNeeded !== false) {
|
||||
scrollIntoView(linkRef.current, {
|
||||
scrollMode: "if-needed",
|
||||
behavior: "auto",
|
||||
boundary: (parent) => parent.id !== "sidebar",
|
||||
});
|
||||
// If the page has an anchor hash then this means we're linking to an
|
||||
// anchor in the document – smooth scrolling the sidebar may the scrolling
|
||||
// to the anchor of the document so we must avoid it.
|
||||
if (!window.location.hash) {
|
||||
scrollIntoView(linkRef.current, {
|
||||
scrollMode: "if-needed",
|
||||
behavior: "auto",
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [linkRef, scrollIntoViewIfNeeded, isActive]);
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@ import includes from "lodash/includes";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import Icon from "~/components/Icon";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
import { descendants } from "~/utils/tree";
|
||||
@@ -16,7 +16,6 @@ type Props = {
|
||||
collection?: Collection;
|
||||
activeDocumentId?: string;
|
||||
activeDocument?: Document;
|
||||
prefetchDocument?: (documentId: string) => Promise<Document | void>;
|
||||
isDraft?: boolean;
|
||||
depth: number;
|
||||
index: number;
|
||||
@@ -30,7 +29,6 @@ function DocumentLink(
|
||||
collection,
|
||||
activeDocument,
|
||||
activeDocumentId,
|
||||
prefetchDocument,
|
||||
isDraft,
|
||||
depth,
|
||||
shareId,
|
||||
@@ -99,10 +97,6 @@ function DocumentLink(
|
||||
node,
|
||||
]);
|
||||
|
||||
const handlePrefetch = React.useCallback(() => {
|
||||
void prefetchDocument?.(node.id);
|
||||
}, [prefetchDocument, node]);
|
||||
|
||||
const title =
|
||||
(activeDocument?.id === node.id ? activeDocument.title : node.title) ||
|
||||
t("Untitled");
|
||||
@@ -120,7 +114,6 @@ function DocumentLink(
|
||||
}}
|
||||
expanded={hasChildDocuments && depth !== 0 ? expanded : undefined}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
onClickIntent={handlePrefetch}
|
||||
icon={icon && <Icon value={icon} color={node.color} />}
|
||||
label={title}
|
||||
depth={depth}
|
||||
@@ -139,7 +132,6 @@ function DocumentLink(
|
||||
node={childNode}
|
||||
activeDocumentId={activeDocumentId}
|
||||
activeDocument={activeDocument}
|
||||
prefetchDocument={prefetchDocument}
|
||||
isDraft={childNode.isDraft}
|
||||
depth={depth + 1}
|
||||
index={index}
|
||||
|
||||
@@ -9,7 +9,6 @@ import GroupMembership from "~/models/GroupMembership";
|
||||
import UserMembership from "~/models/UserMembership";
|
||||
import Fade from "~/components/Fade";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import {
|
||||
@@ -17,6 +16,7 @@ import {
|
||||
useDropToReorderUserMembership,
|
||||
useDropToReparentDocument,
|
||||
} from "../hooks/useDragAndDrop";
|
||||
import { useLocationState } from "../hooks/useLocationState";
|
||||
import { useSidebarLabelAndIcon } from "../hooks/useSidebarLabelAndIcon";
|
||||
import DocumentLink from "./DocumentLink";
|
||||
import DropCursor from "./DropCursor";
|
||||
@@ -36,7 +36,7 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const { documentId } = membership;
|
||||
const isActiveDocument = documentId === ui.activeDocumentId;
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
const locationSidebarContext = useLocationState();
|
||||
const sidebarContext = useSidebarContext();
|
||||
const document = documentId ? documents.get(documentId) : undefined;
|
||||
|
||||
|
||||
@@ -1,58 +1,9 @@
|
||||
import * as React from "react";
|
||||
import Document from "~/models/Document";
|
||||
import User from "~/models/User";
|
||||
|
||||
export type SidebarContextType =
|
||||
| "collections"
|
||||
| "shared"
|
||||
| "archive"
|
||||
| `group-${string}`
|
||||
| `starred-${string}`
|
||||
| undefined;
|
||||
export type SidebarContextType = "collections" | "starred" | string | undefined;
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextType>(undefined);
|
||||
|
||||
export const useSidebarContext = () => React.useContext(SidebarContext);
|
||||
|
||||
export const groupSidebarContext = (groupId: string): SidebarContextType =>
|
||||
`group-${groupId}`;
|
||||
|
||||
export const starredSidebarContext = (modelId: string): SidebarContextType =>
|
||||
`starred-${modelId}`;
|
||||
|
||||
export const determineSidebarContext = ({
|
||||
document,
|
||||
user,
|
||||
currentContext,
|
||||
}: {
|
||||
document: Document;
|
||||
user: User;
|
||||
currentContext?: SidebarContextType;
|
||||
}): SidebarContextType => {
|
||||
const isStarred = document.isStarred || !!document.collection?.isStarred;
|
||||
const preferStarred = !currentContext || currentContext.startsWith("starred");
|
||||
|
||||
if (isStarred && preferStarred) {
|
||||
const currentlyInStarredCollection =
|
||||
currentContext === starredSidebarContext(document.collectionId ?? "");
|
||||
|
||||
return document.isStarred && !currentlyInStarredCollection
|
||||
? starredSidebarContext(document.id)
|
||||
: starredSidebarContext(document.collectionId!);
|
||||
}
|
||||
|
||||
if (document.collection) {
|
||||
return document.collection.isArchived ? "archive" : "collections";
|
||||
} else if (
|
||||
user.documentMemberships.find((m) => m.documentId === document.id)
|
||||
) {
|
||||
return "shared";
|
||||
} else {
|
||||
const group = user.groupsWithDocumentMemberships.find(
|
||||
(g) => !!g.documentMemberships.find((m) => m.documentId === document.id)
|
||||
);
|
||||
return groupSidebarContext(group?.id ?? "");
|
||||
}
|
||||
};
|
||||
|
||||
export default SidebarContext;
|
||||
|
||||
@@ -4,6 +4,7 @@ import styled, { useTheme, css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import { s } from "@shared/styles";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { UnreadBadge } from "~/components/UnreadBadge";
|
||||
import useUnmount from "~/hooks/useUnmount";
|
||||
@@ -11,6 +12,11 @@ import { undraggableOnDesktop } from "~/styles";
|
||||
import Disclosure from "./Disclosure";
|
||||
import NavLink, { Props as NavLinkProps } from "./NavLink";
|
||||
|
||||
export type DragObject = NavigationNode & {
|
||||
depth: number;
|
||||
collectionId: string;
|
||||
};
|
||||
|
||||
type Props = Omit<NavLinkProps, "to"> & {
|
||||
to?: LocationDescriptor;
|
||||
innerRef?: (ref: HTMLElement | null | undefined) => void;
|
||||
|
||||
@@ -15,6 +15,7 @@ import DropCursor from "./DropCursor";
|
||||
import Header from "./Header";
|
||||
import PlaceholderCollections from "./PlaceholderCollections";
|
||||
import Relative from "./Relative";
|
||||
import SidebarContext from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import StarredLink from "./StarredLink";
|
||||
|
||||
@@ -41,46 +42,48 @@ function Starred() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<Header id="starred" title={t("Starred")}>
|
||||
<Relative>
|
||||
{reorderStarProps.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={reorderStarProps.isOverCursor}
|
||||
innerRef={dropToReorder}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
{createStarProps.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={createStarProps.isOverCursor}
|
||||
innerRef={dropToStarRef}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
{stars.orderedData
|
||||
.slice(0, page * STARRED_PAGINATION_LIMIT)
|
||||
.map((star) => (
|
||||
<StarredLink key={star.id} star={star} />
|
||||
))}
|
||||
{!end && (
|
||||
<SidebarLink
|
||||
onClick={next}
|
||||
label={`${t("Show more")}…`}
|
||||
disabled={stars.isFetching}
|
||||
depth={0}
|
||||
/>
|
||||
)}
|
||||
{loading && (
|
||||
<Flex column>
|
||||
<DelayedMount>
|
||||
<PlaceholderCollections />
|
||||
</DelayedMount>
|
||||
</Flex>
|
||||
)}
|
||||
</Relative>
|
||||
</Header>
|
||||
</Flex>
|
||||
<SidebarContext.Provider value="starred">
|
||||
<Flex column>
|
||||
<Header id="starred" title={t("Starred")}>
|
||||
<Relative>
|
||||
{reorderStarProps.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={reorderStarProps.isOverCursor}
|
||||
innerRef={dropToReorder}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
{createStarProps.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={createStarProps.isOverCursor}
|
||||
innerRef={dropToStarRef}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
{stars.orderedData
|
||||
.slice(0, page * STARRED_PAGINATION_LIMIT)
|
||||
.map((star) => (
|
||||
<StarredLink key={star.id} star={star} />
|
||||
))}
|
||||
{!end && (
|
||||
<SidebarLink
|
||||
onClick={next}
|
||||
label={`${t("Show more")}…`}
|
||||
disabled={stars.isFetching}
|
||||
depth={0}
|
||||
/>
|
||||
)}
|
||||
{loading && (
|
||||
<Flex column>
|
||||
<DelayedMount>
|
||||
<PlaceholderCollections />
|
||||
</DelayedMount>
|
||||
</Flex>
|
||||
)}
|
||||
</Relative>
|
||||
</Header>
|
||||
</Flex>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import styled, { useTheme } from "styled-components";
|
||||
import Star from "~/models/Star";
|
||||
import Fade from "~/components/Fade";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import {
|
||||
@@ -16,6 +15,7 @@ import {
|
||||
useDropToCreateStar,
|
||||
useDropToReorderStar,
|
||||
} from "../hooks/useDragAndDrop";
|
||||
import { useLocationState } from "../hooks/useLocationState";
|
||||
import { useSidebarLabelAndIcon } from "../hooks/useSidebarLabelAndIcon";
|
||||
import CollectionLink from "./CollectionLink";
|
||||
import CollectionLinkChildren from "./CollectionLinkChildren";
|
||||
@@ -25,7 +25,7 @@ import Folder from "./Folder";
|
||||
import Relative from "./Relative";
|
||||
import SidebarContext, {
|
||||
SidebarContextType,
|
||||
starredSidebarContext,
|
||||
useSidebarContext,
|
||||
} from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
@@ -39,14 +39,10 @@ function StarredLink({ star }: Props) {
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const { documentId, collectionId } = star;
|
||||
const collection = collections.get(collectionId);
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
const sidebarContext = starredSidebarContext(
|
||||
star.documentId ?? star.collectionId
|
||||
);
|
||||
const locationSidebarContext = useLocationState();
|
||||
const sidebarContext = useSidebarContext();
|
||||
const [expanded, setExpanded] = useState(
|
||||
(star.documentId
|
||||
? star.documentId === ui.activeDocumentId
|
||||
: star.collectionId === ui.activeCollectionId) &&
|
||||
star.collectionId === ui.activeCollectionId &&
|
||||
sidebarContext === locationSidebarContext
|
||||
);
|
||||
|
||||
@@ -86,11 +82,6 @@ function StarredLink({ star }: Props) {
|
||||
[]
|
||||
);
|
||||
|
||||
const handlePrefetch = React.useCallback(
|
||||
() => documentId && documents.prefetchDocument(documentId),
|
||||
[documents, documentId]
|
||||
);
|
||||
|
||||
const getIndex = () => {
|
||||
const next = star?.next();
|
||||
return fractionalIndex(star?.index || null, next?.index || null);
|
||||
@@ -147,7 +138,6 @@ function StarredLink({ star }: Props) {
|
||||
}}
|
||||
expanded={hasChildDocuments && !isDragging ? expanded : undefined}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
onClickIntent={handlePrefetch}
|
||||
icon={icon}
|
||||
isActive={(
|
||||
match,
|
||||
@@ -169,7 +159,7 @@ function StarredLink({ star }: Props) {
|
||||
}
|
||||
/>
|
||||
</Draggable>
|
||||
<SidebarContext.Provider value={sidebarContext}>
|
||||
<SidebarContext.Provider value={document.id}>
|
||||
<Relative>
|
||||
<Folder expanded={displayChildDocuments}>
|
||||
{childDocuments.map((node, index) => (
|
||||
@@ -178,7 +168,6 @@ function StarredLink({ star }: Props) {
|
||||
node={node}
|
||||
collection={collection}
|
||||
activeDocument={documents.active}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
isDraft={node.isDraft}
|
||||
depth={2}
|
||||
index={index}
|
||||
@@ -194,7 +183,7 @@ function StarredLink({ star }: Props) {
|
||||
|
||||
if (collection) {
|
||||
return (
|
||||
<SidebarContext.Provider value={sidebarContext}>
|
||||
<>
|
||||
<Draggable key={star?.id} ref={draggableRef} $isDragging={isDragging}>
|
||||
<CollectionLink
|
||||
collection={collection}
|
||||
@@ -204,14 +193,16 @@ function StarredLink({ star }: Props) {
|
||||
isDraggingAnyCollection={reorderStarProps.isDragging}
|
||||
/>
|
||||
</Draggable>
|
||||
<Relative>
|
||||
<CollectionLinkChildren
|
||||
collection={collection}
|
||||
expanded={displayChildDocuments}
|
||||
/>
|
||||
{cursor}
|
||||
</Relative>
|
||||
</SidebarContext.Provider>
|
||||
<SidebarContext.Provider value={collection.id}>
|
||||
<Relative>
|
||||
<CollectionLinkChildren
|
||||
collection={collection}
|
||||
expanded={displayChildDocuments}
|
||||
/>
|
||||
{cursor}
|
||||
</Relative>
|
||||
</SidebarContext.Provider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import styled from "styled-components";
|
||||
import { hover } from "@shared/styles";
|
||||
import { hover } from "~/styles";
|
||||
import SidebarButton from "./SidebarButton";
|
||||
|
||||
const ToggleButton = styled(SidebarButton)`
|
||||
|
||||
@@ -6,8 +6,7 @@ import { useTranslation } from "react-i18next";
|
||||
import DocumentDelete from "~/scenes/DocumentDelete";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { trashPath } from "~/utils/routeHelpers";
|
||||
import { DragObject } from "../hooks/useDragAndDrop";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import SidebarLink, { DragObject } from "./SidebarLink";
|
||||
|
||||
function TrashLink() {
|
||||
const { policies, dialogs, documents } = useStores();
|
||||
|
||||
@@ -6,7 +6,6 @@ import { getEmptyImage } from "react-dnd-html5-backend";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useTheme } from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
@@ -14,50 +13,12 @@ import GroupMembership from "~/models/GroupMembership";
|
||||
import Star from "~/models/Star";
|
||||
import UserMembership from "~/models/UserMembership";
|
||||
import ConfirmMoveDialog from "~/components/ConfirmMoveDialog";
|
||||
import Icon from "~/components/Icon";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { AuthorizationError } from "~/utils/errors";
|
||||
import { DragObject } from "../components/SidebarLink";
|
||||
import { useSidebarLabelAndIcon } from "./useSidebarLabelAndIcon";
|
||||
|
||||
export type DragObject = NavigationNode & {
|
||||
depth: number;
|
||||
collectionId: string;
|
||||
};
|
||||
|
||||
function useHover(
|
||||
elementRef: React.RefObject<HTMLDivElement>,
|
||||
callback: () => void
|
||||
) {
|
||||
const hoverTimeoutRef = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const startHover = React.useCallback(() => {
|
||||
if (!hoverTimeoutRef.current) {
|
||||
hoverTimeoutRef.current = setTimeout(() => {
|
||||
hoverTimeoutRef.current = undefined;
|
||||
callback();
|
||||
}, 500);
|
||||
}
|
||||
}, [callback]);
|
||||
|
||||
const unsetHover = React.useCallback(() => {
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
hoverTimeoutRef.current = undefined;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// We set a timeout when the user first starts hovering over the document link,
|
||||
// to trigger expansion of children. Clear this timeout when they stop hovering.
|
||||
React.useEffect(() => {
|
||||
const element = elementRef.current;
|
||||
element?.addEventListener("dragleave", unsetHover);
|
||||
return () => element?.removeEventListener("dragleave", unsetHover);
|
||||
}, [elementRef, unsetHover]);
|
||||
|
||||
return startHover;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for shared logic that allows dragging a Starred item
|
||||
*
|
||||
@@ -201,84 +162,6 @@ export function useDragDocument(
|
||||
return [{ isDragging }, draggableRef] as const;
|
||||
}
|
||||
|
||||
export function useDropToChangeCollection(
|
||||
collection: Collection,
|
||||
expandNode: () => void,
|
||||
parentRef: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const { documents, collections, dialogs } = useStores();
|
||||
const can = usePolicy(collection);
|
||||
const startHover = useHover(parentRef, expandNode);
|
||||
|
||||
return useDrop<
|
||||
DragObject,
|
||||
Promise<void>,
|
||||
{ isOver: boolean; canDrop: boolean }
|
||||
>({
|
||||
accept: "document",
|
||||
drop: async (item, monitor) => {
|
||||
if (monitor.didDrop()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { id, collectionId } = item;
|
||||
const prevCollection = collections.get(collectionId);
|
||||
const document = documents.get(id);
|
||||
|
||||
if (
|
||||
prevCollection &&
|
||||
prevCollection.permission !== collection.permission &&
|
||||
!document?.isDraft
|
||||
) {
|
||||
dialogs.openModal({
|
||||
title: t("Change permissions?"),
|
||||
content: (
|
||||
<ConfirmMoveDialog item={item} collection={collection} index={0} />
|
||||
),
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
await documents.move({
|
||||
documentId: id,
|
||||
collectionId: collection.id,
|
||||
index: 0,
|
||||
});
|
||||
expandNode();
|
||||
} catch (err) {
|
||||
if (err instanceof AuthorizationError) {
|
||||
toast.error(
|
||||
t(
|
||||
"You do not have permission to move {{ documentName }} to the {{ collectionName }} collection",
|
||||
{
|
||||
documentName: item.title,
|
||||
collectionName: collection.name,
|
||||
}
|
||||
)
|
||||
);
|
||||
} else {
|
||||
toast.error(err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
canDrop: () => can.createDocument,
|
||||
hover: (_, monitor) => {
|
||||
if (
|
||||
collection.hasDocuments &&
|
||||
monitor.canDrop() &&
|
||||
monitor.isOver({ shallow: true })
|
||||
) {
|
||||
startHover();
|
||||
}
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOver: monitor.isOver({ shallow: true }),
|
||||
canDrop: monitor.canDrop(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for shared logic that allows dropping documents to reparent
|
||||
*
|
||||
@@ -292,7 +175,7 @@ export function useDropToReparentDocument(
|
||||
parentRef: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const { documents, collections, dialogs } = useStores();
|
||||
const { documents, collections, dialogs, policies } = useStores();
|
||||
const hasChildDocuments = !!node?.children.length;
|
||||
const document = node ? documents.get(node.id) : undefined;
|
||||
const pathToNode = React.useMemo(
|
||||
@@ -300,7 +183,25 @@ export function useDropToReparentDocument(
|
||||
[document]
|
||||
);
|
||||
|
||||
const startHover = useHover(parentRef, setExpanded);
|
||||
const hoverExpanding = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
// We set a timeout when the user first starts hovering over the document link,
|
||||
// to trigger expansion of children. Clear this timeout when they stop hovering.
|
||||
React.useEffect(() => {
|
||||
const resetHoverExpanding = () => {
|
||||
if (hoverExpanding.current) {
|
||||
clearTimeout(hoverExpanding.current);
|
||||
hoverExpanding.current = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const element = parentRef.current;
|
||||
element?.addEventListener("dragleave", resetHoverExpanding);
|
||||
|
||||
return () => {
|
||||
element?.removeEventListener("dragleave", resetHoverExpanding);
|
||||
};
|
||||
}, [parentRef]);
|
||||
|
||||
return useDrop<
|
||||
DragObject,
|
||||
@@ -313,9 +214,7 @@ export function useDropToReparentDocument(
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = node.collectionId
|
||||
? collections.get(node.collectionId)
|
||||
: undefined;
|
||||
const collection = documents.get(node.id)?.collection;
|
||||
const prevCollection = collections.get(item.collectionId);
|
||||
|
||||
if (
|
||||
@@ -334,40 +233,22 @@ export function useDropToReparentDocument(
|
||||
),
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
await documents.move({
|
||||
documentId: item.id,
|
||||
parentDocumentId: node.id,
|
||||
});
|
||||
setExpanded();
|
||||
} catch (err) {
|
||||
if (err instanceof AuthorizationError) {
|
||||
toast.error(
|
||||
t(
|
||||
"{{ documentName }} cannot be moved within {{ parentDocumentName }}",
|
||||
{
|
||||
documentName: item.title,
|
||||
parentDocumentName: node.title,
|
||||
}
|
||||
)
|
||||
);
|
||||
} else {
|
||||
toast.error(err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
canDrop: (item) => {
|
||||
if (!node || item.id === node.id) {
|
||||
return false;
|
||||
await documents.move({
|
||||
documentId: item.id,
|
||||
parentDocumentId: node.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (!document) {
|
||||
return true; // optimistic, in case the document is not loaded yet; server will check for permissions before performing the move.
|
||||
}
|
||||
|
||||
return document.isActive && !!pathToNode && !pathToNode.includes(item.id);
|
||||
setExpanded();
|
||||
},
|
||||
canDrop: (item, monitor) =>
|
||||
!!node &&
|
||||
!!pathToNode &&
|
||||
!pathToNode.includes(monitor.getItem().id) &&
|
||||
item.id !== node.id &&
|
||||
!!document?.isActive &&
|
||||
policies.abilities(node.id).update &&
|
||||
policies.abilities(item.id).move,
|
||||
hover: (_item, monitor) => {
|
||||
// Enables expansion of document children when hovering over the document
|
||||
// for more than half a second.
|
||||
@@ -378,7 +259,15 @@ export function useDropToReparentDocument(
|
||||
shallow: true,
|
||||
})
|
||||
) {
|
||||
startHover();
|
||||
if (!hoverExpanding.current) {
|
||||
hoverExpanding.current = setTimeout(() => {
|
||||
hoverExpanding.current = undefined;
|
||||
|
||||
if (monitor.isOver({ shallow: true })) {
|
||||
setExpanded();
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
@@ -408,7 +297,7 @@ export function useDropToReorderDocument(
|
||||
}
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const { documents, collections, dialogs } = useStores();
|
||||
const { documents, collections, dialogs, policies } = useStores();
|
||||
|
||||
const document = documents.get(node.id);
|
||||
|
||||
@@ -419,9 +308,22 @@ export function useDropToReorderDocument(
|
||||
>({
|
||||
accept: "document",
|
||||
canDrop: (item: DragObject) => {
|
||||
if (item.id === node.id || (document && !document.isActive)) {
|
||||
if (
|
||||
item.id === node.id ||
|
||||
!policies.abilities(item.id)?.move ||
|
||||
!document?.isActive
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const params = getMoveParams(item);
|
||||
if (params?.collectionId) {
|
||||
return policies.abilities(params.collectionId)?.updateDocument;
|
||||
}
|
||||
if (params?.parentDocumentId) {
|
||||
return policies.abilities(params.parentDocumentId)?.update;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
drop: async (item) => {
|
||||
@@ -455,19 +357,7 @@ export function useDropToReorderDocument(
|
||||
),
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
await documents.move(params);
|
||||
} catch (err) {
|
||||
if (err instanceof AuthorizationError) {
|
||||
toast.error(
|
||||
t("The {{ documentName }} cannot be moved here", {
|
||||
documentName: item.title,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
toast.error(err.message);
|
||||
}
|
||||
}
|
||||
void documents.move(params);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -586,45 +476,3 @@ export function useDropToArchive() {
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDropToUnpublish() {
|
||||
const { t } = useTranslation();
|
||||
const { policies, documents } = useStores();
|
||||
|
||||
return useDrop<
|
||||
DragObject,
|
||||
Promise<void>,
|
||||
{ isOver: boolean; canDrop: boolean }
|
||||
>({
|
||||
accept: "document",
|
||||
drop: async (item) => {
|
||||
const document = documents.get(item.id);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await document.unpublish({ detach: true });
|
||||
toast.success(
|
||||
t("Unpublished {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
canDrop: (item) => {
|
||||
const policy = policies.abilities(item.id);
|
||||
if (!policy) {
|
||||
return true; // optimistic, let the server check for the necessary permission.
|
||||
}
|
||||
|
||||
return policy.unpublish;
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOver: monitor.isOver(),
|
||||
canDrop: monitor.canDrop(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { SidebarContextType } from "../components/Sidebar/components/SidebarContext";
|
||||
import { SidebarContextType } from "../components/SidebarContext";
|
||||
|
||||
/**
|
||||
* Hook to retrieve the sidebar context from the current location state.
|
||||
*/
|
||||
export function useLocationSidebarContext() {
|
||||
export function useLocationState() {
|
||||
const location = useLocation<{
|
||||
sidebarContext?: SidebarContextType;
|
||||
}>();
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DocumentIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import Icon from "~/components/Icon";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { ColumnSort } from "@tanstack/react-table";
|
||||
import * as React from "react";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import type { Props as TableProps } from "./Table";
|
||||
|
||||
const Table = lazyWithRetry(() => import("~/components/Table"));
|
||||
|
||||
export type Props<T> = Omit<TableProps<T>, "onChangeSort">;
|
||||
|
||||
export function SortableTable<T>(props: Props<T>) {
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const params = useQuery();
|
||||
|
||||
const handleChangeSort = React.useCallback(
|
||||
(sort: ColumnSort) => {
|
||||
params.set("sort", sort.id);
|
||||
params.set("direction", sort.desc ? "desc" : "asc");
|
||||
|
||||
history.replace({
|
||||
pathname: location.pathname,
|
||||
search: params.toString(),
|
||||
});
|
||||
},
|
||||
[params, history, location.pathname]
|
||||
);
|
||||
|
||||
return <Table onChangeSort={handleChangeSort} {...props} />;
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { StarredIcon, UnstarredIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { hover } from "@shared/styles";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import {
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
} from "~/actions/definitions/collections";
|
||||
import { starDocument, unstarDocument } from "~/actions/definitions/documents";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { hover } from "~/styles";
|
||||
import NudeButton from "./NudeButton";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -4,8 +4,9 @@ import isEqual from "lodash/isEqual";
|
||||
import queryString from "query-string";
|
||||
import * as React from "react";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import { s } from "@shared/styles";
|
||||
import NavLink from "~/components/NavLink";
|
||||
import { hover } from "~/styles";
|
||||
|
||||
type Props = Omit<React.ComponentProps<typeof NavLink>, "children"> & {
|
||||
/**
|
||||
|
||||
+231
-293
@@ -1,283 +1,231 @@
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
SortingState,
|
||||
flexRender,
|
||||
ColumnSort,
|
||||
functionalUpdate,
|
||||
Row as TRow,
|
||||
createColumnHelper,
|
||||
AccessorFn,
|
||||
CellContext,
|
||||
} from "@tanstack/react-table";
|
||||
import { useWindowVirtualizer } from "@tanstack/react-virtual";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { observer } from "mobx-react";
|
||||
import { CollapsedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Waypoint } from "react-waypoint";
|
||||
import { useTable, useSortBy, usePagination } from "react-table";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Button from "~/components/Button";
|
||||
import DelayedMount from "~/components/DelayedMount";
|
||||
import Empty from "~/components/Empty";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import PlaceholderText from "~/components/PlaceholderText";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
|
||||
const HEADER_HEIGHT = 40;
|
||||
|
||||
type DataColumn<TData> = {
|
||||
type: "data";
|
||||
header: string;
|
||||
accessor: AccessorFn<TData>;
|
||||
sortable?: boolean;
|
||||
export type Props = {
|
||||
data: any[];
|
||||
offset?: number;
|
||||
isLoading: boolean;
|
||||
empty?: React.ReactNode;
|
||||
currentPage?: number;
|
||||
page: number;
|
||||
pageSize?: number;
|
||||
totalPages?: number;
|
||||
defaultSort?: string;
|
||||
topRef?: React.Ref<any>;
|
||||
onChangePage: (index: number) => void;
|
||||
onChangeSort: (
|
||||
sort: string | null | undefined,
|
||||
direction: "ASC" | "DESC"
|
||||
) => void;
|
||||
columns: any;
|
||||
defaultSortDirection: "ASC" | "DESC";
|
||||
};
|
||||
|
||||
type ActionColumn = {
|
||||
type: "action";
|
||||
header?: string;
|
||||
};
|
||||
|
||||
export type Column<TData> = {
|
||||
id: string;
|
||||
component: (data: TData) => React.ReactNode;
|
||||
width: string;
|
||||
} & (DataColumn<TData> | ActionColumn);
|
||||
|
||||
export type Props<TData> = {
|
||||
data: TData[];
|
||||
columns: Column<TData>[];
|
||||
sort: ColumnSort;
|
||||
onChangeSort: (sort: ColumnSort) => void;
|
||||
loading: boolean;
|
||||
page: {
|
||||
hasNext: boolean;
|
||||
fetchNext?: () => void;
|
||||
};
|
||||
rowHeight: number;
|
||||
stickyOffset?: number;
|
||||
};
|
||||
|
||||
function Table<TData>({
|
||||
function Table({
|
||||
data,
|
||||
isLoading,
|
||||
totalPages,
|
||||
empty,
|
||||
columns,
|
||||
sort,
|
||||
onChangeSort,
|
||||
loading,
|
||||
page,
|
||||
rowHeight,
|
||||
stickyOffset = 0,
|
||||
}: Props<TData>) {
|
||||
pageSize = 50,
|
||||
defaultSort = "name",
|
||||
topRef,
|
||||
onChangeSort,
|
||||
onChangePage,
|
||||
defaultSortDirection,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const virtualContainerRef = React.useRef<HTMLDivElement>(null);
|
||||
const [virtualContainerTop, setVirtualContainerTop] =
|
||||
React.useState<number>();
|
||||
const {
|
||||
getTableProps,
|
||||
getTableBodyProps,
|
||||
headerGroups,
|
||||
rows,
|
||||
prepareRow,
|
||||
canNextPage,
|
||||
nextPage,
|
||||
canPreviousPage,
|
||||
previousPage,
|
||||
state: { pageIndex, sortBy },
|
||||
} = useTable(
|
||||
{
|
||||
columns,
|
||||
data,
|
||||
manualPagination: true,
|
||||
manualSortBy: true,
|
||||
autoResetSortBy: false,
|
||||
autoResetPage: false,
|
||||
pageCount: totalPages,
|
||||
initialState: {
|
||||
sortBy: [
|
||||
{
|
||||
id: defaultSort,
|
||||
desc: defaultSortDirection === "DESC" ? true : false,
|
||||
},
|
||||
],
|
||||
pageSize,
|
||||
pageIndex: page,
|
||||
},
|
||||
stateReducer: (newState, action, prevState) => {
|
||||
if (!isEqual(newState.sortBy, prevState.sortBy)) {
|
||||
return { ...newState, pageIndex: 0 };
|
||||
}
|
||||
|
||||
const columnHelper = React.useMemo(() => createColumnHelper<TData>(), []);
|
||||
const observedColumns = React.useMemo(
|
||||
() =>
|
||||
columns.map((column) => {
|
||||
const cell = ({ row }: CellContext<TData, unknown>) => (
|
||||
<ObservedCell data={row.original} render={column.component} />
|
||||
);
|
||||
|
||||
return column.type === "data"
|
||||
? columnHelper.accessor(column.accessor, {
|
||||
id: column.id,
|
||||
header: column.header,
|
||||
enableSorting: column.sortable ?? true,
|
||||
cell,
|
||||
})
|
||||
: columnHelper.display({
|
||||
id: column.id,
|
||||
header: column.header ?? "",
|
||||
cell,
|
||||
});
|
||||
}),
|
||||
[columns, columnHelper]
|
||||
);
|
||||
|
||||
const gridColumns = React.useMemo(
|
||||
() => columns.map((column) => column.width).join(" "),
|
||||
[columns]
|
||||
);
|
||||
|
||||
const handleChangeSort = React.useCallback(
|
||||
(sortState: SortingState) => {
|
||||
const newState = functionalUpdate(sortState, [sort]);
|
||||
const newSort = newState[0];
|
||||
onChangeSort(newSort);
|
||||
return newState;
|
||||
},
|
||||
},
|
||||
[sort, onChangeSort]
|
||||
useSortBy,
|
||||
usePagination
|
||||
);
|
||||
|
||||
const prevSort = usePrevious(sort);
|
||||
const sortChanged = sort !== prevSort;
|
||||
|
||||
const isEmpty = !loading && data.length === 0;
|
||||
const showPlaceholder = loading && data.length === 0;
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns: observedColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualSorting: true,
|
||||
enableMultiSort: false,
|
||||
enableSortingRemoval: false,
|
||||
state: {
|
||||
sorting: [sort],
|
||||
},
|
||||
onSortingChange: handleChangeSort,
|
||||
});
|
||||
|
||||
const { rows } = table.getRowModel();
|
||||
|
||||
const rowVirtualizer = useWindowVirtualizer({
|
||||
count: rows.length,
|
||||
estimateSize: () => rowHeight,
|
||||
scrollMargin: virtualContainerTop,
|
||||
overscan: 5,
|
||||
});
|
||||
const prevSortBy = React.useRef(sortBy);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!sortChanged || !virtualContainerTop) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollThreshold =
|
||||
virtualContainerTop - (stickyOffset + HEADER_HEIGHT);
|
||||
const reset = window.scrollY > scrollThreshold;
|
||||
|
||||
if (reset) {
|
||||
rowVirtualizer.scrollToOffset(scrollThreshold, {
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
}, [rowVirtualizer, sortChanged, virtualContainerTop, stickyOffset]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (virtualContainerRef.current) {
|
||||
// determine the scrollable virtual container offsetTop on mount
|
||||
setVirtualContainerTop(
|
||||
virtualContainerRef.current.getBoundingClientRect().top
|
||||
if (!isEqual(sortBy, prevSortBy.current)) {
|
||||
prevSortBy.current = sortBy;
|
||||
onChangePage(0);
|
||||
onChangeSort(
|
||||
sortBy.length ? sortBy[0].id : undefined,
|
||||
!sortBy.length ? defaultSortDirection : sortBy[0].desc ? "DESC" : "ASC"
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
}, [defaultSortDirection, onChangePage, onChangeSort, sortBy]);
|
||||
|
||||
const handleNextPage = () => {
|
||||
nextPage();
|
||||
onChangePage(pageIndex + 1);
|
||||
};
|
||||
|
||||
const handlePreviousPage = () => {
|
||||
previousPage();
|
||||
onChangePage(pageIndex - 1);
|
||||
};
|
||||
|
||||
const isEmpty = !isLoading && data.length === 0;
|
||||
const showPlaceholder = isLoading && data.length === 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<InnerTable role="table">
|
||||
<THead role="rowgroup" $topPos={stickyOffset}>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TR role="row" key={headerGroup.id} $columns={gridColumns}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TH role="columnheader" key={header.id}>
|
||||
<SortWrapper
|
||||
align="center"
|
||||
gap={4}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
$sortable={header.column.getCanSort()}
|
||||
>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
{header.column.getIsSorted() === "asc" ? (
|
||||
<AscSortIcon />
|
||||
) : header.column.getIsSorted() === "desc" ? (
|
||||
<DescSortIcon />
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</SortWrapper>
|
||||
</TH>
|
||||
))}
|
||||
</TR>
|
||||
))}
|
||||
</THead>
|
||||
|
||||
<TBody
|
||||
ref={virtualContainerRef}
|
||||
role="rowgroup"
|
||||
$height={rowVirtualizer.getTotalSize()}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const row = rows[virtualRow.index] as TRow<TData>;
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<Anchor ref={topRef} />
|
||||
<InnerTable {...getTableProps()}>
|
||||
<thead>
|
||||
{headerGroups.map((headerGroup) => {
|
||||
const groupProps = headerGroup.getHeaderGroupProps();
|
||||
return (
|
||||
<TR
|
||||
role="row"
|
||||
key={row.id}
|
||||
data-index={virtualRow.index}
|
||||
style={{
|
||||
position: "absolute",
|
||||
transform: `translateY(${
|
||||
virtualRow.start - rowVirtualizer.options.scrollMargin
|
||||
}px)`,
|
||||
height: `${virtualRow.size}px`,
|
||||
}}
|
||||
$columns={gridColumns}
|
||||
>
|
||||
{row.getAllCells().map((cell) => (
|
||||
<TD role="cell" key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TD>
|
||||
<tr {...groupProps} key={groupProps.key}>
|
||||
{headerGroup.headers.map((column) => (
|
||||
<Head
|
||||
{...column.getHeaderProps(column.getSortByToggleProps())}
|
||||
key={column.id}
|
||||
>
|
||||
<SortWrapper
|
||||
align="center"
|
||||
$sortable={!column.disableSortBy}
|
||||
gap={4}
|
||||
>
|
||||
{column.render("Header")}
|
||||
{column.isSorted &&
|
||||
(column.isSortedDesc ? (
|
||||
<DescSortIcon />
|
||||
) : (
|
||||
<AscSortIcon />
|
||||
))}
|
||||
</SortWrapper>
|
||||
</Head>
|
||||
))}
|
||||
</TR>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
{showPlaceholder && (
|
||||
<Placeholder columns={columns.length} gridColumns={gridColumns} />
|
||||
)}
|
||||
</thead>
|
||||
<tbody {...getTableBodyProps()}>
|
||||
{rows.map((row) => {
|
||||
prepareRow(row);
|
||||
return (
|
||||
<Row {...row.getRowProps()} key={row.id}>
|
||||
{row.cells.map((cell) => (
|
||||
<Cell
|
||||
{...cell.getCellProps([
|
||||
{
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'className' does not exist on type 'Colum... Remove this comment to see the full error message
|
||||
className: cell.column.className,
|
||||
},
|
||||
])}
|
||||
key={cell.column.id}
|
||||
>
|
||||
{cell.render("Cell")}
|
||||
</Cell>
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
{showPlaceholder && <Placeholder columns={columns.length} />}
|
||||
</InnerTable>
|
||||
{page.hasNext && (
|
||||
<Waypoint
|
||||
key={data?.length}
|
||||
onEnter={page.fetchNext}
|
||||
bottomOffset={-rowHeight * 5}
|
||||
/>
|
||||
{isEmpty ? (
|
||||
empty || <Empty>{t("No results")}</Empty>
|
||||
) : (
|
||||
<Pagination
|
||||
justify={canPreviousPage ? "space-between" : "flex-end"}
|
||||
gap={8}
|
||||
>
|
||||
{/* Note: the page > 0 check shouldn't be needed here but is */}
|
||||
{canPreviousPage && page > 0 && (
|
||||
<Button onClick={handlePreviousPage} neutral>
|
||||
{t("Previous page")}
|
||||
</Button>
|
||||
)}
|
||||
{canNextPage && (
|
||||
<Button onClick={handleNextPage} neutral>
|
||||
{t("Next page")}
|
||||
</Button>
|
||||
)}
|
||||
</Pagination>
|
||||
)}
|
||||
{isEmpty && <Empty>{t("No results")}</Empty>}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ObservedCell = observer(function <TData>({
|
||||
data,
|
||||
render,
|
||||
}: {
|
||||
data: TData;
|
||||
render: (data: TData) => React.ReactNode;
|
||||
}) {
|
||||
return <>{render(data)}</>;
|
||||
});
|
||||
|
||||
function Placeholder({
|
||||
export const Placeholder = ({
|
||||
columns,
|
||||
rows = 3,
|
||||
gridColumns,
|
||||
}: {
|
||||
columns: number;
|
||||
rows?: number;
|
||||
gridColumns: string;
|
||||
}) {
|
||||
return (
|
||||
<DelayedMount>
|
||||
<TBody $height={150}>
|
||||
{new Array(rows).fill(1).map((_r, row) => (
|
||||
<TR key={row} $columns={gridColumns}>
|
||||
{new Array(columns).fill(1).map((_c, col) => (
|
||||
<TD key={col}>
|
||||
<PlaceholderText minWidth={25} maxWidth={75} />
|
||||
</TD>
|
||||
))}
|
||||
</TR>
|
||||
))}
|
||||
</TBody>
|
||||
</DelayedMount>
|
||||
);
|
||||
}
|
||||
}) => (
|
||||
<DelayedMount>
|
||||
<tbody>
|
||||
{new Array(rows).fill(1).map((_, row) => (
|
||||
<Row key={row}>
|
||||
{new Array(columns).fill(1).map((_, col) => (
|
||||
<Cell key={col}>
|
||||
<PlaceholderText minWidth={25} maxWidth={75} />
|
||||
</Cell>
|
||||
))}
|
||||
</Row>
|
||||
))}
|
||||
</tbody>
|
||||
</DelayedMount>
|
||||
);
|
||||
|
||||
const Anchor = styled.div`
|
||||
top: -32px;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const Pagination = styled(Flex)`
|
||||
margin: 0 0 32px;
|
||||
`;
|
||||
|
||||
const DescSortIcon = styled(CollapsedIcon)`
|
||||
margin-left: -2px;
|
||||
@@ -291,6 +239,12 @@ const AscSortIcon = styled(DescSortIcon)`
|
||||
transform: rotate(180deg);
|
||||
`;
|
||||
|
||||
const InnerTable = styled.table`
|
||||
border-collapse: collapse;
|
||||
margin: 16px 0;
|
||||
min-width: 100%;
|
||||
`;
|
||||
|
||||
const SortWrapper = styled(Flex)<{ $sortable: boolean }>`
|
||||
display: inline-flex;
|
||||
height: 24px;
|
||||
@@ -307,66 +261,15 @@ const SortWrapper = styled(Flex)<{ $sortable: boolean }>`
|
||||
}
|
||||
`;
|
||||
|
||||
const InnerTable = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const THead = styled.div<{ $topPos: number }>`
|
||||
position: sticky;
|
||||
top: ${({ $topPos }) => `${$topPos}px`};
|
||||
height: ${HEADER_HEIGHT}px;
|
||||
z-index: 1;
|
||||
font-size: 14px;
|
||||
color: ${s("textSecondary")};
|
||||
font-weight: 500;
|
||||
|
||||
border-bottom: 1px solid ${s("divider")};
|
||||
background: ${s("background")};
|
||||
`;
|
||||
|
||||
const TBody = styled.div<{ $height: number }>`
|
||||
position: relative;
|
||||
height: ${({ $height }) => `${$height}px`};
|
||||
`;
|
||||
|
||||
const TR = styled.div<{ $columns: string }>`
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: ${({ $columns }) => `${$columns}`};
|
||||
align-items: center;
|
||||
border-bottom: 1px solid ${s("divider")};
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const TH = styled.span`
|
||||
padding: 6px 6px 2px;
|
||||
|
||||
&:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const TD = styled.span`
|
||||
const Cell = styled.td`
|
||||
padding: 10px 6px;
|
||||
border-bottom: 1px solid ${s("divider")};
|
||||
font-size: 14px;
|
||||
text-wrap: wrap;
|
||||
word-break: break-word;
|
||||
text-wrap: nowrap;
|
||||
|
||||
&:first-child {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
&.actions,
|
||||
@@ -389,4 +292,39 @@ const TD = styled.span`
|
||||
}
|
||||
`;
|
||||
|
||||
const Row = styled.tr`
|
||||
${Cell} {
|
||||
&:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
&:last-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
&:last-child {
|
||||
${Cell} {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Head = styled.th`
|
||||
text-align: left;
|
||||
padding: 6px 6px 2px;
|
||||
border-bottom: 1px solid ${s("divider")};
|
||||
background: ${s("background")};
|
||||
font-size: 14px;
|
||||
color: ${s("textSecondary")};
|
||||
font-weight: 500;
|
||||
z-index: 1;
|
||||
|
||||
:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
:last-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(Table);
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import type { Props } from "./Table";
|
||||
|
||||
const Table = lazyWithRetry(() => import("~/components/Table"));
|
||||
|
||||
const TableFromParams = (
|
||||
props: Omit<Props, "onChangeSort" | "onChangePage" | "topRef">
|
||||
) => {
|
||||
const topRef = React.useRef();
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const params = useQuery();
|
||||
|
||||
const handleChangeSort = React.useCallback(
|
||||
(sort, direction) => {
|
||||
if (sort) {
|
||||
params.set("sort", sort);
|
||||
} else {
|
||||
params.delete("sort");
|
||||
}
|
||||
|
||||
params.set("direction", direction.toLowerCase());
|
||||
|
||||
history.replace({
|
||||
pathname: location.pathname,
|
||||
search: params.toString(),
|
||||
});
|
||||
},
|
||||
[params, history, location.pathname]
|
||||
);
|
||||
|
||||
const handleChangePage = React.useCallback(
|
||||
(page) => {
|
||||
if (page) {
|
||||
params.set("page", page.toString());
|
||||
} else {
|
||||
params.delete("page");
|
||||
}
|
||||
|
||||
history.replace({
|
||||
pathname: location.pathname,
|
||||
search: params.toString(),
|
||||
});
|
||||
|
||||
if (topRef.current) {
|
||||
scrollIntoView(topRef.current, {
|
||||
scrollMode: "if-needed",
|
||||
behavior: "auto",
|
||||
block: "start",
|
||||
});
|
||||
}
|
||||
},
|
||||
[params, history, location.pathname]
|
||||
);
|
||||
|
||||
return (
|
||||
<Table
|
||||
topRef={topRef}
|
||||
onChangeSort={handleChangeSort}
|
||||
onChangePage={handleChangePage}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(TableFromParams);
|
||||
@@ -1,8 +1,6 @@
|
||||
import Tippy, { TippyProps } from "@tippyjs/react";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import styled, { createGlobalStyle } from "styled-components";
|
||||
import { roundArrow } from "tippy.js";
|
||||
import { s } from "@shared/styles";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { useTooltipContext } from "./TooltipContext";
|
||||
@@ -12,24 +10,9 @@ export type Props = Omit<TippyProps, "content" | "theme"> & {
|
||||
content?: React.ReactChild | React.ReactChild[];
|
||||
/** A keyboard shortcut to display next to the content */
|
||||
shortcut?: React.ReactNode;
|
||||
/** Whether to show the shortcut on a new line */
|
||||
shortcutOnNewline?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A tooltip component that wraps Tippy and provides a consistent look and feel. Optionally
|
||||
* displays a keyboard shortcut next to the content.
|
||||
*
|
||||
* Wrap this component in a TooltipProvider to allow multiple tooltips to share the same
|
||||
* singleton instance (delay, animation, etc).
|
||||
*/
|
||||
function Tooltip({
|
||||
shortcut,
|
||||
shortcutOnNewline,
|
||||
content: tooltip,
|
||||
delay = 500,
|
||||
...rest
|
||||
}: Props) {
|
||||
function Tooltip({ shortcut, content: tooltip, delay = 500, ...rest }: Props) {
|
||||
const isMobile = useMobile();
|
||||
const singleton = useTooltipContext();
|
||||
|
||||
@@ -42,50 +25,28 @@ function Tooltip({
|
||||
if (shortcut) {
|
||||
content = (
|
||||
<>
|
||||
{tooltip}
|
||||
{shortcutOnNewline ? <br /> : " "}
|
||||
{typeof shortcut === "string" ? (
|
||||
shortcut
|
||||
.split("+")
|
||||
.map((key, i) => (
|
||||
<Shortcut key={`${key}${i}`}>
|
||||
{key.length === 1 ? key.toUpperCase() : key}
|
||||
</Shortcut>
|
||||
))
|
||||
) : (
|
||||
<Shortcut>{shortcut}</Shortcut>
|
||||
)}
|
||||
{tooltip} · <Shortcut>{shortcut}</Shortcut>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tippy
|
||||
arrow={roundArrow}
|
||||
content={content}
|
||||
delay={delay}
|
||||
animation="shift-away"
|
||||
singleton={singleton}
|
||||
duration={[200, 150]}
|
||||
inertia
|
||||
{...rest}
|
||||
/>
|
||||
<Tippy content={content} delay={delay} singleton={singleton} {...rest} />
|
||||
);
|
||||
}
|
||||
|
||||
const Shortcut = styled.kbd`
|
||||
position: relative;
|
||||
top: -1px;
|
||||
top: -2px;
|
||||
|
||||
margin-left: 2px;
|
||||
display: inline-block;
|
||||
padding: 2px 4px;
|
||||
font-size: 12px;
|
||||
font-family: ${s("fontFamilyMono")};
|
||||
font: 10px "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier,
|
||||
monospace;
|
||||
line-height: 10px;
|
||||
color: ${s("tooltipText")};
|
||||
border: 1px solid ${(props) => transparentize(0.75, props.theme.tooltipText)};
|
||||
color: ${s("tooltipBackground")};
|
||||
vertical-align: middle;
|
||||
background-color: ${s("tooltipText")};
|
||||
border-radius: 3px;
|
||||
`;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user