mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b9275dff0 | |||
| 39f0f78ff4 | |||
| b2a0a9cf21 | |||
| c0ee1aa3d7 | |||
| b9e34e4227 | |||
| b405e1e985 | |||
| e0e6b3f3db | |||
| b2b0bd8c8f | |||
| 1a8d75b81b | |||
| 6c3816e07c | |||
| d264848024 | |||
| 65a3d1ac47 | |||
| af98549ca7 | |||
| ce1d2a90c0 |
+1
-9
@@ -1,3 +1,4 @@
|
||||
__mocks__
|
||||
.git
|
||||
.vscode
|
||||
.github
|
||||
@@ -7,19 +8,10 @@
|
||||
.eslint*
|
||||
.oxlintrc*
|
||||
.log
|
||||
*.md
|
||||
Makefile
|
||||
Procfile
|
||||
app.json
|
||||
crowdin.yml
|
||||
lint-staged.config.mjs
|
||||
build
|
||||
docker-compose.yml
|
||||
node_modules
|
||||
.yarn
|
||||
**/*.test.ts
|
||||
**/*.test.tsx
|
||||
**/*.test.js
|
||||
**/*.test.jsx
|
||||
**/__tests__
|
||||
**/__mocks__
|
||||
|
||||
+2
-21
@@ -61,11 +61,6 @@ DATABASE_CONNECTION_POOL_MAX=
|
||||
# DOCS: https://docs.getoutline.com/s/hosting/doc/redis-LGM4BFXYp4
|
||||
REDIS_URL=redis://redis:6379
|
||||
|
||||
# To enable horizontal scaling of the collaboration service you must provide a Redis URL, it may
|
||||
# be the same as above, or a different server.
|
||||
# DOCS: https://docs.getoutline.com/s/hosting/doc/horizontal-scaling-hkfU5Stao7
|
||||
REDIS_COLLABORATION_URL=
|
||||
|
||||
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
# ––––––––––– FILE STORAGE –––––––––––
|
||||
@@ -119,11 +114,6 @@ SSL_CERT=
|
||||
# false if you can be sure that SSL is terminated at an external loadbalancer.
|
||||
FORCE_HTTPS=true
|
||||
|
||||
# When behind a reverse proxy, the header to use for the client IP.
|
||||
# The default value is "X-Forwarded-For", common values are "X-Real-IP"
|
||||
# and "X-Client-IP".
|
||||
# PROXY_IP_HEADER=
|
||||
|
||||
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
# –––––––––– AUTHENTICATION ––––––––––
|
||||
@@ -208,7 +198,7 @@ RATE_LIMITER_DURATION_WINDOW=60
|
||||
# ––––––––––– INTEGRATIONS –––––––––––
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
|
||||
# GitHub integration allows previewing issue and pull request links
|
||||
# The GitHub integration allows previewing issue and pull request links
|
||||
# DOCS: https://docs.getoutline.com/s/hosting/doc/github-GchT3NNxI9
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
@@ -217,12 +207,7 @@ GITHUB_APP_NAME=
|
||||
GITHUB_APP_ID=
|
||||
GITHUB_APP_PRIVATE_KEY=
|
||||
|
||||
# The GitLab integration allows previewing issue and merge request links
|
||||
# DOCS:
|
||||
GITLAB_CLIENT_ID=
|
||||
GITLAB_CLIENT_SECRET=
|
||||
|
||||
# Linear integration allows previewing issue links as rich mentions
|
||||
# The Linear integration allows previewing issue links as rich mentions
|
||||
LINEAR_CLIENT_ID=
|
||||
LINEAR_CLIENT_SECRET=
|
||||
|
||||
@@ -233,10 +218,6 @@ SLACK_VERIFICATION_TOKEN=your_token
|
||||
SLACK_APP_ID=A0XXXXXXX
|
||||
SLACK_MESSAGE_ACTIONS=true
|
||||
|
||||
# Figma integration allows previewing design files as rich mentions
|
||||
FIGMA_CLIENT_ID=
|
||||
FIGMA_CLIENT_SECRET=
|
||||
|
||||
# For Dropbox integration, follow these instructions to get the key https://www.dropbox.com/developers/embedder#setup
|
||||
# and do not forget to whitelist your domain name in the app settings
|
||||
DROPBOX_APP_KEY=
|
||||
|
||||
@@ -12,15 +12,11 @@ GOOGLE_CLIENT_SECRET=123
|
||||
|
||||
SLACK_CLIENT_ID=123
|
||||
SLACK_CLIENT_SECRET=123
|
||||
SLACK_VERIFICATION_TOKEN=test-token-123
|
||||
|
||||
GITHUB_CLIENT_ID=123;
|
||||
GITHUB_CLIENT_SECRET=123;
|
||||
GITHUB_APP_NAME=outline-test;
|
||||
|
||||
GITLAB_CLIENT_ID=123
|
||||
GITLAB_CLIENT_SECRET=123
|
||||
|
||||
OIDC_CLIENT_ID=client-id
|
||||
OIDC_CLIENT_SECRET=client-secret
|
||||
OIDC_AUTH_URI=http://localhost/authorize
|
||||
@@ -33,11 +29,3 @@ RATE_LIMITER_ENABLED=false
|
||||
|
||||
FILE_STORAGE=local
|
||||
FILE_STORAGE_LOCAL_ROOT_DIR=/tmp
|
||||
|
||||
URL=http://localhost:3000
|
||||
COLLABORATION_URL=
|
||||
REDIS_URL=redis://localhost:6379
|
||||
UTILS_SECRET=test-utils-secret
|
||||
|
||||
DEBUG=
|
||||
LOG_LEVEL=error
|
||||
|
||||
+20
-55
@@ -18,61 +18,44 @@ env:
|
||||
SMTP_USERNAME: localhost
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20.x, 22.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- name: Use Node.js 22.x
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: "yarn"
|
||||
- name: Cache node_modules
|
||||
id: cache-node-modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
|
||||
- name: Install dependencies
|
||||
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
||||
run: yarn install --immutable
|
||||
run: yarn install --frozen-lockfile --prefer-offline
|
||||
|
||||
lint:
|
||||
needs: setup
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
- name: Restore node_modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
|
||||
- run: yarn install --frozen-lockfile --prefer-offline
|
||||
- run: yarn lint --quiet
|
||||
|
||||
types:
|
||||
needs: setup
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
- name: Restore node_modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
|
||||
- run: yarn install --frozen-lockfile --prefer-offline
|
||||
- run: yarn tsc
|
||||
|
||||
changes:
|
||||
@@ -102,7 +85,7 @@ jobs:
|
||||
- 'yarn.lock'
|
||||
|
||||
test:
|
||||
needs: [setup, changes]
|
||||
needs: [build, changes]
|
||||
if: ${{ needs.changes.outputs.app == 'true' || needs.changes.outputs.config == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
@@ -110,21 +93,15 @@ jobs:
|
||||
test-group: [app, shared]
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
- name: Restore node_modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
|
||||
- run: yarn install --frozen-lockfile --prefer-offline
|
||||
- run: yarn test:${{ matrix.test-group }}
|
||||
|
||||
test-server:
|
||||
needs: [setup, changes]
|
||||
needs: [build, changes]
|
||||
if: ${{ needs.changes.outputs.server == 'true' || needs.changes.outputs.config == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
@@ -148,40 +125,28 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
- name: Restore node_modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
|
||||
- run: yarn install --frozen-lockfile --prefer-offline
|
||||
- run: yarn sequelize db:migrate
|
||||
- name: Run server tests
|
||||
run: |
|
||||
TESTFILES=$(find . -name "*.test.ts" -path "*/server/*" | sort | awk "NR % 4 == (${{ matrix.shard }} - 1)")
|
||||
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: [setup, types, changes]
|
||||
if: ${{ (needs.changes.outputs.app == 'true' || needs.changes.outputs.config == 'true') && github.repository == 'outline/outline' }}
|
||||
needs: [build, types, changes]
|
||||
if: ${{ needs.changes.outputs.app == 'true' && github.repository == 'outline/outline' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
- name: Restore node_modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
|
||||
- run: yarn install --frozen-lockfile --prefer-offline
|
||||
- name: Set environment to production
|
||||
run: echo "NODE_ENV=production" >> $GITHUB_ENV
|
||||
- run: yarn vite:build
|
||||
|
||||
@@ -14,11 +14,3 @@ data/*
|
||||
*.pem
|
||||
*.key
|
||||
*.cert
|
||||
|
||||
# Yarn Berry
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
.yarn/releases
|
||||
!.yarn/sdks
|
||||
|
||||
+1
-4
@@ -10,10 +10,7 @@
|
||||
"^@server/(.*)$": "<rootDir>/server/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1"
|
||||
},
|
||||
"setupFiles": [
|
||||
"<rootDir>/__mocks__/console.js",
|
||||
"<rootDir>/server/test/setupMocks.js"
|
||||
],
|
||||
"setupFiles": ["<rootDir>/__mocks__/console.js"],
|
||||
"setupFilesAfterEnv": ["<rootDir>/server/test/setup.ts"],
|
||||
"globalTeardown": "<rootDir>/server/test/globalTeardown.js",
|
||||
"testEnvironment": "node"
|
||||
|
||||
+1
-3
@@ -86,7 +86,6 @@
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
"import/no-named-as-default": "off",
|
||||
"import/no-named-as-default-member": "off",
|
||||
"typescript/consistent-type-imports": "error",
|
||||
"no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
@@ -95,8 +94,7 @@
|
||||
"args": "after-used",
|
||||
"ignoreRestSiblings": true
|
||||
}
|
||||
],
|
||||
"react/rules-of-hooks": "error"
|
||||
]
|
||||
},
|
||||
"plugins": ["eslint", "oxc", "react", "typescript", "import"]
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
nodeLinker: node-modules
|
||||
|
||||
npmMinimalAgeGate: 86400
|
||||
@@ -30,12 +30,10 @@ You're an expert in the following areas:
|
||||
|
||||
## General Guidelines
|
||||
|
||||
- Critical – Do not create new markdown (.md) files.
|
||||
- Use early returns for readability.
|
||||
- Emphasize type safety and static analysis.
|
||||
- Follow consistent Prettier formatting.
|
||||
- Do not replace smart quotes ("") or ('') with simple quotes ("").
|
||||
- Do not add translation strings manually; they will be extracted automatically from the codebase.
|
||||
|
||||
## Dependencies and Upgrading
|
||||
|
||||
@@ -64,8 +62,8 @@ yarn install
|
||||
2. Public static methods
|
||||
3. Public variables
|
||||
4. Public methods
|
||||
5. Protected variables & methods
|
||||
6. Private variables & methods
|
||||
6. Protected variables & methods
|
||||
8. Private variables & methods
|
||||
|
||||
### Exports
|
||||
|
||||
@@ -79,7 +77,7 @@ yarn install
|
||||
- Event handlers should be prefixed with "handle", like "handleClick" for onClick.
|
||||
- Avoid unnecessary re-renders by using React.memo, useMemo, and useCallback appropriately.
|
||||
- Use descriptive prop types with TypeScript interfaces.
|
||||
- Do not import React unless it is used directly.
|
||||
- You do not need to import React unless it is used directly.
|
||||
- Use styled-components for component styling.
|
||||
- Ensure high accessibility (a11y) standards using ARIA roles and semantic HTML.
|
||||
|
||||
@@ -143,21 +141,20 @@ yarn sequelize migration:create --name=add-field-to-table
|
||||
- Run tests with Jest:
|
||||
|
||||
```bash
|
||||
# Run a specific test file (preferred)
|
||||
yarn test path/to/test.spec.ts
|
||||
|
||||
# Run every test (avoid)
|
||||
# Run all tests
|
||||
yarn test
|
||||
|
||||
# Run test suites (avoid)
|
||||
yarn test:app # All frontend tests
|
||||
yarn test:server # All backend tests
|
||||
yarn test:shared # All shared code tests
|
||||
# Run specific test suites
|
||||
yarn test:app # Frontend tests
|
||||
yarn test:server # Backend tests
|
||||
yarn test:shared # Shared code tests
|
||||
|
||||
# Run specific test file
|
||||
yarn test path/to/test.spec.ts
|
||||
```
|
||||
|
||||
- Write unit tests for utilities and business logic in a collocated .test.ts file.
|
||||
- Do not create new test directories
|
||||
- Mock external dependencies appropriately in **mocks** folder.
|
||||
- Mock external dependencies appropriately in __mocks__ folder.
|
||||
- Aim for high code coverage but focus on critical paths.
|
||||
|
||||
## Code Quality
|
||||
|
||||
+8
-8
@@ -18,15 +18,15 @@ ENV NODE_ENV=production
|
||||
RUN addgroup --gid 1001 nodejs && \
|
||||
adduser --uid 1001 --ingroup nodejs nodejs && \
|
||||
mkdir -p /var/lib/outline && \
|
||||
chown -R nodejs:nodejs /var/lib/outline && \
|
||||
chown -R nodejs:nodejs $APP_PATH
|
||||
chown -R nodejs:nodejs /var/lib/outline
|
||||
|
||||
COPY --from=base --chown=nodejs:nodejs $APP_PATH/build ./build
|
||||
COPY --from=base --chown=nodejs:nodejs $APP_PATH/server ./server
|
||||
COPY --from=base --chown=nodejs:nodejs $APP_PATH/public ./public
|
||||
COPY --from=base --chown=nodejs:nodejs $APP_PATH/.sequelizerc ./.sequelizerc
|
||||
COPY --from=base --chown=nodejs:nodejs $APP_PATH/node_modules ./node_modules
|
||||
COPY --from=base --chown=nodejs:nodejs $APP_PATH/package.json ./package.json
|
||||
COPY --from=base $APP_PATH/server ./server
|
||||
COPY --from=base $APP_PATH/public ./public
|
||||
COPY --from=base $APP_PATH/.sequelizerc ./.sequelizerc
|
||||
COPY --from=base $APP_PATH/node_modules ./node_modules
|
||||
COPY --from=base $APP_PATH/package.json ./package.json
|
||||
|
||||
# Install wget to healthcheck the server
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y wget \
|
||||
@@ -44,4 +44,4 @@ USER nodejs
|
||||
HEALTHCHECK --interval=1m CMD wget -qO- "http://localhost:${PORT:-3000}/_health" | grep -q "OK" || exit 1
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["node", "build/server/index.js"]
|
||||
CMD ["yarn", "start"]
|
||||
|
||||
+5
-4
@@ -3,21 +3,22 @@ FROM node:22.21.0 AS deps
|
||||
|
||||
ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
COPY ./package.json ./yarn.lock ./.yarnrc.yml ./
|
||||
COPY ./package.json ./yarn.lock ./
|
||||
COPY ./patches ./patches
|
||||
|
||||
RUN apt-get update && apt-get install -y cmake
|
||||
ENV NODE_OPTIONS="--max-old-space-size=24000"
|
||||
|
||||
RUN corepack enable
|
||||
RUN yarn install --immutable --network-timeout 1000000 && \
|
||||
RUN yarn install --no-optional --frozen-lockfile --network-timeout 1000000 && \
|
||||
yarn cache clean
|
||||
|
||||
COPY . .
|
||||
ARG CDN_URL
|
||||
RUN yarn build
|
||||
|
||||
RUN yarn workspaces focus --production && \
|
||||
RUN rm -rf node_modules
|
||||
|
||||
RUN yarn install --production=true --frozen-lockfile --network-timeout 1000000 && \
|
||||
yarn cache clean
|
||||
|
||||
ENV PORT=3000
|
||||
|
||||
@@ -3,8 +3,8 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 1.5.0
|
||||
The Licensed Work is (c) 2026 General Outline, Inc.
|
||||
Licensed Work: Outline 1.1.0
|
||||
The Licensed Work is (c) 2025 General Outline, Inc.
|
||||
Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
you may not use the Licensed Work for a Document
|
||||
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: 2030-02-15
|
||||
Change Date: 2029-11-16
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
up:
|
||||
docker compose up -d redis postgres
|
||||
yarn install-local-ssl
|
||||
yarn install --immutable
|
||||
yarn install --pure-lockfile
|
||||
yarn dev:watch
|
||||
|
||||
build:
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./public/logos/outline-logo-dark.png" height="29">
|
||||
<source media="(prefers-color-scheme: light)" srcset="./public/logos/outline-logo-light.png" height="29">
|
||||
<img src="./public/logos/outline-logo-light.png" height="29" alt="Outline" />
|
||||
</picture>
|
||||
<img src="https://user-images.githubusercontent.com/31465/34380645-bd67f474-eb0b-11e7-8d03-0151c1730654.png" height="29" />
|
||||
</p>
|
||||
<p align="center">
|
||||
<i>A fast, collaborative, knowledge base for your team built using React and Node.js.<br/>Try out Outline using our hosted version at <a href="https://www.getoutline.com">www.getoutline.com</a>.</i>
|
||||
|
||||
@@ -21,23 +21,7 @@
|
||||
}
|
||||
],
|
||||
"scripts": {
|
||||
"postdeploy": "yarn sequelize db:migrate",
|
||||
"pr-predeploy": "yarn sequelize db:migrate"
|
||||
},
|
||||
"environments": {
|
||||
"review": {
|
||||
"scripts": {
|
||||
"postdeploy": "yarn sequelize db:migrate"
|
||||
},
|
||||
"addons": [
|
||||
{
|
||||
"plan": "heroku-redis:mini"
|
||||
},
|
||||
{
|
||||
"plan": "heroku-postgresql:essential-0"
|
||||
}
|
||||
]
|
||||
}
|
||||
"postdeploy": "yarn sequelize db:migrate"
|
||||
},
|
||||
"env": {
|
||||
"NODE_ENV": {
|
||||
@@ -59,12 +43,8 @@
|
||||
"required": true
|
||||
},
|
||||
"URL": {
|
||||
"description": "https://{your app name}.herokuapp.com, or the domain you are binding to. For review apps, this is auto-generated.",
|
||||
"required": false
|
||||
},
|
||||
"HEROKU_APP_NAME": {
|
||||
"description": "Automatically set by Heroku for review apps",
|
||||
"required": false
|
||||
"description": "https://{your app name}.herokuapp.com, or the domain you are binding to",
|
||||
"required": true
|
||||
},
|
||||
"GOOGLE_CLIENT_ID": {
|
||||
"description": "See https://developers.google.com/identity/protocols/OAuth2 to create a new Google OAuth client. You must configure at least one of Slack or Google to control login.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PlusIcon, TrashIcon } from "outline-icons";
|
||||
import stores from "~/stores";
|
||||
import type ApiKey from "~/models/ApiKey";
|
||||
import ApiKey from "~/models/ApiKey";
|
||||
import ApiKeyNew from "~/scenes/ApiKeyNew";
|
||||
import ApiKeyRevokeDialog from "~/scenes/Settings/components/ApiKeyRevokeDialog";
|
||||
import { createAction } from "..";
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { ArchiveIcon, MoveIcon, TrashIcon } from "outline-icons";
|
||||
import DocumentMove from "~/scenes/DocumentMove";
|
||||
import { createAction } from "~/actions";
|
||||
import { ActiveDocumentSection } from "~/actions/sections";
|
||||
import DocumentDelete from "~/scenes/DocumentDelete";
|
||||
import DocumentArchive from "~/scenes/DocumentArchive";
|
||||
import Document from "~/models/Document";
|
||||
|
||||
type Props = {
|
||||
documents: Document[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Archive multiple documents at once.
|
||||
*/
|
||||
export const bulkArchiveDocuments = ({ documents }: Props) =>
|
||||
createAction({
|
||||
name: ({ t }) => `${t("Archive")}…`,
|
||||
analyticsName: "Bulk archive documents",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <ArchiveIcon />,
|
||||
visible: ({ stores }) => {
|
||||
if (documents.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return documents.every(({ id }) => stores.policies.abilities(id).archive);
|
||||
},
|
||||
perform: async ({ stores, t }) => {
|
||||
const { dialogs, documents: documentsStore } = stores;
|
||||
const count = documents.length;
|
||||
|
||||
if (count === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
dialogs.openModal({
|
||||
title: t("Archive {{ count }} documents", { count }),
|
||||
content: (
|
||||
<DocumentArchive
|
||||
documents={documents}
|
||||
onSubmit={() => documentsStore.clearSelection()}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Move multiple documents at once.
|
||||
*/
|
||||
export const bulkMoveDocuments = ({ documents }: Props) =>
|
||||
createAction({
|
||||
name: ({ t }) => `${t("Move")}…`,
|
||||
analyticsName: "Bulk move documents",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <MoveIcon />,
|
||||
visible: ({ stores }) => {
|
||||
if (documents.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return documents.every(({ id }) => stores.policies.abilities(id).move);
|
||||
},
|
||||
perform: ({ stores, t }) => {
|
||||
const { dialogs, documents: documentsStore } = stores;
|
||||
const count = documents.length;
|
||||
|
||||
if (count === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
dialogs.openModal({
|
||||
title: t("Move {{ count }} documents", { count }),
|
||||
content: (
|
||||
<DocumentMove
|
||||
documents={documents}
|
||||
onSubmit={() => documentsStore.clearSelection()}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete multiple documents at once.
|
||||
*/
|
||||
export const bulkDeleteDocuments = ({ documents }: Props) =>
|
||||
createAction({
|
||||
name: ({ t }) => `${t("Delete")}…`,
|
||||
analyticsName: "Bulk delete documents",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <TrashIcon />,
|
||||
dangerous: true,
|
||||
visible: ({ stores }) => {
|
||||
if (documents.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return documents.every(({ id }) => stores.policies.abilities(id).delete);
|
||||
},
|
||||
perform: async ({ stores, t }) => {
|
||||
const { dialogs, documents: documentsStore } = stores;
|
||||
const count = documents.length;
|
||||
|
||||
if (count === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
dialogs.openModal({
|
||||
title: t("Delete {{ count }} documents", { count }),
|
||||
content: (
|
||||
<DocumentDelete
|
||||
documents={documents}
|
||||
onSubmit={() => documentsStore.clearSelection()}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const rootBulkDocumentActions = [
|
||||
bulkArchiveDocuments,
|
||||
bulkMoveDocuments,
|
||||
bulkDeleteDocuments,
|
||||
];
|
||||
@@ -1,12 +1,12 @@
|
||||
import {
|
||||
SortAlphabeticalReverseIcon,
|
||||
SortAlphabeticalIcon,
|
||||
AlphabeticalReverseSortIcon,
|
||||
AlphabeticalSortIcon,
|
||||
ArchiveIcon,
|
||||
CollectionIcon,
|
||||
EditIcon,
|
||||
ExportIcon,
|
||||
ImportIcon,
|
||||
SortManualIcon,
|
||||
ManualSortIcon,
|
||||
NewDocumentIcon,
|
||||
PadlockIcon,
|
||||
PlusIcon,
|
||||
@@ -29,8 +29,8 @@ import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||
import {
|
||||
createAction,
|
||||
createInternalLinkAction,
|
||||
createActionWithChildren,
|
||||
createInternalLinkAction,
|
||||
} from "~/actions";
|
||||
import { ActiveCollectionSection, CollectionSection } from "~/actions/sections";
|
||||
import { setPersistedState } from "~/hooks/usePersistedState";
|
||||
@@ -96,11 +96,11 @@ export const editCollection = createAction({
|
||||
analyticsName: "Edit collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <EditIcon />,
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some((policy) => policy.abilities.update),
|
||||
perform: ({ t, getActiveModel, stores }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
visible: ({ activeCollectionId, stores }) =>
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).update,
|
||||
perform: ({ t, activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ export const editCollection = createAction({
|
||||
content: (
|
||||
<CollectionEdit
|
||||
onSubmit={stores.dialogs.closeAllModals}
|
||||
collectionId={collection.id}
|
||||
collectionId={activeCollectionId}
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -122,16 +122,21 @@ export const editCollectionPermissions = createAction({
|
||||
analyticsName: "Collection permissions",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <PadlockIcon />,
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some((policy) => policy.abilities.update),
|
||||
perform: ({ t, getActiveModel, stores }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
visible: ({ activeCollectionId, stores }) =>
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).update,
|
||||
perform: ({ t, activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Share this collection"),
|
||||
style: { marginBottom: -12 },
|
||||
content: (
|
||||
<SharePopover
|
||||
collection={collection}
|
||||
@@ -148,16 +153,15 @@ export const importDocument = createAction({
|
||||
analyticsName: "Import document",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ImportIcon />,
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some(
|
||||
(policy) => policy.abilities.createDocument
|
||||
),
|
||||
perform: ({ t, getActiveModel, stores }) => {
|
||||
const { documents } = stores;
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
return;
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (activeCollectionId) {
|
||||
return !!stores.policies.abilities(activeCollectionId).createDocument;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
perform: ({ activeCollectionId, stores }) => {
|
||||
const { documents } = stores;
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = documents.importFileTypesString;
|
||||
@@ -165,17 +169,19 @@ export const importDocument = createAction({
|
||||
input.onchange = async (ev) => {
|
||||
const files = getEventFiles(ev);
|
||||
const file = files[0];
|
||||
const toastId = toast.loading(`${t("Uploading")}…`);
|
||||
|
||||
try {
|
||||
const document = await documents.import(file, null, collection.id, {
|
||||
publish: true,
|
||||
});
|
||||
history.push(document.path);
|
||||
const document = await documents.import(
|
||||
file,
|
||||
null,
|
||||
activeCollectionId,
|
||||
{
|
||||
publish: true,
|
||||
}
|
||||
);
|
||||
history.push(document.url);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
} finally {
|
||||
toast.dismiss(toastId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -186,36 +192,37 @@ export const importDocument = createAction({
|
||||
export const sortCollection = createActionWithChildren({
|
||||
name: ({ t }) => t("Sort in sidebar"),
|
||||
section: ActiveCollectionSection,
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some((policy) => policy.abilities.update),
|
||||
icon: ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
visible: ({ activeCollectionId, stores }) =>
|
||||
!!activeCollectionId &&
|
||||
!!stores.policies.abilities(activeCollectionId).update,
|
||||
icon: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
const sortAlphabetical = collection?.sort.field === "title";
|
||||
const sortDir = collection?.sort.direction;
|
||||
|
||||
return sortAlphabetical ? (
|
||||
sortDir === "asc" ? (
|
||||
<SortAlphabeticalIcon />
|
||||
<AlphabeticalSortIcon />
|
||||
) : (
|
||||
<SortAlphabeticalReverseIcon />
|
||||
<AlphabeticalReverseSortIcon />
|
||||
)
|
||||
) : (
|
||||
<SortManualIcon />
|
||||
<ManualSortIcon />
|
||||
);
|
||||
},
|
||||
children: [
|
||||
createAction({
|
||||
name: ({ t }) => t("A-Z sort"),
|
||||
section: ActiveCollectionSection,
|
||||
selected: ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
selected: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return (
|
||||
collection?.sort.field === "title" &&
|
||||
collection?.sort.direction === "asc"
|
||||
);
|
||||
},
|
||||
perform: ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
perform: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return collection?.save({
|
||||
sort: {
|
||||
field: "title",
|
||||
@@ -227,15 +234,15 @@ export const sortCollection = createActionWithChildren({
|
||||
createAction({
|
||||
name: ({ t }) => t("Z-A sort"),
|
||||
section: ActiveCollectionSection,
|
||||
selected: ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
selected: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return (
|
||||
collection?.sort.field === "title" &&
|
||||
collection?.sort.direction === "desc"
|
||||
);
|
||||
},
|
||||
perform: ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
perform: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return collection?.save({
|
||||
sort: {
|
||||
field: "title",
|
||||
@@ -247,12 +254,12 @@ export const sortCollection = createActionWithChildren({
|
||||
createAction({
|
||||
name: ({ t }) => t("Manual sort"),
|
||||
section: ActiveCollectionSection,
|
||||
selected: ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
selected: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return collection?.sort.field !== "title";
|
||||
},
|
||||
perform: ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
perform: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return collection?.save({
|
||||
sort: {
|
||||
field: "index",
|
||||
@@ -269,19 +276,22 @@ export const searchInCollection = createInternalLinkAction({
|
||||
analyticsName: "Search collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <SearchIcon />,
|
||||
visible: ({ getActiveModel, stores }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
if (!collection?.isActive) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return stores.policies.abilities(collection.id).readDocument;
|
||||
return stores.policies.abilities(activeCollectionId).readDocument;
|
||||
},
|
||||
to: ({ getActiveModel, sidebarContext }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
|
||||
to: ({ activeCollectionId, sidebarContext }) => {
|
||||
const [pathname, search] = searchPath({
|
||||
collectionId: collection?.id,
|
||||
collectionId: activeCollectionId,
|
||||
}).split("?");
|
||||
|
||||
return {
|
||||
@@ -298,22 +308,23 @@ export const starCollection = createAction({
|
||||
section: ActiveCollectionSection,
|
||||
icon: <StarredIcon />,
|
||||
keywords: "favorite bookmark",
|
||||
visible: ({ getActiveModel, stores }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return (
|
||||
!collection.isStarred && stores.policies.abilities(collection.id).star
|
||||
!collection?.isStarred &&
|
||||
stores.policies.abilities(activeCollectionId).star
|
||||
);
|
||||
},
|
||||
perform: async ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
perform: async ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
await collection.star();
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
await collection?.star();
|
||||
setPersistedState(getHeaderExpandedKey("starred"), true);
|
||||
},
|
||||
});
|
||||
@@ -324,18 +335,22 @@ export const unstarCollection = createAction({
|
||||
section: ActiveCollectionSection,
|
||||
icon: <UnstarredIcon />,
|
||||
keywords: "unfavorite unbookmark",
|
||||
visible: ({ getActiveModel, stores }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return (
|
||||
!!collection.isStarred && stores.policies.abilities(collection.id).unstar
|
||||
!!collection?.isStarred &&
|
||||
stores.policies.abilities(activeCollectionId).unstar
|
||||
);
|
||||
},
|
||||
perform: async ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
perform: async ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
await collection?.unstar();
|
||||
},
|
||||
});
|
||||
@@ -345,25 +360,28 @@ export const subscribeCollection = createAction({
|
||||
analyticsName: "Subscribe to collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <SubscribeIcon />,
|
||||
visible: ({ getActiveModel, stores }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
return (
|
||||
!!collection.isActive &&
|
||||
!collection.isSubscribed &&
|
||||
stores.policies.abilities(collection.id).subscribe
|
||||
!!collection?.isActive &&
|
||||
!collection?.isSubscribed &&
|
||||
stores.policies.abilities(activeCollectionId).subscribe
|
||||
);
|
||||
},
|
||||
perform: async ({ getActiveModel, t }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
perform: async ({ activeCollectionId, stores, t }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await collection.subscribe();
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
await collection?.subscribe();
|
||||
|
||||
toast.success(t("Subscribed to document notifications"));
|
||||
},
|
||||
});
|
||||
@@ -373,25 +391,28 @@ export const unsubscribeCollection = createAction({
|
||||
analyticsName: "Unsubscribe from collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <UnsubscribeIcon />,
|
||||
visible: ({ getActiveModel, stores }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
return (
|
||||
!!collection.isActive &&
|
||||
!!collection.isSubscribed &&
|
||||
stores.policies.abilities(collection.id).unsubscribe
|
||||
!!collection?.isActive &&
|
||||
!!collection?.isSubscribed &&
|
||||
stores.policies.abilities(activeCollectionId).unsubscribe
|
||||
);
|
||||
},
|
||||
perform: async ({ getActiveModel, t }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
perform: async ({ activeCollectionId, currentUserId, stores, t }) => {
|
||||
if (!activeCollectionId || !currentUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await collection.unsubscribe();
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
await collection?.unsubscribe();
|
||||
|
||||
toast.success(t("Unsubscribed from document notifications"));
|
||||
},
|
||||
});
|
||||
@@ -401,15 +422,23 @@ export const archiveCollection = createAction({
|
||||
analyticsName: "Archive collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ArchiveIcon />,
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some((policy) => policy.abilities.archive),
|
||||
perform: async ({ getActiveModel, stores, t }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
return !!stores.policies.abilities(activeCollectionId).archive;
|
||||
},
|
||||
perform: async ({ activeCollectionId, stores, t }) => {
|
||||
const { dialogs, collections } = stores;
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
const collection = collections.get(activeCollectionId);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
dialogs.openModal({
|
||||
title: t("Archive collection"),
|
||||
content: (
|
||||
<ConfirmationDialog
|
||||
@@ -434,10 +463,17 @@ export const restoreCollection = createAction({
|
||||
analyticsName: "Restore collection",
|
||||
section: CollectionSection,
|
||||
icon: <RestoreIcon />,
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some((policy) => policy.abilities.restore),
|
||||
perform: async ({ getActiveModel, t }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
return !!stores.policies.abilities(activeCollectionId).restore;
|
||||
},
|
||||
perform: async ({ activeCollectionId, stores, t }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
@@ -453,10 +489,18 @@ export const deleteCollection = createAction({
|
||||
section: ActiveCollectionSection,
|
||||
dangerous: true,
|
||||
icon: <TrashIcon />,
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some((policy) => policy.abilities.delete),
|
||||
perform: ({ getActiveModel, t, stores }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
return stores.policies.abilities(activeCollectionId).delete;
|
||||
},
|
||||
perform: ({ activeCollectionId, t, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
@@ -478,10 +522,18 @@ export const exportCollection = createAction({
|
||||
analyticsName: "Export collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ExportIcon />,
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some((policy) => policy.abilities.export),
|
||||
perform: async ({ getActiveModel, stores, t }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
visible: ({ currentTeamId, activeCollectionId, stores }) => {
|
||||
if (!currentTeamId || !activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !!stores.policies.abilities(activeCollectionId).export;
|
||||
},
|
||||
perform: async ({ activeCollectionId, stores, t }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
@@ -504,13 +556,13 @@ export const createDocument = createInternalLinkAction({
|
||||
section: ActiveCollectionSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "new create document",
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some(
|
||||
(policy) => policy.abilities.createDocument
|
||||
visible: ({ activeCollectionId, stores }) =>
|
||||
!!(
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).createDocument
|
||||
),
|
||||
to: ({ getActiveModel, sidebarContext }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
const [pathname, search] = newDocumentPath(collection?.id).split("?");
|
||||
to: ({ activeCollectionId, sidebarContext }) => {
|
||||
const [pathname, search] = newDocumentPath(activeCollectionId).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
@@ -526,13 +578,19 @@ export const createTemplate = createInternalLinkAction({
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ShapesIcon />,
|
||||
keywords: "new create template",
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some(
|
||||
(policy) => policy.abilities.createTemplate
|
||||
visible: ({ activeCollectionId, stores }) =>
|
||||
!!(
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).createDocument
|
||||
),
|
||||
to: ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
return newTemplatePath(collection?.id);
|
||||
to: ({ activeCollectionId, sidebarContext }) => {
|
||||
const [pathname, search] = newTemplatePath(activeCollectionId).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DoneIcon, SmileyIcon, TrashIcon } from "outline-icons";
|
||||
import { toast } from "sonner";
|
||||
import type Comment from "~/models/Comment";
|
||||
import Comment from "~/models/Comment";
|
||||
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
|
||||
import ViewReactionsDialog from "~/components/Reactions/ViewReactionsDialog";
|
||||
import { createAction } from "..";
|
||||
|
||||
@@ -17,17 +17,7 @@ import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
|
||||
import Logger from "~/utils/Logger";
|
||||
import { deleteAllDatabases } from "~/utils/developer";
|
||||
import history from "~/utils/history";
|
||||
import { homePath, debugPath } from "~/utils/routeHelpers";
|
||||
|
||||
export const goToDebug = createAction({
|
||||
name: "Go to debug screen",
|
||||
icon: <BeakerIcon />,
|
||||
section: DeveloperSection,
|
||||
visible: () => env.ENVIRONMENT === "development",
|
||||
perform: () => {
|
||||
history.push(debugPath());
|
||||
},
|
||||
});
|
||||
import { homePath } from "~/utils/routeHelpers";
|
||||
|
||||
export const copyId = createActionWithChildren({
|
||||
name: ({ t }) => t("Copy ID"),
|
||||
@@ -232,7 +222,6 @@ export const developer = createActionWithChildren({
|
||||
iconInContextMenu: false,
|
||||
section: DeveloperSection,
|
||||
children: [
|
||||
goToDebug,
|
||||
copyId,
|
||||
toggleDebugLogging,
|
||||
toggleDebugSafeArea,
|
||||
|
||||
@@ -35,19 +35,19 @@ import {
|
||||
} from "outline-icons";
|
||||
import { toast } from "sonner";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import type { NavigationNode } from "@shared/types";
|
||||
import { ExportContentType, TeamPreference } from "@shared/types";
|
||||
import {
|
||||
ExportContentType,
|
||||
TeamPreference,
|
||||
NavigationNode,
|
||||
} from "@shared/types";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import { Week } from "@shared/utils/time";
|
||||
import type UserMembership from "~/models/UserMembership";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import UserMembership from "~/models/UserMembership";
|
||||
import DocumentDelete from "~/scenes/DocumentDelete";
|
||||
import DocumentMove from "~/scenes/DocumentMove";
|
||||
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
|
||||
import DocumentPublish from "~/scenes/DocumentPublish";
|
||||
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import DocumentCopy from "~/components/DocumentExplorer/DocumentCopy";
|
||||
import { DocumentDownload } from "~/components/DocumentDownload";
|
||||
import DocumentCopy from "~/components/DocumentCopy";
|
||||
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
|
||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||
import DocumentTemplatizeDialog from "~/components/TemplatizeDialog";
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
DocumentSection,
|
||||
TrashSection,
|
||||
} from "~/actions/sections";
|
||||
import env from "~/env";
|
||||
import { setPersistedState } from "~/hooks/usePersistedState";
|
||||
import history from "~/utils/history";
|
||||
import {
|
||||
@@ -69,7 +70,6 @@ import {
|
||||
homePath,
|
||||
newDocumentPath,
|
||||
newNestedDocumentPath,
|
||||
newSiblingDocumentPath,
|
||||
searchPath,
|
||||
documentPath,
|
||||
urlify,
|
||||
@@ -78,15 +78,9 @@ import {
|
||||
} from "~/utils/routeHelpers";
|
||||
import capitalize from "lodash/capitalize";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import type {
|
||||
Action,
|
||||
ActionContext,
|
||||
ActionGroup,
|
||||
ActionSeparator,
|
||||
} from "~/types";
|
||||
import { Action, ActionGroup, ActionSeparator } from "~/types";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import env from "~/env";
|
||||
import DocumentMove from "~/components/DocumentExplorer/DocumentMove";
|
||||
import DocumentArchive from "~/scenes/DocumentArchive";
|
||||
|
||||
const Insights = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/Insights")
|
||||
@@ -138,13 +132,18 @@ export const editDocument = createInternalLinkAction({
|
||||
keywords: "edit",
|
||||
icon: <EditIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
const { auth, policies } = stores;
|
||||
const { auth, documents, policies } = stores;
|
||||
|
||||
const document = activeDocumentId
|
||||
? documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
const can = activeDocumentId
|
||||
? policies.abilities(activeDocumentId)
|
||||
: undefined;
|
||||
|
||||
return !!can?.update && !!auth.user?.separateEditMode;
|
||||
return (
|
||||
!!can?.update && !!auth.user?.separateEditMode && !document?.template
|
||||
);
|
||||
},
|
||||
to: ({ activeDocumentId, stores }) => {
|
||||
const document = activeDocumentId
|
||||
@@ -201,41 +200,59 @@ export const createDraftDocument = createInternalLinkAction({
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Finds the index of a document among its siblings in the collection tree.
|
||||
*
|
||||
* @param stores - the root stores.
|
||||
* @param document - the document to find the index of.
|
||||
* @returns the index of the document among its siblings, or -1 if not found.
|
||||
*/
|
||||
function findDocumentSiblingIndex(
|
||||
stores: ActionContext["stores"],
|
||||
document: {
|
||||
id: string;
|
||||
collectionId?: string | null;
|
||||
parentDocumentId?: string;
|
||||
}
|
||||
): number {
|
||||
if (!document.collectionId) {
|
||||
return -1;
|
||||
}
|
||||
const collection = stores.collections.get(document.collectionId);
|
||||
if (!collection) {
|
||||
return -1;
|
||||
}
|
||||
export const createDocumentFromTemplate = createInternalLinkAction({
|
||||
name: ({ t }) => t("New from template"),
|
||||
analyticsName: "New document",
|
||||
section: DocumentSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "create",
|
||||
visible: ({
|
||||
currentTeamId,
|
||||
activeCollectionId,
|
||||
activeDocumentId,
|
||||
stores,
|
||||
}) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
|
||||
const siblings = document.parentDocumentId
|
||||
? collection.getChildrenForDocument(document.parentDocumentId)
|
||||
: collection.sortedDocuments;
|
||||
if (
|
||||
!currentTeamId ||
|
||||
!document?.isTemplate ||
|
||||
!!document?.isDraft ||
|
||||
!!document?.isDeleted
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return siblings?.findIndex((node) => node.id === document.id) ?? -1;
|
||||
}
|
||||
if (activeCollectionId) {
|
||||
return stores.policies.abilities(activeCollectionId).createDocument;
|
||||
}
|
||||
return stores.policies.abilities(currentTeamId).createDocument;
|
||||
},
|
||||
to: ({ activeDocumentId, activeCollectionId, sidebarContext }) => {
|
||||
if (!activeDocumentId || !activeCollectionId) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const [pathname, search] = newDocumentPath(activeCollectionId, {
|
||||
templateId: activeDocumentId,
|
||||
}).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const createNestedDocument = createInternalLinkAction({
|
||||
name: ({ t }) => t("Nested document"),
|
||||
name: ({ t }) => t("New nested document"),
|
||||
analyticsName: "New document",
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "create nested",
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "create",
|
||||
visible: ({ currentTeamId, activeDocumentId, stores }) =>
|
||||
!!currentTeamId &&
|
||||
!!activeDocumentId &&
|
||||
@@ -253,93 +270,6 @@ export const createNestedDocument = createInternalLinkAction({
|
||||
},
|
||||
});
|
||||
|
||||
const createDocumentBefore = createInternalLinkAction({
|
||||
name: ({ t }) => t("Before"),
|
||||
analyticsName: "New document before",
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "create before",
|
||||
visible: ({ currentTeamId, activeDocumentId, stores }) => {
|
||||
if (!currentTeamId || !activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
return (
|
||||
!!document?.collectionId &&
|
||||
stores.policies.abilities(currentTeamId).createDocument
|
||||
);
|
||||
},
|
||||
to: ({ activeDocumentId, stores, sidebarContext }) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (!document) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const index = findDocumentSiblingIndex(stores, document);
|
||||
const [pathname, search] = newSiblingDocumentPath({
|
||||
collectionId: document.collectionId,
|
||||
parentDocumentId: document.parentDocumentId,
|
||||
index: Math.max(0, index),
|
||||
}).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const createDocumentAfter = createInternalLinkAction({
|
||||
name: ({ t }) => t("After"),
|
||||
analyticsName: "New document after",
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "create after",
|
||||
visible: ({ currentTeamId, activeDocumentId, stores }) => {
|
||||
if (!currentTeamId || !activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
return (
|
||||
!!document?.collectionId &&
|
||||
stores.policies.abilities(currentTeamId).createDocument
|
||||
);
|
||||
},
|
||||
to: ({ activeDocumentId, stores, sidebarContext }) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (!document) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const index = findDocumentSiblingIndex(stores, document);
|
||||
const [pathname, search] = newSiblingDocumentPath({
|
||||
collectionId: document.collectionId,
|
||||
parentDocumentId: document.parentDocumentId,
|
||||
index: index + 1,
|
||||
}).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const createNewDocument = createActionWithChildren({
|
||||
name: ({ t }) => t("New document"),
|
||||
analyticsName: "New document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "create",
|
||||
visible: ({ currentTeamId, stores }) =>
|
||||
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument,
|
||||
children: [createDocumentBefore, createDocumentAfter, createNestedDocument],
|
||||
});
|
||||
|
||||
export const starDocument = createAction({
|
||||
name: ({ t }) => t("Star"),
|
||||
analyticsName: "Star document",
|
||||
@@ -416,7 +346,7 @@ export const publishDocument = createAction({
|
||||
return;
|
||||
}
|
||||
|
||||
if (document?.collectionId) {
|
||||
if (document?.collectionId || document?.template) {
|
||||
await document.save(undefined, {
|
||||
publish: true,
|
||||
});
|
||||
@@ -579,6 +509,7 @@ export const shareDocument = createAction({
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
style: { marginBottom: -12 },
|
||||
title: t("Share this document"),
|
||||
content: (
|
||||
<SharePopover
|
||||
@@ -591,61 +522,13 @@ export const shareDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocument = createAction({
|
||||
name: ({ t, isMenu }) => (isMenu ? t("Download") : t("Download document")),
|
||||
analyticsName: "Download document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <DownloadIcon />,
|
||||
keywords: "export md markdown html",
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
perform: ({ activeDocumentId, t, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
invariant(document, "Document must exist");
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Download document"),
|
||||
content: (
|
||||
<DocumentDownload
|
||||
document={document}
|
||||
onSubmit={stores.dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocumentAsMarkdown = createAction({
|
||||
name: ({ t }) => t("Download as Markdown"),
|
||||
analyticsName: "Download document as Markdown",
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "md markdown export",
|
||||
icon: <DownloadIcon />,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
perform: async ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
await document?.download({
|
||||
contentType: ExportContentType.Markdown,
|
||||
includeChildDocuments: false,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocumentAsHTML = createAction({
|
||||
name: ({ t }) => t("Download as HTML"),
|
||||
name: ({ t }) => t("HTML"),
|
||||
analyticsName: "Download document as HTML",
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "xml html export",
|
||||
keywords: "html export",
|
||||
icon: <DownloadIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
perform: async ({ activeDocumentId, stores }) => {
|
||||
@@ -654,38 +537,70 @@ export const downloadDocumentAsHTML = createAction({
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
await document?.download({
|
||||
contentType: ExportContentType.Html,
|
||||
includeChildDocuments: false,
|
||||
});
|
||||
await document?.download(ExportContentType.Html);
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocumentAsPDF = createAction({
|
||||
name: ({ t }) => t("Download as PDF"),
|
||||
name: ({ t }) => t("PDF"),
|
||||
analyticsName: "Download document as PDF",
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "pdf export",
|
||||
keywords: "export",
|
||||
icon: <DownloadIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!(
|
||||
activeDocumentId &&
|
||||
stores.policies.abilities(activeDocumentId).download &&
|
||||
env.PDF_EXPORT_ENABLED
|
||||
),
|
||||
perform: ({ activeDocumentId, t, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = toast.loading(`${t("Exporting")}…`);
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
return document
|
||||
?.download(ExportContentType.Pdf)
|
||||
.finally(() => id && toast.dismiss(id));
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocumentAsMarkdown = createAction({
|
||||
name: ({ t }) => t("Markdown"),
|
||||
analyticsName: "Download document as Markdown",
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "md markdown export",
|
||||
icon: <DownloadIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
perform: async ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
await document?.download({
|
||||
contentType: ExportContentType.Pdf,
|
||||
includeChildDocuments: false,
|
||||
});
|
||||
await document?.download(ExportContentType.Markdown);
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocument = createActionWithChildren({
|
||||
name: ({ t, isMenu }) => (isMenu ? t("Download") : t("Download document")),
|
||||
analyticsName: "Download document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <DownloadIcon />,
|
||||
keywords: "export",
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
children: [
|
||||
downloadDocumentAsHTML,
|
||||
downloadDocumentAsPDF,
|
||||
downloadDocumentAsMarkdown,
|
||||
],
|
||||
});
|
||||
|
||||
export const copyDocumentAsMarkdown = createAction({
|
||||
name: ({ t }) => t("Copy as Markdown"),
|
||||
section: ActiveDocumentSection,
|
||||
@@ -699,11 +614,10 @@ export const copyDocumentAsMarkdown = createAction({
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (document) {
|
||||
const res = await client.post("/documents.export", {
|
||||
id: document.id,
|
||||
signedUrls: Week.seconds, // 7 days (AWS S3 max for presigned URLs)
|
||||
});
|
||||
copy(res.data);
|
||||
const { ProsemirrorHelper } = await import(
|
||||
"~/models/helpers/ProsemirrorHelper"
|
||||
);
|
||||
copy(ProsemirrorHelper.toMarkdown(document));
|
||||
toast.success(t("Markdown copied to clipboard"));
|
||||
}
|
||||
},
|
||||
@@ -722,8 +636,9 @@ export const copyDocumentAsPlainText = createAction({
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (document) {
|
||||
const { ProsemirrorHelper } =
|
||||
await import("~/models/helpers/ProsemirrorHelper");
|
||||
const { ProsemirrorHelper } = await import(
|
||||
"~/models/helpers/ProsemirrorHelper"
|
||||
);
|
||||
copy(ProsemirrorHelper.toPlainText(document));
|
||||
toast.success(t("Text copied to clipboard"));
|
||||
}
|
||||
@@ -940,7 +855,7 @@ export const printDocument = createAction({
|
||||
icon: <PrintIcon />,
|
||||
visible: ({ activeDocumentId }) => !!(activeDocumentId && window.print),
|
||||
perform: () => {
|
||||
setTimeout(window.print, 0);
|
||||
queueMicrotask(window.print);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -961,7 +876,7 @@ export const importDocument = createAction({
|
||||
|
||||
return false;
|
||||
},
|
||||
perform: ({ t, activeDocumentId, activeCollectionId, stores }) => {
|
||||
perform: ({ activeDocumentId, activeCollectionId, stores }) => {
|
||||
const { documents } = stores;
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
@@ -970,7 +885,6 @@ export const importDocument = createAction({
|
||||
input.onchange = async (ev) => {
|
||||
const files = getEventFiles(ev);
|
||||
const file = files[0];
|
||||
const toastId = toast.loading(`${t("Uploading")}…`);
|
||||
|
||||
try {
|
||||
const document = await documents.import(
|
||||
@@ -984,8 +898,6 @@ export const importDocument = createAction({
|
||||
history.push(document.url);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
} finally {
|
||||
toast.dismiss(toastId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1003,12 +915,12 @@ export const createTemplateFromDocument = createAction({
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (!document?.isActive) {
|
||||
if (document?.isTemplate || !document?.isActive) {
|
||||
return false;
|
||||
}
|
||||
return !!(
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).createTemplate
|
||||
stores.policies.abilities(activeCollectionId).updateDocument
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores, t, event }) => {
|
||||
@@ -1055,8 +967,46 @@ export const searchDocumentsForQuery = (query: string) =>
|
||||
visible: ({ location }) => location.pathname !== searchPath(),
|
||||
});
|
||||
|
||||
export const moveTemplateToWorkspace = createAction({
|
||||
name: ({ t }) => t("Move to workspace"),
|
||||
analyticsName: "Move template to workspace",
|
||||
section: DocumentSection,
|
||||
icon: <MoveIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document || !document.template || document.isWorkspaceTemplate) {
|
||||
return false;
|
||||
}
|
||||
return !!stores.policies.abilities(activeDocumentId).move;
|
||||
},
|
||||
perform: async ({ activeDocumentId, stores }) => {
|
||||
if (activeDocumentId) {
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
await document.move({
|
||||
collectionId: null,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const moveDocumentToCollection = createAction({
|
||||
name: ({ t }) => t("Move"),
|
||||
name: ({ activeDocumentId, stores, t }) => {
|
||||
if (!activeDocumentId) {
|
||||
return t("Move");
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
return document?.template && document?.collectionId
|
||||
? t("Move to collection")
|
||||
: t("Move");
|
||||
},
|
||||
analyticsName: "Move document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <MoveIcon />,
|
||||
@@ -1078,7 +1028,7 @@ export const moveDocumentToCollection = createAction({
|
||||
title: t("Move {{ documentType }}", {
|
||||
documentType: document.noun,
|
||||
}),
|
||||
content: <DocumentMove document={document} />,
|
||||
content: <DocumentMove documents={[document]} />,
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -1094,7 +1044,8 @@ export const moveDocument = createAction({
|
||||
return false;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document) {
|
||||
// Don't show the button if this is a non-workspace template.
|
||||
if (!document || (document.template && !document.isWorkspaceTemplate)) {
|
||||
return false;
|
||||
}
|
||||
return !!stores.policies.abilities(activeDocumentId).move;
|
||||
@@ -1102,6 +1053,25 @@ export const moveDocument = createAction({
|
||||
perform: moveDocumentToCollection.perform,
|
||||
});
|
||||
|
||||
export const moveTemplate = createActionWithChildren({
|
||||
name: ({ t }) => t("Move"),
|
||||
analyticsName: "Move document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <MoveIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
// Don't show the menu if this is not a template (or) a workspace template.
|
||||
if (!document || !document.template || document.isWorkspaceTemplate) {
|
||||
return false;
|
||||
}
|
||||
return !!stores.policies.abilities(activeDocumentId).move;
|
||||
},
|
||||
children: [moveTemplateToWorkspace, moveDocumentToCollection],
|
||||
});
|
||||
|
||||
export const archiveDocument = createAction({
|
||||
name: ({ t }) => `${t("Archive")}…`,
|
||||
analyticsName: "Archive document",
|
||||
@@ -1124,19 +1094,7 @@ export const archiveDocument = createAction({
|
||||
|
||||
dialogs.openModal({
|
||||
title: t("Are you sure you want to archive this document?"),
|
||||
content: (
|
||||
<ConfirmationDialog
|
||||
onSubmit={async () => {
|
||||
await document.archive();
|
||||
toast.success(t("Document archived"));
|
||||
}}
|
||||
savingText={`${t("Archiving")}…`}
|
||||
>
|
||||
{t(
|
||||
"Archiving this document will remove it from the collection and search results."
|
||||
)}
|
||||
</ConfirmationDialog>
|
||||
),
|
||||
content: <DocumentArchive documents={[document]} />,
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -1160,7 +1118,10 @@ export const restoreDocument = createAction({
|
||||
: undefined;
|
||||
const can = stores.policies.abilities(document.id);
|
||||
|
||||
return !!collection?.isActive && !!(can.restore || can.unarchive);
|
||||
return (
|
||||
!!(document.isWorkspaceTemplate || collection?.isActive) &&
|
||||
!!(can.restore || can.unarchive)
|
||||
);
|
||||
},
|
||||
perform: async ({ t, stores, activeDocumentId }) => {
|
||||
const document = activeDocumentId
|
||||
@@ -1197,7 +1158,10 @@ export const restoreDocumentToCollection = createActionWithChildren({
|
||||
? stores.collections.get(document.collectionId)
|
||||
: undefined;
|
||||
|
||||
return !collection?.isActive && !!(can.restore || can.unarchive);
|
||||
return (
|
||||
!(document.isWorkspaceTemplate || collection?.isActive) &&
|
||||
!!(can.restore || can.unarchive)
|
||||
);
|
||||
},
|
||||
children: ({ t, activeDocumentId, stores }) => {
|
||||
const { collections, documents, policies } = stores;
|
||||
@@ -1254,12 +1218,7 @@ export const deleteDocument = createAction({
|
||||
title: t("Delete {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
}),
|
||||
content: (
|
||||
<DocumentDelete
|
||||
document={document}
|
||||
onSubmit={stores.dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
content: <DocumentDelete documents={[document]} />,
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -1339,7 +1298,7 @@ export const openDocumentComments = createAction({
|
||||
return;
|
||||
}
|
||||
|
||||
stores.ui.set({ rightSidebar: "comments" });
|
||||
stores.ui.toggleComments();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1374,7 +1333,6 @@ export const openDocumentInsights = createAction({
|
||||
name: ({ t }) => t("Insights"),
|
||||
analyticsName: "Open document insights",
|
||||
section: ActiveDocumentSection,
|
||||
shortcut: [`Meta+Shift+I`],
|
||||
icon: <GraphIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
const can = stores.policies.abilities(activeDocumentId ?? "");
|
||||
@@ -1382,7 +1340,12 @@ export const openDocumentInsights = createAction({
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
|
||||
return !!activeDocumentId && can.listViews && !document?.isDeleted;
|
||||
return (
|
||||
!!activeDocumentId &&
|
||||
can.listViews &&
|
||||
!document?.isTemplate &&
|
||||
!document?.isDeleted
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores, t }) => {
|
||||
const document = activeDocumentId
|
||||
@@ -1461,15 +1424,11 @@ export const rootDocumentActions = [
|
||||
archiveDocument,
|
||||
createDocument,
|
||||
createDraftDocument,
|
||||
createNewDocument,
|
||||
createNestedDocument,
|
||||
createTemplateFromDocument,
|
||||
deleteDocument,
|
||||
importDocument,
|
||||
downloadDocument,
|
||||
downloadDocumentAsMarkdown,
|
||||
downloadDocumentAsHTML,
|
||||
downloadDocumentAsPDF,
|
||||
copyDocumentLink,
|
||||
copyDocumentShareLink,
|
||||
copyDocumentAsMarkdown,
|
||||
@@ -1483,6 +1442,7 @@ export const rootDocumentActions = [
|
||||
searchInDocument,
|
||||
duplicateDocument,
|
||||
leaveDocument,
|
||||
moveTemplateToWorkspace,
|
||||
moveDocumentToCollection,
|
||||
openRandomDocument,
|
||||
permanentlyDeleteDocument,
|
||||
|
||||
@@ -2,9 +2,9 @@ import { TrashIcon } from "outline-icons";
|
||||
import stores from "~/stores";
|
||||
import { createAction } from "..";
|
||||
import { SettingsSection } from "../sections";
|
||||
import type Integration from "~/models/Integration";
|
||||
import Integration from "~/models/Integration";
|
||||
import { DisconnectAnalyticsDialog } from "~/scenes/Settings/components/DisconnectAnalyticsDialog";
|
||||
import type { IntegrationType } from "@shared/types";
|
||||
import { IntegrationType } from "@shared/types";
|
||||
import { settingsPath } from "@shared/utils/routeHelpers";
|
||||
import history from "~/utils/history";
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { UrlHelper } from "@shared/utils/UrlHelper";
|
||||
import { isMac } from "@shared/utils/browser";
|
||||
import stores from "~/stores";
|
||||
import type SearchQuery from "~/models/SearchQuery";
|
||||
import SearchQuery from "~/models/SearchQuery";
|
||||
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
|
||||
import {
|
||||
createAction,
|
||||
@@ -224,13 +224,13 @@ export const openKeyboardShortcuts = createAction({
|
||||
export const downloadApp = createExternalLinkAction({
|
||||
name: ({ t }) =>
|
||||
t("Download {{ platform }} app", {
|
||||
platform: isMac ? "macOS" : "Windows",
|
||||
platform: isMac() ? "macOS" : "Windows",
|
||||
}),
|
||||
analyticsName: "Download app",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <BrowserIcon />,
|
||||
visible: () => !Desktop.isElectron() && isMac && isCloudHosted,
|
||||
visible: () => !Desktop.isElectron() && isMac() && isCloudHosted,
|
||||
url: "https://desktop.getoutline.com",
|
||||
target: "_blank",
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ArchiveIcon, CheckmarkIcon, MarkAsReadIcon } from "outline-icons";
|
||||
import { ArchiveIcon, MarkAsReadIcon } from "outline-icons";
|
||||
import { createAction } from "..";
|
||||
import { NotificationSection } from "../sections";
|
||||
import type Notification from "~/models/Notification";
|
||||
|
||||
export const markNotificationsAsRead = createAction({
|
||||
name: ({ t }) => t("Mark notifications as read"),
|
||||
@@ -23,36 +22,6 @@ export const markNotificationsAsArchived = createAction({
|
||||
visible: ({ stores }) => stores.notifications.orderedData.length > 0,
|
||||
});
|
||||
|
||||
export const notificationMarkRead = (notification: Notification) =>
|
||||
createAction({
|
||||
name: ({ t }) => t("Mark as read"),
|
||||
analyticsName: "Mark notification read",
|
||||
section: NotificationSection,
|
||||
icon: <CheckmarkIcon />,
|
||||
perform: () => notification.toggleRead(),
|
||||
visible: () => !notification.viewedAt,
|
||||
});
|
||||
|
||||
export const notificationMarkUnread = (notification: Notification) =>
|
||||
createAction({
|
||||
name: ({ t }) => t("Mark as unread"),
|
||||
analyticsName: "Mark notification unread",
|
||||
section: NotificationSection,
|
||||
icon: <CheckmarkIcon />,
|
||||
perform: () => notification.toggleRead(),
|
||||
visible: () => !!notification.viewedAt,
|
||||
});
|
||||
|
||||
export const notificationArchive = (notification: Notification) =>
|
||||
createAction({
|
||||
name: ({ t }) => t("Archive"),
|
||||
analyticsName: "Mark notification as archived",
|
||||
section: NotificationSection,
|
||||
icon: <ArchiveIcon />,
|
||||
perform: () => notification.archive(),
|
||||
visible: () => !notification.archivedAt,
|
||||
});
|
||||
|
||||
export const rootNotificationActions = [
|
||||
markNotificationsAsRead,
|
||||
markNotificationsAsArchived,
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import { LinkIcon, RestoreIcon, TrashIcon, DownloadIcon } from "outline-icons";
|
||||
import { LinkIcon, RestoreIcon, TrashIcon } from "outline-icons";
|
||||
import { matchPath } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { ExportContentType } from "@shared/types";
|
||||
import stores from "~/stores";
|
||||
import { createAction, createActionWithChildren } from "~/actions";
|
||||
import { createAction } from "~/actions";
|
||||
import { RevisionSection } from "~/actions/sections";
|
||||
import env from "~/env";
|
||||
import history from "~/utils/history";
|
||||
import {
|
||||
documentHistoryPath,
|
||||
matchDocumentHistory,
|
||||
urlify,
|
||||
} from "~/utils/routeHelpers";
|
||||
|
||||
export const restoreRevision = createAction({
|
||||
@@ -76,105 +73,37 @@ export const deleteRevision = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const copyLinkToRevision = (revisionId: string) =>
|
||||
createAction({
|
||||
name: ({ t }) => t("Copy link"),
|
||||
analyticsName: "Copy link to revision",
|
||||
icon: <LinkIcon />,
|
||||
section: RevisionSection,
|
||||
perform: async ({ activeDocumentId, t }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
export const copyLinkToRevision = createAction({
|
||||
name: ({ t }) => t("Copy link"),
|
||||
analyticsName: "Copy link to revision",
|
||||
icon: <LinkIcon />,
|
||||
section: RevisionSection,
|
||||
perform: async ({ activeDocumentId, t }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
const match = matchPath<{ revisionId: string }>(location.pathname, {
|
||||
path: matchDocumentHistory,
|
||||
});
|
||||
const revisionId = match?.params.revisionId;
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = urlify(documentHistoryPath(document, revisionId));
|
||||
const url = `${window.location.origin}${documentHistoryPath(
|
||||
document,
|
||||
revisionId
|
||||
)}`;
|
||||
|
||||
copy(url, {
|
||||
format: "text/plain",
|
||||
onCopy: () => {
|
||||
toast.message(t("Link copied"));
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadRevisionAsHTML = (revisionId: string) =>
|
||||
createAction({
|
||||
name: ({ t }) => t("HTML"),
|
||||
analyticsName: "Download revision as HTML",
|
||||
section: RevisionSection,
|
||||
keywords: "html export",
|
||||
icon: <DownloadIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId }) =>
|
||||
!!activeDocumentId &&
|
||||
stores.policies.abilities(activeDocumentId).download,
|
||||
perform: async () => {
|
||||
const revision = stores.revisions.get(revisionId);
|
||||
await revision?.download(ExportContentType.Html);
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadRevisionAsPDF = (revisionId: string) =>
|
||||
createAction({
|
||||
name: ({ t }) => t("PDF"),
|
||||
analyticsName: "Download revision as PDF",
|
||||
section: RevisionSection,
|
||||
keywords: "export",
|
||||
icon: <DownloadIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId }) =>
|
||||
!!(
|
||||
activeDocumentId &&
|
||||
stores.policies.abilities(activeDocumentId).download &&
|
||||
env.PDF_EXPORT_ENABLED
|
||||
),
|
||||
perform: ({ t }) => {
|
||||
const id = toast.loading(`${t("Exporting")}…`);
|
||||
const revision = stores.revisions.get(revisionId);
|
||||
return revision
|
||||
?.download(ExportContentType.Pdf)
|
||||
.finally(() => id && toast.dismiss(id));
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadRevisionAsMarkdown = (revisionId: string) =>
|
||||
createAction({
|
||||
name: ({ t }) => t("Markdown"),
|
||||
analyticsName: "Download revision as Markdown",
|
||||
section: RevisionSection,
|
||||
keywords: "md markdown export",
|
||||
icon: <DownloadIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId }) =>
|
||||
!!activeDocumentId &&
|
||||
stores.policies.abilities(activeDocumentId).download,
|
||||
perform: async () => {
|
||||
const revision = stores.revisions.get(revisionId);
|
||||
await revision?.download(ExportContentType.Markdown);
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadRevision = (revisionId: string) =>
|
||||
createActionWithChildren({
|
||||
name: ({ t, isMenu }) => (isMenu ? t("Download") : t("Download revision")),
|
||||
analyticsName: "Download revision",
|
||||
section: RevisionSection,
|
||||
icon: <DownloadIcon />,
|
||||
keywords: "export",
|
||||
visible: ({ activeDocumentId }) =>
|
||||
!!activeDocumentId &&
|
||||
stores.policies.abilities(activeDocumentId).download,
|
||||
children: [
|
||||
downloadRevisionAsHTML(revisionId),
|
||||
downloadRevisionAsPDF(revisionId),
|
||||
downloadRevisionAsMarkdown(revisionId),
|
||||
],
|
||||
});
|
||||
copy(url, {
|
||||
format: "text/plain",
|
||||
onCopy: () => {
|
||||
toast.message(t("Link copied"));
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const rootRevisionActions = [];
|
||||
|
||||
@@ -25,21 +25,6 @@ export const changeToLightTheme = createAction({
|
||||
perform: ({ stores }) => stores.ui.setTheme(Theme.Light),
|
||||
});
|
||||
|
||||
export const toggleTheme = createAction({
|
||||
name: ({ t }) => t("Toggle theme"),
|
||||
analyticsName: "Change theme",
|
||||
iconInContextMenu: false,
|
||||
icon: ({ stores }) =>
|
||||
stores.ui.resolvedTheme === "light" ? <MoonIcon /> : <SunIcon />,
|
||||
keywords: "theme light day",
|
||||
section: SettingsSection,
|
||||
shortcut: ["Meta+Shift+l"],
|
||||
perform: ({ stores }) =>
|
||||
stores.ui.setTheme(
|
||||
stores.ui.resolvedTheme === "light" ? Theme.Dark : Theme.Light
|
||||
),
|
||||
});
|
||||
|
||||
export const changeToSystemTheme = createAction({
|
||||
name: ({ t }) => t("System"),
|
||||
analyticsName: "Change to system theme",
|
||||
@@ -62,4 +47,4 @@ export const changeTheme = createActionWithChildren({
|
||||
children: [changeToLightTheme, changeToDarkTheme, changeToSystemTheme],
|
||||
});
|
||||
|
||||
export const rootSettingsActions = [changeTheme, toggleTheme];
|
||||
export const rootSettingsActions = [changeTheme];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import type Share from "~/models/Share";
|
||||
import Share from "~/models/Share";
|
||||
import { createAction, createInternalLinkAction } from "..";
|
||||
import { ArrowIcon, CopyIcon, TrashIcon } from "outline-icons";
|
||||
import { ShareSection } from "../sections";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ArrowIcon, PlusIcon } from "outline-icons";
|
||||
import styled from "styled-components";
|
||||
import { stringToColor } from "@shared/utils/color";
|
||||
import type RootStore from "~/stores/RootStore";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import { LoginDialog } from "~/scenes/Login/components/LoginDialog";
|
||||
import TeamNew from "~/scenes/TeamNew";
|
||||
import TeamLogo from "~/components/TeamLogo";
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
createActionWithChildren,
|
||||
createExternalLinkAction,
|
||||
} from "~/actions";
|
||||
import type { ActionContext, ExternalLinkAction } from "~/types";
|
||||
import { ActionContext, ExternalLinkAction } from "~/types";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import { TeamSection } from "../sections";
|
||||
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import {
|
||||
CaseSensitiveIcon,
|
||||
CollectionIcon,
|
||||
CopyIcon,
|
||||
MoveIcon,
|
||||
NewDocumentIcon,
|
||||
PlusIcon,
|
||||
PrintIcon,
|
||||
TrashIcon,
|
||||
} from "outline-icons";
|
||||
import { Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import TemplateMove from "~/components/DocumentExplorer/TemplateMove";
|
||||
import {
|
||||
createAction,
|
||||
createActionWithChildren,
|
||||
createInternalLinkAction,
|
||||
} from "~/actions";
|
||||
import history from "~/utils/history";
|
||||
import {
|
||||
newDocumentPath,
|
||||
newTemplatePath,
|
||||
settingsPath,
|
||||
urlify,
|
||||
} from "~/utils/routeHelpers";
|
||||
import { ActiveTemplateSection, TemplateSection } from "../sections";
|
||||
import Template from "~/models/Template";
|
||||
import { AvatarSize } from "~/components/Avatar";
|
||||
import TeamLogo from "~/components/TeamLogo";
|
||||
|
||||
export const createTemplate = createInternalLinkAction({
|
||||
name: ({ t }) => t("New template"),
|
||||
analyticsName: "New template",
|
||||
section: TemplateSection,
|
||||
icon: <PlusIcon />,
|
||||
keywords: "new create template",
|
||||
visible: ({ currentTeamId, stores }) =>
|
||||
!!stores.policies.abilities(currentTeamId!).createTemplate,
|
||||
to: newTemplatePath(),
|
||||
});
|
||||
|
||||
export const deleteTemplate = createAction({
|
||||
name: ({ t }) => `${t("Delete")}…`,
|
||||
analyticsName: "Delete template",
|
||||
section: ActiveTemplateSection,
|
||||
icon: <TrashIcon />,
|
||||
dangerous: true,
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Template).some((policy) => policy.abilities.delete),
|
||||
perform: ({ getActiveModel, stores, t }) => {
|
||||
const template = getActiveModel(Template);
|
||||
if (!template) {
|
||||
return;
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Delete {{ documentName }}", {
|
||||
documentName: t("template"),
|
||||
}),
|
||||
content: (
|
||||
<ConfirmationDialog
|
||||
onSubmit={async () => {
|
||||
await template.delete();
|
||||
history.push(settingsPath("templates"));
|
||||
toast.success(t("Template deleted"));
|
||||
}}
|
||||
savingText={`${t("Deleting")}…`}
|
||||
danger
|
||||
>
|
||||
<Trans
|
||||
defaults="Are you sure about that? Deleting the <em>{{ templateName }}</em> template is permanent."
|
||||
values={{
|
||||
templateName: template.titleWithDefault,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</ConfirmationDialog>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const moveTemplateToWorkspace = createAction({
|
||||
name: ({ t }) => t("Move to workspace"),
|
||||
analyticsName: "Move template to workspace",
|
||||
section: ActiveTemplateSection,
|
||||
icon: ({ stores }) => {
|
||||
const { team } = stores.auth;
|
||||
return <TeamLogo model={team} size={AvatarSize.Small} />;
|
||||
},
|
||||
visible: ({ getActiveModel }) => {
|
||||
const template = getActiveModel(Template);
|
||||
return !!template?.collectionId;
|
||||
},
|
||||
perform: async ({ getActiveModel, stores, t }) => {
|
||||
const template = getActiveModel(Template);
|
||||
if (!template) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await template.save({ collectionId: null });
|
||||
toast.success(t("Template moved"));
|
||||
stores.dialogs.closeAllModals();
|
||||
} catch (_err) {
|
||||
toast.error(t("Couldn't move the template, try again?"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const moveTemplateToCollection = createAction({
|
||||
name: ({ t }) => t("Move to collection"),
|
||||
analyticsName: "Move template to collection",
|
||||
section: ActiveTemplateSection,
|
||||
icon: <CollectionIcon />,
|
||||
perform: ({ getActiveModel, stores, t }) => {
|
||||
const template = getActiveModel(Template);
|
||||
if (!template) {
|
||||
return;
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Move template"),
|
||||
content: <TemplateMove template={template} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const moveTemplate = createActionWithChildren({
|
||||
name: ({ t }) => t("Move"),
|
||||
analyticsName: "Move template",
|
||||
section: ActiveTemplateSection,
|
||||
icon: <MoveIcon />,
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Template).some((policy) => policy.abilities.move),
|
||||
children: [moveTemplateToWorkspace, moveTemplateToCollection],
|
||||
});
|
||||
|
||||
export const createDocumentFromTemplate = createInternalLinkAction({
|
||||
name: ({ t }) => t("New document"),
|
||||
analyticsName: "New document from template",
|
||||
section: ActiveTemplateSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "create",
|
||||
visible: ({ currentTeamId, getActiveModel, stores }) => {
|
||||
const template = getActiveModel(Template);
|
||||
if (!template || !currentTeamId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (template.collectionId) {
|
||||
return !!stores.policies.abilities(template.collectionId).createDocument;
|
||||
}
|
||||
return !!stores.policies.abilities(currentTeamId).createDocument;
|
||||
},
|
||||
to: ({ getActiveModel, activeCollectionId, sidebarContext }) => {
|
||||
const template = getActiveModel(Template);
|
||||
if (!template) {
|
||||
return "";
|
||||
}
|
||||
const collectionId = template?.collectionId ?? activeCollectionId;
|
||||
|
||||
const [pathname, search] = newDocumentPath(collectionId, {
|
||||
templateId: template.id,
|
||||
}).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const copyTemplateLink = createAction({
|
||||
name: ({ t }) => t("Copy link"),
|
||||
analyticsName: "Copy template link",
|
||||
section: ActiveTemplateSection,
|
||||
icon: <CopyIcon />,
|
||||
iconInContextMenu: false,
|
||||
perform: ({ getActiveModel, t }) => {
|
||||
const template = getActiveModel(Template);
|
||||
if (template) {
|
||||
copy(urlify(template.path));
|
||||
toast.success(t("Link copied to clipboard"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const copyTemplateAsPlainText = createAction({
|
||||
name: ({ t }) => t("Copy as text"),
|
||||
analyticsName: "Copy template as text",
|
||||
section: ActiveTemplateSection,
|
||||
icon: <CaseSensitiveIcon />,
|
||||
iconInContextMenu: false,
|
||||
perform: async ({ getActiveModel, t }) => {
|
||||
const template = getActiveModel(Template);
|
||||
if (template) {
|
||||
const { ProsemirrorHelper } =
|
||||
await import("~/models/helpers/ProsemirrorHelper");
|
||||
copy(ProsemirrorHelper.toPlainText(template));
|
||||
toast.success(t("Text copied to clipboard"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const copyTemplate = createActionWithChildren({
|
||||
name: ({ t }) => t("Copy"),
|
||||
analyticsName: "Copy template",
|
||||
section: ActiveTemplateSection,
|
||||
icon: <CopyIcon />,
|
||||
keywords: "clipboard",
|
||||
children: [copyTemplateLink, copyTemplateAsPlainText],
|
||||
});
|
||||
|
||||
export const printTemplate = createAction({
|
||||
name: ({ t, isMenu }) => (isMenu ? t("Print") : t("Print template")),
|
||||
analyticsName: "Print template",
|
||||
section: ActiveTemplateSection,
|
||||
icon: <PrintIcon />,
|
||||
visible: ({ getActiveModel }) => !!getActiveModel(Template) && !!window.print,
|
||||
perform: () => {
|
||||
setTimeout(window.print, 0);
|
||||
},
|
||||
});
|
||||
|
||||
export const rootTemplateActions = [moveTemplate, createDocumentFromTemplate];
|
||||
@@ -1,8 +1,8 @@
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import type { UserRole } from "@shared/types";
|
||||
import { UserRole } from "@shared/types";
|
||||
import { UserRoleHelper } from "@shared/utils/UserRoleHelper";
|
||||
import stores from "~/stores";
|
||||
import type User from "~/models/User";
|
||||
import User from "~/models/User";
|
||||
import Invite from "~/scenes/Invite";
|
||||
import {
|
||||
UserChangeRoleDialog,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { LocationDescriptor } from "history";
|
||||
import { LocationDescriptor } from "history";
|
||||
import { toast } from "sonner";
|
||||
import type { Optional } from "utility-types";
|
||||
import { Optional } from "utility-types";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type {
|
||||
import {
|
||||
ActionContext,
|
||||
Action,
|
||||
ActionGroup,
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
} from "~/types";
|
||||
import Analytics from "~/utils/Analytics";
|
||||
import history from "~/utils/history";
|
||||
import type { Action as KbarAction } from "kbar";
|
||||
import { Action as KbarAction } from "kbar";
|
||||
|
||||
export function resolve<T>(value: any, context: ActionContext): T {
|
||||
return typeof value === "function" ? value(context) : value;
|
||||
|
||||
+1
-10
@@ -1,4 +1,4 @@
|
||||
import type { ActionContext } from "~/types";
|
||||
import { ActionContext } from "~/types";
|
||||
|
||||
export const CollectionSection = ({ t }: ActionContext) => t("Collection");
|
||||
|
||||
@@ -24,15 +24,6 @@ export const ActiveDocumentSection = ({ t, stores }: ActionContext) => {
|
||||
|
||||
ActiveDocumentSection.priority = 0.9;
|
||||
|
||||
export const TemplateSection = ({ t }: ActionContext) => t("Template");
|
||||
|
||||
export const ActiveTemplateSection = ({ t, stores }: ActionContext) => {
|
||||
const activeTemplate = stores.templates.active;
|
||||
return `${t("Template")} · ${activeTemplate?.titleWithDefault}`;
|
||||
};
|
||||
|
||||
ActiveTemplateSection.priority = 0.9;
|
||||
|
||||
export const RecentSection = ({ t }: ActionContext) => t("Recently viewed");
|
||||
|
||||
RecentSection.priority = 1;
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
/* oxlint-disable react/prop-types */
|
||||
import * as React from "react";
|
||||
import type { Props as TooltipProps } from "~/components/Tooltip";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import Tooltip, { Props as TooltipProps } from "~/components/Tooltip";
|
||||
import { performAction, resolve } from "~/actions";
|
||||
import useIsMounted from "~/hooks/useIsMounted";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import type { ActionVariant, ActionWithChildren } from "~/types";
|
||||
import { ActionVariant, ActionWithChildren } from "~/types";
|
||||
|
||||
export type Props = React.HTMLAttributes<HTMLButtonElement> & {
|
||||
/** Show the button in a disabled state */
|
||||
@@ -22,7 +21,7 @@ export type Props = React.HTMLAttributes<HTMLButtonElement> & {
|
||||
* Button that can be used to trigger an action definition.
|
||||
*/
|
||||
const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
|
||||
function ActionButton_(
|
||||
function _ActionButton(
|
||||
{ action, tooltip, hideOnActionDisabled, ...rest }: Props,
|
||||
ref: React.Ref<HTMLButtonElement>
|
||||
) {
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
/* global ga */
|
||||
import escape from "lodash/escape";
|
||||
import * as React from "react";
|
||||
import type { PublicEnv } from "@shared/types";
|
||||
import { IntegrationService } from "@shared/types";
|
||||
import { IntegrationService, PublicEnv } from "@shared/types";
|
||||
import env from "~/env";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Switch, Route, Redirect } from "react-router-dom";
|
||||
import {
|
||||
Switch,
|
||||
Route,
|
||||
useLocation,
|
||||
matchPath,
|
||||
Redirect,
|
||||
} from "react-router-dom";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
|
||||
import Layout from "~/components/Layout";
|
||||
import RegisterKeyDown from "~/components/RegisterKeyDown";
|
||||
import { RightSidebarProvider } from "~/components/RightSidebarContext";
|
||||
import Sidebar from "~/components/Sidebar";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
|
||||
@@ -16,13 +23,20 @@ import {
|
||||
searchPath,
|
||||
newDocumentPath,
|
||||
settingsPath,
|
||||
matchDocumentHistory,
|
||||
matchDocumentSlug as slug,
|
||||
} from "~/utils/routeHelpers";
|
||||
import { DocumentContextProvider } from "./DocumentContext";
|
||||
import Fade from "./Fade";
|
||||
import NotificationBadge from "./NotificationBadge";
|
||||
import { PortalContext } from "./Portal";
|
||||
import CommandBar from "./CommandBar";
|
||||
|
||||
const DocumentComments = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/Comments")
|
||||
);
|
||||
const DocumentHistory = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/History")
|
||||
);
|
||||
const SettingsSidebar = lazyWithRetry(
|
||||
() => import("~/components/Sidebar/Settings")
|
||||
);
|
||||
@@ -33,7 +47,9 @@ type Props = {
|
||||
|
||||
const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
const { ui, auth } = useStores();
|
||||
const location = useLocation();
|
||||
const layoutRef = React.useRef<HTMLDivElement>(null);
|
||||
const can = usePolicy(ui.activeDocumentId);
|
||||
const canCollection = usePolicy(ui.activeCollectionId);
|
||||
const team = useCurrentTeam();
|
||||
const [spendPostLoginPath] = usePostLoginPath();
|
||||
@@ -75,20 +91,49 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
</Fade>
|
||||
);
|
||||
|
||||
const showHistory =
|
||||
!!matchPath(location.pathname, {
|
||||
path: matchDocumentHistory,
|
||||
}) && can.listRevisions;
|
||||
const showComments =
|
||||
!showHistory &&
|
||||
can.comment &&
|
||||
ui.activeDocumentId &&
|
||||
ui.commentsExpanded &&
|
||||
!!team.getPreference(TeamPreference.Commenting);
|
||||
|
||||
const sidebarRight = (
|
||||
<AnimatePresence
|
||||
initial={false}
|
||||
key={ui.activeDocumentId ? "active" : "inactive"}
|
||||
>
|
||||
{(showHistory || showComments) && (
|
||||
<Route path={`/doc/${slug}`}>
|
||||
<React.Suspense fallback={null}>
|
||||
{showHistory && <DocumentHistory />}
|
||||
{showComments && <DocumentComments />}
|
||||
</React.Suspense>
|
||||
</Route>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
||||
return (
|
||||
<DocumentContextProvider>
|
||||
<RightSidebarProvider>
|
||||
<PortalContext.Provider value={layoutRef.current}>
|
||||
<Layout title={team.name} sidebar={sidebar} ref={layoutRef}>
|
||||
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
|
||||
<RegisterKeyDown trigger="t" handler={goToSearch} />
|
||||
<RegisterKeyDown trigger="/" handler={goToSearch} />
|
||||
{children}
|
||||
<CommandBar />
|
||||
<NotificationBadge />
|
||||
</Layout>
|
||||
</PortalContext.Provider>
|
||||
</RightSidebarProvider>
|
||||
<PortalContext.Provider value={layoutRef.current}>
|
||||
<Layout
|
||||
title={team.name}
|
||||
sidebar={sidebar}
|
||||
sidebarRight={sidebarRight}
|
||||
ref={layoutRef}
|
||||
>
|
||||
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
|
||||
<RegisterKeyDown trigger="t" handler={goToSearch} />
|
||||
<RegisterKeyDown trigger="/" handler={goToSearch} />
|
||||
{children}
|
||||
<CommandBar />
|
||||
</Layout>
|
||||
</PortalContext.Provider>
|
||||
</DocumentContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
@@ -110,4 +109,4 @@ const Image = styled.img<{ size: number }>`
|
||||
height: ${(props) => props.size}px;
|
||||
`;
|
||||
|
||||
export default observer(Avatar);
|
||||
export default Avatar;
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import type User from "~/models/User";
|
||||
import User from "~/models/User";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import Avatar, { AvatarSize } from "./Avatar";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { GroupIcon } from "outline-icons";
|
||||
import { useTheme } from "styled-components";
|
||||
import Squircle from "@shared/components/Squircle";
|
||||
import type Group from "~/models/Group";
|
||||
import Group from "~/models/Group";
|
||||
import { AvatarSize } from "../Avatar/Avatar";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { IAvatar } from "./Avatar";
|
||||
import Avatar, { AvatarSize, AvatarVariant } from "./Avatar";
|
||||
import Avatar, { IAvatar, AvatarSize, AvatarVariant } from "./Avatar";
|
||||
import AvatarWithPresence from "./AvatarWithPresence";
|
||||
import { GroupAvatar } from "./GroupAvatar";
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import { transparentize } from "polished";
|
||||
import styled from "styled-components";
|
||||
|
||||
const Badge = styled.span<{ yellow?: boolean; primary?: boolean }>`
|
||||
padding: 1.5px 5.5px;
|
||||
margin: 0 2px;
|
||||
margin-left: 10px;
|
||||
padding: 1px 5px 2px;
|
||||
background-color: ${({ yellow, primary, theme }) =>
|
||||
yellow ? theme.yellow : primary ? theme.accent : "transparent"};
|
||||
color: ${({ primary, yellow, theme }) =>
|
||||
@@ -17,11 +17,10 @@ const Badge = styled.span<{ yellow?: boolean; primary?: boolean }>`
|
||||
primary || yellow
|
||||
? "transparent"
|
||||
: transparentize(0.4, theme.textTertiary)};
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
export default Badge;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { GoToIcon } from "outline-icons";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
@@ -7,7 +6,7 @@ import { s, ellipsis } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
import BreadcrumbMenu from "~/menus/BreadcrumbMenu";
|
||||
import { undraggableOnDesktop } from "~/styles";
|
||||
import type { InternalLinkAction, MenuInternalLink } from "~/types";
|
||||
import { InternalLinkAction, MenuInternalLink } from "~/types";
|
||||
import { actionToMenuItem } from "~/actions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { useComputed } from "~/hooks/useComputed";
|
||||
@@ -122,4 +121,4 @@ const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(React.forwardRef<HTMLDivElement, Props>(Breadcrumb));
|
||||
export default React.forwardRef<HTMLDivElement, Props>(Breadcrumb);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import {
|
||||
MenuHeader,
|
||||
MenuSeparator,
|
||||
} from "~/components/primitives/components/Menu";
|
||||
import { Portal } from "~/components/Portal";
|
||||
import { toMobileMenuItems } from "~/components/Menu/transformer";
|
||||
import { actionToMenuItem } from "~/actions";
|
||||
import { useBulkDocumentMenuAction } from "~/hooks/useBulkDocumentMenuAction";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { ActionVariant } from "~/types";
|
||||
import NudeButton from "./NudeButton";
|
||||
import { CrossIcon } from "outline-icons";
|
||||
|
||||
function BulkSelectionToolbar() {
|
||||
const { t } = useTranslation();
|
||||
const { documents, ui } = useStores();
|
||||
const selectedCount = documents.selectedDocumentIds.length;
|
||||
const selectedDocuments = documents.selectedDocuments;
|
||||
const sidebarWidth = ui.sidebarWidth;
|
||||
|
||||
const handleClearSelection = React.useCallback(() => {
|
||||
documents.clearSelection();
|
||||
}, [documents]);
|
||||
|
||||
const rootAction = useBulkDocumentMenuAction({
|
||||
documents: selectedDocuments,
|
||||
});
|
||||
|
||||
const actionContext = useActionContext({
|
||||
isMenu: true,
|
||||
});
|
||||
|
||||
const menuItems = React.useMemo(() => {
|
||||
if (!rootAction.children || selectedCount === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (rootAction.children as ActionVariant[]).map((childAction) =>
|
||||
actionToMenuItem(childAction, actionContext)
|
||||
);
|
||||
}, [rootAction.children, selectedCount, actionContext]);
|
||||
|
||||
const content = toMobileMenuItems(menuItems, handleClearSelection, () => {});
|
||||
|
||||
if (selectedCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Wrapper $sidebarWidth={sidebarWidth}>
|
||||
<MenuContainer>
|
||||
<Header>
|
||||
<MenuHeader>
|
||||
{t("{{ count }} selected", { count: selectedCount })}
|
||||
</MenuHeader>
|
||||
<ClearButton
|
||||
onClick={handleClearSelection}
|
||||
tooltip={{
|
||||
content: t("Clear selection"),
|
||||
}}
|
||||
>
|
||||
<CrossIcon size={18} />
|
||||
</ClearButton>
|
||||
</Header>
|
||||
<MenuSeparator />
|
||||
{content}
|
||||
</MenuContainer>
|
||||
</Wrapper>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
const ClearButton = styled(NudeButton)`
|
||||
&:hover {
|
||||
color: ${s("text")};
|
||||
background: ${s("sidebarControlHoverBackground")};
|
||||
}
|
||||
`;
|
||||
|
||||
const Header = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const Wrapper = styled.div<{ $sidebarWidth: number }>`
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: ${(props) => props.$sidebarWidth + 16}px;
|
||||
z-index: ${depths.menu};
|
||||
`;
|
||||
|
||||
const MenuContainer = styled.div`
|
||||
min-width: 180px;
|
||||
background: ${s("menuBackground")};
|
||||
box-shadow: ${s("menuShadow")};
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
`;
|
||||
|
||||
export default observer(BulkSelectionToolbar);
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { LocationDescriptor } from "history";
|
||||
import { LocationDescriptor } from "history";
|
||||
import { DisclosureIcon } from "outline-icons";
|
||||
import { darken, lighten, transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import type { Props as ActionButtonProps } from "~/components/ActionButton";
|
||||
import ActionButton from "~/components/ActionButton";
|
||||
import ActionButton, {
|
||||
Props as ActionButtonProps,
|
||||
} from "~/components/ActionButton";
|
||||
import { undraggableOnDesktop } from "~/styles";
|
||||
|
||||
type RealProps = {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -23,9 +22,12 @@ const Container = styled.div<Props>`
|
||||
type ContentProps = { $maxWidth?: string };
|
||||
|
||||
const Content = styled.div<ContentProps>`
|
||||
max-width: ${(props: ContentProps) =>
|
||||
props.$maxWidth ?? EditorStyleHelper.documentWidth};
|
||||
max-width: ${(props) => props.$maxWidth ?? "46em"};
|
||||
margin: 0 auto;
|
||||
|
||||
${breakpoint("desktopLarge")`
|
||||
max-width: ${(props: ContentProps) => props.$maxWidth ?? "52em"};
|
||||
`};
|
||||
`;
|
||||
|
||||
const CenteredContent: React.FC<Props> = ({
|
||||
|
||||
@@ -5,7 +5,7 @@ import uniq from "lodash/uniq";
|
||||
import { observer } from "mobx-react";
|
||||
import { useState, useMemo, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type Document from "~/models/Document";
|
||||
import Document from "~/models/Document";
|
||||
import { AvatarSize, AvatarWithPresence } from "~/components/Avatar";
|
||||
import DocumentViews from "~/components/DocumentViews";
|
||||
import Facepile from "~/components/Facepile";
|
||||
@@ -125,8 +125,8 @@ function Collaborators(props: Props) {
|
||||
|
||||
return (
|
||||
<AvatarWithPresence
|
||||
key={collaborator.id}
|
||||
{...rest}
|
||||
key={collaborator.id}
|
||||
user={collaborator}
|
||||
isPresent={isPresent}
|
||||
isEditing={isEditing}
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import * as RadixCollapsible from "@radix-ui/react-collapsible";
|
||||
import { ExpandedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
interface CollapsibleProps {
|
||||
/** The label displayed on the trigger button. */
|
||||
label: React.ReactNode;
|
||||
/** The content to show/hide inside the collapsible panel. */
|
||||
children: React.ReactNode;
|
||||
/** Whether the collapsible is open by default. */
|
||||
defaultOpen?: boolean;
|
||||
/** Controlled open state. */
|
||||
open?: boolean;
|
||||
/** Callback fired when the open state changes. */
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
/** Additional class name for the root element. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An accessible collapsible section built on Radix UI Collapsible.
|
||||
* Renders a trigger button with a disclosure chevron and animated content panel.
|
||||
*
|
||||
* @param props - component props.
|
||||
* @returns the collapsible component.
|
||||
*/
|
||||
export function Collapsible({
|
||||
label,
|
||||
children,
|
||||
defaultOpen = false,
|
||||
open,
|
||||
onOpenChange,
|
||||
className,
|
||||
}: CollapsibleProps) {
|
||||
return (
|
||||
<RadixCollapsible.Root
|
||||
defaultOpen={defaultOpen}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
className={className}
|
||||
>
|
||||
<StyledTrigger>
|
||||
<StyledExpandedIcon aria-hidden="true" />
|
||||
{label}
|
||||
</StyledTrigger>
|
||||
<StyledContent>{children}</StyledContent>
|
||||
</RadixCollapsible.Root>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledExpandedIcon = styled(ExpandedIcon)`
|
||||
flex-shrink: 0;
|
||||
transition: transform 150ms ease-out;
|
||||
margin-left: -4px;
|
||||
`;
|
||||
|
||||
const StyledTrigger = styled(RadixCollapsible.Trigger)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0 0 8px 0;
|
||||
cursor: var(--pointer);
|
||||
color: ${s("textTertiary")};
|
||||
font-size: 14pxte
|
||||
|
||||
&:hover {
|
||||
color: ${s("textSecondary")};
|
||||
}
|
||||
|
||||
&[data-state="closed"] {
|
||||
${StyledExpandedIcon} {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledContent = styled(RadixCollapsible.Content)`
|
||||
overflow: hidden;
|
||||
|
||||
&[data-state="open"] {
|
||||
animation: slideDown 200ms ease-out;
|
||||
}
|
||||
|
||||
&[data-state="closed"] {
|
||||
animation: slideUp 200ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-collapsible-content-height);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
height: var(--radix-collapsible-content-height);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -2,8 +2,7 @@ import { observer } from "mobx-react";
|
||||
import { useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import type { FormData } from "./CollectionForm";
|
||||
import { CollectionForm } from "./CollectionForm";
|
||||
import { CollectionForm, FormData } from "./CollectionForm";
|
||||
|
||||
type Props = {
|
||||
collectionId: string;
|
||||
|
||||
@@ -6,14 +6,13 @@ import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { randomElement } from "@shared/random";
|
||||
import type { CollectionPermission } from "@shared/types";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import { CollectionPermission, TeamPreference } from "@shared/types";
|
||||
import { IconLibrary } from "@shared/utils/IconLibrary";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import { CollectionValidation } from "@shared/validations";
|
||||
import type Collection from "~/models/Collection";
|
||||
import Collection from "~/models/Collection";
|
||||
import Button from "~/components/Button";
|
||||
import { Collapsible } from "~/components/Collapsible";
|
||||
import Flex from "~/components/Flex";
|
||||
import Input from "~/components/Input";
|
||||
import { InputSelectPermission } from "~/components/InputSelectPermission";
|
||||
import { createLazyComponent } from "~/components/LazyLoad";
|
||||
@@ -23,7 +22,6 @@ import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { EmptySelectValue } from "~/types";
|
||||
import { HStack } from "../primitives/HStack";
|
||||
|
||||
const IconPicker = createLazyComponent(() => import("~/components/IconPicker"));
|
||||
|
||||
@@ -142,10 +140,10 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
Collections are used to group documents and choose permissions
|
||||
</Trans>
|
||||
</Text>
|
||||
<HStack>
|
||||
<Flex gap={8}>
|
||||
<Input
|
||||
type="text"
|
||||
label={t("Name")}
|
||||
placeholder={t("Name")}
|
||||
{...register("name", {
|
||||
required: true,
|
||||
maxLength: CollectionValidation.maxNameLength,
|
||||
@@ -166,7 +164,7 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
autoFocus
|
||||
flex
|
||||
/>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* Following controls are available in create flow, but moved elsewhere for edit */}
|
||||
{!collection && (
|
||||
@@ -190,47 +188,41 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
/>
|
||||
)}
|
||||
|
||||
{(team.sharing || team.getPreference(TeamPreference.Commenting)) && (
|
||||
<Collapsible label={t("Advanced options")}>
|
||||
{team.sharing && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="sharing"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
id="sharing"
|
||||
label={t("Public document sharing")}
|
||||
note={t(
|
||||
"Allow documents within this collection to be shared publicly on the internet."
|
||||
)}
|
||||
checked={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
{team.sharing && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="sharing"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
id="sharing"
|
||||
label={t("Public document sharing")}
|
||||
note={t(
|
||||
"Allow documents within this collection to be shared publicly on the internet."
|
||||
)}
|
||||
checked={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{team.getPreference(TeamPreference.Commenting) && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="commenting"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
id="commenting"
|
||||
label={t("Commenting")}
|
||||
note={t(
|
||||
"Allow commenting on documents within this collection."
|
||||
)}
|
||||
checked={!!field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Collapsible>
|
||||
/>
|
||||
)}
|
||||
|
||||
<HStack justify="flex-end">
|
||||
{team.getPreference(TeamPreference.Commenting) && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="commenting"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
id="commenting"
|
||||
label={t("Commenting")}
|
||||
note={t("Allow commenting on documents within this collection.")}
|
||||
checked={!!field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Flex justify="flex-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={formState.isSubmitting || !formState.isValid}
|
||||
@@ -243,7 +235,7 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
? `${t("Creating")}…`
|
||||
: t("Create")}
|
||||
</Button>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -4,8 +4,7 @@ import { useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
import type { FormData } from "./CollectionForm";
|
||||
import { CollectionForm } from "./CollectionForm";
|
||||
import { CollectionForm, FormData } from "./CollectionForm";
|
||||
|
||||
type Props = {
|
||||
onSubmit: () => void;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ArchiveIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type Collection from "~/models/Collection";
|
||||
import Collection from "~/models/Collection";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { archivePath, collectionPath } from "~/utils/routeHelpers";
|
||||
import Breadcrumb from "./Breadcrumb";
|
||||
@@ -28,7 +28,7 @@ export const CollectionBreadcrumb: React.FC<Props> = ({ collection }) => {
|
||||
name: collection.name,
|
||||
section: ActiveCollectionSection,
|
||||
icon: <CollectionIcon collection={collection} expanded />,
|
||||
to: collectionPath(collection),
|
||||
to: collectionPath(collection.path),
|
||||
}),
|
||||
],
|
||||
[collection, t]
|
||||
|
||||
@@ -2,7 +2,7 @@ import { observer } from "mobx-react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import type Collection from "~/models/Collection";
|
||||
import Collection from "~/models/Collection";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Text from "~/components/Text";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ActionImpl } from "kbar";
|
||||
import { ActionImpl } from "kbar";
|
||||
import { ArrowIcon, BackIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled, { css, useTheme } from "styled-components";
|
||||
@@ -7,7 +7,6 @@ import { normalizeKeyDisplay } from "@shared/utils/keyboard";
|
||||
import Flex from "~/components/Flex";
|
||||
import Key from "~/components/Key";
|
||||
import Text from "~/components/Text";
|
||||
import { HStack } from "../primitives/HStack";
|
||||
|
||||
type Props = {
|
||||
action: ActionImpl;
|
||||
@@ -36,7 +35,7 @@ function CommandBarItem(
|
||||
|
||||
return (
|
||||
<Item active={active} ref={ref}>
|
||||
<Content>
|
||||
<Content align="center" gap={8}>
|
||||
<Icon>
|
||||
{action.icon ? (
|
||||
// @ts-expect-error no icon on ActionImpl
|
||||
@@ -101,7 +100,7 @@ const Ancestor = styled.span`
|
||||
color: ${s("textSecondary")};
|
||||
`;
|
||||
|
||||
const Content = styled(HStack)`
|
||||
const Content = styled(Flex)`
|
||||
${ellipsis()}
|
||||
flex-shrink: 1;
|
||||
`;
|
||||
|
||||
@@ -11,15 +11,15 @@ import useStores from "~/hooks/useStores";
|
||||
import { newDocumentPath } from "~/utils/routeHelpers";
|
||||
|
||||
const useTemplatesAction = () => {
|
||||
const { templates } = useStores();
|
||||
const { documents } = useStores();
|
||||
|
||||
useEffect(() => {
|
||||
void templates.fetchAll();
|
||||
}, [templates]);
|
||||
void documents.fetchAllTemplates();
|
||||
}, [documents]);
|
||||
|
||||
const actions = useMemo(
|
||||
() =>
|
||||
templates.alphabetical.map((template) =>
|
||||
documents.templatesAlphabetical.map((template) =>
|
||||
createInternalLinkAction({
|
||||
name: template.titleWithDefault,
|
||||
analyticsName: "New document",
|
||||
@@ -66,7 +66,7 @@ const useTemplatesAction = () => {
|
||||
},
|
||||
})
|
||||
),
|
||||
[templates.alphabetical]
|
||||
[documents.templatesAlphabetical]
|
||||
);
|
||||
|
||||
const newFromTemplate = useMemo(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import type Comment from "~/models/Comment";
|
||||
import Comment from "~/models/Comment";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import type { NavigationNode } from "@shared/types";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import { CollectionPermission, NavigationNode } from "@shared/types";
|
||||
import type Collection from "~/models/Collection";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
@@ -31,7 +31,7 @@ export type RefHandle = {
|
||||
* Defines a content editable component with the same interface as a native
|
||||
* HTMLInputElement (or, as close as we can get).
|
||||
*/
|
||||
const ContentEditable = React.forwardRef(function ContentEditable_(
|
||||
const ContentEditable = React.forwardRef(function _ContentEditable(
|
||||
{
|
||||
disabled,
|
||||
onChange,
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import {
|
||||
CollectionIcon as CollectionIconComponent,
|
||||
HomeIcon,
|
||||
PrivateCollectionIcon,
|
||||
} from "outline-icons";
|
||||
import { observer } from "mobx-react";
|
||||
import { getLuminance } from "polished";
|
||||
import { HomeIcon } from "outline-icons";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import type { Option } from "~/components/InputSelect";
|
||||
import { InputSelect } from "~/components/InputSelect";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { InputSelect, Option } from "~/components/InputSelect";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type DefaultCollectionInputSelectProps = {
|
||||
@@ -19,112 +11,74 @@ type DefaultCollectionInputSelectProps = {
|
||||
defaultCollectionId: string | null;
|
||||
};
|
||||
|
||||
const DefaultCollectionInputSelect = observer(
|
||||
({
|
||||
onSelectCollection,
|
||||
defaultCollectionId,
|
||||
}: DefaultCollectionInputSelectProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { collections, ui } = useStores();
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [fetchError, setFetchError] = useState();
|
||||
const DefaultCollectionInputSelect = ({
|
||||
onSelectCollection,
|
||||
defaultCollectionId,
|
||||
}: DefaultCollectionInputSelectProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { collections } = useStores();
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [fetchError, setFetchError] = useState();
|
||||
|
||||
React.useEffect(() => {
|
||||
async function fetchData() {
|
||||
if (!collections.isLoaded && !fetching && !fetchError) {
|
||||
try {
|
||||
setFetching(true);
|
||||
await collections.fetchPage({
|
||||
limit: 100,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t("Collections could not be loaded, please reload the app")
|
||||
);
|
||||
setFetchError(error);
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
React.useEffect(() => {
|
||||
async function fetchData() {
|
||||
if (!collections.isLoaded && !fetching && !fetchError) {
|
||||
try {
|
||||
setFetching(true);
|
||||
await collections.fetchPage({
|
||||
limit: 100,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t("Collections could not be loaded, please reload the app")
|
||||
);
|
||||
setFetchError(error);
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
}
|
||||
void fetchData();
|
||||
}, [fetchError, t, fetching, collections]);
|
||||
|
||||
if (fetching) {
|
||||
return null;
|
||||
}
|
||||
void fetchData();
|
||||
}, [fetchError, t, fetching, collections]);
|
||||
|
||||
const isDark = ui.resolvedTheme === "dark";
|
||||
|
||||
// Eagerly resolve collection icon properties within this observer context
|
||||
// to avoid MobX warnings when Radix Select clones elements for the trigger.
|
||||
const options: Option[] = collections.nonPrivate.reduce(
|
||||
(acc, collection) => {
|
||||
const collectionIcon = collection.icon;
|
||||
const rawColor = collection.color ?? colorPalette[0];
|
||||
|
||||
let icon: React.ReactElement;
|
||||
if (!collectionIcon || collectionIcon === "collection") {
|
||||
const color =
|
||||
isDark && rawColor !== "currentColor"
|
||||
? getLuminance(rawColor) > 0.09
|
||||
? rawColor
|
||||
: "currentColor"
|
||||
: rawColor;
|
||||
const Component = collection.isPrivate
|
||||
? PrivateCollectionIcon
|
||||
: CollectionIconComponent;
|
||||
icon = <Component color={color} />;
|
||||
} else {
|
||||
let color = rawColor;
|
||||
if (color !== "currentColor") {
|
||||
if (isDark) {
|
||||
color = getLuminance(color) > 0.09 ? color : "currentColor";
|
||||
} else {
|
||||
color = getLuminance(color) < 0.9 ? color : "currentColor";
|
||||
}
|
||||
}
|
||||
icon = (
|
||||
<Icon
|
||||
value={collectionIcon}
|
||||
color={color}
|
||||
initial={collection.initial}
|
||||
forceColor
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
const options: Option[] = React.useMemo(
|
||||
() =>
|
||||
collections.nonPrivate.reduce(
|
||||
(acc, collection) => [
|
||||
...acc,
|
||||
{
|
||||
type: "item" as const,
|
||||
type: "item",
|
||||
label: collection.name,
|
||||
value: collection.id,
|
||||
icon,
|
||||
icon: <CollectionIcon collection={collection} />,
|
||||
},
|
||||
];
|
||||
},
|
||||
[
|
||||
{
|
||||
type: "item",
|
||||
label: t("Home"),
|
||||
value: "home",
|
||||
icon: <HomeIcon />,
|
||||
},
|
||||
] satisfies Option[]
|
||||
);
|
||||
],
|
||||
[
|
||||
{
|
||||
type: "item",
|
||||
label: t("Home"),
|
||||
value: "home",
|
||||
icon: <HomeIcon />,
|
||||
},
|
||||
] satisfies Option[]
|
||||
),
|
||||
[collections.nonPrivate, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<InputSelect
|
||||
options={options}
|
||||
value={defaultCollectionId ?? "home"}
|
||||
onChange={onSelectCollection}
|
||||
label={t("Start view")}
|
||||
hideLabel
|
||||
short
|
||||
/>
|
||||
);
|
||||
if (fetching) {
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<InputSelect
|
||||
options={options}
|
||||
value={defaultCollectionId ?? "home"}
|
||||
onChange={onSelectCollection}
|
||||
label={t("Start view")}
|
||||
hideLabel
|
||||
short
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DefaultCollectionInputSelect;
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { ArchiveIcon, GoToIcon, TrashIcon } from "outline-icons";
|
||||
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 type Document from "~/models/Document";
|
||||
import Document from "~/models/Document";
|
||||
import Breadcrumb from "~/components/Breadcrumb";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { archivePath, trashPath } from "~/utils/routeHelpers";
|
||||
import { archivePath, settingsPath, trashPath } from "~/utils/routeHelpers";
|
||||
import { createInternalLinkAction } from "~/actions";
|
||||
import { ActiveDocumentSection } from "~/actions/sections";
|
||||
|
||||
@@ -67,6 +67,13 @@ function DocumentBreadcrumb(
|
||||
visible: document.isArchived,
|
||||
to: archivePath(),
|
||||
}),
|
||||
createInternalLinkAction({
|
||||
name: t("Templates"),
|
||||
section: ActiveDocumentSection,
|
||||
icon: <ShapesIcon />,
|
||||
visible: document.template,
|
||||
to: settingsPath("templates"),
|
||||
}),
|
||||
createInternalLinkAction({
|
||||
name: collection?.name,
|
||||
section: ActiveDocumentSection,
|
||||
|
||||
@@ -13,8 +13,8 @@ import Squircle from "@shared/components/Squircle";
|
||||
import { s, hover, ellipsis } from "@shared/styles";
|
||||
import { IconType } from "@shared/types";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
import type Document from "~/models/Document";
|
||||
import type Pin from "~/models/Pin";
|
||||
import Document from "~/models/Document";
|
||||
import Pin from "~/models/Pin";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Time from "~/components/Time";
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { action, computed, observable } from "mobx";
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
import type { Heading } from "@shared/utils/ProsemirrorHelper";
|
||||
import type Document from "~/models/Document";
|
||||
import type { Editor } from "~/editor";
|
||||
import { createContext, useContext, useMemo, PropsWithChildren } from "react";
|
||||
import { Heading } from "@shared/utils/ProsemirrorHelper";
|
||||
import Document from "~/models/Document";
|
||||
import { Editor } from "~/editor";
|
||||
|
||||
class DocumentContext {
|
||||
/** The current document */
|
||||
|
||||
+42
-39
@@ -3,15 +3,15 @@ import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import type { NavigationNode } from "@shared/types";
|
||||
import type Document from "~/models/Document";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import Document from "~/models/Document";
|
||||
import { FlexContainer, Footer, StyledText } from "~/scenes/DocumentMove";
|
||||
import Button from "~/components/Button";
|
||||
import Switch from "~/components/Switch";
|
||||
import Text from "~/components/Text";
|
||||
import DocumentExplorer from "~/components/DocumentExplorer";
|
||||
import useCollectionTrees from "~/hooks/useCollectionTrees";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { FlexContainer, Footer } from "./Components";
|
||||
import DocumentExplorer from "./DocumentExplorer";
|
||||
import Switch from "./Switch";
|
||||
import Text from "./Text";
|
||||
|
||||
type Props = {
|
||||
/** The original document to duplicate */
|
||||
@@ -24,7 +24,6 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
const { policies } = useStores();
|
||||
const collectionTrees = useCollectionTrees();
|
||||
const [publish, setPublish] = React.useState<boolean>(!!document.publishedAt);
|
||||
const [copying, setCopying] = React.useState<boolean>(false);
|
||||
const [recursive, setRecursive] = React.useState<boolean>(true);
|
||||
const [selectedPath, selectPath] = React.useState<NavigationNode | null>(
|
||||
null
|
||||
@@ -37,8 +36,13 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
: true
|
||||
);
|
||||
|
||||
if (document.isTemplate) {
|
||||
return nodes
|
||||
.filter((node) => node.type === "collection")
|
||||
.map((node) => ({ ...node, children: [] }));
|
||||
}
|
||||
return nodes;
|
||||
}, [policies, collectionTrees]);
|
||||
}, [policies, collectionTrees, document.isTemplate]);
|
||||
|
||||
const copy = async () => {
|
||||
if (!selectedPath) {
|
||||
@@ -47,7 +51,6 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
}
|
||||
|
||||
try {
|
||||
setCopying(true);
|
||||
const result = await document.duplicate({
|
||||
publish,
|
||||
recursive,
|
||||
@@ -62,8 +65,6 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
onSubmit(result);
|
||||
} catch (_err) {
|
||||
toast.error(t("Couldn’t copy the document, try again?"));
|
||||
} finally {
|
||||
setCopying(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -75,32 +76,34 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
onSelect={selectPath}
|
||||
defaultValue={document.parentDocumentId || document.collectionId || ""}
|
||||
/>
|
||||
<OptionsContainer>
|
||||
{document.collectionId && (
|
||||
<Text size="small">
|
||||
<Switch
|
||||
name="publish"
|
||||
label={t("Publish")}
|
||||
labelPosition="right"
|
||||
checked={publish}
|
||||
onChange={setPublish}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
{document.publishedAt && document.childDocuments.length > 0 && (
|
||||
<Text size="small">
|
||||
<Switch
|
||||
name="recursive"
|
||||
label={t("Include nested documents")}
|
||||
labelPosition="right"
|
||||
checked={recursive}
|
||||
onChange={setRecursive}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
</OptionsContainer>
|
||||
{!document.isTemplate && (
|
||||
<OptionsContainer>
|
||||
{document.collectionId && (
|
||||
<Text size="small">
|
||||
<Switch
|
||||
name="publish"
|
||||
label={t("Publish")}
|
||||
labelPosition="right"
|
||||
checked={publish}
|
||||
onChange={setPublish}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
{document.publishedAt && document.childDocuments.length > 0 && (
|
||||
<Text size="small">
|
||||
<Switch
|
||||
name="recursive"
|
||||
label={t("Include nested documents")}
|
||||
labelPosition="right"
|
||||
checked={recursive}
|
||||
onChange={setRecursive}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
</OptionsContainer>
|
||||
)}
|
||||
<Footer justify="space-between" align="center" gap={8}>
|
||||
<Text ellipsis type="secondary">
|
||||
<StyledText type="secondary">
|
||||
{selectedPath ? (
|
||||
<Trans
|
||||
defaults="Copy to <em>{{ location }}</em>"
|
||||
@@ -110,9 +113,9 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
) : (
|
||||
t("Select a location to copy")
|
||||
)}
|
||||
</Text>
|
||||
<Button disabled={!selectedPath || copying} onClick={copy}>
|
||||
{copying ? `${t("Copying")}…` : t("Copy")}
|
||||
</StyledText>
|
||||
<Button disabled={!selectedPath} onClick={copy}>
|
||||
{t("Copy")}
|
||||
</Button>
|
||||
</Footer>
|
||||
</FlexContainer>
|
||||
@@ -1,185 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { ExportContentType, NotificationEventType } from "@shared/types";
|
||||
import type Document from "~/models/Document";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
export const DocumentDownload = observer(({ document, onSubmit }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { ui } = useStores();
|
||||
const user = useCurrentUser();
|
||||
const hasChildDocuments = !!document.childDocuments.length;
|
||||
|
||||
const [contentType, setContentType] = useState<ExportContentType>(
|
||||
ExportContentType.Markdown
|
||||
);
|
||||
const [includeChildDocuments, setIncludeChildDocuments] =
|
||||
useState<boolean>(hasChildDocuments);
|
||||
|
||||
const handleContentTypeChange = useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setContentType(ev.target.value as ExportContentType);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleIncludeChildDocumentsChange = useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setIncludeChildDocuments(ev.target.checked);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const response = await document.download({
|
||||
contentType,
|
||||
includeChildDocuments,
|
||||
});
|
||||
|
||||
if (includeChildDocuments && response?.data?.fileOperation) {
|
||||
const fileOperationId = response.data.fileOperation.id;
|
||||
const toastId = `export-${fileOperationId}`;
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
toast.success(t("Export started"), {
|
||||
id: toastId,
|
||||
description: t("A link to your file will be sent through email soon"),
|
||||
duration: 3000,
|
||||
});
|
||||
ui.exportToasts.delete(fileOperationId);
|
||||
}, 6000);
|
||||
|
||||
ui.registerExportToast(fileOperationId, toastId, timeoutId);
|
||||
|
||||
toast.loading(t("Export started"), {
|
||||
id: toastId,
|
||||
description: `${t("Preparing your download")}…`,
|
||||
duration: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
onSubmit();
|
||||
}, [t, ui, document, contentType, includeChildDocuments, onSubmit]);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const radioItems = [
|
||||
{
|
||||
title: "Markdown",
|
||||
description: t(
|
||||
"A file containing the selected documents in Markdown format."
|
||||
),
|
||||
value: ExportContentType.Markdown,
|
||||
},
|
||||
{
|
||||
title: "HTML",
|
||||
description: t(
|
||||
"A file containing the selected documents in HTML format."
|
||||
),
|
||||
value: ExportContentType.Html,
|
||||
},
|
||||
];
|
||||
|
||||
if (env.PDF_EXPORT_ENABLED) {
|
||||
radioItems.push({
|
||||
title: "PDF",
|
||||
description: t(
|
||||
"A file containing the selected documents in PDF format."
|
||||
),
|
||||
value: ExportContentType.Pdf,
|
||||
});
|
||||
}
|
||||
|
||||
return radioItems;
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
submitText={includeChildDocuments ? t("Export") : t("Download")}
|
||||
>
|
||||
<Flex gap={12} column>
|
||||
{items.map((item) => (
|
||||
<Option key={item.value}>
|
||||
<StyledInput
|
||||
type="radio"
|
||||
name="format"
|
||||
value={item.value}
|
||||
checked={contentType === item.value}
|
||||
onChange={handleContentTypeChange}
|
||||
/>
|
||||
<div>
|
||||
<Text as="p" size="small" weight="bold">
|
||||
{item.title}
|
||||
</Text>
|
||||
{item.description ? (
|
||||
<Text size="small" type="secondary">
|
||||
{item.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
</Option>
|
||||
))}
|
||||
</Flex>
|
||||
{hasChildDocuments && (
|
||||
<>
|
||||
<hr style={{ margin: "16px 0 " }} />
|
||||
<Option>
|
||||
<StyledInput
|
||||
type="checkbox"
|
||||
name="includeChildDocuments"
|
||||
checked={includeChildDocuments}
|
||||
onChange={handleIncludeChildDocumentsChange}
|
||||
/>
|
||||
<Flex column gap={4}>
|
||||
<Text as="p" size="small" weight="bold">
|
||||
{t("Include child documents")}
|
||||
</Text>
|
||||
<Text as="p" size="small" type="secondary">
|
||||
<Trans
|
||||
defaults="When selected, exporting the document <em>{{documentName}}</em> may take some time."
|
||||
values={{
|
||||
documentName: document.titleWithDefault,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>{" "}
|
||||
{user.subscribedToEventType(
|
||||
NotificationEventType.ExportCompleted
|
||||
) && t("You will receive an email when it's complete.")}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Option>
|
||||
</>
|
||||
)}
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
});
|
||||
|
||||
const Option = styled.label`
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 16px;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledInput = styled.input`
|
||||
position: relative;
|
||||
top: 1.5px;
|
||||
`;
|
||||
+7
-20
@@ -16,11 +16,11 @@ 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 type { NavigationNode } from "@shared/types";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import { ancestors, descendants, flattenTree } from "@shared/utils/tree";
|
||||
import DocumentExplorerNode from "./DocumentExplorerNode";
|
||||
import DocumentExplorerSearchResult from "./DocumentExplorerSearchResult";
|
||||
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
|
||||
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
|
||||
import Flex from "~/components/Flex";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { Outline } from "~/components/Input";
|
||||
@@ -38,17 +38,9 @@ type Props = {
|
||||
items: NavigationNode[];
|
||||
/** Automatically expand to and select item with the given id */
|
||||
defaultValue?: string;
|
||||
/** Whether to show child documents */
|
||||
showDocuments?: boolean;
|
||||
};
|
||||
|
||||
function DocumentExplorer({
|
||||
onSubmit,
|
||||
onSelect,
|
||||
items,
|
||||
defaultValue,
|
||||
showDocuments,
|
||||
}: Props) {
|
||||
function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
const isMobile = useMobile();
|
||||
const { collections, documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
@@ -149,8 +141,7 @@ function DocumentExplorer({
|
||||
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
|
||||
Infinity
|
||||
);
|
||||
const normalizedBaseDepth =
|
||||
(baseDepth === Infinity ? 0 : baseDepth) + (showDocuments ? 0 : 1);
|
||||
const normalizedBaseDepth = baseDepth === Infinity ? 0 : baseDepth;
|
||||
|
||||
const scrollNodeIntoView = React.useCallback(
|
||||
(node: number) => {
|
||||
@@ -225,7 +216,7 @@ function DocumentExplorer({
|
||||
};
|
||||
|
||||
const hasChildren = (node: number) =>
|
||||
nodes[node].children.length > 0 || showDocuments !== false;
|
||||
nodes[node].children.length > 0 || nodes[node].type === "collection";
|
||||
|
||||
const toggleCollapse = (node: number) => {
|
||||
if (!hasChildren(node)) {
|
||||
@@ -411,11 +402,7 @@ function DocumentExplorer({
|
||||
<ListSearch
|
||||
ref={inputSearchRef}
|
||||
onChange={handleSearch}
|
||||
placeholder={
|
||||
showDocuments
|
||||
? `${t("Search collections & documents")}…`
|
||||
: `${t("Search collections")}…`
|
||||
}
|
||||
placeholder={`${t("Search collections & documents")}…`}
|
||||
autoFocus
|
||||
/>
|
||||
<ListContainer>
|
||||
@@ -1,17 +0,0 @@
|
||||
import styled from "styled-components";
|
||||
import Flex from "../Flex";
|
||||
|
||||
export const FlexContainer = styled(Flex)`
|
||||
margin-left: -24px;
|
||||
margin-right: -24px;
|
||||
margin-bottom: -24px;
|
||||
outline: none;
|
||||
`;
|
||||
|
||||
export const Footer = styled(Flex)`
|
||||
height: 64px;
|
||||
border-top: 1px solid ${(props) => props.theme.horizontalRule};
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
@@ -1,103 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import type { NavigationNode } from "@shared/types";
|
||||
import type Document from "~/models/Document";
|
||||
import Button from "~/components/Button";
|
||||
import Text from "~/components/Text";
|
||||
import useCollectionTrees from "~/hooks/useCollectionTrees";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { FlexContainer, Footer } from "./Components";
|
||||
import DocumentExplorer from "./DocumentExplorer";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
};
|
||||
|
||||
function DocumentMove({ document }: Props) {
|
||||
const { dialogs, policies } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const collectionTrees = useCollectionTrees();
|
||||
const [moving, setMoving] = useState<boolean>(false);
|
||||
const [selectedPath, selectPath] = useState<NavigationNode | null>(null);
|
||||
|
||||
const items = useMemo(() => {
|
||||
// Recursively filter out the document itself and its existing parent doc, if any.
|
||||
const filterSourceDocument = (node: NavigationNode): NavigationNode => ({
|
||||
...node,
|
||||
children: node.children
|
||||
?.filter(
|
||||
(c) => c.id !== document.id && c.id !== document.parentDocumentId
|
||||
)
|
||||
.map(filterSourceDocument),
|
||||
});
|
||||
|
||||
const nodes = collectionTrees
|
||||
.map(filterSourceDocument)
|
||||
// Filter out collections that we don't have permission to create documents in.
|
||||
.filter((node) =>
|
||||
node.collectionId
|
||||
? policies.get(node.collectionId)?.abilities.createDocument
|
||||
: true
|
||||
);
|
||||
|
||||
return nodes;
|
||||
}, [policies, collectionTrees, document.id, document.parentDocumentId]);
|
||||
|
||||
const move = async () => {
|
||||
if (!selectedPath) {
|
||||
toast.message(t("Select a location to move"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setMoving(true);
|
||||
const { type, id: parentDocumentId } = selectedPath;
|
||||
|
||||
const collectionId = selectedPath.collectionId as string;
|
||||
|
||||
if (type === "document") {
|
||||
await document.move({ collectionId, parentDocumentId });
|
||||
} else {
|
||||
await document.move({ collectionId });
|
||||
}
|
||||
|
||||
toast.success(t("Document moved"));
|
||||
|
||||
dialogs.closeAllModals();
|
||||
} catch (_err) {
|
||||
toast.error(t("Couldn’t move the document, try again?"));
|
||||
} finally {
|
||||
setMoving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FlexContainer column>
|
||||
<DocumentExplorer items={items} onSubmit={move} onSelect={selectPath} />
|
||||
<Footer justify="space-between" align="center" gap={8}>
|
||||
<Text ellipsis type="secondary">
|
||||
{selectedPath ? (
|
||||
<Trans
|
||||
defaults="Move to <em>{{ location }}</em>"
|
||||
values={{
|
||||
location: selectedPath.title || t("Untitled"),
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
t("Select a location to move")
|
||||
)}
|
||||
</Text>
|
||||
<Button disabled={!selectedPath || moving} onClick={move}>
|
||||
{moving ? `${t("Moving")}…` : t("Move")}
|
||||
</Button>
|
||||
</Footer>
|
||||
</FlexContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(DocumentMove);
|
||||
@@ -1,87 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import type { NavigationNode } from "@shared/types";
|
||||
import type Template from "~/models/Template";
|
||||
import Button from "~/components/Button";
|
||||
import Text from "~/components/Text";
|
||||
import useCollectionTrees from "~/hooks/useCollectionTrees";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { FlexContainer, Footer } from "./Components";
|
||||
import DocumentExplorer from "./DocumentExplorer";
|
||||
|
||||
type Props = {
|
||||
template: Template;
|
||||
};
|
||||
|
||||
function TemplateMove({ template }: Props) {
|
||||
const { dialogs, policies } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const collectionTrees = useCollectionTrees();
|
||||
const [selectedPath, selectPath] = useState<NavigationNode | null>(null);
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
collectionTrees
|
||||
.map((node) => ({ ...node, children: [] }))
|
||||
.filter((node) =>
|
||||
node.collectionId
|
||||
? policies.get(node.collectionId)?.abilities.createDocument
|
||||
: true
|
||||
),
|
||||
[policies, collectionTrees]
|
||||
);
|
||||
|
||||
const move = async () => {
|
||||
if (!selectedPath) {
|
||||
toast.message(t("Select a location to move"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const collectionId = (selectedPath.collectionId ??
|
||||
selectedPath.id) as string;
|
||||
await template.save({ collectionId });
|
||||
|
||||
toast.success(t("Template moved"));
|
||||
|
||||
dialogs.closeAllModals();
|
||||
} catch (_err) {
|
||||
toast.error(t("Couldn’t move the template, try again?"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FlexContainer column>
|
||||
<DocumentExplorer
|
||||
items={items}
|
||||
onSubmit={move}
|
||||
onSelect={selectPath}
|
||||
showDocuments={false}
|
||||
/>
|
||||
<Footer justify="space-between" align="center" gap={8}>
|
||||
<Text ellipsis type="secondary">
|
||||
{selectedPath ? (
|
||||
<Trans
|
||||
defaults="Move to <em>{{ location }}</em>"
|
||||
values={{
|
||||
location: selectedPath.title,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
t("Select a location to move")
|
||||
)}
|
||||
</Text>
|
||||
<Button disabled={!selectedPath} onClick={move}>
|
||||
{t("Move")}
|
||||
</Button>
|
||||
</Footer>
|
||||
</FlexContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(TemplateMove);
|
||||
@@ -1,3 +0,0 @@
|
||||
import DocumentExplorer from "./DocumentExplorer";
|
||||
|
||||
export default DocumentExplorer;
|
||||
-1
@@ -54,7 +54,6 @@ function DocumentExplorerNode(
|
||||
style={style}
|
||||
onPointerMove={onPointerMove}
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
>
|
||||
<Spacer width={width}>
|
||||
{hasChildren && (
|
||||
+1
-2
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled from "styled-components";
|
||||
import { ellipsis } from "@shared/styles";
|
||||
import { Node as SearchResult } from "./DocumentExplorerNode";
|
||||
import { Node as SearchResult } from "~/components/DocumentExplorerNode";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
|
||||
@@ -54,7 +54,6 @@ function DocumentExplorerSearchResult({
|
||||
style={style}
|
||||
onPointerMove={onPointerMove}
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
>
|
||||
{icon}
|
||||
<Flex>
|
||||
@@ -6,13 +6,12 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { DocumentIcon } from "outline-icons";
|
||||
import styled, { css, useTheme } from "styled-components";
|
||||
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 type Document from "~/models/Document";
|
||||
import Document from "~/models/Document";
|
||||
import Badge from "~/components/Badge";
|
||||
import DocumentMeta from "~/components/DocumentMeta";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -22,7 +21,6 @@ import StarButton, { AnimatedStar } from "~/components/Star";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
@@ -40,6 +38,7 @@ type Props = {
|
||||
showCollection?: boolean;
|
||||
showPublished?: boolean;
|
||||
showDraft?: boolean;
|
||||
showTemplate?: boolean;
|
||||
};
|
||||
|
||||
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
|
||||
@@ -55,11 +54,9 @@ function DocumentListItem(
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
const theme = useTheme();
|
||||
const { userMemberships, groupMemberships } = useStores();
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const isMobile = useMobile();
|
||||
|
||||
let itemRef: React.Ref<HTMLAnchorElement> =
|
||||
React.useRef<HTMLAnchorElement>(null);
|
||||
@@ -76,6 +73,7 @@ function DocumentListItem(
|
||||
showCollection,
|
||||
showPublished,
|
||||
showDraft = true,
|
||||
showTemplate,
|
||||
highlight,
|
||||
context,
|
||||
...rest
|
||||
@@ -83,7 +81,7 @@ function DocumentListItem(
|
||||
const queryIsInTitle =
|
||||
!!highlight &&
|
||||
!!document.title.toLowerCase().includes(highlight.toLowerCase());
|
||||
const canStar = !document.isArchived;
|
||||
const canStar = !document.isArchived && !document.isTemplate;
|
||||
|
||||
const isShared = !!(
|
||||
userMemberships.getByDocumentId(document.id) ||
|
||||
@@ -101,10 +99,11 @@ function DocumentListItem(
|
||||
return (
|
||||
<ActionContextProvider
|
||||
value={{
|
||||
activeModels: [
|
||||
document,
|
||||
...(!isShared && document.collection ? [document.collection] : []),
|
||||
],
|
||||
activeDocumentId: document.id,
|
||||
activeCollectionId:
|
||||
!isShared && document.collectionId
|
||||
? document.collectionId
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<ContextMenu
|
||||
@@ -120,9 +119,6 @@ function DocumentListItem(
|
||||
$menuOpen={menuOpen}
|
||||
to={{
|
||||
pathname: documentPath(document),
|
||||
search: highlight
|
||||
? `?q=${encodeURIComponent(highlight)}`
|
||||
: undefined,
|
||||
state: {
|
||||
title: document.titleWithDefault,
|
||||
sidebarContext,
|
||||
@@ -131,55 +127,56 @@ function DocumentListItem(
|
||||
{...rest}
|
||||
{...rovingTabIndex}
|
||||
>
|
||||
<Flex gap={4} auto>
|
||||
<IconWrapper>
|
||||
{document.icon ? (
|
||||
<Icon
|
||||
value={document.icon}
|
||||
color={document.color ?? undefined}
|
||||
initial={document.initial}
|
||||
/>
|
||||
) : (
|
||||
<DocumentIcon
|
||||
outline={document.isDraft}
|
||||
color={theme.textSecondary}
|
||||
/>
|
||||
<Content>
|
||||
<Heading dir={document.dir}>
|
||||
{document.icon && (
|
||||
<>
|
||||
<Icon
|
||||
value={document.icon}
|
||||
color={document.color ?? undefined}
|
||||
initial={document.initial}
|
||||
/>
|
||||
|
||||
</>
|
||||
)}
|
||||
</IconWrapper>
|
||||
<Content>
|
||||
<Heading dir={document.dir}>
|
||||
<Title
|
||||
text={document.titleWithDefault}
|
||||
highlight={highlight}
|
||||
dir={document.dir}
|
||||
/>
|
||||
{document.isBadgedNew && document.createdBy?.id !== user.id && (
|
||||
<Badge yellow>{t("New")}</Badge>
|
||||
)}
|
||||
{document.isDraft && showDraft && (
|
||||
<Tooltip content={t("Only visible to you")} placement="top">
|
||||
<Badge>{t("Draft")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canStar && !isMobile && <StarButton document={document} />}
|
||||
</Heading>
|
||||
|
||||
{!queryIsInTitle && (
|
||||
<ResultContext
|
||||
text={context}
|
||||
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
|
||||
processResult={replaceResultMarks}
|
||||
/>
|
||||
)}
|
||||
<DocumentMeta
|
||||
document={document}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
showParentDocuments={showParentDocuments}
|
||||
showLastViewed
|
||||
<Title
|
||||
text={document.titleWithDefault}
|
||||
highlight={highlight}
|
||||
dir={document.dir}
|
||||
/>
|
||||
</Content>
|
||||
</Flex>
|
||||
{document.isBadgedNew && document.createdBy?.id !== user.id && (
|
||||
<Badge yellow>{t("New")}</Badge>
|
||||
)}
|
||||
{document.isDraft && showDraft && (
|
||||
<Tooltip content={t("Only visible to you")} placement="top">
|
||||
<Badge>{t("Draft")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canStar && (
|
||||
<StarPositioner>
|
||||
<StarButton document={document} />
|
||||
</StarPositioner>
|
||||
)}
|
||||
{document.isTemplate && showTemplate && (
|
||||
<Badge primary>{t("Template")}</Badge>
|
||||
)}
|
||||
</Heading>
|
||||
|
||||
{!queryIsInTitle && (
|
||||
<ResultContext
|
||||
text={context}
|
||||
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
|
||||
processResult={replaceResultMarks}
|
||||
/>
|
||||
)}
|
||||
<DocumentMeta
|
||||
document={document}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
showParentDocuments={showParentDocuments}
|
||||
showLastViewed
|
||||
/>
|
||||
</Content>
|
||||
<Actions>
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
@@ -193,14 +190,6 @@ function DocumentListItem(
|
||||
);
|
||||
}
|
||||
|
||||
const IconWrapper = styled.div`
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
width: 24px;
|
||||
`;
|
||||
|
||||
const Content = styled.div`
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
@@ -215,9 +204,12 @@ const Actions = styled(EventBoundary)`
|
||||
flex-grow: 0;
|
||||
color: ${s("textSecondary")};
|
||||
|
||||
${NudeButton}:${hover},
|
||||
${NudeButton}[aria-expanded= "true"] {
|
||||
background: ${s("sidebarControlHoverBackground")};
|
||||
${NudeButton} {
|
||||
&:
|
||||
${hover},
|
||||
&[aria-expanded= "true"] {
|
||||
background: ${s("sidebarControlHoverBackground")};
|
||||
}
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
@@ -293,14 +285,18 @@ const Heading = styled.span<{ rtl?: boolean }>`
|
||||
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||
align-items: center;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.1em;
|
||||
margin-bottom: 0.25em;
|
||||
white-space: nowrap;
|
||||
color: ${s("text")};
|
||||
font-family: ${s("fontFamily")};
|
||||
font-weight: 500;
|
||||
font-size: 18px;
|
||||
font-size: 20px;
|
||||
line-height: 1.2;
|
||||
gap: 4px;
|
||||
`;
|
||||
|
||||
const StarPositioner = styled(Flex)`
|
||||
margin-left: 4px;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const Title = styled(Highlight)`
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { LocationDescriptor } from "history";
|
||||
import { LocationDescriptor } from "history";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import type Document from "~/models/Document";
|
||||
import type Revision from "~/models/Revision";
|
||||
import Document from "~/models/Document";
|
||||
import Revision from "~/models/Revision";
|
||||
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
|
||||
import DocumentTasks from "~/components/DocumentTasks";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -52,6 +52,7 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
isDraft,
|
||||
lastViewedAt,
|
||||
isTasks,
|
||||
isTemplate,
|
||||
} = document;
|
||||
|
||||
// Prevent meta information from displaying if updatedBy is not available.
|
||||
@@ -141,7 +142,7 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
const nestedDocumentsCount = collection
|
||||
? collection.getChildrenForDocument(document.id).length
|
||||
: 0;
|
||||
const canShowProgressBar = isTasks;
|
||||
const canShowProgressBar = isTasks && !isTemplate;
|
||||
|
||||
const timeSinceNow = () => {
|
||||
if (isDraft || !showLastViewed) {
|
||||
@@ -169,7 +170,7 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Container align="center" $rtl={document.dir === "rtl"} {...rest} dir="ltr">
|
||||
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
|
||||
{to ? (
|
||||
<Link to={to} replace={replace}>
|
||||
{content}
|
||||
@@ -181,7 +182,7 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
<span>
|
||||
{t("in")}
|
||||
<Strong>
|
||||
<DocumentBreadcrumb document={document} maxDepth={1} onlyText />
|
||||
<DocumentBreadcrumb document={document} onlyText />
|
||||
</Strong>
|
||||
</span>
|
||||
)}
|
||||
@@ -218,8 +219,8 @@ const Strong = styled.strong`
|
||||
font-weight: 550;
|
||||
`;
|
||||
|
||||
const Container = styled(Flex)<{ $rtl?: boolean }>`
|
||||
justify-content: ${(props) => (props.$rtl ? "flex-end" : "flex-start")};
|
||||
const Container = styled(Flex)<{ rtl?: boolean }>`
|
||||
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||
color: ${s("textTertiary")};
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { TFunction } from "i18next";
|
||||
import { observer } from "mobx-react";
|
||||
import { DoneIcon } from "outline-icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import type Document from "~/models/Document";
|
||||
import Document from "~/models/Document";
|
||||
import CircularProgressBar from "~/components/CircularProgressBar";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import { bounceIn } from "~/styles/animations";
|
||||
@@ -42,7 +42,7 @@ function DocumentTasks({ document }: Props) {
|
||||
const message = getMessage(t, total, completed);
|
||||
|
||||
return (
|
||||
<Flex align="center" style={{ padding: "0 1px" }} gap={2} shrink={false}>
|
||||
<Flex align="center" style={{ padding: "0 1px" }} gap={2}>
|
||||
{completed === total ? (
|
||||
<Done
|
||||
color={theme.accent}
|
||||
|
||||
@@ -4,8 +4,8 @@ import { observer } from "mobx-react";
|
||||
import { useMemo, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { dateLocale, dateToRelative } from "@shared/utils/date";
|
||||
import type Document from "~/models/Document";
|
||||
import type User from "~/models/User";
|
||||
import Document from "~/models/Document";
|
||||
import User from "~/models/User";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import type Collection from "~/models/Collection";
|
||||
import Collection from "~/models/Collection";
|
||||
|
||||
type Props = {
|
||||
enabled: boolean;
|
||||
|
||||
@@ -2,7 +2,6 @@ import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
|
||||
type Props = Omit<React.HTMLAttributes<HTMLInputElement>, "onSubmit"> & {
|
||||
/** A callback when the title is submitted. */
|
||||
@@ -142,12 +141,11 @@ function EditableTitle(
|
||||
return (
|
||||
<>
|
||||
{isEditing ? (
|
||||
<EventBoundary as="form" onSubmit={handleSave}>
|
||||
<form onSubmit={handleSave}>
|
||||
<Input
|
||||
dir="auto"
|
||||
type="text"
|
||||
lang=""
|
||||
name="title"
|
||||
value={value}
|
||||
onClick={stopPropagation}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -157,7 +155,7 @@ function EditableTitle(
|
||||
autoFocus
|
||||
{...rest}
|
||||
/>
|
||||
</EventBoundary>
|
||||
</form>
|
||||
) : (
|
||||
<Text
|
||||
onDoubleClick={canUpdate ? handleDoubleClick : undefined}
|
||||
|
||||
@@ -3,9 +3,8 @@ import { observer } from "mobx-react";
|
||||
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
|
||||
import { TextSelection } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import type { Optional } from "utility-types";
|
||||
import { Optional } from "utility-types";
|
||||
import insertFiles from "@shared/editor/commands/insertFiles";
|
||||
import EditorContainer from "@shared/editor/components/Styles";
|
||||
import { AttachmentPreset } from "@shared/types";
|
||||
@@ -42,14 +41,7 @@ export type Props = Optional<
|
||||
};
|
||||
|
||||
function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
const {
|
||||
id,
|
||||
onChange,
|
||||
onCreateCommentMark,
|
||||
onDeleteCommentMark,
|
||||
onFileUploadStart,
|
||||
onFileUploadStop,
|
||||
} = props;
|
||||
const { id, onChange, onCreateCommentMark, onDeleteCommentMark } = props;
|
||||
const { comments } = useStores();
|
||||
const { shareId } = useShare();
|
||||
const dictionary = useDictionary();
|
||||
@@ -58,27 +50,11 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
const preferences = useCurrentUser({ rejectOnEmpty: false })?.preferences;
|
||||
const previousCommentIds = React.useRef<string[]>();
|
||||
|
||||
// Upload progress tracking for delayed toast
|
||||
const progressMap = React.useMemo(() => new Map<string, number>(), []);
|
||||
const uploadState = React.useRef<{
|
||||
toastId?: string | number;
|
||||
timeoutId?: ReturnType<typeof setTimeout>;
|
||||
progress: Map<string, number>;
|
||||
}>({ progress: progressMap });
|
||||
|
||||
const handleUploadFile = React.useCallback(
|
||||
async (
|
||||
file: File | string,
|
||||
uploadOptions?: {
|
||||
id?: string;
|
||||
onProgress?: (fractionComplete: number) => void;
|
||||
}
|
||||
) => {
|
||||
async (file: File | string) => {
|
||||
const options = {
|
||||
id: uploadOptions?.id,
|
||||
documentId: id,
|
||||
preset: AttachmentPreset.DocumentAttachment,
|
||||
onProgress: uploadOptions?.onProgress,
|
||||
};
|
||||
const result =
|
||||
file instanceof File
|
||||
@@ -91,49 +67,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
|
||||
const { handleClickLink } = useEditorClickHandlers({ shareId });
|
||||
|
||||
// Show toast only after uploads have been running for 2 seconds
|
||||
const handleFileUploadStart = React.useCallback(() => {
|
||||
uploadState.current.timeoutId = setTimeout(() => {
|
||||
uploadState.current.toastId = toast.loading(
|
||||
dictionary.uploadingWithProgress(0)
|
||||
);
|
||||
}, 2000);
|
||||
onFileUploadStart?.();
|
||||
}, [onFileUploadStart, dictionary.uploadingWithProgress]);
|
||||
|
||||
const handleFileUploadProgress = React.useCallback(
|
||||
(fileId: string, fractionComplete: number) => {
|
||||
uploadState.current.progress.set(fileId, fractionComplete);
|
||||
|
||||
// Calculate average progress across all files
|
||||
const progressValues = Array.from(uploadState.current.progress.values());
|
||||
const avgProgress =
|
||||
progressValues.reduce((a, b) => a + b, 0) / progressValues.length;
|
||||
const percent = Math.round(avgProgress * 100);
|
||||
|
||||
// Update toast if visible
|
||||
if (uploadState.current.toastId) {
|
||||
toast.loading(dictionary.uploadingWithProgress(percent), {
|
||||
id: uploadState.current.toastId,
|
||||
});
|
||||
}
|
||||
},
|
||||
[dictionary.uploadingWithProgress]
|
||||
);
|
||||
|
||||
const handleFileUploadStop = React.useCallback(() => {
|
||||
if (uploadState.current.timeoutId) {
|
||||
clearTimeout(uploadState.current.timeoutId);
|
||||
uploadState.current.timeoutId = undefined;
|
||||
}
|
||||
if (uploadState.current.toastId) {
|
||||
toast.dismiss(uploadState.current.toastId);
|
||||
uploadState.current.toastId = undefined;
|
||||
}
|
||||
uploadState.current.progress.clear();
|
||||
onFileUploadStop?.();
|
||||
}, [onFileUploadStop]);
|
||||
|
||||
const focusAtEnd = React.useCallback(() => {
|
||||
localRef?.current?.focusAtEnd();
|
||||
}, [localRef]);
|
||||
@@ -180,18 +113,16 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
|
||||
return insertFiles(view, event, pos, files, {
|
||||
uploadFile: handleUploadFile,
|
||||
onFileUploadStart: handleFileUploadStart,
|
||||
onFileUploadStop: handleFileUploadStop,
|
||||
onFileUploadProgress: handleFileUploadProgress,
|
||||
onFileUploadStart: props.onFileUploadStart,
|
||||
onFileUploadStop: props.onFileUploadStop,
|
||||
dictionary,
|
||||
isAttachment,
|
||||
});
|
||||
},
|
||||
[
|
||||
localRef,
|
||||
handleFileUploadStart,
|
||||
handleFileUploadStop,
|
||||
handleFileUploadProgress,
|
||||
props.onFileUploadStart,
|
||||
props.onFileUploadStop,
|
||||
dictionary,
|
||||
handleUploadFile,
|
||||
]
|
||||
@@ -266,7 +197,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
<>
|
||||
{paragraphs ? (
|
||||
<EditorContainer
|
||||
$rtl={props.dir === "rtl"}
|
||||
rtl={props.dir === "rtl"}
|
||||
grow={props.grow}
|
||||
style={props.style}
|
||||
editorStyle={props.editorStyle}
|
||||
@@ -292,9 +223,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
{...props}
|
||||
onClickLink={handleClickLink}
|
||||
onChange={handleChange}
|
||||
onFileUploadStart={handleFileUploadStart}
|
||||
onFileUploadStop={handleFileUploadStop}
|
||||
onFileUploadProgress={handleFileUploadProgress}
|
||||
placeholder={props.placeholder || ""}
|
||||
defaultValue={props.defaultValue || ""}
|
||||
/>
|
||||
|
||||
@@ -7,7 +7,8 @@ import { s } from "@shared/styles";
|
||||
import { AttachmentPreset } from "@shared/types";
|
||||
import { getDataTransferFiles } from "@shared/utils/files";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Input, { LabelText } from "~/components/Input";
|
||||
import Flex from "~/components/Flex";
|
||||
import Input from "~/components/Input";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { uploadFile } from "~/utils/files";
|
||||
@@ -15,7 +16,6 @@ import { compressImage } from "~/utils/compressImage";
|
||||
import { generateEmojiNameFromFilename } from "~/utils/emoji";
|
||||
import { AttachmentValidation, EmojiValidation } from "@shared/validations";
|
||||
import { bytesToHumanReadable } from "@shared/utils/files";
|
||||
import { VStack } from "./primitives/VStack";
|
||||
|
||||
type Props = {
|
||||
onSubmit: () => void;
|
||||
@@ -108,16 +108,12 @@ export function EmojiCreateDialog({ onSubmit }: Props) {
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
// Skip compression for GIFs to preserve animation
|
||||
const fileToUpload =
|
||||
file.type === "image/gif"
|
||||
? file
|
||||
: await compressImage(file, {
|
||||
maxHeight: 64,
|
||||
maxWidth: 64,
|
||||
});
|
||||
const compressed = await compressImage(file, {
|
||||
maxHeight: 64,
|
||||
maxWidth: 64,
|
||||
});
|
||||
|
||||
const attachment = await uploadFile(fileToUpload, {
|
||||
const attachment = await uploadFile(compressed, {
|
||||
name: file.name,
|
||||
preset: AttachmentPreset.Emoji,
|
||||
});
|
||||
@@ -151,14 +147,29 @@ export function EmojiCreateDialog({ onSubmit }: Props) {
|
||||
>
|
||||
<Text as="p" type="secondary">
|
||||
{t(
|
||||
"Square images with transparent backgrounds work best. If your image is too large, we’ll try to resize it for you."
|
||||
"The emoji name should be unique and contain only lowercase letters, numbers, and underscores."
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<LabelText as="label">{t("Upload an image")}</LabelText>
|
||||
<Input
|
||||
label={t("Name")}
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
placeholder="my_custom_emoji"
|
||||
autoFocus
|
||||
required
|
||||
error={
|
||||
!isValidName
|
||||
? t(
|
||||
"name can only contain lowercase letters, numbers, and underscores."
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<DropZone {...getRootProps()}>
|
||||
<input {...getInputProps()} />
|
||||
<VStack>
|
||||
<Flex column align="center" gap={8}>
|
||||
{file ? (
|
||||
<>
|
||||
<PreviewImage src={URL.createObjectURL(file)} alt="Preview" />
|
||||
@@ -183,25 +194,9 @@ export function EmojiCreateDialog({ onSubmit }: Props) {
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</VStack>
|
||||
</Flex>
|
||||
</DropZone>
|
||||
|
||||
<Input
|
||||
label={t("Choose a name")}
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
placeholder="my_custom_emoji"
|
||||
autoFocus
|
||||
required
|
||||
error={
|
||||
!isValidName
|
||||
? t(
|
||||
"name can only contain lowercase letters, numbers, and underscores."
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{name.trim() && isValidName && (
|
||||
<Text type="secondary" style={{ marginTop: "8px" }}>
|
||||
{t("This emoji will be available as")} <code>:{name}:</code>
|
||||
@@ -218,7 +213,6 @@ const DropZone = styled.div`
|
||||
text-align: center;
|
||||
cursor: var(--pointer);
|
||||
transition: border-color 0.2s;
|
||||
margin-bottom: 1em;
|
||||
|
||||
&:hover {
|
||||
border-color: ${s("inputBorderFocused")};
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import type { WithTranslation } from "react-i18next";
|
||||
import { withTranslation, Trans } from "react-i18next";
|
||||
import { withTranslation, Trans, WithTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { UrlHelper } from "@shared/utils/UrlHelper";
|
||||
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import type Document from "~/models/Document";
|
||||
import type Event from "~/models/Event";
|
||||
import Document from "~/models/Document";
|
||||
import Event from "~/models/Event";
|
||||
import Time from "~/components/Time";
|
||||
import Logger from "~/utils/Logger";
|
||||
import Text from "./Text";
|
||||
|
||||
@@ -4,13 +4,15 @@ import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { FileOperationFormat, NotificationEventType } from "@shared/types";
|
||||
import type Collection from "~/models/Collection";
|
||||
import Collection from "~/models/Collection";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
collection?: Collection;
|
||||
@@ -25,7 +27,7 @@ function ExportDialog({ collection, onSubmit }: Props) {
|
||||
React.useState<boolean>(true);
|
||||
const [includePrivate, setIncludePrivate] = React.useState<boolean>(true);
|
||||
const user = useCurrentUser();
|
||||
const { collections, ui } = useStores();
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const appName = env.APP_NAME;
|
||||
|
||||
@@ -51,40 +53,23 @@ function ExportDialog({ collection, onSubmit }: Props) {
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
let response;
|
||||
|
||||
if (collection) {
|
||||
response = await collection.export(format, includeAttachments);
|
||||
await collection.export(format, includeAttachments);
|
||||
toast.success(t("Export started"), {
|
||||
description: t(`Your file will be available in {{ location }} soon`, {
|
||||
location: `"${t("Settings")} > ${t("Export")}"`,
|
||||
}),
|
||||
action: {
|
||||
label: t("View"),
|
||||
onClick: () => {
|
||||
history.push(settingsPath("export"));
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
response = await collections.export({
|
||||
format,
|
||||
includeAttachments,
|
||||
includePrivate,
|
||||
});
|
||||
await collections.export({ format, includeAttachments, includePrivate });
|
||||
toast.success(t("Export started"));
|
||||
}
|
||||
|
||||
if (response?.data?.fileOperation) {
|
||||
const fileOperationId = response.data.fileOperation.id;
|
||||
const toastId = `export-${fileOperationId}`;
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
toast.success(t("Export started"), {
|
||||
id: toastId,
|
||||
description: t("A link to your file will be sent through email soon"),
|
||||
duration: 3000,
|
||||
});
|
||||
ui.exportToasts.delete(fileOperationId);
|
||||
}, 6000);
|
||||
|
||||
ui.registerExportToast(fileOperationId, toastId, timeoutId);
|
||||
|
||||
toast.loading(t("Export started"), {
|
||||
id: toastId,
|
||||
description: `${t("Preparing your download")}…`,
|
||||
duration: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
onSubmit();
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import type User from "~/models/User";
|
||||
import User from "~/models/User";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
@@ -7,8 +7,7 @@ import type { FetchPageParams } from "~/stores/base/Store";
|
||||
import Button, { Inner } from "~/components/Button";
|
||||
import Text from "~/components/Text";
|
||||
import Input, { NativeInput, Outline } from "./Input";
|
||||
import type { PaginatedItem } from "./PaginatedList";
|
||||
import PaginatedList from "./PaginatedList";
|
||||
import PaginatedList, { PaginatedItem } from "./PaginatedList";
|
||||
import { MenuProvider } from "./primitives/Menu/MenuContext";
|
||||
import { Menu, MenuContent, MenuTrigger, MenuButton } from "./primitives/Menu";
|
||||
import { MenuIconWrapper } from "./primitives/components/Menu";
|
||||
@@ -26,10 +25,8 @@ type Props = {
|
||||
className?: string;
|
||||
onSelect: (key: string | null | undefined) => void;
|
||||
showFilter?: boolean;
|
||||
showIcons?: boolean;
|
||||
fetchQuery?: (options: FetchPageParams) => Promise<PaginatedItem[]>;
|
||||
fetchQueryOptions?: Record<string, string>;
|
||||
disclosure?: boolean;
|
||||
};
|
||||
|
||||
const FilterOptions = ({
|
||||
@@ -38,10 +35,8 @@ const FilterOptions = ({
|
||||
className,
|
||||
onSelect,
|
||||
showFilter,
|
||||
showIcons = true,
|
||||
fetchQuery,
|
||||
fetchQueryOptions,
|
||||
disclosure = true,
|
||||
...rest
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -62,7 +57,7 @@ const FilterOptions = ({
|
||||
<MenuButton
|
||||
key={option.key}
|
||||
icon={
|
||||
option.icon && showIcons ? (
|
||||
option.icon ? (
|
||||
<MenuIconWrapper aria-hidden>{option.icon}</MenuIconWrapper>
|
||||
) : undefined
|
||||
}
|
||||
@@ -74,7 +69,7 @@ const FilterOptions = ({
|
||||
selected={selectedKeys.includes(option.key)}
|
||||
/>
|
||||
),
|
||||
[onSelect, showIcons, selectedKeys]
|
||||
[onSelect, selectedKeys]
|
||||
);
|
||||
|
||||
const handleFilter = React.useCallback(
|
||||
@@ -185,8 +180,8 @@ const FilterOptions = ({
|
||||
<StyledButton
|
||||
className={className}
|
||||
icon={selectedItems[0]?.key && selectedItems[0]?.icon}
|
||||
disclosure={disclosure}
|
||||
neutral
|
||||
disclosure
|
||||
>
|
||||
{selectedItems.length ? selectedLabel : defaultLabel}
|
||||
</StyledButton>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { richExtensions } from "@shared/editor/nodes";
|
||||
import type { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import Editor from "~/components/Editor";
|
||||
import Flex from "~/components/Flex";
|
||||
import ErrorBoundary from "../ErrorBoundary";
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
|
||||
type Props = Omit<UnfurlResponse[UnfurlResourceType.Document], "type">;
|
||||
|
||||
const HoverPreviewDocument = React.forwardRef(function HoverPreviewDocument_(
|
||||
const HoverPreviewDocument = React.forwardRef(function _HoverPreviewDocument(
|
||||
{ url, id, title, summary, lastActivityByViewer }: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { MAX_AVATAR_DISPLAY } from "@shared/constants";
|
||||
import type User from "~/models/User";
|
||||
import User from "~/models/User";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import Flex from "~/components/Flex";
|
||||
import {
|
||||
@@ -17,7 +17,7 @@ import ErrorBoundary from "../ErrorBoundary";
|
||||
|
||||
type Props = Omit<UnfurlResponse[UnfurlResourceType.Group], "type">;
|
||||
|
||||
const HoverPreviewGroup = React.forwardRef(function HoverPreviewGroup_(
|
||||
const HoverPreviewGroup = React.forwardRef(function _HoverPreviewGroup(
|
||||
{ name, description, memberCount, users }: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
|
||||
@@ -3,11 +3,12 @@ import { Trans } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { Backticks } from "@shared/components/Backticks";
|
||||
import { IssueStatusIcon } from "@shared/components/IssueStatusIcon";
|
||||
import { richExtensions } from "@shared/editor/nodes";
|
||||
import type { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { IntegrationService } from "@shared/types";
|
||||
import {
|
||||
IntegrationService,
|
||||
UnfurlResourceType,
|
||||
UnfurlResponse,
|
||||
} from "@shared/types";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Editor from "~/components/Editor";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "../Text";
|
||||
import Time from "../Time";
|
||||
@@ -23,18 +24,16 @@ import {
|
||||
|
||||
type Props = Omit<UnfurlResponse[UnfurlResourceType.Issue], "type">;
|
||||
|
||||
const HoverPreviewIssue = React.forwardRef(function HoverPreviewIssue_(
|
||||
const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
|
||||
{ url, id, title, description, author, labels, state, createdAt }: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
const authorName = author.name;
|
||||
const urlObj = new URL(url);
|
||||
const service =
|
||||
urlObj.hostname === "linear.app"
|
||||
? IntegrationService.Linear
|
||||
: urlObj.hostname === "github.com"
|
||||
? IntegrationService.GitHub
|
||||
: IntegrationService.GitLab;
|
||||
urlObj.hostname === "github.com"
|
||||
? IntegrationService.GitHub
|
||||
: IntegrationService.Linear;
|
||||
|
||||
return (
|
||||
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
|
||||
@@ -62,18 +61,7 @@ const HoverPreviewIssue = React.forwardRef(function HoverPreviewIssue_(
|
||||
</Trans>
|
||||
</Info>
|
||||
</Flex>
|
||||
{description && (
|
||||
<Description as="div">
|
||||
<React.Suspense fallback={<div />}>
|
||||
<Editor
|
||||
extensions={richExtensions}
|
||||
defaultValue={description}
|
||||
embedsDisabled
|
||||
readOnly
|
||||
/>
|
||||
</React.Suspense>
|
||||
</Description>
|
||||
)}
|
||||
<Description>{description}</Description>
|
||||
|
||||
<Flex wrap>
|
||||
{labels.map((label, index) => (
|
||||
|
||||
@@ -20,7 +20,7 @@ type Props = {
|
||||
description: string;
|
||||
};
|
||||
|
||||
const HoverPreviewLink = React.forwardRef(function HoverPreviewLink_(
|
||||
const HoverPreviewLink = React.forwardRef(function _HoverPreviewLink(
|
||||
{ url, thumbnailUrl, title, description }: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import * as React from "react";
|
||||
import type { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import { Preview, Title, Info, Card, CardContent } from "./Components";
|
||||
|
||||
type Props = Omit<UnfurlResponse[UnfurlResourceType.Mention], "type">;
|
||||
|
||||
const HoverPreviewMention = React.forwardRef(function HoverPreviewMention_(
|
||||
const HoverPreviewMention = React.forwardRef(function _HoverPreviewMention(
|
||||
{ avatarUrl, name, lastActive, color }: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
|
||||
@@ -3,10 +3,8 @@ import { Trans } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { Backticks } from "@shared/components/Backticks";
|
||||
import { PullRequestIcon } from "@shared/components/PullRequestIcon";
|
||||
import { richExtensions } from "@shared/editor/nodes";
|
||||
import type { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Editor from "~/components/Editor";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "../Text";
|
||||
import Time from "../Time";
|
||||
@@ -22,7 +20,7 @@ import {
|
||||
type Props = Omit<UnfurlResponse[UnfurlResourceType.PR], "type">;
|
||||
|
||||
const HoverPreviewPullRequest = React.forwardRef(
|
||||
function HoverPreviewPullRequest_(
|
||||
function _HoverPreviewPullRequest(
|
||||
{ url, title, id, description, author, state, createdAt }: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
@@ -50,18 +48,7 @@ const HoverPreviewPullRequest = React.forwardRef(
|
||||
</Trans>
|
||||
</Info>
|
||||
</Flex>
|
||||
{description && (
|
||||
<Description as="div">
|
||||
<React.Suspense fallback={<div />}>
|
||||
<Editor
|
||||
extensions={richExtensions}
|
||||
defaultValue={description}
|
||||
embedsDisabled
|
||||
readOnly
|
||||
/>
|
||||
</React.Suspense>
|
||||
</Description>
|
||||
)}
|
||||
<Description>{description}</Description>
|
||||
</Flex>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
+29
-9
@@ -1,4 +1,5 @@
|
||||
import * as React from "react";
|
||||
import debounce from "lodash/debounce";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
@@ -12,23 +13,37 @@ type Props = {
|
||||
onSelect: (color: string) => void;
|
||||
};
|
||||
|
||||
const IconColorPicker = ({ activeColor, onSelect }: Props) => {
|
||||
const ColorPicker = ({ activeColor, onSelect }: Props) => {
|
||||
const [selectedColor, setSelectedColor] = React.useState(activeColor);
|
||||
const isBuiltInColor = colorPalette.includes(selectedColor);
|
||||
const color = isBuiltInColor ? undefined : selectedColor;
|
||||
|
||||
const debouncedOnSelect = React.useMemo(
|
||||
() =>
|
||||
debounce((color: string) => {
|
||||
onSelect(color);
|
||||
}, 250),
|
||||
[onSelect]
|
||||
);
|
||||
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
debouncedOnSelect.cancel();
|
||||
},
|
||||
[debouncedOnSelect]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedColor(activeColor);
|
||||
}, [activeColor]);
|
||||
|
||||
const handleSelect = (color: string) => {
|
||||
setSelectedColor(color);
|
||||
onSelect(color);
|
||||
debouncedOnSelect(color);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container justify="space-between" align="center" auto>
|
||||
<PresetColors activeColor={selectedColor} onClick={handleSelect} />
|
||||
<BuiltinColors activeColor={selectedColor} onClick={handleSelect}>
|
||||
<Divider />
|
||||
<SwatchButton
|
||||
color={color}
|
||||
@@ -36,7 +51,7 @@ const IconColorPicker = ({ activeColor, onSelect }: Props) => {
|
||||
onChange={handleSelect}
|
||||
pickerInModal
|
||||
/>
|
||||
</Container>
|
||||
</BuiltinColors>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -46,14 +61,18 @@ const Divider = styled.div`
|
||||
background-color: ${s("inputBorder")};
|
||||
`;
|
||||
|
||||
const PresetColors = ({
|
||||
const BuiltinColors = ({
|
||||
activeColor,
|
||||
onClick,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
activeColor: string;
|
||||
onClick: (color: string) => void;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}) => (
|
||||
<>
|
||||
<Container className={className} justify="space-between" align="center" auto>
|
||||
{colorPalette.map((color) => (
|
||||
<ColorButton
|
||||
key={color}
|
||||
@@ -62,7 +81,8 @@ const PresetColors = ({
|
||||
onClick={() => onClick(color)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
{children}
|
||||
</Container>
|
||||
);
|
||||
|
||||
const Container = styled(Flex)`
|
||||
@@ -71,4 +91,4 @@ const Container = styled(Flex)`
|
||||
border-bottom: 1px solid ${s("inputBorder")};
|
||||
`;
|
||||
|
||||
export default IconColorPicker;
|
||||
export default ColorPicker;
|
||||
@@ -1,13 +1,12 @@
|
||||
import Flex from "@shared/components/Flex";
|
||||
import styled from "styled-components";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
import { HStack } from "~/components/primitives/HStack";
|
||||
|
||||
export const UserInputContainer = styled(HStack)`
|
||||
export const UserInputContainer = styled(Flex)`
|
||||
height: 48px;
|
||||
padding: 6px 12px 0px;
|
||||
`;
|
||||
|
||||
export const StyledInputSearch = styled(InputSearch)`
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Flex from "~/components/Flex";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import GridTemplate, { DataNode, EmojiNode } from "./GridTemplate";
|
||||
import { IconType } from "@shared/types";
|
||||
import { DisplayCategory } from "../utils";
|
||||
import { StyledInputSearch, UserInputContainer } from "./Components";
|
||||
import { useIconState } from "../useIconState";
|
||||
import Emoji from "~/models/Emoji";
|
||||
|
||||
const GRID_HEIGHT = 410;
|
||||
|
||||
type Props = {
|
||||
panelWidth: number;
|
||||
height?: number;
|
||||
query: string;
|
||||
panelActive: boolean;
|
||||
onEmojiChange: (emoji: string) => void;
|
||||
onQueryChange: (query: string) => void;
|
||||
};
|
||||
|
||||
const CustomEmojiPanel = ({
|
||||
query,
|
||||
panelActive,
|
||||
panelWidth,
|
||||
height = GRID_HEIGHT,
|
||||
onEmojiChange,
|
||||
onQueryChange,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const searchRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const scrollableRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [searchData, setSearchData] = useState<DataNode[]>([]);
|
||||
const [freqEmojis, setFreqEmojis] = useState<EmojiNode[]>([]);
|
||||
const { getFrequentIcons, incrementIconCount } = useIconState(
|
||||
IconType.Custom
|
||||
);
|
||||
|
||||
const { emojis } = useStores();
|
||||
|
||||
const handleFilter = React.useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onQueryChange(event.target.value);
|
||||
},
|
||||
[onQueryChange]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (query.trim()) {
|
||||
const initialData = emojis.findByQuery(query);
|
||||
if (initialData.length) {
|
||||
setSearchData([
|
||||
{
|
||||
category: DisplayCategory.Search,
|
||||
icons: initialData?.map(toIcon),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
emojis
|
||||
.fetchAll({
|
||||
query,
|
||||
})
|
||||
.then((data) => {
|
||||
if (data.length) {
|
||||
const iconMap = new Map([
|
||||
...initialData.map((emoji): [string, EmojiNode] => [
|
||||
emoji.name,
|
||||
toIcon(emoji),
|
||||
]),
|
||||
...data.map((emoji): [string, EmojiNode] => [
|
||||
emoji.name,
|
||||
toIcon(emoji),
|
||||
]),
|
||||
]);
|
||||
|
||||
setSearchData([
|
||||
{
|
||||
category: DisplayCategory.Search,
|
||||
icons: Array.from(iconMap.values()),
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
setSearchData([]);
|
||||
});
|
||||
} else {
|
||||
setSearchData([]);
|
||||
}
|
||||
}, [query, emojis]);
|
||||
|
||||
useEffect(() => {
|
||||
getFrequentIcons().forEach((id) => {
|
||||
emojis
|
||||
.fetch(id)
|
||||
.then((emoji) => {
|
||||
setFreqEmojis((prev) => {
|
||||
if (prev.some((item) => item.id === id)) {
|
||||
return prev;
|
||||
}
|
||||
return [...prev, toIcon(emoji)];
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// ignore
|
||||
});
|
||||
});
|
||||
}, [getFrequentIcons, emojis]);
|
||||
|
||||
const handleEmojiSelection = React.useCallback(
|
||||
({ id }: { id: string }) => {
|
||||
onEmojiChange(id);
|
||||
incrementIconCount(id);
|
||||
},
|
||||
[onEmojiChange, incrementIconCount]
|
||||
);
|
||||
|
||||
const templateData: DataNode[] = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
category: DisplayCategory.Frequent,
|
||||
icons: freqEmojis,
|
||||
},
|
||||
{
|
||||
category: DisplayCategory.All,
|
||||
icons: emojis.orderedData.map(toIcon),
|
||||
},
|
||||
],
|
||||
[emojis.orderedData, freqEmojis]
|
||||
);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!panelActive) {
|
||||
return;
|
||||
}
|
||||
scrollableRef.current?.scroll({ top: 0 });
|
||||
requestAnimationFrame(() => searchRef.current?.focus());
|
||||
}, [panelActive]);
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<UserInputContainer align="center" gap={12}>
|
||||
<StyledInputSearch
|
||||
ref={searchRef}
|
||||
value={query}
|
||||
placeholder={`${t("Search")}…`}
|
||||
onChange={handleFilter}
|
||||
/>
|
||||
</UserInputContainer>
|
||||
<GridTemplate
|
||||
ref={scrollableRef}
|
||||
width={panelWidth}
|
||||
height={height - 48}
|
||||
data={searchData.length ? searchData : templateData}
|
||||
onIconSelect={handleEmojiSelection}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
const toIcon = (emoji: Emoji): EmojiNode => ({
|
||||
type: IconType.Custom,
|
||||
id: emoji.id,
|
||||
value: emoji.id,
|
||||
name: emoji.name,
|
||||
});
|
||||
|
||||
export default CustomEmojiPanel;
|
||||
@@ -1,25 +1,14 @@
|
||||
import concat from "lodash/concat";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { EmojiSkinTone } from "@shared/types";
|
||||
import { EmojiCategory, IconType } from "@shared/types";
|
||||
import { EmojiCategory, EmojiSkinTone, IconType } from "@shared/types";
|
||||
import { getEmojis, getEmojisWithCategory, search } from "@shared/utils/emoji";
|
||||
import Flex from "~/components/Flex";
|
||||
import { EmojiCreateDialog } from "~/components/EmojiCreateDialog";
|
||||
import { DisplayCategory } from "../utils";
|
||||
import type { DataNode, EmojiNode } from "./GridTemplate";
|
||||
import GridTemplate from "./GridTemplate";
|
||||
import GridTemplate, { DataNode } from "./GridTemplate";
|
||||
import SkinTonePicker from "./SkinTonePicker";
|
||||
import { StyledInputSearch, UserInputContainer } from "./Components";
|
||||
import { useIconState } from "../useIconState";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import type Emoji from "~/models/Emoji";
|
||||
import { useComputed } from "~/hooks/useComputed";
|
||||
import { MenuButton } from "./MenuButton";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import { IconButton } from "./IconButton";
|
||||
|
||||
const GRID_HEIGHT = 410;
|
||||
|
||||
@@ -41,15 +30,9 @@ const EmojiPanel = ({
|
||||
height = GRID_HEIGHT,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { emojis, dialogs } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const can = usePolicy(team);
|
||||
|
||||
const searchRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const scrollableRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const customEmojis = useComputed(
|
||||
() => emojis.orderedData.map(toIcon),
|
||||
[emojis.orderedData]
|
||||
);
|
||||
|
||||
const {
|
||||
emojiSkinTone: skinTone,
|
||||
@@ -58,20 +41,11 @@ const EmojiPanel = ({
|
||||
getFrequentIcons,
|
||||
} = useIconState(IconType.Emoji);
|
||||
|
||||
const {
|
||||
incrementIconCount: incrementCustomIconCount,
|
||||
getFrequentIcons: getFrequentCustomIcons,
|
||||
} = useIconState(IconType.Custom);
|
||||
|
||||
const freqEmojis = React.useMemo(
|
||||
() => getFrequentIcons(),
|
||||
[getFrequentIcons]
|
||||
);
|
||||
|
||||
const [freqCustomEmojis, setFreqCustomEmojis] = React.useState<EmojiNode[]>(
|
||||
[]
|
||||
);
|
||||
|
||||
const handleFilter = React.useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onQueryChange(event.target.value);
|
||||
@@ -86,68 +60,23 @@ const EmojiPanel = ({
|
||||
[setEmojiSkinTone]
|
||||
);
|
||||
|
||||
const handleUploadClick = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Upload emoji"),
|
||||
content: <EmojiCreateDialog onSubmit={dialogs.closeAllModals} />,
|
||||
});
|
||||
}, [dialogs, t]);
|
||||
|
||||
const handleEmojiSelection = React.useCallback(
|
||||
({ id, value }: { id: string; value: string }) => {
|
||||
onEmojiChange(value);
|
||||
|
||||
// Determine if this is a custom emoji by checking if it's in the custom emoji data
|
||||
const isCustomEmoji =
|
||||
customEmojis.some((emoji) => emoji.id === id) ||
|
||||
freqCustomEmojis.some((emoji) => emoji.id === id);
|
||||
|
||||
if (isCustomEmoji) {
|
||||
incrementCustomIconCount(id);
|
||||
} else {
|
||||
incrementIconCount(id);
|
||||
}
|
||||
incrementIconCount(id);
|
||||
},
|
||||
[
|
||||
onEmojiChange,
|
||||
incrementIconCount,
|
||||
incrementCustomIconCount,
|
||||
customEmojis,
|
||||
freqCustomEmojis,
|
||||
]
|
||||
[onEmojiChange, incrementIconCount]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Load frequent custom emojis
|
||||
getFrequentCustomIcons().forEach((id) => {
|
||||
emojis
|
||||
.fetch(id)
|
||||
.then((emoji) => {
|
||||
setFreqCustomEmojis((prev) => {
|
||||
if (prev.some((item) => item.id === id)) {
|
||||
return prev;
|
||||
}
|
||||
return [...prev, toIcon(emoji)];
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// ignore
|
||||
});
|
||||
});
|
||||
}, [emojis, getFrequentCustomIcons]);
|
||||
|
||||
const isSearch = query !== "";
|
||||
const templateData: DataNode[] = isSearch
|
||||
? getSearchResults({
|
||||
query,
|
||||
skinTone,
|
||||
customEmojis,
|
||||
})
|
||||
: getAllEmojis({
|
||||
skinTone,
|
||||
freqEmojis,
|
||||
customEmojis,
|
||||
freqCustomEmojis,
|
||||
});
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
@@ -160,7 +89,7 @@ const EmojiPanel = ({
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<UserInputContainer>
|
||||
<UserInputContainer align="center" gap={12}>
|
||||
<StyledInputSearch
|
||||
ref={searchRef}
|
||||
value={query}
|
||||
@@ -168,14 +97,6 @@ const EmojiPanel = ({
|
||||
onChange={handleFilter}
|
||||
/>
|
||||
<SkinTonePicker skinTone={skinTone} onChange={handleSkinChange} />
|
||||
{can.update && (
|
||||
<MenuButton
|
||||
onClick={handleUploadClick}
|
||||
aria-label={t("Upload emoji")}
|
||||
>
|
||||
<PlusIcon />
|
||||
</MenuButton>
|
||||
)}
|
||||
</UserInputContainer>
|
||||
<GridTemplate
|
||||
ref={scrollableRef}
|
||||
@@ -183,11 +104,6 @@ const EmojiPanel = ({
|
||||
height={height - 48}
|
||||
data={templateData}
|
||||
onIconSelect={handleEmojiSelection}
|
||||
empty={
|
||||
<IconButton onClick={handleUploadClick}>
|
||||
<PlusIcon />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
@@ -196,32 +112,19 @@ const EmojiPanel = ({
|
||||
const getSearchResults = ({
|
||||
query,
|
||||
skinTone,
|
||||
customEmojis,
|
||||
}: {
|
||||
query: string;
|
||||
skinTone: EmojiSkinTone;
|
||||
customEmojis: EmojiNode[];
|
||||
}): DataNode[] => {
|
||||
const emojis = search({ query, skinTone });
|
||||
|
||||
// Search custom emojis by name
|
||||
const matchingCustomEmojis = customEmojis.filter((emoji) =>
|
||||
emoji.name?.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
|
||||
const allResults = [
|
||||
...matchingCustomEmojis,
|
||||
...emojis.map((emoji) => ({
|
||||
type: IconType.Emoji as const,
|
||||
id: emoji.id,
|
||||
value: emoji.value,
|
||||
})),
|
||||
];
|
||||
|
||||
return [
|
||||
{
|
||||
category: DisplayCategory.Search,
|
||||
icons: allResults,
|
||||
icons: emojis.map((emoji) => ({
|
||||
type: IconType.Emoji,
|
||||
id: emoji.id,
|
||||
value: emoji.value,
|
||||
})),
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -229,32 +132,21 @@ const getSearchResults = ({
|
||||
const getAllEmojis = ({
|
||||
skinTone,
|
||||
freqEmojis,
|
||||
customEmojis,
|
||||
freqCustomEmojis,
|
||||
}: {
|
||||
skinTone: EmojiSkinTone;
|
||||
freqEmojis: string[];
|
||||
customEmojis: EmojiNode[];
|
||||
freqCustomEmojis: EmojiNode[];
|
||||
}): DataNode[] => {
|
||||
const emojisWithCategory = getEmojisWithCategory({ skinTone });
|
||||
|
||||
const getFrequentIcons = (): DataNode => {
|
||||
const emojis = getEmojis({ ids: freqEmojis, skinTone });
|
||||
|
||||
// Combine frequent standard and custom emojis
|
||||
const allFrequent = [
|
||||
...emojis.map((emoji) => ({
|
||||
type: IconType.Emoji as const,
|
||||
return {
|
||||
category: DisplayCategory.Frequent,
|
||||
icons: emojis.map((emoji) => ({
|
||||
type: IconType.Emoji,
|
||||
id: emoji.id,
|
||||
value: emoji.value,
|
||||
})),
|
||||
...freqCustomEmojis,
|
||||
];
|
||||
|
||||
return {
|
||||
category: DisplayCategory.Frequent,
|
||||
icons: allFrequent,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -270,7 +162,7 @@ const getAllEmojis = ({
|
||||
};
|
||||
};
|
||||
|
||||
const allData = concat(
|
||||
return concat(
|
||||
getFrequentIcons(),
|
||||
getCategoryData(EmojiCategory.People),
|
||||
getCategoryData(EmojiCategory.Nature),
|
||||
@@ -281,22 +173,6 @@ const getAllEmojis = ({
|
||||
getCategoryData(EmojiCategory.Symbols),
|
||||
getCategoryData(EmojiCategory.Flags)
|
||||
);
|
||||
|
||||
if (customEmojis.length) {
|
||||
allData.push({
|
||||
category: "Custom",
|
||||
icons: customEmojis,
|
||||
});
|
||||
}
|
||||
|
||||
return allData;
|
||||
};
|
||||
|
||||
const toIcon = (emoji: Emoji): EmojiNode => ({
|
||||
type: IconType.Custom,
|
||||
id: emoji.id,
|
||||
value: emoji.id,
|
||||
name: emoji.name,
|
||||
});
|
||||
|
||||
export default EmojiPanel;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from "react";
|
||||
import type { ListChildComponentProps } from "react-window";
|
||||
import { FixedSizeList } from "react-window";
|
||||
import { FixedSizeList, ListChildComponentProps } from "react-window";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -37,20 +37,14 @@ export type DataNode = {
|
||||
};
|
||||
|
||||
type Props = {
|
||||
/** Width of the grid container */
|
||||
width: number;
|
||||
/** Height of the grid container */
|
||||
height: number;
|
||||
/** Data to be displayed in the grid */
|
||||
data: DataNode[];
|
||||
/** Content to display when search results are empty */
|
||||
empty?: React.ReactNode;
|
||||
/** Callback when an icon is selected */
|
||||
onIconSelect: ({ id, value }: { id: string; value: string }) => void;
|
||||
};
|
||||
|
||||
const GridTemplate = (
|
||||
{ width, height, data, empty, onIconSelect }: Props,
|
||||
{ width, height, data, onIconSelect }: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) => {
|
||||
// 24px padding for the Grid Container
|
||||
@@ -58,6 +52,10 @@ const GridTemplate = (
|
||||
|
||||
const gridItems = compact(
|
||||
data.flatMap((node) => {
|
||||
if (node.icons.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const category = (
|
||||
<CategoryName
|
||||
key={node.category}
|
||||
@@ -69,13 +67,6 @@ const GridTemplate = (
|
||||
</CategoryName>
|
||||
);
|
||||
|
||||
if (node.icons.length === 0) {
|
||||
if (node.category !== "Search") {
|
||||
return [];
|
||||
}
|
||||
return [[category], [empty]];
|
||||
}
|
||||
|
||||
const items = node.icons.map((item) => {
|
||||
if (item.type === IconType.SVG) {
|
||||
return (
|
||||
|
||||
@@ -6,9 +6,8 @@ import { IconLibrary } from "@shared/utils/IconLibrary";
|
||||
import Flex from "~/components/Flex";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
import { DisplayCategory } from "../utils";
|
||||
import IconColorPicker from "./IconColorPicker";
|
||||
import type { DataNode } from "./GridTemplate";
|
||||
import GridTemplate from "./GridTemplate";
|
||||
import ColorPicker from "./ColorPicker";
|
||||
import GridTemplate, { DataNode } from "./GridTemplate";
|
||||
import { useIconState } from "../useIconState";
|
||||
|
||||
const IconNames = Object.keys(IconLibrary.mapping);
|
||||
@@ -122,7 +121,7 @@ const IconPanel = ({
|
||||
onChange={handleFilter}
|
||||
/>
|
||||
</InputSearchContainer>
|
||||
<IconColorPicker
|
||||
<ColorPicker
|
||||
width={panelWidth}
|
||||
activeColor={color}
|
||||
onSelect={onColorChange}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { hover, s } from "@shared/styles";
|
||||
import styled from "styled-components";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
|
||||
export const MenuButton = styled(NudeButton)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: ${s("textSecondary")};
|
||||
border: 1px solid ${s("inputBorder")};
|
||||
padding: 4px;
|
||||
|
||||
&: ${hover} {
|
||||
border: 1px solid ${s("inputBorderFocused")};
|
||||
}
|
||||
`;
|
||||
@@ -1,17 +1,18 @@
|
||||
import { useMemo, useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s, hover } 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 {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "~/components/primitives/Popover";
|
||||
import { IconButton } from "./IconButton";
|
||||
import { MenuButton } from "./MenuButton";
|
||||
|
||||
const SkinTonePicker = ({
|
||||
skinTone,
|
||||
@@ -56,9 +57,9 @@ const SkinTonePicker = ({
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger>
|
||||
<MenuButton aria-label={t("Choose default skin tone")}>
|
||||
<StyledMenuButton aria-label={t("Choose default skin tone")}>
|
||||
{handEmojiVariants[skinTone]?.value}
|
||||
</MenuButton>
|
||||
</StyledMenuButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
@@ -78,4 +79,15 @@ const Emojis = styled(Flex)`
|
||||
padding: 0 8px;
|
||||
`;
|
||||
|
||||
const StyledMenuButton = styled(NudeButton)`
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid ${s("inputBorder")};
|
||||
padding: 4px;
|
||||
|
||||
&: ${hover} {
|
||||
border: 1px solid ${s("inputBorderFocused")};
|
||||
}
|
||||
`;
|
||||
|
||||
export default SkinTonePicker;
|
||||
|
||||
@@ -21,11 +21,13 @@ import { Drawer, DrawerContent, DrawerTrigger } from "../primitives/Drawer";
|
||||
import EmojiPanel from "./components/EmojiPanel";
|
||||
import IconPanel from "./components/IconPanel";
|
||||
import { PopoverButton } from "./components/PopoverButton";
|
||||
import CustomEmojiPanel from "./components/CustomEmojiPanel";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
const TAB_NAMES = {
|
||||
Icon: "icon",
|
||||
Emoji: "emoji",
|
||||
Custom: "custom",
|
||||
} as const;
|
||||
|
||||
type TabName = (typeof TAB_NAMES)[keyof typeof TAB_NAMES];
|
||||
@@ -173,7 +175,7 @@ const IconPicker = ({
|
||||
if (open) {
|
||||
void emojis.fetchAll();
|
||||
}
|
||||
}, [open, emojis]);
|
||||
}, [open]);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
@@ -252,6 +254,13 @@ const Content = ({
|
||||
>
|
||||
{t("Emojis")}
|
||||
</StyledTab>
|
||||
<StyledTab
|
||||
value={TAB_NAMES["Custom"]}
|
||||
aria-label={t("Custom Emojis")}
|
||||
$active={activeTab === TAB_NAMES["Custom"]}
|
||||
>
|
||||
{t("Custom")}
|
||||
</StyledTab>
|
||||
</Tabs.List>
|
||||
{allowDelete && (
|
||||
<RemoveButton onClick={onIconRemove}>{t("Remove")}</RemoveButton>
|
||||
@@ -278,6 +287,15 @@ const Content = ({
|
||||
onQueryChange={onQueryChange}
|
||||
/>
|
||||
</StyledTabContent>
|
||||
<StyledTabContent value={TAB_NAMES["Custom"]}>
|
||||
<CustomEmojiPanel
|
||||
panelWidth={panelWidth}
|
||||
query={query}
|
||||
panelActive={open && activeTab === TAB_NAMES["Custom"]}
|
||||
onEmojiChange={onIconChange}
|
||||
onQueryChange={onQueryChange}
|
||||
/>
|
||||
</StyledTabContent>
|
||||
</Tabs.Root>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,35 +13,17 @@ export default function CircleIcon({
|
||||
retainColor,
|
||||
...rest
|
||||
}: Props) {
|
||||
const isGradient = color === "rainbow";
|
||||
const fillValue = isGradient ? "url(#circleIconGradient)" : color;
|
||||
|
||||
return (
|
||||
<svg
|
||||
fill={fillValue}
|
||||
fill={color}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
version="1.1"
|
||||
style={retainColor ? { fill: fillValue } : undefined}
|
||||
style={retainColor ? { fill: color } : undefined}
|
||||
{...rest}
|
||||
>
|
||||
{isGradient && (
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="circleIconGradient"
|
||||
x1="0%"
|
||||
y1="0%"
|
||||
x2="100%"
|
||||
y2="100%"
|
||||
>
|
||||
<stop offset="0%" stopColor="#ff5858" />
|
||||
<stop offset="50%" stopColor="#fbcc34" />
|
||||
<stop offset="100%" stopColor="#00c6ff" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
)}
|
||||
<circle cx="12" cy="12" r="8" />
|
||||
<circle xmlns="http://www.w3.org/2000/svg" cx="12" cy="12" r="8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user