mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 514566ee75 |
@@ -15,4 +15,3 @@ crowdin.yml
|
||||
build
|
||||
docker-compose.yml
|
||||
node_modules
|
||||
.yarn
|
||||
|
||||
+2
-11
@@ -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 –––––––––––
|
||||
@@ -203,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=
|
||||
@@ -212,7 +207,7 @@ GITHUB_APP_NAME=
|
||||
GITHUB_APP_ID=
|
||||
GITHUB_APP_PRIVATE_KEY=
|
||||
|
||||
# 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=
|
||||
|
||||
@@ -223,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,7 +12,6 @@ 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;
|
||||
@@ -30,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
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Close unsigned PRs
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
const now = new Date();
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
github.event.pull_request.head.repo.full_name == github.repository)
|
||||
steps:
|
||||
- name: Checkout Branch
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v2
|
||||
- name: Compress Images
|
||||
id: calibre
|
||||
uses: calibreapp/image-actions@main
|
||||
@@ -48,7 +48,6 @@ jobs:
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
# For non-Pull Requests, run in compressOnly mode and we'll PR after.
|
||||
compressOnly: ${{ github.event_name != 'pull_request' }}
|
||||
minPctChange: "10"
|
||||
- name: Create Pull Request
|
||||
# If it's not a Pull Request then commit any changes as a new PR.
|
||||
if: |
|
||||
|
||||
+32
-67
@@ -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
|
||||
uses: actions/setup-node@v5
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
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
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
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
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
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:
|
||||
@@ -82,7 +65,7 @@ jobs:
|
||||
server: ${{ steps.filter.outputs.server }}
|
||||
app: ${{ steps.filter.outputs.app }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dorny/paths-filter@v2
|
||||
id: filter
|
||||
with:
|
||||
@@ -102,29 +85,23 @@ 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:
|
||||
matrix:
|
||||
test-group: [app, shared]
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- uses: actions/setup-node@v5
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
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:
|
||||
@@ -147,41 +124,29 @@ jobs:
|
||||
shard: [1, 2, 3, 4]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- uses: actions/setup-node@v5
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
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]
|
||||
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
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
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
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v2
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubicloud-standard-8-arm
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
runs-on: ubicloud-standard-8
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
run-linters:
|
||||
if: startsWith(github.actor, 'codegen-sh')
|
||||
name: Run linters
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
# Give the default GITHUB_TOKEN write permission to commit and push the
|
||||
# added or changed files to the repository.
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: "yarn"
|
||||
- run: yarn install --frozen-lockfile --prefer-offline
|
||||
- run: yarn lint --fix
|
||||
|
||||
- name: Commit changes
|
||||
uses: stefanzweifel/git-auto-commit-action@v5
|
||||
with:
|
||||
commit_message: "Applied automatic fixes"
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v10
|
||||
- uses: actions/stale@v5
|
||||
with:
|
||||
stale-pr-message: "This PR is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 5 days"
|
||||
stale-issue-message: "This issue is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 5 days"
|
||||
|
||||
@@ -14,10 +14,3 @@ data/*
|
||||
*.pem
|
||||
*.key
|
||||
*.cert
|
||||
|
||||
# Yarn Berry
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
|
||||
+5
-7
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"workerIdleMemoryLimit": "0.75",
|
||||
"maxWorkers": "50%",
|
||||
"transformIgnorePatterns": ["node_modules/(?!(franc|trigram-utils)/)"],
|
||||
"projects": [
|
||||
{
|
||||
"displayName": "server",
|
||||
@@ -10,10 +9,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"
|
||||
@@ -24,7 +20,8 @@
|
||||
"moduleNameMapper": {
|
||||
"^~/(.*)$": "<rootDir>/app/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
|
||||
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
|
||||
},
|
||||
"modulePaths": ["<rootDir>/app"],
|
||||
"setupFiles": ["<rootDir>/__mocks__/window.js"],
|
||||
@@ -51,7 +48,8 @@
|
||||
"moduleNameMapper": {
|
||||
"^~/(.*)$": "<rootDir>/app/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
|
||||
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
|
||||
},
|
||||
"setupFiles": ["<rootDir>/__mocks__/window.js"],
|
||||
"testEnvironment": "jsdom",
|
||||
|
||||
+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
|
||||
@@ -1,195 +0,0 @@
|
||||
Outline is a fast, collaborative knowledge base built for teams. It's built with React and TypeScript in both frontend and backend, uses a real-time collaboration engine, and is designed for excellent performance and user experience. The backend is a Koa server with an RPC API and uses PostgreSQL and Redis. The application can be self-hosted or used as a cloud service.
|
||||
|
||||
There is a web client which is fully responsive and works on mobile devices.
|
||||
|
||||
**Monorepo Structure:**
|
||||
|
||||
- **`app/`** - React web application with MobX state management
|
||||
- **`server/`** - Koa API server with Sequelize ORM and background workers
|
||||
- **`shared/`** - Shared TypeScript types, utilities, and editor components
|
||||
- **`plugins/`** - Plugin system for extending functionality
|
||||
- **`public/`** - Static assets served directly
|
||||
- **Various config files** - TypeScript, Vite, Jest, Prettier, Oxlint configurations
|
||||
|
||||
Refer to /docs/ARCHITECTURE.md for detailed architecture documentation.
|
||||
|
||||
## Instructions
|
||||
|
||||
You're an expert in the following areas:
|
||||
|
||||
- TypeScript
|
||||
- React and React Router
|
||||
- MobX and MobX-React
|
||||
- Node.js and Koa
|
||||
- Sequelize ORM
|
||||
- PostgreSQL
|
||||
- Redis
|
||||
- HTML, CSS and Styled Components
|
||||
- Prosemirror (rich text editor)
|
||||
- WebSockets and real-time collaboration
|
||||
|
||||
## 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
|
||||
|
||||
- Use yarn for all dependency management.
|
||||
- After updating dependency versions, install to update lockfiles:
|
||||
|
||||
```bash
|
||||
yarn install
|
||||
```
|
||||
|
||||
## TypeScript Usage
|
||||
|
||||
- Use strict mode.
|
||||
- Avoid "unknown" unless absolutely necessary.
|
||||
- Never use "any".
|
||||
- Prefer type definitions; avoid type assertions (as, !).
|
||||
- Always use curly braces for if statements.
|
||||
- Avoid # for private properties.
|
||||
- Prefer interface over type for object shapes.
|
||||
|
||||
## Classes & Code Organization
|
||||
|
||||
### Class Member Order
|
||||
|
||||
1. Public static variables
|
||||
2. Public static methods
|
||||
3. Public variables
|
||||
4. Public methods
|
||||
5. Protected variables & methods
|
||||
6. Private variables & methods
|
||||
|
||||
### Exports
|
||||
|
||||
- Exported members must appear at the top of the file.
|
||||
- Prefer named exports for components & classes.
|
||||
- Document ALL public/exported functions with JSDoc.
|
||||
|
||||
## React Usage
|
||||
|
||||
- Use functional components with hooks.
|
||||
- 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.
|
||||
- Use styled-components for component styling.
|
||||
- Ensure high accessibility (a11y) standards using ARIA roles and semantic HTML.
|
||||
|
||||
## MobX State Management
|
||||
|
||||
- Use MobX stores for global state management.
|
||||
- Keep stores in `app/stores/`.
|
||||
- Use `observable`, `action`, and `computed` decorators appropriately.
|
||||
- Prefer computed values over manual calculations in render.
|
||||
- Keep business logic in stores, not components.
|
||||
|
||||
## Database & ORM
|
||||
|
||||
- Use Sequelize models in `server/models/`.
|
||||
- Generate migrations with Sequelize CLI:
|
||||
|
||||
```bash
|
||||
yarn sequelize migration:create --name=add-field-to-table
|
||||
```
|
||||
|
||||
- Run migrations with `yarn db:migrate`.
|
||||
- Use transactions for multi-table operations.
|
||||
- Add appropriate indexes for query performance.
|
||||
- Always handle database errors gracefully.
|
||||
|
||||
## API Design
|
||||
|
||||
- RESTful endpoints under `/api/`.
|
||||
- Authentication endpoints under `/auth/`.
|
||||
- Use consistent error responses.
|
||||
- Validate request data using the validation middleware and schemas
|
||||
- Use presenters to format API responses.
|
||||
- Keep API routes thin, use model methods for business logic, or commands if logic spans multiple models.
|
||||
|
||||
## Authentication & Authorization
|
||||
|
||||
- JWT tokens for authentication.
|
||||
- Policies in `server/policies/` for authorization.
|
||||
- Use cancan-style ability checks.
|
||||
- Use authenticated middleware for protected routes.
|
||||
- Always verify user permissions before data access.
|
||||
|
||||
## Real-time Collaboration
|
||||
|
||||
- WebSocket connections for real-time updates.
|
||||
- Use Y.js for collaborative editing.
|
||||
- Handle connection state changes gracefully.
|
||||
|
||||
## Documentation
|
||||
|
||||
- All public/exported functions & classes must have JSDoc.
|
||||
- Include:
|
||||
- Description
|
||||
- @param and @return (start lowercase, end with period)
|
||||
- @throws if applicable
|
||||
- Add a newline between the description and the @ block.
|
||||
- Use correct punctuation.
|
||||
|
||||
## Testing
|
||||
|
||||
- Run tests with Jest:
|
||||
|
||||
```bash
|
||||
# Run a specific test file (preferred)
|
||||
yarn test path/to/test.spec.ts
|
||||
|
||||
# Run every test (avoid)
|
||||
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
|
||||
```
|
||||
|
||||
- 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.
|
||||
- Aim for high code coverage but focus on critical paths.
|
||||
|
||||
## Code Quality
|
||||
|
||||
- Use Oxlint for linting: `yarn lint`
|
||||
- Format code with Prettier: `yarn format`
|
||||
- Check types with TypeScript: `yarn tsc`
|
||||
- Pre-commit hooks run automatically via Husky.
|
||||
- Fix linting issues before committing.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Use custom error classes in `server/errors.ts`.
|
||||
- Always catch and handle errors appropriately.
|
||||
- Log errors with appropriate context.
|
||||
- Return user-friendly error messages.
|
||||
- Never expose sensitive information in errors.
|
||||
|
||||
## Performance
|
||||
|
||||
- Use React.memo for expensive components.
|
||||
- Implement pagination for large lists.
|
||||
- Use database indexes effectively.
|
||||
- Cache expensive computations.
|
||||
- Monitor performance with appropriate tools.
|
||||
- Lazy load routes and components where appropriate.
|
||||
|
||||
## Security
|
||||
|
||||
- Sanitize all user input.
|
||||
- Use CSRF protection.
|
||||
- Use rateLimiter middleware for sensitive endpoints.
|
||||
- Follow OWASP guidelines.
|
||||
- Never store sensitive data in plain text.
|
||||
- Use environment variables for secrets.
|
||||
+15
-15
@@ -6,7 +6,7 @@ ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
|
||||
# ---
|
||||
FROM node:22.21.0-slim AS runner
|
||||
FROM node:22-slim AS runner
|
||||
|
||||
LABEL org.opencontainers.image.source="https://github.com/outline/outline"
|
||||
|
||||
@@ -16,26 +16,26 @@ ENV NODE_ENV=production
|
||||
|
||||
# Create a non-root user compatible with Debian and BusyBox based images
|
||||
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
|
||||
adduser --uid 1001 --ingroup nodejs nodejs && \
|
||||
mkdir -p /var/lib/outline && \
|
||||
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 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
&& apt-get install -y wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
|
||||
RUN mkdir -p "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
|
||||
chown -R nodejs:nodejs "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
|
||||
chmod 1777 "$FILE_STORAGE_LOCAL_ROOT_DIR"
|
||||
chown -R nodejs:nodejs "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
|
||||
chmod 1777 "$FILE_STORAGE_LOCAL_ROOT_DIR"
|
||||
|
||||
VOLUME /var/lib/outline/data
|
||||
|
||||
@@ -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"]
|
||||
|
||||
+6
-5
@@ -1,23 +1,24 @@
|
||||
ARG APP_PATH=/opt/outline
|
||||
FROM node:22.21.0 AS deps
|
||||
FROM node:22 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.4.0
|
||||
The Licensed Work is (c) 2026 General Outline, Inc.
|
||||
Licensed Work: Outline 0.87.4
|
||||
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-01-27
|
||||
Change Date: 2029-09-18
|
||||
|
||||
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:
|
||||
|
||||
@@ -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.",
|
||||
@@ -214,11 +194,6 @@
|
||||
"description": "Use a secure SMTP connection (optional)",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_DISABLE_STARTTLS": {
|
||||
"value": "false",
|
||||
"description": "Disable STARTTLS even if the server supports it (optional)",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_TLS_CIPHERS": {
|
||||
"description": "Override SMTP cipher configuration (optional)",
|
||||
"required": false
|
||||
|
||||
@@ -5,13 +5,6 @@
|
||||
{
|
||||
"files": ["**/*.{jsx,tsx}"],
|
||||
"rules": {
|
||||
"no-restricted-globals": [
|
||||
"error",
|
||||
{
|
||||
"name": "crypto",
|
||||
"message": "Do not use, does not work in environments without SSL."
|
||||
}
|
||||
],
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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 "..";
|
||||
import { createAction, createActionV2 } from "..";
|
||||
import { SettingsSection } from "../sections";
|
||||
|
||||
export const createApiKey = createAction({
|
||||
@@ -26,7 +26,7 @@ export const createApiKey = createAction({
|
||||
});
|
||||
|
||||
export const revokeApiKeyFactory = ({ apiKey }: { apiKey: ApiKey }) =>
|
||||
createAction({
|
||||
createActionV2({
|
||||
name: ({ t, isMenu }) =>
|
||||
isMenu
|
||||
? apiKey.isExpired
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import {
|
||||
SortAlphabeticalReverseIcon,
|
||||
SortAlphabeticalIcon,
|
||||
AlphabeticalReverseSortIcon,
|
||||
AlphabeticalSortIcon,
|
||||
ArchiveIcon,
|
||||
CollectionIcon,
|
||||
EditIcon,
|
||||
ExportIcon,
|
||||
ImportIcon,
|
||||
SortManualIcon,
|
||||
ManualSortIcon,
|
||||
NewDocumentIcon,
|
||||
PadlockIcon,
|
||||
PlusIcon,
|
||||
@@ -29,8 +29,9 @@ import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||
import {
|
||||
createAction,
|
||||
createActionWithChildren,
|
||||
createInternalLinkAction,
|
||||
createActionV2,
|
||||
createActionV2WithChildren,
|
||||
createInternalLinkActionV2,
|
||||
} from "~/actions";
|
||||
import { ActiveCollectionSection, CollectionSection } from "~/actions/sections";
|
||||
import { setPersistedState } from "~/hooks/usePersistedState";
|
||||
@@ -51,7 +52,7 @@ const SharePopover = lazyWithRetry(
|
||||
() => import("~/components/Sharing/Collection/SharePopover")
|
||||
);
|
||||
|
||||
export const openCollection = createActionWithChildren({
|
||||
export const openCollection = createAction({
|
||||
name: ({ t }) => t("Open collection"),
|
||||
analyticsName: "Open collection",
|
||||
section: CollectionSection,
|
||||
@@ -59,17 +60,15 @@ export const openCollection = createActionWithChildren({
|
||||
icon: <CollectionIcon />,
|
||||
children: ({ stores }) => {
|
||||
const collections = stores.collections.orderedData;
|
||||
return collections.map((collection) =>
|
||||
createInternalLinkAction({
|
||||
// Note: using url which includes the slug rather than id here to bust
|
||||
// cache if the collection is renamed
|
||||
id: collection.path,
|
||||
name: collection.name,
|
||||
icon: <ColorCollectionIcon collection={collection} />,
|
||||
section: CollectionSection,
|
||||
to: collection.path,
|
||||
})
|
||||
);
|
||||
return collections.map((collection) => ({
|
||||
// Note: using url which includes the slug rather than id here to bust
|
||||
// cache if the collection is renamed
|
||||
id: collection.path,
|
||||
name: collection.name,
|
||||
icon: <ColorCollectionIcon collection={collection} />,
|
||||
section: CollectionSection,
|
||||
to: collection.path,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -91,16 +90,16 @@ export const createCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const editCollection = createAction({
|
||||
export const editCollection = createActionV2({
|
||||
name: ({ t, isMenu }) => (isMenu ? `${t("Edit")}…` : t("Edit collection")),
|
||||
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,29 +108,34 @@ export const editCollection = createAction({
|
||||
content: (
|
||||
<CollectionEdit
|
||||
onSubmit={stores.dialogs.closeAllModals}
|
||||
collectionId={collection.id}
|
||||
collectionId={activeCollectionId}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const editCollectionPermissions = createAction({
|
||||
export const editCollectionPermissions = createActionV2({
|
||||
name: ({ t, isMenu }) =>
|
||||
isMenu ? `${t("Permissions")}…` : t("Collection permissions"),
|
||||
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}
|
||||
@@ -143,34 +147,38 @@ export const editCollectionPermissions = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const importDocument = createAction({
|
||||
export const importDocument = createActionV2({
|
||||
name: ({ t }) => t("Import document"),
|
||||
analyticsName: "Import document",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ImportIcon />,
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some(
|
||||
(policy) => policy.abilities.createDocument
|
||||
),
|
||||
perform: ({ 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;
|
||||
input.accept = documents.importFileTypes.join(", ");
|
||||
|
||||
input.onchange = async (ev) => {
|
||||
const files = getEventFiles(ev);
|
||||
const file = files[0];
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -180,39 +188,40 @@ export const importDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const sortCollection = createActionWithChildren({
|
||||
export const sortCollection = createActionV2WithChildren({
|
||||
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({
|
||||
createActionV2({
|
||||
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",
|
||||
@@ -221,18 +230,18 @@ export const sortCollection = createActionWithChildren({
|
||||
});
|
||||
},
|
||||
}),
|
||||
createAction({
|
||||
createActionV2({
|
||||
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",
|
||||
@@ -241,15 +250,15 @@ export const sortCollection = createActionWithChildren({
|
||||
});
|
||||
},
|
||||
}),
|
||||
createAction({
|
||||
createActionV2({
|
||||
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",
|
||||
@@ -261,24 +270,27 @@ export const sortCollection = createActionWithChildren({
|
||||
],
|
||||
});
|
||||
|
||||
export const searchInCollection = createInternalLinkAction({
|
||||
export const searchInCollection = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("Search in collection"),
|
||||
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 {
|
||||
@@ -289,124 +301,143 @@ export const searchInCollection = createInternalLinkAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const starCollection = createAction({
|
||||
export const starCollection = createActionV2({
|
||||
name: ({ t }) => t("Star"),
|
||||
analyticsName: "Star collection",
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
export const unstarCollection = createAction({
|
||||
export const unstarCollection = createActionV2({
|
||||
name: ({ t }) => t("Unstar"),
|
||||
analyticsName: "Unstar collection",
|
||||
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();
|
||||
},
|
||||
});
|
||||
|
||||
export const subscribeCollection = createAction({
|
||||
export const subscribeCollection = createActionV2({
|
||||
name: ({ t }) => t("Subscribe"),
|
||||
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"));
|
||||
},
|
||||
});
|
||||
|
||||
export const unsubscribeCollection = createAction({
|
||||
export const unsubscribeCollection = createActionV2({
|
||||
name: ({ t }) => t("Unsubscribe"),
|
||||
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"));
|
||||
},
|
||||
});
|
||||
|
||||
export const archiveCollection = createAction({
|
||||
export const archiveCollection = createActionV2({
|
||||
name: ({ t }) => `${t("Archive")}…`,
|
||||
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
|
||||
@@ -426,15 +457,22 @@ export const archiveCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const restoreCollection = createAction({
|
||||
export const restoreCollection = createActionV2({
|
||||
name: ({ t }) => t("Restore"),
|
||||
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;
|
||||
}
|
||||
@@ -444,16 +482,24 @@ export const restoreCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteCollection = createAction({
|
||||
export const deleteCollection = createActionV2({
|
||||
name: ({ t }) => `${t("Delete")}…`,
|
||||
analyticsName: "Delete collection",
|
||||
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;
|
||||
}
|
||||
@@ -470,15 +516,26 @@ export const deleteCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const exportCollection = createAction({
|
||||
export const exportCollection = createActionV2({
|
||||
name: ({ t }) => `${t("Export")}…`,
|
||||
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(currentTeamId).createExport &&
|
||||
!!stores.policies.abilities(activeCollectionId).export
|
||||
);
|
||||
},
|
||||
perform: async ({ activeCollectionId, stores, t }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
@@ -495,19 +552,19 @@ export const exportCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const createDocument = createInternalLinkAction({
|
||||
export const createDocument = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("New document"),
|
||||
analyticsName: "New document",
|
||||
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,
|
||||
@@ -517,19 +574,19 @@ export const createDocument = createInternalLinkAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const createTemplate = createInternalLinkAction({
|
||||
export const createTemplate = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("New template"),
|
||||
analyticsName: "New template",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ShapesIcon />,
|
||||
keywords: "new create template",
|
||||
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] = newTemplatePath(collection?.id).split("?");
|
||||
to: ({ activeCollectionId, sidebarContext }) => {
|
||||
const [pathname, search] = newTemplatePath(activeCollectionId).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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 "..";
|
||||
import { createActionV2 } from "..";
|
||||
import { ActiveDocumentSection } from "../sections";
|
||||
|
||||
export const deleteCommentFactory = ({
|
||||
@@ -13,7 +13,7 @@ export const deleteCommentFactory = ({
|
||||
comment: Comment;
|
||||
onDelete: () => void;
|
||||
}) =>
|
||||
createAction({
|
||||
createActionV2({
|
||||
name: ({ t }) => `${t("Delete")}…`,
|
||||
analyticsName: "Delete comment",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -39,7 +39,7 @@ export const resolveCommentFactory = ({
|
||||
comment: Comment;
|
||||
onResolve: () => void;
|
||||
}) =>
|
||||
createAction({
|
||||
createActionV2({
|
||||
name: ({ t }) => t("Mark as resolved"),
|
||||
analyticsName: "Resolve thread",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -61,7 +61,7 @@ export const unresolveCommentFactory = ({
|
||||
comment: Comment;
|
||||
onUnresolve: () => void;
|
||||
}) =>
|
||||
createAction({
|
||||
createActionV2({
|
||||
name: ({ t }) => t("Mark as unresolved"),
|
||||
analyticsName: "Unresolve thread",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -80,7 +80,7 @@ export const viewCommentReactionsFactory = ({
|
||||
}: {
|
||||
comment: Comment;
|
||||
}) =>
|
||||
createAction({
|
||||
createActionV2({
|
||||
name: ({ t }) => `${t("View reactions")}`,
|
||||
analyticsName: "View comment reactions",
|
||||
section: ActiveDocumentSection,
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
UserIcon,
|
||||
} from "outline-icons";
|
||||
import { toast } from "sonner";
|
||||
import { createAction, createActionWithChildren } from "~/actions";
|
||||
import { createAction } from "~/actions";
|
||||
import { DeveloperSection } from "~/actions/sections";
|
||||
import env from "~/env";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
@@ -17,19 +17,9 @@ 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";
|
||||
import { homePath } from "~/utils/routeHelpers";
|
||||
|
||||
export const goToDebug = createAction({
|
||||
name: "Go to debug screen",
|
||||
icon: <BeakerIcon />,
|
||||
section: DeveloperSection,
|
||||
visible: () => env.ENVIRONMENT === "development",
|
||||
perform: () => {
|
||||
history.push(debugPath());
|
||||
},
|
||||
});
|
||||
|
||||
export const copyId = createActionWithChildren({
|
||||
export const copyId = createAction({
|
||||
name: ({ t }) => t("Copy ID"),
|
||||
icon: <CopyIcon />,
|
||||
keywords: "uuid",
|
||||
@@ -186,22 +176,7 @@ export const toggleDebugLogging = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const toggleDebugSafeArea = createAction({
|
||||
name: () => "Toggle menu safe area debugging",
|
||||
icon: <ToolsIcon />,
|
||||
section: DeveloperSection,
|
||||
visible: () => env.ENVIRONMENT === "development",
|
||||
perform: ({ stores }) => {
|
||||
stores.ui.toggleDebugSafeArea();
|
||||
toast.message(
|
||||
stores.ui.debugSafeArea
|
||||
? "Menu safe area debugging enabled"
|
||||
: "Menu safe area debugging disabled"
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const toggleFeatureFlag = createActionWithChildren({
|
||||
export const toggleFeatureFlag = createAction({
|
||||
name: "Toggle feature flag",
|
||||
icon: <BeakerIcon />,
|
||||
section: DeveloperSection,
|
||||
@@ -225,17 +200,15 @@ export const toggleFeatureFlag = createActionWithChildren({
|
||||
),
|
||||
});
|
||||
|
||||
export const developer = createActionWithChildren({
|
||||
export const developer = createAction({
|
||||
name: ({ t }) => t("Development"),
|
||||
keywords: "debug",
|
||||
icon: <ToolsIcon />,
|
||||
iconInContextMenu: false,
|
||||
section: DeveloperSection,
|
||||
children: [
|
||||
goToDebug,
|
||||
copyId,
|
||||
toggleDebugLogging,
|
||||
toggleDebugSafeArea,
|
||||
toggleFeatureFlag,
|
||||
createToast,
|
||||
createTestUsers,
|
||||
|
||||
@@ -35,12 +35,13 @@ 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";
|
||||
@@ -48,21 +49,22 @@ import DocumentPublish from "~/scenes/DocumentPublish";
|
||||
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import DocumentCopy from "~/components/DocumentCopy";
|
||||
import { DocumentDownload } from "~/components/DocumentDownload";
|
||||
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
|
||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||
import DocumentTemplatizeDialog from "~/components/TemplatizeDialog";
|
||||
import {
|
||||
createAction,
|
||||
createActionGroup,
|
||||
createActionWithChildren,
|
||||
createInternalLinkAction,
|
||||
createActionV2,
|
||||
createActionV2Group,
|
||||
createActionV2WithChildren,
|
||||
createInternalLinkActionV2,
|
||||
} from "~/actions";
|
||||
import {
|
||||
ActiveDocumentSection,
|
||||
DocumentSection,
|
||||
TrashSection,
|
||||
} from "~/actions/sections";
|
||||
import env from "~/env";
|
||||
import { setPersistedState } from "~/hooks/usePersistedState";
|
||||
import history from "~/utils/history";
|
||||
import {
|
||||
@@ -78,9 +80,8 @@ import {
|
||||
} from "~/utils/routeHelpers";
|
||||
import capitalize from "lodash/capitalize";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import type { Action, ActionGroup, ActionSeparator } from "~/types";
|
||||
import { ActionV2, ActionV2Group, ActionV2Separator } from "~/types";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import env from "~/env";
|
||||
|
||||
const Insights = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/Insights")
|
||||
@@ -89,7 +90,7 @@ const SharePopover = lazyWithRetry(
|
||||
() => import("~/components/Sharing/Document/SharePopover")
|
||||
);
|
||||
|
||||
export const openDocument = createActionWithChildren({
|
||||
export const openDocument = createAction({
|
||||
name: ({ t }) => t("Open document"),
|
||||
analyticsName: "Open document",
|
||||
section: DocumentSection,
|
||||
@@ -103,29 +104,23 @@ export const openDocument = createActionWithChildren({
|
||||
);
|
||||
const documents = stores.documents.orderedData;
|
||||
|
||||
return uniqBy([...documents, ...nodes], "id").map((item) =>
|
||||
createInternalLinkAction({
|
||||
// Note: using url which includes the slug rather than id here to bust
|
||||
// cache if the document is renamed
|
||||
id: item.url,
|
||||
name: item.title,
|
||||
icon: item.icon ? (
|
||||
<Icon
|
||||
value={item.icon}
|
||||
initial={item.title}
|
||||
color={item.color ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<DocumentIcon />
|
||||
),
|
||||
section: DocumentSection,
|
||||
to: item.url,
|
||||
})
|
||||
);
|
||||
return uniqBy([...documents, ...nodes], "id").map((item) => ({
|
||||
// Note: using url which includes the slug rather than id here to bust
|
||||
// cache if the document is renamed
|
||||
id: item.url,
|
||||
name: item.title,
|
||||
icon: item.icon ? (
|
||||
<Icon value={item.icon} color={item.color ?? undefined} />
|
||||
) : (
|
||||
<DocumentIcon />
|
||||
),
|
||||
section: DocumentSection,
|
||||
to: item.url,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
export const editDocument = createInternalLinkAction({
|
||||
export const editDocument = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("Edit"),
|
||||
analyticsName: "Edit document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -157,7 +152,7 @@ export const editDocument = createInternalLinkAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const createDocument = createInternalLinkAction({
|
||||
export const createDocument = createAction({
|
||||
name: ({ t }) => t("New document"),
|
||||
analyticsName: "New document",
|
||||
section: DocumentSection,
|
||||
@@ -175,18 +170,13 @@ export const createDocument = createInternalLinkAction({
|
||||
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument
|
||||
);
|
||||
},
|
||||
to: ({ activeCollectionId, sidebarContext }) => {
|
||||
const [pathname, search] = newDocumentPath(activeCollectionId).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
perform: ({ activeCollectionId, sidebarContext }) =>
|
||||
history.push(newDocumentPath(activeCollectionId), {
|
||||
sidebarContext,
|
||||
}),
|
||||
});
|
||||
|
||||
export const createDraftDocument = createInternalLinkAction({
|
||||
export const createDraftDocument = createAction({
|
||||
name: ({ t }) => t("New draft"),
|
||||
analyticsName: "New document",
|
||||
section: DocumentSection,
|
||||
@@ -194,13 +184,13 @@ export const createDraftDocument = createInternalLinkAction({
|
||||
keywords: "create document",
|
||||
visible: ({ currentTeamId, stores }) =>
|
||||
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument,
|
||||
to: ({ sidebarContext }) => ({
|
||||
pathname: newDocumentPath(),
|
||||
state: { sidebarContext },
|
||||
}),
|
||||
perform: ({ sidebarContext }) =>
|
||||
history.push(newDocumentPath(), {
|
||||
sidebarContext,
|
||||
}),
|
||||
});
|
||||
|
||||
export const createDocumentFromTemplate = createInternalLinkAction({
|
||||
export const createDocumentFromTemplate = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("New from template"),
|
||||
analyticsName: "New document",
|
||||
section: DocumentSection,
|
||||
@@ -247,7 +237,7 @@ export const createDocumentFromTemplate = createInternalLinkAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const createNestedDocument = createInternalLinkAction({
|
||||
export const createNestedDocument = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("New nested document"),
|
||||
analyticsName: "New document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -270,7 +260,7 @@ export const createNestedDocument = createInternalLinkAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const starDocument = createAction({
|
||||
export const starDocument = createActionV2({
|
||||
name: ({ t }) => t("Star"),
|
||||
analyticsName: "Star document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -296,7 +286,7 @@ export const starDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const unstarDocument = createAction({
|
||||
export const unstarDocument = createActionV2({
|
||||
name: ({ t }) => t("Unstar"),
|
||||
analyticsName: "Unstar document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -322,7 +312,7 @@ export const unstarDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const publishDocument = createAction({
|
||||
export const publishDocument = createActionV2({
|
||||
name: ({ t }) => t("Publish"),
|
||||
analyticsName: "Publish document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -364,7 +354,7 @@ export const publishDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const unpublishDocument = createAction({
|
||||
export const unpublishDocument = createActionV2({
|
||||
name: ({ t }) => t("Unpublish"),
|
||||
analyticsName: "Unpublish document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -395,7 +385,7 @@ export const unpublishDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const subscribeDocument = createAction({
|
||||
export const subscribeDocument = createActionV2({
|
||||
name: ({ t }) => t("Subscribe"),
|
||||
analyticsName: "Subscribe to document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -441,7 +431,7 @@ export const subscribeDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const unsubscribeDocument = createAction({
|
||||
export const unsubscribeDocument = createActionV2({
|
||||
name: ({ t }) => t("Unsubscribe"),
|
||||
analyticsName: "Unsubscribe from document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -489,7 +479,7 @@ export const unsubscribeDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const shareDocument = createAction({
|
||||
export const shareDocument = createActionV2({
|
||||
name: ({ t }) => `${t("Permissions")}…`,
|
||||
analyticsName: "Share document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -509,6 +499,7 @@ export const shareDocument = createAction({
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
style: { marginBottom: -12 },
|
||||
title: t("Share this document"),
|
||||
content: (
|
||||
<SharePopover
|
||||
@@ -521,61 +512,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"),
|
||||
export const downloadDocumentAsHTML = createActionV2({
|
||||
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 }) => {
|
||||
@@ -584,39 +527,71 @@ 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"),
|
||||
export const downloadDocumentAsPDF = createActionV2({
|
||||
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 = createActionV2({
|
||||
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 copyDocumentAsMarkdown = createAction({
|
||||
export const downloadDocument = createActionV2WithChildren({
|
||||
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 = createActionV2({
|
||||
name: ({ t }) => t("Copy as Markdown"),
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "clipboard",
|
||||
@@ -629,17 +604,16 @@ 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"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const copyDocumentAsPlainText = createAction({
|
||||
export const copyDocumentAsPlainText = createActionV2({
|
||||
name: ({ t }) => t("Copy as text"),
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "clipboard",
|
||||
@@ -652,15 +626,16 @@ 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"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const copyDocumentShareLink = createAction({
|
||||
export const copyDocumentShareLink = createActionV2({
|
||||
name: ({ t }) => t("Copy public link"),
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "clipboard share",
|
||||
@@ -681,7 +656,7 @@ export const copyDocumentShareLink = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const copyDocumentLink = createAction({
|
||||
export const copyDocumentLink = createActionV2({
|
||||
name: ({ t }) => t("Copy link"),
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "clipboard",
|
||||
@@ -699,7 +674,7 @@ export const copyDocumentLink = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const copyDocument = createActionWithChildren({
|
||||
export const copyDocument = createActionV2WithChildren({
|
||||
name: ({ t }) => t("Copy"),
|
||||
analyticsName: "Copy document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -713,7 +688,7 @@ export const copyDocument = createActionWithChildren({
|
||||
],
|
||||
});
|
||||
|
||||
export const duplicateDocument = createAction({
|
||||
export const duplicateDocument = createActionV2({
|
||||
name: ({ t, isMenu }) => (isMenu ? t("Duplicate") : t("Duplicate document")),
|
||||
analyticsName: "Duplicate document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -748,7 +723,7 @@ export const duplicateDocument = createAction({
|
||||
* Pin a document to a collection. Pinned documents will be displayed at the top
|
||||
* of the collection for all collection members to see.
|
||||
*/
|
||||
export const pinDocumentToCollection = createAction({
|
||||
export const pinDocumentToCollection = createActionV2({
|
||||
name: ({ activeDocumentId = "", t, stores }) => {
|
||||
const selectedDocument = stores.documents.get(activeDocumentId);
|
||||
const collectionName = selectedDocument
|
||||
@@ -793,7 +768,7 @@ export const pinDocumentToCollection = createAction({
|
||||
* Pin a document to team home. Pinned documents will be displayed at the top
|
||||
* of the home screen for all team members to see.
|
||||
*/
|
||||
export const pinDocumentToHome = createAction({
|
||||
export const pinDocumentToHome = createActionV2({
|
||||
name: ({ t }) => t("Pin to home"),
|
||||
analyticsName: "Pin document to home",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -825,7 +800,7 @@ export const pinDocumentToHome = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const pinDocument = createActionWithChildren({
|
||||
export const pinDocument = createActionV2WithChildren({
|
||||
name: ({ t }) => t("Pin"),
|
||||
analyticsName: "Pin document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -833,7 +808,7 @@ export const pinDocument = createActionWithChildren({
|
||||
children: [pinDocumentToCollection, pinDocumentToHome],
|
||||
});
|
||||
|
||||
export const searchInDocument = createInternalLinkAction({
|
||||
export const searchInDocument = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("Search in document"),
|
||||
analyticsName: "Search document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -863,7 +838,7 @@ export const searchInDocument = createInternalLinkAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const printDocument = createAction({
|
||||
export const printDocument = createActionV2({
|
||||
name: ({ t, isMenu }) => (isMenu ? t("Print") : t("Print document")),
|
||||
analyticsName: "Print document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -874,7 +849,7 @@ export const printDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const importDocument = createAction({
|
||||
export const importDocument = createActionV2({
|
||||
name: ({ t }) => t("Import document"),
|
||||
analyticsName: "Import document",
|
||||
section: DocumentSection,
|
||||
@@ -895,7 +870,7 @@ export const importDocument = createAction({
|
||||
const { documents } = stores;
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = documents.importFileTypesString;
|
||||
input.accept = documents.importFileTypes.join(", ");
|
||||
|
||||
input.onchange = async (ev) => {
|
||||
const files = getEventFiles(ev);
|
||||
@@ -920,7 +895,7 @@ export const importDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const createTemplateFromDocument = createAction({
|
||||
export const createTemplateFromDocument = createActionV2({
|
||||
name: ({ t }) => t("Templatize"),
|
||||
analyticsName: "Templatize document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -971,7 +946,7 @@ export const openRandomDocument = createAction({
|
||||
});
|
||||
|
||||
export const searchDocumentsForQuery = (query: string) =>
|
||||
createInternalLinkAction({
|
||||
createAction({
|
||||
id: "search",
|
||||
name: ({ t }) =>
|
||||
t(`Search documents for "{{searchQuery}}"`, { searchQuery: query }),
|
||||
@@ -982,7 +957,7 @@ export const searchDocumentsForQuery = (query: string) =>
|
||||
visible: ({ location }) => location.pathname !== searchPath(),
|
||||
});
|
||||
|
||||
export const moveTemplateToWorkspace = createAction({
|
||||
export const moveTemplateToWorkspace = createActionV2({
|
||||
name: ({ t }) => t("Move to workspace"),
|
||||
analyticsName: "Move template to workspace",
|
||||
section: DocumentSection,
|
||||
@@ -1012,7 +987,7 @@ export const moveTemplateToWorkspace = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const moveDocumentToCollection = createAction({
|
||||
export const moveDocumentToCollection = createActionV2({
|
||||
name: ({ activeDocumentId, stores, t }) => {
|
||||
if (!activeDocumentId) {
|
||||
return t("Move");
|
||||
@@ -1049,7 +1024,7 @@ export const moveDocumentToCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const moveDocument = createAction({
|
||||
export const moveDocument = createActionV2({
|
||||
name: ({ t }) => t("Move"),
|
||||
analyticsName: "Move document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -1068,7 +1043,7 @@ export const moveDocument = createAction({
|
||||
perform: moveDocumentToCollection.perform,
|
||||
});
|
||||
|
||||
export const moveTemplate = createActionWithChildren({
|
||||
export const moveTemplate = createActionV2WithChildren({
|
||||
name: ({ t }) => t("Move"),
|
||||
analyticsName: "Move document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -1087,7 +1062,7 @@ export const moveTemplate = createActionWithChildren({
|
||||
children: [moveTemplateToWorkspace, moveDocumentToCollection],
|
||||
});
|
||||
|
||||
export const archiveDocument = createAction({
|
||||
export const archiveDocument = createActionV2({
|
||||
name: ({ t }) => `${t("Archive")}…`,
|
||||
analyticsName: "Archive document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -1127,7 +1102,7 @@ export const archiveDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const restoreDocument = createAction({
|
||||
export const restoreDocument = createActionV2({
|
||||
name: ({ t }) => `${t("Restore")}`,
|
||||
analyticsName: "Restore document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -1167,7 +1142,7 @@ export const restoreDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const restoreDocumentToCollection = createActionWithChildren({
|
||||
export const restoreDocumentToCollection = createActionV2WithChildren({
|
||||
name: ({ t }) => `${t("Restore")}…`,
|
||||
analyticsName: "Restore document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -1202,7 +1177,7 @@ export const restoreDocumentToCollection = createActionWithChildren({
|
||||
|
||||
const actions = collections.orderedData.map((collection) => {
|
||||
const can = policies.abilities(collection.id);
|
||||
return createAction({
|
||||
return createActionV2({
|
||||
name: collection.name,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <CollectionIcon collection={collection} />,
|
||||
@@ -1218,11 +1193,11 @@ export const restoreDocumentToCollection = createActionWithChildren({
|
||||
});
|
||||
});
|
||||
|
||||
return [createActionGroup({ name: t("Choose a collection"), actions })];
|
||||
return [createActionV2Group({ name: t("Choose a collection"), actions })];
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteDocument = createAction({
|
||||
export const deleteDocument = createActionV2({
|
||||
name: ({ t }) => `${t("Delete")}…`,
|
||||
analyticsName: "Delete document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -1256,7 +1231,7 @@ export const deleteDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const permanentlyDeleteDocument = createAction({
|
||||
export const permanentlyDeleteDocument = createActionV2({
|
||||
name: ({ t }) => t("Permanently delete"),
|
||||
analyticsName: "Permanently delete document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -1311,7 +1286,7 @@ export const permanentlyDeleteDocumentsInTrash = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const openDocumentComments = createAction({
|
||||
export const openDocumentComments = createActionV2({
|
||||
name: ({ t }) => t("Comments"),
|
||||
analyticsName: "Open comments",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -1334,7 +1309,7 @@ export const openDocumentComments = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const openDocumentHistory = createInternalLinkAction({
|
||||
export const openDocumentHistory = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("History"),
|
||||
analyticsName: "Open document history",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -1361,7 +1336,7 @@ export const openDocumentHistory = createInternalLinkAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const openDocumentInsights = createAction({
|
||||
export const openDocumentInsights = createActionV2({
|
||||
name: ({ t }) => t("Insights"),
|
||||
analyticsName: "Open document insights",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -1394,7 +1369,7 @@ export const openDocumentInsights = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const leaveDocument = createAction({
|
||||
export const leaveDocument = createActionV2({
|
||||
name: ({ t }) => t("Leave document"),
|
||||
analyticsName: "Leave document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -1433,9 +1408,9 @@ export const leaveDocument = createAction({
|
||||
export const applyTemplateFactory = ({
|
||||
actions,
|
||||
}: {
|
||||
actions: (Action | ActionGroup | ActionSeparator)[];
|
||||
actions: (ActionV2 | ActionV2Group | ActionV2Separator)[];
|
||||
}) =>
|
||||
createActionWithChildren({
|
||||
createActionV2WithChildren({
|
||||
name: ({ t }) => t("Apply template"),
|
||||
analyticsName: "Apply template",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -1461,9 +1436,6 @@ export const rootDocumentActions = [
|
||||
deleteDocument,
|
||||
importDocument,
|
||||
downloadDocument,
|
||||
downloadDocumentAsMarkdown,
|
||||
downloadDocumentAsHTML,
|
||||
downloadDocumentAsPDF,
|
||||
copyDocumentLink,
|
||||
copyDocumentShareLink,
|
||||
copyDocumentAsMarkdown,
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import { createAction } from "~/actions";
|
||||
import { TeamSection } from "../sections";
|
||||
import stores from "~/stores";
|
||||
import { EmojiCreateDialog } from "~/components/EmojiCreateDialog";
|
||||
|
||||
export const createEmoji = createAction({
|
||||
name: ({ t }) => `${t("New emoji")}…`,
|
||||
analyticsName: "Create emoji",
|
||||
icon: <PlusIcon />,
|
||||
keywords: "emoji custom upload image",
|
||||
section: TeamSection,
|
||||
visible: () =>
|
||||
stores.policies.abilities(stores.auth.team?.id || "").createEmoji,
|
||||
perform: ({ t }) => {
|
||||
stores.dialogs.openModal({
|
||||
title: t("Upload emoji"),
|
||||
content: <EmojiCreateDialog onSubmit={stores.dialogs.closeAllModals} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -2,28 +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 { DisconnectAnalyticsDialog } from "~/scenes/Settings/components/DisconnectAnalyticsDialog";
|
||||
import type { IntegrationType } from "@shared/types";
|
||||
import { settingsPath } from "@shared/utils/routeHelpers";
|
||||
import history from "~/utils/history";
|
||||
|
||||
export const disconnectIntegrationFactory = (integration?: Integration) =>
|
||||
createAction({
|
||||
name: ({ t }) => t("Disconnect"),
|
||||
analyticsName: "Disconnect integration",
|
||||
section: SettingsSection,
|
||||
icon: <TrashIcon />,
|
||||
keywords: "disconnect",
|
||||
visible: () => !!integration,
|
||||
perform: async ({ event }) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
await integration?.delete();
|
||||
history.push(settingsPath("integrations"));
|
||||
},
|
||||
});
|
||||
import Integration from "~/models/Integration";
|
||||
import { IntegrationType } from "@shared/types";
|
||||
import { DisconnectAnalyticsDialog } from "~/components/DisconnectAnalyticsDialog";
|
||||
|
||||
export const disconnectAnalyticsIntegrationFactory = (
|
||||
integration?: Integration<IntegrationType.Analytics>
|
||||
|
||||
@@ -17,12 +17,13 @@ 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,
|
||||
createExternalLinkAction,
|
||||
createInternalLinkAction,
|
||||
createActionV2,
|
||||
createExternalLinkActionV2,
|
||||
createInternalLinkActionV2,
|
||||
} from "~/actions";
|
||||
import { NavigationSection, RecentSearchesSection } from "~/actions/sections";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
@@ -36,7 +37,7 @@ import {
|
||||
settingsPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
|
||||
export const navigateToHome = createInternalLinkAction({
|
||||
export const navigateToHome = createAction({
|
||||
name: ({ t }) => t("Home"),
|
||||
analyticsName: "Navigate to home",
|
||||
section: NavigationSection,
|
||||
@@ -47,7 +48,7 @@ export const navigateToHome = createInternalLinkAction({
|
||||
});
|
||||
|
||||
export const navigateToRecentSearchQuery = (searchQuery: SearchQuery) =>
|
||||
createInternalLinkAction({
|
||||
createAction({
|
||||
section: RecentSearchesSection,
|
||||
name: searchQuery.query,
|
||||
analyticsName: "Navigate to recent search query",
|
||||
@@ -55,7 +56,7 @@ export const navigateToRecentSearchQuery = (searchQuery: SearchQuery) =>
|
||||
to: searchPath({ query: searchQuery.query }),
|
||||
});
|
||||
|
||||
export const navigateToDrafts = createInternalLinkAction({
|
||||
export const navigateToDrafts = createAction({
|
||||
name: ({ t }) => t("Drafts"),
|
||||
analyticsName: "Navigate to drafts",
|
||||
section: NavigationSection,
|
||||
@@ -64,7 +65,7 @@ export const navigateToDrafts = createInternalLinkAction({
|
||||
visible: ({ location }) => location.pathname !== draftsPath(),
|
||||
});
|
||||
|
||||
export const navigateToSearch = createInternalLinkAction({
|
||||
export const navigateToSearch = createAction({
|
||||
name: ({ t }) => t("Search"),
|
||||
analyticsName: "Navigate to search",
|
||||
section: NavigationSection,
|
||||
@@ -73,7 +74,7 @@ export const navigateToSearch = createInternalLinkAction({
|
||||
visible: ({ location }) => location.pathname !== searchPath(),
|
||||
});
|
||||
|
||||
export const navigateToArchive = createInternalLinkAction({
|
||||
export const navigateToArchive = createAction({
|
||||
name: ({ t }) => t("Archive"),
|
||||
analyticsName: "Navigate to archive",
|
||||
section: NavigationSection,
|
||||
@@ -83,7 +84,7 @@ export const navigateToArchive = createInternalLinkAction({
|
||||
visible: ({ location }) => location.pathname !== archivePath(),
|
||||
});
|
||||
|
||||
export const navigateToTrash = createInternalLinkAction({
|
||||
export const navigateToTrash = createAction({
|
||||
name: ({ t }) => t("Trash"),
|
||||
analyticsName: "Navigate to trash",
|
||||
section: NavigationSection,
|
||||
@@ -92,7 +93,7 @@ export const navigateToTrash = createInternalLinkAction({
|
||||
visible: ({ location }) => location.pathname !== trashPath(),
|
||||
});
|
||||
|
||||
export const navigateToSettings = createInternalLinkAction({
|
||||
export const navigateToSettings = createAction({
|
||||
name: ({ t }) => t("Settings"),
|
||||
analyticsName: "Navigate to settings",
|
||||
section: NavigationSection,
|
||||
@@ -102,7 +103,7 @@ export const navigateToSettings = createInternalLinkAction({
|
||||
to: settingsPath(),
|
||||
});
|
||||
|
||||
export const navigateToWorkspaceSettings = createInternalLinkAction({
|
||||
export const navigateToWorkspaceSettings = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("Settings"),
|
||||
analyticsName: "Navigate to workspace settings",
|
||||
section: NavigationSection,
|
||||
@@ -111,7 +112,7 @@ export const navigateToWorkspaceSettings = createInternalLinkAction({
|
||||
to: settingsPath("details"),
|
||||
});
|
||||
|
||||
export const navigateToProfileSettings = createInternalLinkAction({
|
||||
export const navigateToProfileSettings = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("Profile"),
|
||||
analyticsName: "Navigate to profile settings",
|
||||
section: NavigationSection,
|
||||
@@ -120,7 +121,7 @@ export const navigateToProfileSettings = createInternalLinkAction({
|
||||
to: settingsPath(),
|
||||
});
|
||||
|
||||
export const navigateToTemplateSettings = createInternalLinkAction({
|
||||
export const navigateToTemplateSettings = createAction({
|
||||
name: ({ t }) => t("Templates"),
|
||||
analyticsName: "Navigate to template settings",
|
||||
section: NavigationSection,
|
||||
@@ -129,7 +130,7 @@ export const navigateToTemplateSettings = createInternalLinkAction({
|
||||
to: settingsPath("templates"),
|
||||
});
|
||||
|
||||
export const navigateToNotificationSettings = createInternalLinkAction({
|
||||
export const navigateToNotificationSettings = createInternalLinkActionV2({
|
||||
name: ({ t, isMenu }) =>
|
||||
isMenu ? t("Notification settings") : t("Notifications"),
|
||||
analyticsName: "Navigate to notification settings",
|
||||
@@ -139,7 +140,7 @@ export const navigateToNotificationSettings = createInternalLinkAction({
|
||||
to: settingsPath("notifications"),
|
||||
});
|
||||
|
||||
export const navigateToAccountPreferences = createInternalLinkAction({
|
||||
export const navigateToAccountPreferences = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("Preferences"),
|
||||
analyticsName: "Navigate to account preferences",
|
||||
section: NavigationSection,
|
||||
@@ -148,7 +149,7 @@ export const navigateToAccountPreferences = createInternalLinkAction({
|
||||
to: settingsPath("preferences"),
|
||||
});
|
||||
|
||||
export const openDocumentation = createExternalLinkAction({
|
||||
export const openDocumentation = createExternalLinkActionV2({
|
||||
name: ({ t }) => t("Documentation"),
|
||||
analyticsName: "Open documentation",
|
||||
section: NavigationSection,
|
||||
@@ -158,7 +159,7 @@ export const openDocumentation = createExternalLinkAction({
|
||||
target: "_blank",
|
||||
});
|
||||
|
||||
export const openAPIDocumentation = createExternalLinkAction({
|
||||
export const openAPIDocumentation = createExternalLinkActionV2({
|
||||
name: ({ t }) => t("API documentation"),
|
||||
analyticsName: "Open API documentation",
|
||||
section: NavigationSection,
|
||||
@@ -176,7 +177,7 @@ export const toggleSidebar = createAction({
|
||||
perform: () => stores.ui.toggleCollapsedSidebar(),
|
||||
});
|
||||
|
||||
export const openFeedbackUrl = createExternalLinkAction({
|
||||
export const openFeedbackUrl = createExternalLinkActionV2({
|
||||
name: ({ t }) => t("Send us feedback"),
|
||||
analyticsName: "Open feedback",
|
||||
section: NavigationSection,
|
||||
@@ -186,7 +187,7 @@ export const openFeedbackUrl = createExternalLinkAction({
|
||||
target: "_blank",
|
||||
});
|
||||
|
||||
export const openBugReportUrl = createExternalLinkAction({
|
||||
export const openBugReportUrl = createExternalLinkActionV2({
|
||||
name: ({ t }) => t("Report a bug"),
|
||||
analyticsName: "Open bug report",
|
||||
section: NavigationSection,
|
||||
@@ -196,7 +197,7 @@ export const openBugReportUrl = createExternalLinkAction({
|
||||
target: "_blank",
|
||||
});
|
||||
|
||||
export const openChangelog = createExternalLinkAction({
|
||||
export const openChangelog = createExternalLinkActionV2({
|
||||
name: ({ t }) => t("Changelog"),
|
||||
analyticsName: "Open changelog",
|
||||
section: NavigationSection,
|
||||
@@ -206,7 +207,7 @@ export const openChangelog = createExternalLinkAction({
|
||||
target: "_blank",
|
||||
});
|
||||
|
||||
export const openKeyboardShortcuts = createAction({
|
||||
export const openKeyboardShortcuts = createActionV2({
|
||||
name: ({ t }) => t("Keyboard shortcuts"),
|
||||
analyticsName: "Open keyboard shortcuts",
|
||||
section: NavigationSection,
|
||||
@@ -221,21 +222,23 @@ export const openKeyboardShortcuts = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadApp = createExternalLinkAction({
|
||||
export const downloadApp = createAction({
|
||||
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,
|
||||
url: "https://desktop.getoutline.com",
|
||||
target: "_blank",
|
||||
visible: () => !Desktop.isElectron() && isMac() && isCloudHosted,
|
||||
to: {
|
||||
url: "https://desktop.getoutline.com",
|
||||
target: "_blank",
|
||||
},
|
||||
});
|
||||
|
||||
export const logout = createAction({
|
||||
export const logout = createActionV2({
|
||||
name: ({ t }) => t("Log out"),
|
||||
analyticsName: "Log out",
|
||||
section: NavigationSection,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ArchiveIcon, CheckmarkIcon, MarkAsReadIcon } from "outline-icons";
|
||||
import { createAction } from "..";
|
||||
import { ArchiveIcon, MarkAsReadIcon } from "outline-icons";
|
||||
import { createAction, createActionV2 } from "..";
|
||||
import { NotificationSection } from "../sections";
|
||||
import type Notification from "~/models/Notification";
|
||||
|
||||
export const markNotificationsAsRead = createAction({
|
||||
name: ({ t }) => t("Mark notifications as read"),
|
||||
@@ -13,7 +12,7 @@ export const markNotificationsAsRead = createAction({
|
||||
visible: ({ stores }) => stores.notifications.approximateUnreadCount > 0,
|
||||
});
|
||||
|
||||
export const markNotificationsAsArchived = createAction({
|
||||
export const markNotificationsAsArchived = createActionV2({
|
||||
name: ({ t }) => t("Archive all notifications"),
|
||||
analyticsName: "Mark notifications as archived",
|
||||
section: NotificationSection,
|
||||
@@ -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,20 +1,17 @@
|
||||
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, createActionV2 } 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({
|
||||
export const restoreRevision = createActionV2({
|
||||
name: ({ t }) => t("Restore"),
|
||||
analyticsName: "Restore revision",
|
||||
icon: <RestoreIcon />,
|
||||
@@ -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 = createActionV2({
|
||||
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 = [];
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { SunIcon, MoonIcon, BrowserIcon } from "outline-icons";
|
||||
import { Theme } from "~/stores/UiStore";
|
||||
import { createAction, createActionWithChildren } from "~/actions";
|
||||
import { createActionV2, createActionV2WithChildren } from "~/actions";
|
||||
import { SettingsSection } from "~/actions/sections";
|
||||
|
||||
export const changeToDarkTheme = createAction({
|
||||
export const changeToDarkTheme = createActionV2({
|
||||
name: ({ t }) => t("Dark"),
|
||||
analyticsName: "Change to dark theme",
|
||||
icon: <MoonIcon />,
|
||||
@@ -14,7 +14,7 @@ export const changeToDarkTheme = createAction({
|
||||
perform: ({ stores }) => stores.ui.setTheme(Theme.Dark),
|
||||
});
|
||||
|
||||
export const changeToLightTheme = createAction({
|
||||
export const changeToLightTheme = createActionV2({
|
||||
name: ({ t }) => t("Light"),
|
||||
analyticsName: "Change to light theme",
|
||||
icon: <SunIcon />,
|
||||
@@ -25,22 +25,7 @@ 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({
|
||||
export const changeToSystemTheme = createActionV2({
|
||||
name: ({ t }) => t("System"),
|
||||
analyticsName: "Change to system theme",
|
||||
icon: <BrowserIcon />,
|
||||
@@ -51,7 +36,7 @@ export const changeToSystemTheme = createAction({
|
||||
perform: ({ stores }) => stores.ui.setTheme(Theme.System),
|
||||
});
|
||||
|
||||
export const changeTheme = createActionWithChildren({
|
||||
export const changeTheme = createActionV2WithChildren({
|
||||
name: ({ t, isMenu }) => (isMenu ? t("Appearance") : t("Change theme")),
|
||||
analyticsName: "Change theme",
|
||||
placeholder: ({ t }) => t("Change theme to"),
|
||||
@@ -62,4 +47,4 @@ export const changeTheme = createActionWithChildren({
|
||||
children: [changeToLightTheme, changeToDarkTheme, changeToSystemTheme],
|
||||
});
|
||||
|
||||
export const rootSettingsActions = [changeTheme, toggleTheme];
|
||||
export const rootSettingsActions = [changeTheme];
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import type Share from "~/models/Share";
|
||||
import { createAction, createInternalLinkAction } from "..";
|
||||
import Share from "~/models/Share";
|
||||
import { createActionV2, createInternalLinkActionV2 } from "..";
|
||||
import { ArrowIcon, CopyIcon, TrashIcon } from "outline-icons";
|
||||
import { ShareSection } from "../sections";
|
||||
import env from "~/env";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const copyShareUrlFactory = ({ share }: { share: Share }) =>
|
||||
createAction({
|
||||
createActionV2({
|
||||
name: ({ t }) => t("Copy link"),
|
||||
analyticsName: "Copy share link",
|
||||
section: ShareSection,
|
||||
@@ -22,7 +22,7 @@ export const copyShareUrlFactory = ({ share }: { share: Share }) =>
|
||||
});
|
||||
|
||||
export const goToShareSourceFactory = ({ share }: { share: Share }) =>
|
||||
createInternalLinkAction({
|
||||
createInternalLinkActionV2({
|
||||
name: ({ t }) =>
|
||||
share.collectionId ? t("Go to collection") : t("Go to document"),
|
||||
analyticsName: "Go to share source",
|
||||
@@ -41,7 +41,7 @@ export const revokeShareFactory = ({
|
||||
share: Share;
|
||||
can: Record<string, boolean>;
|
||||
}) =>
|
||||
createAction({
|
||||
createActionV2({
|
||||
name: ({ t }) => t("Revoke link"),
|
||||
analyticsName: "Revoke share",
|
||||
section: ShareSection,
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
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";
|
||||
import {
|
||||
createAction,
|
||||
createActionWithChildren,
|
||||
createExternalLinkAction,
|
||||
createActionV2,
|
||||
createActionV2WithChildren,
|
||||
createExternalLinkActionV2,
|
||||
} from "~/actions";
|
||||
import type { ActionContext, ExternalLinkAction } from "~/types";
|
||||
import { ActionContext, ExternalLinkActionV2 } from "~/types";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import { TeamSection } from "../sections";
|
||||
|
||||
export const switchTeamsList = ({ stores }: { stores: RootStore }) =>
|
||||
stores.auth.availableTeams?.map<ExternalLinkAction>((session) =>
|
||||
createExternalLinkAction({
|
||||
stores.auth.availableTeams?.map<ExternalLinkActionV2>((session) =>
|
||||
createExternalLinkActionV2({
|
||||
id: `switch-${session.id}`,
|
||||
name: session.name,
|
||||
analyticsName: "Switch workspace",
|
||||
@@ -41,7 +41,7 @@ export const switchTeamsList = ({ stores }: { stores: RootStore }) =>
|
||||
})
|
||||
) ?? [];
|
||||
|
||||
export const switchTeam = createActionWithChildren({
|
||||
export const switchTeam = createActionV2WithChildren({
|
||||
name: ({ t }) => t("Switch workspace"),
|
||||
placeholder: ({ t }) => t("Select a workspace"),
|
||||
analyticsName: "Switch workspace",
|
||||
@@ -52,7 +52,7 @@ export const switchTeam = createActionWithChildren({
|
||||
children: switchTeamsList,
|
||||
});
|
||||
|
||||
export const createTeam = createAction({
|
||||
export const createTeam = createActionV2({
|
||||
name: ({ t }) => `${t("New workspace")}…`,
|
||||
analyticsName: "New workspace",
|
||||
keywords: "create change switch workspace organization team",
|
||||
@@ -74,7 +74,7 @@ export const createTeam = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const desktopLoginTeam = createAction({
|
||||
export const desktopLoginTeam = createActionV2({
|
||||
name: ({ t }) => t("Login to workspace"),
|
||||
analyticsName: "Login to workspace",
|
||||
keywords: "change switch workspace organization team",
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
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,
|
||||
UserDeleteDialog,
|
||||
} from "~/components/UserDialogs";
|
||||
import { createAction } from "~/actions";
|
||||
import { createAction, createActionV2 } from "~/actions";
|
||||
import { UserSection } from "~/actions/sections";
|
||||
|
||||
export const inviteUser = createAction({
|
||||
@@ -22,14 +22,13 @@ export const inviteUser = createAction({
|
||||
perform: ({ t }) => {
|
||||
stores.dialogs.openModal({
|
||||
title: t("Invite to workspace"),
|
||||
width: "500px",
|
||||
content: <Invite onSubmit={stores.dialogs.closeAllModals} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const updateUserRoleActionFactory = (user: User, role: UserRole) =>
|
||||
createAction({
|
||||
createActionV2({
|
||||
name: ({ t }) =>
|
||||
UserRoleHelper.isRoleHigher(role, user!.role)
|
||||
? `${t("Promote to {{ role }}", {
|
||||
@@ -64,7 +63,7 @@ export const updateUserRoleActionFactory = (user: User, role: UserRole) =>
|
||||
});
|
||||
|
||||
export const deleteUserActionFactory = (userId: string) =>
|
||||
createAction({
|
||||
createActionV2({
|
||||
name: ({ t }) => `${t("Delete user")}…`,
|
||||
analyticsName: "Delete user",
|
||||
keywords: "leave",
|
||||
|
||||
+201
-48
@@ -1,33 +1,186 @@
|
||||
import type { LocationDescriptor } from "history";
|
||||
import { LocationDescriptor } from "history";
|
||||
import flattenDeep from "lodash/flattenDeep";
|
||||
import { toast } from "sonner";
|
||||
import type { Optional } from "utility-types";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type {
|
||||
ActionContext,
|
||||
import { Optional } from "utility-types";
|
||||
import {
|
||||
Action,
|
||||
ActionGroup,
|
||||
ActionSeparator as TActionSeparator,
|
||||
ActionVariant,
|
||||
ActionWithChildren,
|
||||
ExternalLinkAction,
|
||||
InternalLinkAction,
|
||||
ActionContext,
|
||||
ActionV2,
|
||||
ActionV2Group,
|
||||
ActionV2Separator as TActionV2Separator,
|
||||
ActionV2Variant,
|
||||
ActionV2WithChildren,
|
||||
ExternalLinkActionV2,
|
||||
InternalLinkActionV2,
|
||||
MenuExternalLink,
|
||||
MenuInternalLink,
|
||||
MenuItem,
|
||||
MenuItemButton,
|
||||
MenuItemWithChildren,
|
||||
} 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;
|
||||
}
|
||||
|
||||
export const ActionSeparator: TActionSeparator = {
|
||||
export function createAction(definition: Optional<Action, "id">): Action {
|
||||
return {
|
||||
...definition,
|
||||
perform: definition.perform
|
||||
? (context) => {
|
||||
// We must use the specific analytics name here as the action name is
|
||||
// translated and potentially contains user strings.
|
||||
if (definition.analyticsName) {
|
||||
Analytics.track("perform_action", definition.analyticsName, {
|
||||
context: context.isButton
|
||||
? "button"
|
||||
: context.isCommandBar
|
||||
? "commandbar"
|
||||
: "contextmenu",
|
||||
});
|
||||
}
|
||||
return definition.perform?.(context);
|
||||
}
|
||||
: undefined,
|
||||
id: definition.id ?? crypto.randomUUID(),
|
||||
};
|
||||
}
|
||||
|
||||
export function actionToMenuItem(
|
||||
action: Action,
|
||||
context: ActionContext
|
||||
): MenuItemButton | MenuExternalLink | MenuInternalLink | MenuItemWithChildren {
|
||||
const resolvedIcon = resolve<React.ReactElement<any>>(action.icon, context);
|
||||
const resolvedChildren = resolve<Action[]>(action.children, context);
|
||||
const visible = action.visible ? action.visible(context) : true;
|
||||
const title = resolve<string>(action.name, context);
|
||||
const icon =
|
||||
resolvedIcon && action.iconInContextMenu !== false
|
||||
? resolvedIcon
|
||||
: undefined;
|
||||
|
||||
if (resolvedChildren) {
|
||||
const items = resolvedChildren
|
||||
.map((a) => actionToMenuItem(a, context))
|
||||
.filter(Boolean)
|
||||
.filter((a) => a.visible);
|
||||
|
||||
return {
|
||||
type: "submenu",
|
||||
title,
|
||||
icon,
|
||||
items,
|
||||
visible: visible && items.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (action.to) {
|
||||
return typeof action.to === "string"
|
||||
? {
|
||||
type: "route",
|
||||
title,
|
||||
icon,
|
||||
visible,
|
||||
to: action.to,
|
||||
selected: action.selected?.(context),
|
||||
}
|
||||
: {
|
||||
type: "link",
|
||||
title,
|
||||
icon,
|
||||
visible,
|
||||
href: action.to,
|
||||
selected: action.selected?.(context),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "button",
|
||||
title,
|
||||
icon,
|
||||
visible,
|
||||
dangerous: action.dangerous,
|
||||
onClick: () => performAction(action, context),
|
||||
selected: action.selected?.(context),
|
||||
};
|
||||
}
|
||||
|
||||
export function actionToKBar(
|
||||
action: Action,
|
||||
context: ActionContext
|
||||
): KbarAction[] {
|
||||
if (typeof action.visible === "function" && !action.visible(context)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const resolvedIcon = resolve<React.ReactElement>(action.icon, context);
|
||||
const resolvedChildren = resolve<Action[]>(action.children, context);
|
||||
const resolvedSection = resolve<string>(action.section, context);
|
||||
const resolvedName = resolve<string>(action.name, context);
|
||||
const resolvedPlaceholder = resolve<string>(action.placeholder, context);
|
||||
const children = resolvedChildren
|
||||
? flattenDeep(resolvedChildren.map((a) => actionToKBar(a, context))).filter(
|
||||
(a) => !!a
|
||||
)
|
||||
: [];
|
||||
|
||||
const sectionPriority =
|
||||
typeof action.section !== "string" && "priority" in action.section
|
||||
? ((action.section.priority as number) ?? 0)
|
||||
: 0;
|
||||
|
||||
return [
|
||||
{
|
||||
id: action.id,
|
||||
name: resolvedName,
|
||||
analyticsName: action.analyticsName,
|
||||
section: resolvedSection,
|
||||
placeholder: resolvedPlaceholder,
|
||||
keywords: action.keywords ?? "",
|
||||
shortcut: action.shortcut || [],
|
||||
icon: resolvedIcon,
|
||||
priority: (1 + (action.priority ?? 0)) * (1 + (sectionPriority ?? 0)),
|
||||
perform:
|
||||
action.perform || action.to
|
||||
? () => performAction(action, context)
|
||||
: undefined,
|
||||
},
|
||||
].concat(
|
||||
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
|
||||
children.map((child) => ({ ...child, parent: child.parent ?? action.id }))
|
||||
);
|
||||
}
|
||||
|
||||
export async function performAction(action: Action, context: ActionContext) {
|
||||
const result = action.perform
|
||||
? action.perform(context)
|
||||
: action.to
|
||||
? typeof action.to === "string"
|
||||
? history.push(action.to)
|
||||
: window.open(action.to.url, action.to.target)
|
||||
: undefined;
|
||||
|
||||
if (result instanceof Promise) {
|
||||
return result.catch((err: Error) => {
|
||||
toast.error(err.message);
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Actions V2 */
|
||||
|
||||
export const ActionV2Separator: TActionV2Separator = {
|
||||
type: "action_separator",
|
||||
};
|
||||
|
||||
export function createAction(
|
||||
definition: Optional<Omit<Action, "type" | "variant">, "id">
|
||||
): Action {
|
||||
export function createActionV2(
|
||||
definition: Optional<Omit<ActionV2, "type" | "variant">, "id">
|
||||
): ActionV2 {
|
||||
return {
|
||||
...definition,
|
||||
type: "action",
|
||||
@@ -48,46 +201,46 @@ export function createAction(
|
||||
return definition.perform(context);
|
||||
}
|
||||
: () => {},
|
||||
id: definition.id ?? uuidv4(),
|
||||
id: definition.id ?? crypto.randomUUID(),
|
||||
};
|
||||
}
|
||||
|
||||
export function createInternalLinkAction(
|
||||
definition: Optional<Omit<InternalLinkAction, "type" | "variant">, "id">
|
||||
): InternalLinkAction {
|
||||
export function createInternalLinkActionV2(
|
||||
definition: Optional<Omit<InternalLinkActionV2, "type" | "variant">, "id">
|
||||
): InternalLinkActionV2 {
|
||||
return {
|
||||
...definition,
|
||||
type: "action",
|
||||
variant: "internal_link",
|
||||
id: definition.id ?? uuidv4(),
|
||||
id: definition.id ?? crypto.randomUUID(),
|
||||
};
|
||||
}
|
||||
|
||||
export function createExternalLinkAction(
|
||||
definition: Optional<Omit<ExternalLinkAction, "type" | "variant">, "id">
|
||||
): ExternalLinkAction {
|
||||
export function createExternalLinkActionV2(
|
||||
definition: Optional<Omit<ExternalLinkActionV2, "type" | "variant">, "id">
|
||||
): ExternalLinkActionV2 {
|
||||
return {
|
||||
...definition,
|
||||
type: "action",
|
||||
variant: "external_link",
|
||||
id: definition.id ?? uuidv4(),
|
||||
id: definition.id ?? crypto.randomUUID(),
|
||||
};
|
||||
}
|
||||
|
||||
export function createActionWithChildren(
|
||||
definition: Optional<Omit<ActionWithChildren, "type" | "variant">, "id">
|
||||
): ActionWithChildren {
|
||||
export function createActionV2WithChildren(
|
||||
definition: Optional<Omit<ActionV2WithChildren, "type" | "variant">, "id">
|
||||
): ActionV2WithChildren {
|
||||
return {
|
||||
...definition,
|
||||
type: "action",
|
||||
variant: "action_with_children",
|
||||
id: definition.id ?? uuidv4(),
|
||||
id: definition.id ?? crypto.randomUUID(),
|
||||
};
|
||||
}
|
||||
|
||||
export function createActionGroup(
|
||||
definition: Omit<ActionGroup, "type">
|
||||
): ActionGroup {
|
||||
export function createActionV2Group(
|
||||
definition: Omit<ActionV2Group, "type">
|
||||
): ActionV2Group {
|
||||
return {
|
||||
...definition,
|
||||
type: "action_group",
|
||||
@@ -95,10 +248,10 @@ export function createActionGroup(
|
||||
}
|
||||
|
||||
export function createRootMenuAction(
|
||||
actions: (ActionVariant | ActionGroup | TActionSeparator)[]
|
||||
): ActionWithChildren {
|
||||
actions: (ActionV2Variant | ActionV2Group | TActionV2Separator)[]
|
||||
): ActionV2WithChildren {
|
||||
return {
|
||||
id: uuidv4(),
|
||||
id: crypto.randomUUID(),
|
||||
type: "action",
|
||||
variant: "action_with_children",
|
||||
name: "root_action",
|
||||
@@ -107,8 +260,8 @@ export function createRootMenuAction(
|
||||
};
|
||||
}
|
||||
|
||||
export function actionToMenuItem(
|
||||
action: ActionVariant | ActionGroup | TActionSeparator,
|
||||
export function actionV2ToMenuItem(
|
||||
action: ActionV2Variant | ActionV2Group | TActionV2Separator,
|
||||
context: ActionContext
|
||||
): MenuItem {
|
||||
switch (action.type) {
|
||||
@@ -132,7 +285,7 @@ export function actionToMenuItem(
|
||||
tooltip: resolve<React.ReactChild>(action.tooltip, context),
|
||||
selected: resolve<boolean>(action.selected, context),
|
||||
dangerous: action.dangerous,
|
||||
onClick: () => performAction(action, context),
|
||||
onClick: () => performActionV2(action, context),
|
||||
};
|
||||
|
||||
case "internal_link": {
|
||||
@@ -161,10 +314,10 @@ export function actionToMenuItem(
|
||||
|
||||
case "action_with_children": {
|
||||
const children = resolve<
|
||||
(ActionVariant | ActionGroup | TActionSeparator)[]
|
||||
(ActionV2Variant | ActionV2Group | TActionV2Separator)[]
|
||||
>(action.children, context);
|
||||
const subMenuItems = children.map((a) =>
|
||||
actionToMenuItem(a, context)
|
||||
actionV2ToMenuItem(a, context)
|
||||
);
|
||||
return {
|
||||
type: "submenu",
|
||||
@@ -183,7 +336,7 @@ export function actionToMenuItem(
|
||||
|
||||
case "action_group": {
|
||||
const groupItems = action.actions.map((a) =>
|
||||
actionToMenuItem(a, context)
|
||||
actionV2ToMenuItem(a, context)
|
||||
);
|
||||
return {
|
||||
type: "group",
|
||||
@@ -198,8 +351,8 @@ export function actionToMenuItem(
|
||||
}
|
||||
}
|
||||
|
||||
export function actionToKBar(
|
||||
action: ActionVariant,
|
||||
export function actionV2ToKBar(
|
||||
action: ActionV2Variant,
|
||||
context: ActionContext
|
||||
): KbarAction[] {
|
||||
const visible = resolve<boolean>(action.visible, context);
|
||||
@@ -231,18 +384,18 @@ export function actionToKBar(
|
||||
shortcut: action.shortcut,
|
||||
icon,
|
||||
priority,
|
||||
perform: () => performAction(action, context),
|
||||
perform: () => performActionV2(action, context),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
case "action_with_children": {
|
||||
const resolvedChildren = resolve<ActionVariant[]>(
|
||||
const resolvedChildren = resolve<ActionV2Variant[]>(
|
||||
action.children,
|
||||
context
|
||||
);
|
||||
const children = resolvedChildren
|
||||
.map((a) => actionToKBar(a, context))
|
||||
.map((a) => actionV2ToKBar(a, context))
|
||||
.flat()
|
||||
.filter(Boolean);
|
||||
|
||||
@@ -268,8 +421,8 @@ export function actionToKBar(
|
||||
}
|
||||
}
|
||||
|
||||
export async function performAction(
|
||||
action: Exclude<ActionVariant, ActionWithChildren>,
|
||||
export async function performActionV2(
|
||||
action: Exclude<ActionV2Variant, ActionV2WithChildren>,
|
||||
context: ActionContext
|
||||
) {
|
||||
const perform =
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ActionContext } from "~/types";
|
||||
import { ActionContext } from "~/types";
|
||||
|
||||
export const CollectionSection = ({ t }: ActionContext) => t("Collection");
|
||||
|
||||
@@ -38,8 +38,6 @@ export const NotificationSection = ({ t }: ActionContext) => t("Notification");
|
||||
|
||||
export const GroupSection = ({ t }: ActionContext) => t("Groups");
|
||||
|
||||
export const EmojiSecion = ({ t }: ActionContext) => t("Emoji");
|
||||
|
||||
export const UserSection = ({ t }: ActionContext) => t("People");
|
||||
|
||||
UserSection.priority = 0.5;
|
||||
|
||||
@@ -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 { performAction, resolve } from "~/actions";
|
||||
import Tooltip, { Props as TooltipProps } from "~/components/Tooltip";
|
||||
import { performAction, performActionV2, resolve } from "~/actions";
|
||||
import useIsMounted from "~/hooks/useIsMounted";
|
||||
import { Action, ActionV2Variant, ActionV2WithChildren } from "~/types";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import type { ActionVariant, ActionWithChildren } from "~/types";
|
||||
|
||||
export type Props = React.HTMLAttributes<HTMLButtonElement> & {
|
||||
/** Show the button in a disabled state */
|
||||
@@ -13,7 +12,7 @@ export type Props = React.HTMLAttributes<HTMLButtonElement> & {
|
||||
/** Hide the button entirely if action is not applicable */
|
||||
hideOnActionDisabled?: boolean;
|
||||
/** Action to use on button */
|
||||
action?: Exclude<ActionVariant, ActionWithChildren>;
|
||||
action?: Action | Exclude<ActionV2Variant, ActionV2WithChildren>;
|
||||
/** If tooltip props are provided the button will be wrapped in a tooltip */
|
||||
tooltip?: Omit<TooltipProps, "children">;
|
||||
};
|
||||
@@ -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>
|
||||
) {
|
||||
@@ -31,20 +30,20 @@ const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
|
||||
});
|
||||
const isMounted = useIsMounted();
|
||||
const [executing, setExecuting] = React.useState(false);
|
||||
const disabled = rest.disabled;
|
||||
|
||||
if (!actionContext || !action) {
|
||||
return <button {...rest} ref={ref} />;
|
||||
}
|
||||
|
||||
const actionIsDisabled =
|
||||
action.visible && !resolve<boolean>(action.visible, actionContext);
|
||||
|
||||
if (actionIsDisabled && hideOnActionDisabled) {
|
||||
if (
|
||||
action.visible &&
|
||||
!resolve<boolean>(action.visible, actionContext) &&
|
||||
hideOnActionDisabled
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const disabled = rest.disabled || actionIsDisabled;
|
||||
|
||||
const label =
|
||||
rest["aria-label"] ??
|
||||
(typeof action.name === "function"
|
||||
@@ -62,7 +61,10 @@ const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
|
||||
? (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const response = performAction(action, actionContext);
|
||||
const response =
|
||||
"variant" in action
|
||||
? performActionV2(action, actionContext)
|
||||
: performAction(action, actionContext);
|
||||
if (response?.finally) {
|
||||
setExecuting(true);
|
||||
void response.finally(
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -32,7 +32,7 @@ import { PortalContext } from "./Portal";
|
||||
import CommandBar from "./CommandBar";
|
||||
|
||||
const DocumentComments = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/Comments/Comments")
|
||||
() => import("~/scenes/Document/components/Comments")
|
||||
);
|
||||
const DocumentHistory = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/History")
|
||||
|
||||
@@ -2,7 +2,6 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import Initials from "./Initials";
|
||||
import Tooltip from "../Tooltip";
|
||||
|
||||
export enum AvatarSize {
|
||||
Small = 16,
|
||||
@@ -23,7 +22,6 @@ export interface IAvatar {
|
||||
avatarUrl: string | null;
|
||||
color?: string;
|
||||
initial?: string;
|
||||
name?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
@@ -44,8 +42,6 @@ type Props = {
|
||||
className?: string;
|
||||
/** Optional style */
|
||||
style?: React.CSSProperties;
|
||||
/** Whether to show a tooltip */
|
||||
showTooltip?: boolean;
|
||||
};
|
||||
|
||||
function Avatar(props: Props) {
|
||||
@@ -54,15 +50,12 @@ function Avatar(props: Props) {
|
||||
style,
|
||||
variant = AvatarVariant.Round,
|
||||
className,
|
||||
showTooltip,
|
||||
...rest
|
||||
} = props;
|
||||
const src = props.src || model?.avatarUrl;
|
||||
const [error, handleError] = useBoolean(false);
|
||||
const initial =
|
||||
model?.initial || (model?.name ? model.name[0] : "").toUpperCase();
|
||||
|
||||
const content = (
|
||||
return (
|
||||
<Relative
|
||||
style={style}
|
||||
$variant={variant}
|
||||
@@ -73,19 +66,13 @@ function Avatar(props: Props) {
|
||||
<Image onError={handleError} src={src} {...rest} />
|
||||
) : model ? (
|
||||
<Initials color={model.color} {...rest}>
|
||||
{initial}
|
||||
{model.initial}
|
||||
</Initials>
|
||||
) : (
|
||||
<Initials {...rest} />
|
||||
)}
|
||||
</Relative>
|
||||
);
|
||||
|
||||
return showTooltip ? (
|
||||
<Tooltip content={props.alt || model?.name || ""}>{content}</Tooltip>
|
||||
) : (
|
||||
content
|
||||
);
|
||||
}
|
||||
|
||||
Avatar.defaultProps = {
|
||||
|
||||
@@ -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 = {
|
||||
@@ -26,7 +26,6 @@ export function GroupAvatar({
|
||||
return (
|
||||
<Squircle color={color ?? theme.text} size={size} className={className}>
|
||||
<GroupIcon
|
||||
data-fixed-color
|
||||
color={backgroundColor ?? theme.background}
|
||||
size={size * 0.75}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -6,17 +6,17 @@ 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 { actionToMenuItem } from "~/actions";
|
||||
import { InternalLinkActionV2, MenuInternalLink } from "~/types";
|
||||
import { actionV2ToMenuItem } from "~/actions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { useComputed } from "~/hooks/useComputed";
|
||||
|
||||
type TopLevelAction =
|
||||
| InternalLinkAction
|
||||
| { type: "menu"; actions: InternalLinkAction[] };
|
||||
| InternalLinkActionV2
|
||||
| { type: "menu"; actions: InternalLinkActionV2[] };
|
||||
|
||||
type Props = React.PropsWithChildren<{
|
||||
actions: InternalLinkAction[];
|
||||
actions: InternalLinkActionV2[];
|
||||
max?: number;
|
||||
highlightFirstItem?: boolean;
|
||||
}>;
|
||||
@@ -46,7 +46,7 @@ function Breadcrumb(
|
||||
const menuActions = topLevelActions.splice(
|
||||
halfMax,
|
||||
totalVisibleActions - max
|
||||
) as InternalLinkAction[];
|
||||
) as InternalLinkActionV2[];
|
||||
|
||||
topLevelActions.splice(halfMax, 0, {
|
||||
type: "menu",
|
||||
@@ -60,7 +60,10 @@ function Breadcrumb(
|
||||
return <BreadcrumbMenu key="menu" actions={action.actions} />;
|
||||
}
|
||||
|
||||
const item = actionToMenuItem(action, actionContext) as MenuInternalLink;
|
||||
const item = actionV2ToMenuItem(
|
||||
action,
|
||||
actionContext
|
||||
) as MenuInternalLink;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -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 = {
|
||||
@@ -33,7 +34,6 @@ const RealButton = styled(ActionButton)<RealProps>`
|
||||
cursor: var(--pointer);
|
||||
user-select: none;
|
||||
appearance: none !important;
|
||||
transition: background 200ms ease-out;
|
||||
${undraggableOnDesktop()}
|
||||
|
||||
&::-moz-focus-inner {
|
||||
@@ -44,7 +44,6 @@ const RealButton = styled(ActionButton)<RealProps>`
|
||||
&:hover:not(:disabled),
|
||||
&[aria-expanded="true"] {
|
||||
background: ${(props) => darken(0.05, props.theme.accent)};
|
||||
transition: background 0s;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
@@ -79,7 +78,6 @@ const RealButton = styled(ActionButton)<RealProps>`
|
||||
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, ${
|
||||
props.theme.buttonNeutralBorder
|
||||
} 0 0 0 1px inset;
|
||||
transition: background 0s;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
@@ -105,7 +103,6 @@ const RealButton = styled(ActionButton)<RealProps>`
|
||||
&:hover:not(:disabled),
|
||||
&[aria-expanded="true"] {
|
||||
background: ${darken(0.05, props.theme.danger)};
|
||||
transition: background 0s;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
|
||||
@@ -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;
|
||||
@@ -27,7 +26,7 @@ const Content = styled.div<ContentProps>`
|
||||
margin: 0 auto;
|
||||
|
||||
${breakpoint("desktopLarge")`
|
||||
max-width: ${(props: ContentProps) => props.$maxWidth ?? EditorStyleHelper.documentWidth};
|
||||
max-width: ${(props: ContentProps) => props.$maxWidth ?? "52em"};
|
||||
`};
|
||||
`;
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,13 +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 Flex from "~/components/Flex";
|
||||
import Input from "~/components/Input";
|
||||
import { InputSelectPermission } from "~/components/InputSelectPermission";
|
||||
import { createLazyComponent } from "~/components/LazyLoad";
|
||||
@@ -22,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"));
|
||||
|
||||
@@ -68,13 +67,7 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
|
||||
|
||||
const iconColor = useIconColor(collection);
|
||||
const fallbackIcon = (
|
||||
<Icon
|
||||
value="collection"
|
||||
initial={collection?.initial ?? "?"}
|
||||
color={iconColor}
|
||||
/>
|
||||
);
|
||||
const fallbackIcon = <Icon value="collection" color={iconColor} />;
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -141,7 +134,7 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
Collections are used to group documents and choose permissions
|
||||
</Trans>
|
||||
</Text>
|
||||
<HStack>
|
||||
<Flex gap={8}>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("Name")}
|
||||
@@ -165,7 +158,7 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
autoFocus
|
||||
flex
|
||||
/>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* Following controls are available in create flow, but moved elsewhere for edit */}
|
||||
{!collection && (
|
||||
@@ -223,7 +216,7 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
/>
|
||||
)}
|
||||
|
||||
<HStack justify="flex-end">
|
||||
<Flex justify="flex-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={formState.isSubmitting || !formState.isValid}
|
||||
@@ -236,7 +229,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,11 +1,11 @@
|
||||
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";
|
||||
import { createInternalLinkAction } from "~/actions";
|
||||
import { createInternalLinkActionV2 } from "~/actions";
|
||||
import { ActiveCollectionSection } from "~/actions/sections";
|
||||
|
||||
type Props = {
|
||||
@@ -17,18 +17,18 @@ export const CollectionBreadcrumb: React.FC<Props> = ({ collection }) => {
|
||||
|
||||
const actions = React.useMemo(
|
||||
() => [
|
||||
createInternalLinkAction({
|
||||
createInternalLinkActionV2({
|
||||
name: t("Archive"),
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ArchiveIcon />,
|
||||
visible: collection.isArchived,
|
||||
to: archivePath(),
|
||||
}),
|
||||
createInternalLinkAction({
|
||||
createInternalLinkActionV2({
|
||||
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,63 +0,0 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import NudeButton from "./NudeButton";
|
||||
import { hover, s } from "@shared/styles";
|
||||
|
||||
type Props = React.HTMLAttributes<HTMLButtonElement> & {
|
||||
/** The current color value in hex format. If no color is passed a radial gradient will be shown */
|
||||
color?: string;
|
||||
/** Whether the button is currently active/selected */
|
||||
active?: boolean;
|
||||
/** The size of the button in pixels */
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export const ColorButton = React.forwardRef(
|
||||
(
|
||||
{ color, active = false, size = 24, ...rest }: Props,
|
||||
ref: React.Ref<HTMLButtonElement>
|
||||
) => (
|
||||
<ColorButtonInternal
|
||||
$active={active}
|
||||
$size={size}
|
||||
{...rest}
|
||||
style={{ "--color": color, ...rest.style } as React.CSSProperties}
|
||||
ref={ref}
|
||||
>
|
||||
<Selected />
|
||||
</ColorButtonInternal>
|
||||
)
|
||||
);
|
||||
|
||||
const Selected = styled.span`
|
||||
width: 10px;
|
||||
height: 5px;
|
||||
border-left: 2px solid white;
|
||||
border-bottom: 2px solid white;
|
||||
transform: translateY(-25%) rotate(-45deg);
|
||||
`;
|
||||
|
||||
const ColorButtonInternal = styled(NudeButton)<{
|
||||
$active: boolean;
|
||||
$size: number;
|
||||
}>`
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: ${({ $size }) => $size}px;
|
||||
height: ${({ $size }) => $size}px;
|
||||
border-radius: 50%;
|
||||
background: var(
|
||||
--color,
|
||||
linear-gradient(135deg, #ff5858 0%, #fbcc34 50%, #00c6ff 100%)
|
||||
);
|
||||
|
||||
&: ${hover} {
|
||||
outline: 2px solid ${s("menuBackground")} !important;
|
||||
box-shadow: 0px 0px 3px 3px var(--color);
|
||||
}
|
||||
|
||||
& ${Selected} {
|
||||
display: ${({ $active }) => ($active ? "block" : "none")};
|
||||
}
|
||||
`;
|
||||
@@ -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;
|
||||
`;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DocumentIcon } from "outline-icons";
|
||||
import { useMemo } from "react";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { createInternalLinkAction } from "~/actions";
|
||||
import { createAction } from "~/actions";
|
||||
import { RecentSection } from "~/actions/sections";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
@@ -15,16 +15,12 @@ const useRecentDocumentActions = (count = 6) => {
|
||||
.filter((document) => document.id !== ui.activeDocumentId)
|
||||
.slice(0, count)
|
||||
.map((item) =>
|
||||
createInternalLinkAction({
|
||||
createAction({
|
||||
name: item.titleWithDefault,
|
||||
analyticsName: "Recently viewed document",
|
||||
section: RecentSection,
|
||||
icon: item.icon ? (
|
||||
<Icon
|
||||
value={item.icon}
|
||||
initial={item.initial}
|
||||
color={item.color ?? undefined}
|
||||
/>
|
||||
<Icon value={item.icon} color={item.color ?? undefined} />
|
||||
) : (
|
||||
<DocumentIcon />
|
||||
),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { SettingsIcon } from "outline-icons";
|
||||
import { useMemo } from "react";
|
||||
import { createActionWithChildren, createInternalLinkAction } from "~/actions";
|
||||
import { createAction } from "~/actions";
|
||||
import { NavigationSection } from "~/actions/sections";
|
||||
import useSettingsConfig from "~/hooks/useSettingsConfig";
|
||||
|
||||
@@ -10,20 +10,20 @@ const useSettingsAction = () => {
|
||||
() =>
|
||||
config.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return createInternalLinkAction({
|
||||
return {
|
||||
id: item.path,
|
||||
name: item.name,
|
||||
icon: <Icon />,
|
||||
section: NavigationSection,
|
||||
to: item.path,
|
||||
});
|
||||
};
|
||||
}),
|
||||
[config]
|
||||
);
|
||||
|
||||
const navigateToSettings = useMemo(
|
||||
() =>
|
||||
createActionWithChildren({
|
||||
createAction({
|
||||
id: "settings",
|
||||
name: ({ t }) => t("Settings"),
|
||||
section: NavigationSection,
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { NewDocumentIcon, ShapesIcon } from "outline-icons";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { createActionWithChildren, createInternalLinkAction } from "~/actions";
|
||||
import { createAction } from "~/actions";
|
||||
import {
|
||||
ActiveCollectionSection,
|
||||
DocumentSection,
|
||||
TeamSection,
|
||||
} from "~/actions/sections";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
import { newDocumentPath } from "~/utils/routeHelpers";
|
||||
|
||||
const useTemplatesAction = () => {
|
||||
@@ -20,18 +21,14 @@ const useTemplatesAction = () => {
|
||||
const actions = useMemo(
|
||||
() =>
|
||||
documents.templatesAlphabetical.map((template) =>
|
||||
createInternalLinkAction({
|
||||
createAction({
|
||||
name: template.titleWithDefault,
|
||||
analyticsName: "New document",
|
||||
section: template.isWorkspaceTemplate
|
||||
? TeamSection
|
||||
: ActiveCollectionSection,
|
||||
icon: template.icon ? (
|
||||
<Icon
|
||||
value={template.icon}
|
||||
initial={template.initial}
|
||||
color={template.color ?? undefined}
|
||||
/>
|
||||
<Icon value={template.icon} color={template.color ?? undefined} />
|
||||
) : (
|
||||
<NewDocumentIcon />
|
||||
),
|
||||
@@ -50,20 +47,15 @@ const useTemplatesAction = () => {
|
||||
template.isWorkspaceTemplate
|
||||
);
|
||||
},
|
||||
to: ({ activeCollectionId, sidebarContext }) => {
|
||||
const [pathname, search] = newDocumentPath(
|
||||
template.collectionId ?? activeCollectionId,
|
||||
{
|
||||
perform: ({ activeCollectionId, sidebarContext }) =>
|
||||
history.push(
|
||||
newDocumentPath(template.collectionId ?? activeCollectionId, {
|
||||
templateId: template.id,
|
||||
}),
|
||||
{
|
||||
sidebarContext,
|
||||
}
|
||||
).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
),
|
||||
})
|
||||
),
|
||||
[documents.templatesAlphabetical]
|
||||
@@ -71,7 +63,7 @@ const useTemplatesAction = () => {
|
||||
|
||||
const newFromTemplate = useMemo(
|
||||
() =>
|
||||
createActionWithChildren({
|
||||
createAction({
|
||||
id: "templates",
|
||||
name: ({ t }) => t("New from template"),
|
||||
placeholder: ({ t }) => t("Choose a template"),
|
||||
@@ -86,7 +78,7 @@ const useTemplatesAction = () => {
|
||||
stores.policies.abilities(currentTeamId).createDocument
|
||||
);
|
||||
},
|
||||
children: actions,
|
||||
children: () => actions,
|
||||
}),
|
||||
[actions]
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3,8 +3,7 @@ import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import type { Option } from "~/components/InputSelect";
|
||||
import { InputSelect } from "~/components/InputSelect";
|
||||
import { InputSelect, Option } from "~/components/InputSelect";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type DefaultCollectionInputSelectProps = {
|
||||
|
||||
@@ -32,8 +32,6 @@ function Dialogs() {
|
||||
}}
|
||||
title={modal.title}
|
||||
style={modal.style}
|
||||
width={modal.width}
|
||||
height={modal.height}
|
||||
>
|
||||
{modal.content}
|
||||
</Modal>
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Text from "~/components/Text";
|
||||
import type { IntegrationType } from "@shared/types";
|
||||
import type Integration from "~/models/Integration";
|
||||
import { IntegrationType } from "@shared/types";
|
||||
import Integration from "~/models/Integration";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
@@ -5,14 +5,14 @@ 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, settingsPath, trashPath } from "~/utils/routeHelpers";
|
||||
import { createInternalLinkAction } from "~/actions";
|
||||
import { createInternalLinkActionV2 } from "~/actions";
|
||||
import { ActiveDocumentSection } from "~/actions/sections";
|
||||
|
||||
type Props = {
|
||||
@@ -53,28 +53,28 @@ function DocumentBreadcrumb(
|
||||
}
|
||||
|
||||
const outputActions = [
|
||||
createInternalLinkAction({
|
||||
createInternalLinkActionV2({
|
||||
name: t("Trash"),
|
||||
section: ActiveDocumentSection,
|
||||
icon: <TrashIcon />,
|
||||
visible: document.isDeleted,
|
||||
to: trashPath(),
|
||||
}),
|
||||
createInternalLinkAction({
|
||||
createInternalLinkActionV2({
|
||||
name: t("Archive"),
|
||||
section: ActiveDocumentSection,
|
||||
icon: <ArchiveIcon />,
|
||||
visible: document.isArchived,
|
||||
to: archivePath(),
|
||||
}),
|
||||
createInternalLinkAction({
|
||||
createInternalLinkActionV2({
|
||||
name: t("Templates"),
|
||||
section: ActiveDocumentSection,
|
||||
icon: <ShapesIcon />,
|
||||
visible: document.template,
|
||||
to: settingsPath("templates"),
|
||||
}),
|
||||
createInternalLinkAction({
|
||||
createInternalLinkActionV2({
|
||||
name: collection?.name,
|
||||
section: ActiveDocumentSection,
|
||||
icon: collection ? (
|
||||
@@ -88,7 +88,7 @@ function DocumentBreadcrumb(
|
||||
}
|
||||
: "",
|
||||
}),
|
||||
createInternalLinkAction({
|
||||
createInternalLinkActionV2({
|
||||
name: t("Deleted Collection"),
|
||||
section: ActiveDocumentSection,
|
||||
visible: document.isCollectionDeleted,
|
||||
@@ -96,15 +96,10 @@ function DocumentBreadcrumb(
|
||||
}),
|
||||
...path.map((node) => {
|
||||
const title = node.title || t("Untitled");
|
||||
return createInternalLinkAction({
|
||||
return createInternalLinkActionV2({
|
||||
name: node.icon ? (
|
||||
<>
|
||||
<StyledIcon
|
||||
value={node.icon}
|
||||
color={node.color}
|
||||
initial={node.title.charAt(0).toUpperCase()}
|
||||
/>{" "}
|
||||
{title}
|
||||
<StyledIcon value={node.icon} color={node.color} /> {title}
|
||||
</>
|
||||
) : (
|
||||
title
|
||||
|
||||
@@ -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";
|
||||
@@ -187,12 +187,12 @@ function DocumentCard(props: Props) {
|
||||
|
||||
const DocumentSquircle = ({
|
||||
icon,
|
||||
initial,
|
||||
color,
|
||||
initial,
|
||||
}: {
|
||||
icon: string;
|
||||
initial: string;
|
||||
color?: string;
|
||||
initial?: string;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const iconType = determineIconType(icon)!;
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -3,8 +3,8 @@ 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 DocumentExplorer from "~/components/DocumentExplorer";
|
||||
@@ -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
|
||||
@@ -52,7 +51,6 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
}
|
||||
|
||||
try {
|
||||
setCopying(true);
|
||||
const result = await document.duplicate({
|
||||
publish,
|
||||
recursive,
|
||||
@@ -67,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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -118,8 +114,8 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
t("Select a location to copy")
|
||||
)}
|
||||
</StyledText>
|
||||
<Button disabled={!selectedPath || copying} onClick={copy}>
|
||||
{copying ? `${t("Copying")}…` : t("Copy")}
|
||||
<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;
|
||||
`;
|
||||
@@ -16,9 +16,8 @@ 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 "~/components/DocumentExplorerNode";
|
||||
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -28,6 +27,7 @@ import InputSearch from "~/components/InputSearch";
|
||||
import Text from "~/components/Text";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { ancestors, descendants, flattenTree } from "~/utils/tree";
|
||||
|
||||
type Props = {
|
||||
/** Action taken upon submission of selected item, could be publish, move etc. */
|
||||
@@ -268,9 +268,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
title = doc?.title ?? node.title;
|
||||
|
||||
if (icon) {
|
||||
renderedIcon = (
|
||||
<Icon value={icon} initial={node.title} color={color} />
|
||||
);
|
||||
renderedIcon = <Icon value={icon} color={color} />;
|
||||
} else if (doc?.isStarred) {
|
||||
renderedIcon = <StarredIcon color={theme.yellow} />;
|
||||
} else {
|
||||
|
||||
@@ -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";
|
||||
@@ -55,7 +54,6 @@ function DocumentListItem(
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
const theme = useTheme();
|
||||
const { userMemberships, groupMemberships } = useStores();
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
@@ -121,9 +119,6 @@ function DocumentListItem(
|
||||
$menuOpen={menuOpen}
|
||||
to={{
|
||||
pathname: documentPath(document),
|
||||
search: highlight
|
||||
? `?q=${encodeURIComponent(highlight)}`
|
||||
: undefined,
|
||||
state: {
|
||||
title: document.titleWithDefault,
|
||||
sidebarContext,
|
||||
@@ -132,58 +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 && <StarButton document={document} />}
|
||||
{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
|
||||
<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}
|
||||
@@ -197,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;
|
||||
@@ -219,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")`
|
||||
@@ -297,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";
|
||||
@@ -182,7 +182,7 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
<span>
|
||||
{t("in")}
|
||||
<Strong>
|
||||
<DocumentBreadcrumb document={document} maxDepth={1} onlyText />
|
||||
<DocumentBreadcrumb document={document} onlyText />
|
||||
</Strong>
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
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";
|
||||
import Flex from "./Flex";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
@@ -42,7 +41,7 @@ function DocumentTasks({ document }: Props) {
|
||||
const message = getMessage(t, total, completed);
|
||||
|
||||
return (
|
||||
<Flex align="center" style={{ padding: "0 1px" }} gap={2} shrink={false}>
|
||||
<>
|
||||
{completed === total ? (
|
||||
<Done
|
||||
color={theme.accent}
|
||||
@@ -52,8 +51,8 @@ function DocumentTasks({ document }: Props) {
|
||||
) : (
|
||||
<CircularProgressBar percentage={tasksPercentage} />
|
||||
)}
|
||||
{message}
|
||||
</Flex>
|
||||
{message}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
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";
|
||||
import { s, truncateMultiline } from "@shared/styles";
|
||||
|
||||
type Props = Omit<React.HTMLAttributes<HTMLInputElement>, "onSubmit"> & {
|
||||
/** A callback when the title is submitted. */
|
||||
@@ -43,47 +42,27 @@ function EditableTitle(
|
||||
setValue(title);
|
||||
}, [title]);
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setValue(event.target.value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
const handleChange = React.useCallback((event) => {
|
||||
setValue(event.target.value);
|
||||
}, []);
|
||||
|
||||
const handleDoubleClick = React.useCallback(
|
||||
(event: React.MouseEvent<HTMLSpanElement>) => {
|
||||
if (event.altKey) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsEditing(true);
|
||||
},
|
||||
[]
|
||||
);
|
||||
const handleDoubleClick = React.useCallback((event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsEditing(true);
|
||||
}, []);
|
||||
|
||||
const stopPropagation = React.useCallback(
|
||||
(event: React.MouseEvent<HTMLSpanElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
},
|
||||
[]
|
||||
);
|
||||
const stopPropagation = React.useCallback((event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleFocus = React.useCallback(
|
||||
(event: React.FocusEvent<HTMLInputElement>) => {
|
||||
event.target.select();
|
||||
},
|
||||
[]
|
||||
);
|
||||
const handleFocus = React.useCallback((event) => {
|
||||
event.target.select();
|
||||
}, []);
|
||||
|
||||
const handleSave = React.useCallback(
|
||||
async (
|
||||
ev:
|
||||
| React.FocusEvent<HTMLInputElement>
|
||||
| React.KeyboardEvent<HTMLInputElement>
|
||||
| React.FormEvent<HTMLFormElement>
|
||||
) => {
|
||||
async (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
@@ -104,22 +83,20 @@ function EditableTitle(
|
||||
try {
|
||||
await onSubmit(trimmedValue);
|
||||
setOriginalValue(trimmedValue);
|
||||
setIsEditing(false);
|
||||
} catch (error) {
|
||||
setValue(value);
|
||||
setIsEditing(true);
|
||||
|
||||
setValue(originalValue);
|
||||
toast.error(error.message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
setIsEditing(false);
|
||||
}
|
||||
},
|
||||
[originalValue, value, onCancel, onSubmit, isSubmitting]
|
||||
);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
async (ev: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
async (ev) => {
|
||||
if (ev.nativeEvent.isComposing) {
|
||||
return;
|
||||
}
|
||||
@@ -142,12 +119,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 +133,7 @@ function EditableTitle(
|
||||
autoFocus
|
||||
{...rest}
|
||||
/>
|
||||
</EventBoundary>
|
||||
</form>
|
||||
) : (
|
||||
<Text
|
||||
onDoubleClick={canUpdate ? handleDoubleClick : undefined}
|
||||
@@ -170,8 +146,8 @@ function EditableTitle(
|
||||
);
|
||||
}
|
||||
|
||||
const Text = styled.div`
|
||||
${ellipsis()}
|
||||
const Text = styled.span`
|
||||
${truncateMultiline(3)}
|
||||
`;
|
||||
|
||||
const Input = styled.input`
|
||||
|
||||
@@ -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";
|
||||
@@ -22,7 +21,6 @@ import useEmbeds from "~/hooks/useEmbeds";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { uploadFile, uploadFileFromUrl } from "~/utils/files";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import useShare from "@shared/hooks/useShare";
|
||||
|
||||
const LazyLoadedEditor = lazyWithRetry(() => import("~/editor"));
|
||||
|
||||
@@ -35,6 +33,7 @@ export type Props = Optional<
|
||||
| "dictionary"
|
||||
| "extensions"
|
||||
> & {
|
||||
shareId?: string | undefined;
|
||||
embedsDisabled?: boolean;
|
||||
onSynced?: () => Promise<void>;
|
||||
onPublish?: (event: React.MouseEvent) => void;
|
||||
@@ -42,43 +41,20 @@ export type Props = Optional<
|
||||
};
|
||||
|
||||
function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
const {
|
||||
id,
|
||||
onChange,
|
||||
onCreateCommentMark,
|
||||
onDeleteCommentMark,
|
||||
onFileUploadStart,
|
||||
onFileUploadStop,
|
||||
} = props;
|
||||
const { id, shareId, onChange, onCreateCommentMark, onDeleteCommentMark } =
|
||||
props;
|
||||
const { comments } = useStores();
|
||||
const { shareId } = useShare();
|
||||
const dictionary = useDictionary();
|
||||
const embeds = useEmbeds(!shareId);
|
||||
const localRef = React.useRef<SharedEditor>();
|
||||
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,
|
||||
]
|
||||
@@ -271,7 +202,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
style={props.style}
|
||||
editorStyle={props.editorStyle}
|
||||
commenting={!!props.onClickCommentMark}
|
||||
lang={props.lang}
|
||||
>
|
||||
<div className="ProseMirror">
|
||||
{paragraphs.map((paragraph, index) => (
|
||||
@@ -292,9 +222,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 || ""}
|
||||
/>
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { AttachmentPreset } from "@shared/types";
|
||||
import { getDataTransferFiles } from "@shared/utils/files";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Input, { LabelText } from "~/components/Input";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { uploadFile } from "~/utils/files";
|
||||
import { compressImage } from "~/utils/compressImage";
|
||||
import { generateEmojiNameFromFilename } from "~/utils/emoji";
|
||||
import { AttachmentValidation, EmojiValidation } from "@shared/validations";
|
||||
import { bytesToHumanReadable } from "@shared/utils/files";
|
||||
import { VStack } from "./primitives/VStack";
|
||||
|
||||
type Props = {
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
export function EmojiCreateDialog({ onSubmit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { emojis } = useStores();
|
||||
const [name, setName] = React.useState("");
|
||||
const [file, setFile] = React.useState<File | null>(null);
|
||||
const [isUploading, setIsUploading] = React.useState(false);
|
||||
|
||||
const handleFileSelection = React.useCallback(
|
||||
(file: File) => {
|
||||
const isValidType = AttachmentValidation.emojiContentTypes.includes(
|
||||
file.type
|
||||
);
|
||||
|
||||
if (!isValidType) {
|
||||
toast.error(
|
||||
t("File type not supported. Please use PNG, JPG, GIF, or WebP.")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (file.size > AttachmentValidation.emojiMaxFileSize) {
|
||||
toast.error(
|
||||
t("File size too large. Maximum size is {{ size }}.", {
|
||||
size: bytesToHumanReadable(AttachmentValidation.emojiMaxFileSize),
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setFile(file);
|
||||
|
||||
// Auto-populate name field if it's empty
|
||||
setName((currentName) => {
|
||||
if (!currentName.trim()) {
|
||||
const generatedName = generateEmojiNameFromFilename(file.name);
|
||||
return generatedName || currentName;
|
||||
}
|
||||
return currentName;
|
||||
});
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const onDrop = React.useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
if (acceptedFiles.length > 0) {
|
||||
handleFileSelection(acceptedFiles[0]);
|
||||
}
|
||||
},
|
||||
[handleFileSelection]
|
||||
);
|
||||
|
||||
// Handle paste events
|
||||
React.useEffect(() => {
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
const files = getDataTransferFiles(event);
|
||||
if (files.length > 0) {
|
||||
event.preventDefault();
|
||||
handleFileSelection(files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("paste", handlePaste);
|
||||
return () => document.removeEventListener("paste", handlePaste);
|
||||
}, [handleFileSelection]);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDropAccepted: onDrop,
|
||||
accept: AttachmentValidation.emojiContentTypes,
|
||||
maxSize: AttachmentValidation.emojiMaxFileSize,
|
||||
maxFiles: 1,
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) {
|
||||
toast.error(t("Please enter a name for the emoji"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file) {
|
||||
toast.error(t("Please select an image file"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
// Skip compression for GIFs to preserve animation
|
||||
const fileToUpload =
|
||||
file.type === "image/gif"
|
||||
? file
|
||||
: await compressImage(file, {
|
||||
maxHeight: 64,
|
||||
maxWidth: 64,
|
||||
});
|
||||
|
||||
const attachment = await uploadFile(fileToUpload, {
|
||||
name: file.name,
|
||||
preset: AttachmentPreset.Emoji,
|
||||
});
|
||||
|
||||
await emojis.create({
|
||||
name: name.trim(),
|
||||
attachmentId: attachment.id,
|
||||
});
|
||||
|
||||
toast.success(t("Emoji created successfully"));
|
||||
onSubmit();
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = event.target;
|
||||
setName(value);
|
||||
};
|
||||
|
||||
const isValidName = EmojiValidation.allowedNameCharacters.test(name);
|
||||
const isValid = name.trim().length > 0 && file && isValidName;
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
disabled={!isValid || isUploading}
|
||||
savingText={isUploading ? `${t("Uploading")}…` : undefined}
|
||||
submitText={t("Add emoji")}
|
||||
>
|
||||
<Text as="p" type="secondary">
|
||||
{t(
|
||||
"Square images with transparent backgrounds work best. If your image is too large, we’ll try to resize it for you."
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<LabelText as="label">{t("Upload an image")}</LabelText>
|
||||
<DropZone {...getRootProps()}>
|
||||
<input {...getInputProps()} />
|
||||
<VStack>
|
||||
{file ? (
|
||||
<>
|
||||
<PreviewImage src={URL.createObjectURL(file)} alt="Preview" />
|
||||
<Text size="medium">{file.name}</Text>
|
||||
<Text size="medium" type="secondary">
|
||||
{t("Click or drag to replace")}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text size="medium">
|
||||
{isDragActive
|
||||
? t("Drop the image here")
|
||||
: t("Click, drop, or paste an image here")}
|
||||
</Text>
|
||||
<Text size="medium" type="secondary">
|
||||
{t("PNG, JPG, GIF, or WebP up to {{ size }}", {
|
||||
size: bytesToHumanReadable(
|
||||
AttachmentValidation.emojiMaxFileSize
|
||||
),
|
||||
})}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</VStack>
|
||||
</DropZone>
|
||||
|
||||
<Input
|
||||
label={t("Choose a name")}
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
placeholder="my_custom_emoji"
|
||||
autoFocus
|
||||
required
|
||||
error={
|
||||
!isValidName
|
||||
? t(
|
||||
"name can only contain lowercase letters, numbers, and underscores."
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{name.trim() && isValidName && (
|
||||
<Text type="secondary" style={{ marginTop: "8px" }}>
|
||||
{t("This emoji will be available as")} <code>:{name}:</code>
|
||||
</Text>
|
||||
)}
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
const DropZone = styled.div`
|
||||
border: 2px dashed ${s("inputBorder")};
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
cursor: var(--pointer);
|
||||
transition: border-color 0.2s;
|
||||
margin-bottom: 1em;
|
||||
|
||||
&:hover {
|
||||
border-color: ${s("inputBorderFocused")};
|
||||
}
|
||||
`;
|
||||
|
||||
const PreviewImage = styled.img`
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
`;
|
||||
@@ -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";
|
||||
@@ -47,22 +46,20 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
|
||||
componentDidCatch(error: Error) {
|
||||
this.error = error;
|
||||
this.trackError();
|
||||
|
||||
if (
|
||||
this.props.reloadOnChunkMissing &&
|
||||
error.message &&
|
||||
error.message.match(/dynamically imported module/) &&
|
||||
!this.isRepeatedError
|
||||
error.message.match(/dynamically imported module/)
|
||||
) {
|
||||
// If the editor bundle fails to load then reload the entire window. This
|
||||
// can happen if a deploy happens between the user loading the initial JS
|
||||
// bundle and the async-loaded editor JS bundle as the hash will change.
|
||||
// Don't reload if this is a repeated error to avoid infinite reload loops.
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
this.trackError();
|
||||
Logger.error("ErrorBoundary", error);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -23,9 +25,8 @@ function ExportDialog({ collection, onSubmit }: Props) {
|
||||
);
|
||||
const [includeAttachments, setIncludeAttachments] =
|
||||
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;
|
||||
|
||||
@@ -43,48 +44,24 @@ function ExportDialog({ collection, onSubmit }: Props) {
|
||||
[]
|
||||
);
|
||||
|
||||
const handleIncludePrivateChange = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setIncludePrivate(ev.target.checked);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
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);
|
||||
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();
|
||||
};
|
||||
|
||||
@@ -146,64 +123,37 @@ function ExportDialog({ collection, onSubmit }: Props) {
|
||||
<Text as="p" size="small" weight="bold">
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text size="small" type="secondary">
|
||||
{item.description}
|
||||
</Text>
|
||||
<Text size="small">{item.description}</Text>
|
||||
</div>
|
||||
</Option>
|
||||
))}
|
||||
</Flex>
|
||||
<HR />
|
||||
<Flex gap={12} column>
|
||||
<Option>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="includeAttachments"
|
||||
checked={includeAttachments}
|
||||
onChange={handleIncludeAttachmentsChange}
|
||||
/>
|
||||
<div>
|
||||
<Text as="p" size="small" weight="bold">
|
||||
{t("Include attachments")}
|
||||
</Text>
|
||||
<Text size="small" type="secondary">
|
||||
{t("Including uploaded images and files in the exported data")}.
|
||||
</Text>{" "}
|
||||
</div>
|
||||
</Option>
|
||||
{!collection && (
|
||||
<Option>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="includePrivate"
|
||||
checked={includePrivate}
|
||||
onChange={handleIncludePrivateChange}
|
||||
/>
|
||||
<div>
|
||||
<Text as="p" size="small" weight="bold">
|
||||
{t("Include private collections")}
|
||||
</Text>
|
||||
</div>
|
||||
</Option>
|
||||
)}
|
||||
</Flex>
|
||||
<hr />
|
||||
<Option>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="includeAttachments"
|
||||
checked={includeAttachments}
|
||||
onChange={handleIncludeAttachmentsChange}
|
||||
/>
|
||||
<div>
|
||||
<Text as="p" size="small" weight="bold">
|
||||
{t("Include attachments")}
|
||||
</Text>
|
||||
<Text size="small">
|
||||
{t("Including uploaded images and files in the exported data")}.
|
||||
</Text>{" "}
|
||||
</div>
|
||||
</Option>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
const HR = styled.hr`
|
||||
margin: 16px 0;
|
||||
`;
|
||||
|
||||
const Option = styled.label`
|
||||
display: flex;
|
||||
align-items: start;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
input {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -2,11 +2,9 @@ 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";
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||
|
||||
type Props = {
|
||||
/** The users to display */
|
||||
@@ -23,8 +21,6 @@ type Props = {
|
||||
model: User;
|
||||
}
|
||||
>;
|
||||
/** Whether to show tooltips on hover, defaults to true */
|
||||
showTooltip?: boolean;
|
||||
};
|
||||
|
||||
function Facepile({
|
||||
@@ -33,7 +29,6 @@ function Facepile({
|
||||
size = AvatarSize.Large,
|
||||
limit = 8,
|
||||
renderAvatar = Avatar,
|
||||
showTooltip = true,
|
||||
...rest
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
@@ -56,7 +51,6 @@ function Facepile({
|
||||
<Component
|
||||
key={model.id}
|
||||
{...{
|
||||
showTooltip,
|
||||
model,
|
||||
size,
|
||||
style: {
|
||||
@@ -69,9 +63,7 @@ function Facepile({
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<VisuallyHidden>
|
||||
<FacepileClip size={size} />
|
||||
</VisuallyHidden>
|
||||
<FacepileClip size={size} />
|
||||
</Avatars>
|
||||
);
|
||||
}
|
||||
@@ -109,11 +101,6 @@ const Avatars = styled(Flex)`
|
||||
align-items: center;
|
||||
flex-direction: row-reverse;
|
||||
cursor: var(--pointer);
|
||||
|
||||
*:hover {
|
||||
clip-path: none !important;
|
||||
box-shadow: 0 0 0 2px ${s("background")};
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(Facepile);
|
||||
|
||||
@@ -7,11 +7,9 @@ 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";
|
||||
|
||||
interface TFilterOption extends PaginatedItem {
|
||||
key: string;
|
||||
@@ -26,10 +24,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 +34,8 @@ const FilterOptions = ({
|
||||
className,
|
||||
onSelect,
|
||||
showFilter,
|
||||
showIcons = true,
|
||||
fetchQuery,
|
||||
fetchQueryOptions,
|
||||
disclosure = true,
|
||||
...rest
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -61,11 +55,7 @@ const FilterOptions = ({
|
||||
(option) => (
|
||||
<MenuButton
|
||||
key={option.key}
|
||||
icon={
|
||||
option.icon && showIcons ? (
|
||||
<MenuIconWrapper aria-hidden>{option.icon}</MenuIconWrapper>
|
||||
) : undefined
|
||||
}
|
||||
icon={option.icon}
|
||||
label={option.label}
|
||||
onClick={() => {
|
||||
onSelect(option.key);
|
||||
@@ -74,7 +64,7 @@ const FilterOptions = ({
|
||||
selected={selectedKeys.includes(option.key)}
|
||||
/>
|
||||
),
|
||||
[onSelect, showIcons, selectedKeys]
|
||||
[onSelect, selectedKeys]
|
||||
);
|
||||
|
||||
const handleFilter = React.useCallback(
|
||||
@@ -87,45 +77,30 @@ const FilterOptions = ({
|
||||
const filteredOptions = React.useMemo(() => {
|
||||
const normalizedQuery = deburr(query.toLowerCase());
|
||||
|
||||
const filtered = query
|
||||
? options.filter((option) =>
|
||||
deburr(option.label).toLowerCase().includes(normalizedQuery)
|
||||
)
|
||||
return query
|
||||
? options
|
||||
.filter((option) =>
|
||||
deburr(option.label).toLowerCase().includes(normalizedQuery)
|
||||
)
|
||||
// sort options starting with query first
|
||||
.sort((a, b) => {
|
||||
const aStartsWith = deburr(a.label)
|
||||
.toLowerCase()
|
||||
.startsWith(normalizedQuery);
|
||||
const bStartsWith = deburr(b.label)
|
||||
.toLowerCase()
|
||||
.startsWith(normalizedQuery);
|
||||
|
||||
if (aStartsWith && !bStartsWith) {
|
||||
return -1;
|
||||
}
|
||||
if (!aStartsWith && bStartsWith) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
})
|
||||
: options;
|
||||
|
||||
return filtered.sort((a, b) => {
|
||||
const aSelected = selectedKeys.includes(a.key);
|
||||
const bSelected = selectedKeys.includes(b.key);
|
||||
|
||||
// Selected items come first
|
||||
if (aSelected && !bSelected) {
|
||||
return -1;
|
||||
}
|
||||
if (!aSelected && bSelected) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// If both have the same selection state and there's a query,
|
||||
// sort options starting with query first
|
||||
if (query) {
|
||||
const aStartsWith = deburr(a.label)
|
||||
.toLowerCase()
|
||||
.startsWith(normalizedQuery);
|
||||
const bStartsWith = deburr(b.label)
|
||||
.toLowerCase()
|
||||
.startsWith(normalizedQuery);
|
||||
|
||||
if (aStartsWith && !bStartsWith) {
|
||||
return -1;
|
||||
}
|
||||
if (!aStartsWith && bStartsWith) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
}, [options, query, selectedKeys]);
|
||||
}, [options, query]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev: React.KeyboardEvent) => {
|
||||
@@ -133,10 +108,6 @@ const FilterOptions = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop all keyboard events from propagating to prevent Radix UI menu
|
||||
// from handling them and potentially moving focus
|
||||
ev.stopPropagation();
|
||||
|
||||
switch (ev.key) {
|
||||
case "Escape":
|
||||
setOpen(false);
|
||||
@@ -185,8 +156,8 @@ const FilterOptions = ({
|
||||
<StyledButton
|
||||
className={className}
|
||||
icon={selectedItems[0]?.key && selectedItems[0]?.icon}
|
||||
disclosure={disclosure}
|
||||
neutral
|
||||
disclosure
|
||||
>
|
||||
{selectedItems.length ? selectedLabel : defaultLabel}
|
||||
</StyledButton>
|
||||
|
||||
@@ -13,7 +13,6 @@ import useStores from "~/hooks/useStores";
|
||||
import LoadingIndicator from "../LoadingIndicator";
|
||||
import { CARD_MARGIN } from "./Components";
|
||||
import HoverPreviewDocument from "./HoverPreviewDocument";
|
||||
import HoverPreviewGroup from "./HoverPreviewGroup";
|
||||
import HoverPreviewIssue from "./HoverPreviewIssue";
|
||||
import HoverPreviewLink from "./HoverPreviewLink";
|
||||
import HoverPreviewMention from "./HoverPreviewMention";
|
||||
@@ -117,31 +116,12 @@ const HoverPreviewDesktop = observer(
|
||||
<Position top={cardTop} left={cardLeft} aria-hidden>
|
||||
{isVisible ? (
|
||||
<Animate
|
||||
initial={{
|
||||
opacity: 0,
|
||||
y: -20,
|
||||
filter: "blur(5px)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
initial={{ opacity: 0, y: -20, pointerEvents: "none" }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
filter: "blur(0px)",
|
||||
transitionEnd: { pointerEvents: "auto" },
|
||||
}}
|
||||
transition={{
|
||||
y: {
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 25,
|
||||
},
|
||||
opacity: {
|
||||
duration: 0.2,
|
||||
},
|
||||
filter: {
|
||||
duration: 0.2,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{data.type === UnfurlResourceType.Mention ? (
|
||||
<HoverPreviewMention
|
||||
@@ -152,14 +132,6 @@ const HoverPreviewDesktop = observer(
|
||||
lastActive={data.lastActive}
|
||||
email={data.email}
|
||||
/>
|
||||
) : data.type === UnfurlResourceType.Group ? (
|
||||
<HoverPreviewGroup
|
||||
ref={cardRef}
|
||||
name={data.name}
|
||||
description={data.description}
|
||||
memberCount={data.memberCount}
|
||||
users={data.users}
|
||||
/>
|
||||
) : data.type === UnfurlResourceType.Document ? (
|
||||
<HoverPreviewDocument
|
||||
ref={cardRef}
|
||||
@@ -323,10 +295,10 @@ const Pointer = styled.div<{ top: number; left: number; direction: Direction }>`
|
||||
|
||||
&:before {
|
||||
border: 8px solid transparent;
|
||||
${({ direction }) =>
|
||||
${({ direction, theme }) =>
|
||||
direction === Direction.UP
|
||||
? `border-bottom-color: rgba(0, 0, 0, 0.1)`
|
||||
: `border-top-color: rgba(0, 0, 0, 0.1)`};
|
||||
? `border-bottom-color: ${theme.menuBorder || "rgba(0, 0, 0, 0.1)"}`
|
||||
: `border-top-color: ${theme.menuBorder || "rgba(0, 0, 0, 0.1)"}`};
|
||||
${({ direction }) =>
|
||||
direction === Direction.UP ? "right: -1px" : "left: -1px"};
|
||||
}
|
||||
|
||||
@@ -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,70 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { MAX_AVATAR_DISPLAY } from "@shared/constants";
|
||||
import type User from "~/models/User";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import Flex from "~/components/Flex";
|
||||
import {
|
||||
Preview,
|
||||
Title,
|
||||
Info,
|
||||
Card,
|
||||
CardContent,
|
||||
Description,
|
||||
} from "./Components";
|
||||
import ErrorBoundary from "../ErrorBoundary";
|
||||
|
||||
type Props = Omit<UnfurlResponse[UnfurlResourceType.Group], "type">;
|
||||
|
||||
const HoverPreviewGroup = React.forwardRef(function HoverPreviewGroup_(
|
||||
{ name, description, memberCount, users }: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Preview as="div">
|
||||
<Card fadeOut={false} ref={ref}>
|
||||
<CardContent>
|
||||
<ErrorBoundary showTitle={false} reloadOnChunkMissing={false}>
|
||||
<Flex column gap={2} align="start">
|
||||
<Flex
|
||||
justify="space-between"
|
||||
gap={4}
|
||||
style={{ width: "100%" }}
|
||||
auto
|
||||
>
|
||||
<Flex column align="start">
|
||||
<Title>{name}</Title>
|
||||
<Info>
|
||||
{t("{{ count }} members", { count: memberCount })}
|
||||
</Info>
|
||||
</Flex>
|
||||
{users.length > 0 && (
|
||||
<Facepile
|
||||
users={users.map(
|
||||
(member) =>
|
||||
({
|
||||
id: member.id,
|
||||
name: member.name,
|
||||
avatarUrl: member.avatarUrl,
|
||||
color: member.color,
|
||||
initial: member.name ? member.name[0] : "?",
|
||||
}) as User
|
||||
)}
|
||||
overflow={Math.max(0, memberCount - users.length)}
|
||||
limit={MAX_AVATAR_DISPLAY}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
{description && <Description>{description}</Description>}
|
||||
</Flex>
|
||||
</ErrorBoundary>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Preview>
|
||||
);
|
||||
});
|
||||
|
||||
export default HoverPreviewGroup;
|
||||
@@ -3,8 +3,11 @@ import { Trans } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { Backticks } from "@shared/components/Backticks";
|
||||
import { IssueStatusIcon } from "@shared/components/IssueStatusIcon";
|
||||
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 Flex from "~/components/Flex";
|
||||
import Text from "../Text";
|
||||
@@ -21,7 +24,7 @@ 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>
|
||||
) {
|
||||
|
||||
@@ -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,13 +1,13 @@
|
||||
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_(
|
||||
{ avatarUrl, name, lastActive, color }: Props,
|
||||
const HoverPreviewMention = React.forwardRef(function _HoverPreviewMention(
|
||||
{ avatarUrl, name, lastActive, color, email }: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
return (
|
||||
@@ -25,6 +25,7 @@ const HoverPreviewMention = React.forwardRef(function HoverPreviewMention_(
|
||||
/>
|
||||
<Flex column gap={2} justify="center">
|
||||
<Title>{name}</Title>
|
||||
{email && <Info>{email}</Info>}
|
||||
<Info>{lastActive}</Info>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Trans } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { Backticks } from "@shared/components/Backticks";
|
||||
import { PullRequestIcon } from "@shared/components/PullRequestIcon";
|
||||
import type { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "../Text";
|
||||
@@ -20,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>
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { BackIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { breakpoints, s, hover } from "@shared/styles";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import { validateColorHex } from "@shared/utils/color";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Text from "~/components/Text";
|
||||
|
||||
enum Panel {
|
||||
Builtin,
|
||||
Hex,
|
||||
}
|
||||
|
||||
type Props = {
|
||||
width: number;
|
||||
activeColor: string;
|
||||
onSelect: (color: string) => void;
|
||||
};
|
||||
|
||||
const ColorPicker = ({ width, activeColor, onSelect }: Props) => {
|
||||
const [localValue, setLocalValue] = React.useState(activeColor);
|
||||
|
||||
const [panel, setPanel] = React.useState(
|
||||
colorPalette.includes(activeColor) ? Panel.Builtin : Panel.Hex
|
||||
);
|
||||
|
||||
const handleSwitcherClick = React.useCallback(() => {
|
||||
setPanel(panel === Panel.Builtin ? Panel.Hex : Panel.Builtin);
|
||||
}, [panel, setPanel]);
|
||||
|
||||
const isLargeMobile = width > breakpoints.mobileLarge + 12; // 12px for the Container padding
|
||||
|
||||
React.useEffect(() => {
|
||||
setLocalValue(activeColor);
|
||||
setPanel(colorPalette.includes(activeColor) ? Panel.Builtin : Panel.Hex);
|
||||
}, [activeColor]);
|
||||
|
||||
return isLargeMobile ? (
|
||||
<Container justify="space-between">
|
||||
<LargeMobileBuiltinColors activeColor={activeColor} onClick={onSelect} />
|
||||
<LargeMobileCustomColor
|
||||
value={localValue}
|
||||
setLocalValue={setLocalValue}
|
||||
onValidHex={onSelect}
|
||||
/>
|
||||
</Container>
|
||||
) : (
|
||||
<Container gap={12}>
|
||||
<PanelSwitcher align="center">
|
||||
<SwitcherButton panel={panel} onClick={handleSwitcherClick}>
|
||||
{panel === Panel.Builtin ? "#" : <BackIcon />}
|
||||
</SwitcherButton>
|
||||
</PanelSwitcher>
|
||||
{panel === Panel.Builtin ? (
|
||||
<BuiltinColors activeColor={activeColor} onClick={onSelect} />
|
||||
) : (
|
||||
<CustomColor
|
||||
value={localValue}
|
||||
setLocalValue={setLocalValue}
|
||||
onValidHex={onSelect}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
const BuiltinColors = ({
|
||||
activeColor,
|
||||
onClick,
|
||||
className,
|
||||
}: {
|
||||
activeColor: string;
|
||||
onClick: (color: string) => void;
|
||||
className?: string;
|
||||
}) => (
|
||||
<Flex className={className} justify="space-between" align="center" auto>
|
||||
{colorPalette.map((color) => (
|
||||
<ColorButton
|
||||
key={color}
|
||||
$color={color}
|
||||
$active={color === activeColor}
|
||||
onClick={() => onClick(color)}
|
||||
>
|
||||
<Selected />
|
||||
</ColorButton>
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
|
||||
const CustomColor = ({
|
||||
value,
|
||||
setLocalValue,
|
||||
onValidHex,
|
||||
className,
|
||||
}: {
|
||||
value: string;
|
||||
setLocalValue: (value: string) => void;
|
||||
onValidHex: (color: string) => void;
|
||||
className?: string;
|
||||
}) => {
|
||||
const hasHexChars = React.useCallback(
|
||||
(color: string) => /(^#[0-9A-F]{1,6}$)/i.test(color),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleInputChange = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = ev.target.value;
|
||||
|
||||
if (val === "" || val === "#") {
|
||||
setLocalValue("#");
|
||||
return;
|
||||
}
|
||||
|
||||
const uppercasedVal = val.toUpperCase();
|
||||
|
||||
if (hasHexChars(uppercasedVal)) {
|
||||
setLocalValue(uppercasedVal);
|
||||
}
|
||||
|
||||
if (validateColorHex(uppercasedVal)) {
|
||||
onValidHex(uppercasedVal);
|
||||
}
|
||||
},
|
||||
[setLocalValue, hasHexChars, onValidHex]
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex className={className} align="center" gap={8}>
|
||||
<Text type="tertiary" size="small">
|
||||
HEX
|
||||
</Text>
|
||||
<CustomColorInput
|
||||
maxLength={7}
|
||||
value={value}
|
||||
onChange={handleInputChange}
|
||||
autoFocus
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
const Container = styled(Flex)`
|
||||
height: 48px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid ${s("inputBorder")};
|
||||
`;
|
||||
|
||||
const Selected = styled.span`
|
||||
width: 10px;
|
||||
height: 5px;
|
||||
border-left: 2px solid white;
|
||||
border-bottom: 2px solid white;
|
||||
transform: translateY(-25%) rotate(-45deg);
|
||||
`;
|
||||
|
||||
const ColorButton = styled(NudeButton)<{ $color: string; $active: boolean }>`
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background-color: ${({ $color }) => $color};
|
||||
|
||||
&: ${hover} {
|
||||
outline: 2px solid ${s("menuBackground")} !important;
|
||||
box-shadow: ${({ $color }) => `0px 0px 3px 3px ${$color}`};
|
||||
}
|
||||
|
||||
& ${Selected} {
|
||||
display: ${({ $active }) => ($active ? "block" : "none")};
|
||||
}
|
||||
`;
|
||||
|
||||
const PanelSwitcher = styled(Flex)`
|
||||
width: 40px;
|
||||
border-right: 1px solid ${s("inputBorder")};
|
||||
`;
|
||||
|
||||
const SwitcherButton = styled(NudeButton)<{ panel: Panel }>`
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
border: 1px solid ${s("inputBorder")};
|
||||
transition: all 100ms ease-in-out;
|
||||
|
||||
&: ${hover} {
|
||||
border-color: ${s("inputBorderFocused")};
|
||||
}
|
||||
`;
|
||||
|
||||
const LargeMobileBuiltinColors = styled(BuiltinColors)`
|
||||
max-width: 400px;
|
||||
padding-right: 8px;
|
||||
`;
|
||||
|
||||
const LargeMobileCustomColor = styled(CustomColor)`
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid ${s("inputBorder")};
|
||||
width: 120px;
|
||||
`;
|
||||
|
||||
const CustomColorInput = styled.input.attrs(() => ({
|
||||
type: "text",
|
||||
autocomplete: "off",
|
||||
}))`
|
||||
font-size: 14px;
|
||||
color: ${s("textSecondary")};
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
`;
|
||||
|
||||
export default ColorPicker;
|
||||
@@ -1,13 +0,0 @@
|
||||
import styled from "styled-components";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
import { HStack } from "~/components/primitives/HStack";
|
||||
|
||||
export const UserInputContainer = styled(HStack)`
|
||||
height: 48px;
|
||||
padding: 6px 12px 0px;
|
||||
`;
|
||||
|
||||
export const StyledInputSearch = styled(InputSearch)`
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
`;
|
||||
@@ -1,28 +1,77 @@
|
||||
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 styled from "styled-components";
|
||||
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 InputSearch from "~/components/InputSearch";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
import {
|
||||
FREQUENTLY_USED_COUNT,
|
||||
DisplayCategory,
|
||||
emojiSkinToneKey,
|
||||
emojisFreqKey,
|
||||
lastEmojiKey,
|
||||
sortFrequencies,
|
||||
} from "../utils";
|
||||
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;
|
||||
|
||||
const useEmojiState = () => {
|
||||
const [emojiSkinTone, setEmojiSkinTone] = usePersistedState<EmojiSkinTone>(
|
||||
emojiSkinToneKey,
|
||||
EmojiSkinTone.Default
|
||||
);
|
||||
const [emojisFreq, setEmojisFreq] = usePersistedState<Record<string, number>>(
|
||||
emojisFreqKey,
|
||||
{}
|
||||
);
|
||||
const [lastEmoji, setLastEmoji] = usePersistedState<string | undefined>(
|
||||
lastEmojiKey,
|
||||
undefined
|
||||
);
|
||||
|
||||
const incrementEmojiCount = React.useCallback(
|
||||
(emoji: string) => {
|
||||
emojisFreq[emoji] = (emojisFreq[emoji] ?? 0) + 1;
|
||||
setEmojisFreq({ ...emojisFreq });
|
||||
setLastEmoji(emoji);
|
||||
},
|
||||
[emojisFreq, setEmojisFreq, setLastEmoji]
|
||||
);
|
||||
|
||||
const getFreqEmojis = React.useCallback(() => {
|
||||
const freqs = Object.entries(emojisFreq);
|
||||
|
||||
if (freqs.length > FREQUENTLY_USED_COUNT.Track) {
|
||||
sortFrequencies(freqs).splice(FREQUENTLY_USED_COUNT.Track);
|
||||
setEmojisFreq(Object.fromEntries(freqs));
|
||||
}
|
||||
|
||||
const emojis = sortFrequencies(freqs)
|
||||
.slice(0, FREQUENTLY_USED_COUNT.Get)
|
||||
.map(([emoji, _]) => emoji);
|
||||
|
||||
const isLastPresent = emojis.includes(lastEmoji ?? "");
|
||||
if (lastEmoji && !isLastPresent) {
|
||||
emojis.pop();
|
||||
emojis.push(lastEmoji);
|
||||
}
|
||||
|
||||
return emojis;
|
||||
}, [emojisFreq, setEmojisFreq, lastEmoji]);
|
||||
|
||||
return {
|
||||
emojiSkinTone,
|
||||
setEmojiSkinTone,
|
||||
incrementEmojiCount,
|
||||
getFreqEmojis,
|
||||
};
|
||||
};
|
||||
|
||||
type Props = {
|
||||
panelWidth: number;
|
||||
query: string;
|
||||
@@ -41,36 +90,18 @@ 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,
|
||||
setEmojiSkinTone,
|
||||
incrementIconCount,
|
||||
getFrequentIcons,
|
||||
} = useIconState(IconType.Emoji);
|
||||
incrementEmojiCount,
|
||||
getFreqEmojis,
|
||||
} = useEmojiState();
|
||||
|
||||
const {
|
||||
incrementIconCount: incrementCustomIconCount,
|
||||
getFrequentIcons: getFrequentCustomIcons,
|
||||
} = useIconState(IconType.Custom);
|
||||
|
||||
const freqEmojis = React.useMemo(
|
||||
() => getFrequentIcons(),
|
||||
[getFrequentIcons]
|
||||
);
|
||||
|
||||
const [freqCustomEmojis, setFreqCustomEmojis] = React.useState<EmojiNode[]>(
|
||||
[]
|
||||
);
|
||||
const freqEmojis = React.useMemo(() => getFreqEmojis(), [getFreqEmojis]);
|
||||
|
||||
const handleFilter = React.useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -86,68 +117,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);
|
||||
}
|
||||
incrementEmojiCount(id);
|
||||
},
|
||||
[
|
||||
onEmojiChange,
|
||||
incrementIconCount,
|
||||
incrementCustomIconCount,
|
||||
customEmojis,
|
||||
freqCustomEmojis,
|
||||
]
|
||||
[onEmojiChange, incrementEmojiCount]
|
||||
);
|
||||
|
||||
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 +146,7 @@ const EmojiPanel = ({
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<UserInputContainer>
|
||||
<UserInputContainer align="center" gap={12}>
|
||||
<StyledInputSearch
|
||||
ref={searchRef}
|
||||
value={query}
|
||||
@@ -168,14 +154,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 +161,6 @@ const EmojiPanel = ({
|
||||
height={height - 48}
|
||||
data={templateData}
|
||||
onIconSelect={handleEmojiSelection}
|
||||
empty={
|
||||
<IconButton onClick={handleUploadClick}>
|
||||
<PlusIcon />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
@@ -196,32 +169,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 +189,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 getFrequentEmojis = (): 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,8 +219,8 @@ const getAllEmojis = ({
|
||||
};
|
||||
};
|
||||
|
||||
const allData = concat(
|
||||
getFrequentIcons(),
|
||||
return concat(
|
||||
getFrequentEmojis(),
|
||||
getCategoryData(EmojiCategory.People),
|
||||
getCategoryData(EmojiCategory.Nature),
|
||||
getCategoryData(EmojiCategory.Foods),
|
||||
@@ -281,22 +230,15 @@ 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,
|
||||
});
|
||||
const UserInputContainer = styled(Flex)`
|
||||
height: 48px;
|
||||
padding: 6px 12px 0px;
|
||||
`;
|
||||
|
||||
const StyledInputSearch = styled(InputSearch)`
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
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 = {
|
||||
|
||||
@@ -9,7 +9,6 @@ import Text from "~/components/Text";
|
||||
import { TRANSLATED_CATEGORIES } from "../utils";
|
||||
import Grid from "./Grid";
|
||||
import { IconButton } from "./IconButton";
|
||||
import { CustomEmoji } from "@shared/components/CustomEmoji";
|
||||
|
||||
/**
|
||||
* icon/emoji size is 24px; and we add 4px padding on all sides,
|
||||
@@ -24,11 +23,10 @@ type OutlineNode = {
|
||||
delay: number;
|
||||
};
|
||||
|
||||
export type EmojiNode = {
|
||||
type: IconType.Emoji | IconType.Custom;
|
||||
type EmojiNode = {
|
||||
type: IconType.Emoji;
|
||||
id: string;
|
||||
value: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export type DataNode = {
|
||||
@@ -37,20 +35,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 +50,10 @@ const GridTemplate = (
|
||||
|
||||
const gridItems = compact(
|
||||
data.flatMap((node) => {
|
||||
if (node.icons.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const category = (
|
||||
<CategoryName
|
||||
key={node.category}
|
||||
@@ -69,13 +65,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 (
|
||||
@@ -97,11 +86,7 @@ const GridTemplate = (
|
||||
onClick={() => onIconSelect({ id: item.id, value: item.value })}
|
||||
>
|
||||
<Emoji width={24} height={24}>
|
||||
{item.type === IconType.Custom ? (
|
||||
<CustomEmoji value={item.value} title={item.name} />
|
||||
) : (
|
||||
item.value
|
||||
)}
|
||||
{item.value}
|
||||
</Emoji>
|
||||
</IconButton>
|
||||
);
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import Flex from "~/components/Flex";
|
||||
import { SwatchButton } from "~/components/SwatchButton";
|
||||
import { ColorButton } from "~/components/ColorButton";
|
||||
|
||||
type Props = {
|
||||
width: number;
|
||||
activeColor: string;
|
||||
onSelect: (color: string) => void;
|
||||
};
|
||||
|
||||
const IconColorPicker = ({ activeColor, onSelect }: Props) => {
|
||||
const [selectedColor, setSelectedColor] = React.useState(activeColor);
|
||||
const isBuiltInColor = colorPalette.includes(selectedColor);
|
||||
const color = isBuiltInColor ? undefined : selectedColor;
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedColor(activeColor);
|
||||
}, [activeColor]);
|
||||
|
||||
const handleSelect = (color: string) => {
|
||||
setSelectedColor(color);
|
||||
onSelect(color);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container justify="space-between" align="center" auto>
|
||||
<PresetColors activeColor={selectedColor} onClick={handleSelect} />
|
||||
<Divider />
|
||||
<SwatchButton
|
||||
color={color}
|
||||
active={!isBuiltInColor}
|
||||
onChange={handleSelect}
|
||||
pickerInModal
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
const Divider = styled.div`
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background-color: ${s("inputBorder")};
|
||||
`;
|
||||
|
||||
const PresetColors = ({
|
||||
activeColor,
|
||||
onClick,
|
||||
}: {
|
||||
activeColor: string;
|
||||
onClick: (color: string) => void;
|
||||
}) => (
|
||||
<>
|
||||
{colorPalette.map((color) => (
|
||||
<ColorButton
|
||||
key={color}
|
||||
color={color}
|
||||
active={color === activeColor}
|
||||
onClick={() => onClick(color)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
const Container = styled(Flex)`
|
||||
height: 48px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid ${s("inputBorder")};
|
||||
`;
|
||||
|
||||
export default IconColorPicker;
|
||||
@@ -5,11 +5,16 @@ import { IconType } from "@shared/types";
|
||||
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 { useIconState } from "../useIconState";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
import {
|
||||
FREQUENTLY_USED_COUNT,
|
||||
DisplayCategory,
|
||||
iconsFreqKey,
|
||||
lastIconKey,
|
||||
sortFrequencies,
|
||||
} from "../utils";
|
||||
import ColorPicker from "./ColorPicker";
|
||||
import GridTemplate, { DataNode } from "./GridTemplate";
|
||||
|
||||
const IconNames = Object.keys(IconLibrary.mapping);
|
||||
const TotalIcons = IconNames.length;
|
||||
@@ -20,6 +25,52 @@ const TotalIcons = IconNames.length;
|
||||
*/
|
||||
const GRID_HEIGHT = 314;
|
||||
|
||||
const useIconState = () => {
|
||||
const [iconsFreq, setIconsFreq] = usePersistedState<Record<string, number>>(
|
||||
iconsFreqKey,
|
||||
{}
|
||||
);
|
||||
const [lastIcon, setLastIcon] = usePersistedState<string | undefined>(
|
||||
lastIconKey,
|
||||
undefined
|
||||
);
|
||||
|
||||
const incrementIconCount = React.useCallback(
|
||||
(icon: string) => {
|
||||
iconsFreq[icon] = (iconsFreq[icon] ?? 0) + 1;
|
||||
setIconsFreq({ ...iconsFreq });
|
||||
setLastIcon(icon);
|
||||
},
|
||||
[iconsFreq, setIconsFreq, setLastIcon]
|
||||
);
|
||||
|
||||
const getFreqIcons = React.useCallback(() => {
|
||||
const freqs = Object.entries(iconsFreq);
|
||||
|
||||
if (freqs.length > FREQUENTLY_USED_COUNT.Track) {
|
||||
sortFrequencies(freqs).splice(FREQUENTLY_USED_COUNT.Track);
|
||||
setIconsFreq(Object.fromEntries(freqs));
|
||||
}
|
||||
|
||||
const icons = sortFrequencies(freqs)
|
||||
.slice(0, FREQUENTLY_USED_COUNT.Get)
|
||||
.map(([icon, _]) => icon);
|
||||
|
||||
const isLastPresent = icons.includes(lastIcon ?? "");
|
||||
if (lastIcon && !isLastPresent) {
|
||||
icons.pop();
|
||||
icons.push(lastIcon);
|
||||
}
|
||||
|
||||
return icons;
|
||||
}, [iconsFreq, setIconsFreq, lastIcon]);
|
||||
|
||||
return {
|
||||
incrementIconCount,
|
||||
getFreqIcons,
|
||||
};
|
||||
};
|
||||
|
||||
type Props = {
|
||||
panelWidth: number;
|
||||
initial: string;
|
||||
@@ -46,9 +97,9 @@ const IconPanel = ({
|
||||
const searchRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const scrollableRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const { incrementIconCount, getFrequentIcons } = useIconState(IconType.SVG);
|
||||
const { incrementIconCount, getFreqIcons } = useIconState();
|
||||
|
||||
const freqIcons = React.useMemo(() => getFrequentIcons(), [getFrequentIcons]);
|
||||
const freqIcons = React.useMemo(() => getFreqIcons(), [getFreqIcons]);
|
||||
const totalFreqIcons = freqIcons.length;
|
||||
|
||||
const filteredIcons = React.useMemo(
|
||||
@@ -122,7 +173,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")};
|
||||
}
|
||||
`;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user