snapshot of https://github.com/docmost/docmost.git @ 6205bbeb908fe846f87dd6a2cbf562e24777db38 (2026-09-15)
This commit is contained in:
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
**/node_modules
|
||||||
|
**/.git
|
||||||
|
**/dist
|
||||||
|
/data
|
||||||
|
**/.env*
|
||||||
|
**/.nx
|
||||||
70
.env.example
Normal file
70
.env.example
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# your domain, e.g https://example.com
|
||||||
|
APP_URL=http://localhost:3000
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# minimum of 32 characters. Generate one with: openssl rand -hex 32
|
||||||
|
APP_SECRET=REPLACE_WITH_LONG_SECRET
|
||||||
|
|
||||||
|
JWT_TOKEN_EXPIRES_IN=30d
|
||||||
|
|
||||||
|
DATABASE_URL="postgresql://postgres:password@localhost:5432/docmost?schema=public"
|
||||||
|
REDIS_URL=redis://127.0.0.1:6379
|
||||||
|
|
||||||
|
# options: local | s3 | azure
|
||||||
|
STORAGE_DRIVER=local
|
||||||
|
|
||||||
|
# S3 driver config
|
||||||
|
AWS_S3_ACCESS_KEY_ID=
|
||||||
|
AWS_S3_SECRET_ACCESS_KEY=
|
||||||
|
AWS_S3_REGION=
|
||||||
|
AWS_S3_BUCKET=
|
||||||
|
AWS_S3_ENDPOINT=
|
||||||
|
AWS_S3_FORCE_PATH_STYLE=
|
||||||
|
|
||||||
|
# Azure Blob Storage driver config
|
||||||
|
AZURE_STORAGE_ACCOUNT_NAME=
|
||||||
|
AZURE_STORAGE_ACCOUNT_KEY=
|
||||||
|
AZURE_STORAGE_CONTAINER=
|
||||||
|
|
||||||
|
# default: 50mb
|
||||||
|
FILE_UPLOAD_SIZE_LIMIT=
|
||||||
|
|
||||||
|
# options: smtp | postmark
|
||||||
|
MAIL_DRIVER=smtp
|
||||||
|
MAIL_FROM_ADDRESS=hello@example.com
|
||||||
|
MAIL_FROM_NAME=Docmost
|
||||||
|
|
||||||
|
# SMTP driver config
|
||||||
|
SMTP_HOST=127.0.0.1
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USERNAME=
|
||||||
|
SMTP_PASSWORD=
|
||||||
|
SMTP_SECURE=false
|
||||||
|
SMTP_IGNORETLS=false
|
||||||
|
|
||||||
|
# Postmark driver config
|
||||||
|
POSTMARK_TOKEN=
|
||||||
|
|
||||||
|
# for custom drawio server
|
||||||
|
DRAWIO_URL=
|
||||||
|
|
||||||
|
# Gotenberg URL for server-side PDF export
|
||||||
|
GOTENBERG_URL=
|
||||||
|
|
||||||
|
DISABLE_TELEMETRY=false
|
||||||
|
|
||||||
|
# Allow other sites to embed Docmost in an iframe.
|
||||||
|
IFRAME_EMBED_ALLOWED=false
|
||||||
|
|
||||||
|
# Only used when IFRAME_EMBED_ALLOWED=true. When empty, any origin is allowed.
|
||||||
|
# Example: https://intranet.example.com,https://portal.example.com
|
||||||
|
IFRAME_ALLOWED_ORIGINS=
|
||||||
|
|
||||||
|
# Enable debug logging in production (default: false)
|
||||||
|
DEBUG_MODE=false
|
||||||
|
|
||||||
|
# Log database queries
|
||||||
|
DEBUG_DB=false
|
||||||
|
|
||||||
|
# Log http requests
|
||||||
|
LOG_HTTP=false
|
||||||
154
.github/workflows/release.yml
vendored
Normal file
154
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: 'Version tag (e.g. v0.25.3)'
|
||||||
|
required: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
VERSION: ${{ inputs.version || github.ref_name }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: linux/amd64
|
||||||
|
runner: ubuntu-latest
|
||||||
|
suffix: amd64
|
||||||
|
- platform: linux/arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
suffix: arm64
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
steps:
|
||||||
|
- name: Generate token
|
||||||
|
id: app-token
|
||||||
|
uses: actions/create-github-app-token@v1
|
||||||
|
with:
|
||||||
|
app-id: ${{ secrets.BUILD_APP_ID }}
|
||||||
|
private-key: ${{ secrets.BUILD_APP_PRIVATE_KEY }}
|
||||||
|
owner: ${{ github.repository_owner }}
|
||||||
|
|
||||||
|
- name: Checkout with submodules
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
token: ${{ steps.app-token.outputs.token }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push by digest
|
||||||
|
id: build
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: ${{ matrix.platform }}
|
||||||
|
outputs: type=image,name=docmost/docmost,push-by-digest=true,name-canonical=true,push=true
|
||||||
|
cache-from: type=gha,scope=${{ matrix.suffix }}
|
||||||
|
cache-to: type=gha,scope=${{ matrix.suffix }},mode=max
|
||||||
|
|
||||||
|
- name: Export digest
|
||||||
|
run: |
|
||||||
|
mkdir -p /tmp/digests
|
||||||
|
digest="${{ steps.build.outputs.digest }}"
|
||||||
|
touch "/tmp/digests/${digest#sha256:}"
|
||||||
|
|
||||||
|
- name: Upload digest
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: digest-${{ matrix.suffix }}
|
||||||
|
path: /tmp/digests/*
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Strip v prefix
|
||||||
|
id: strip-v
|
||||||
|
run: echo "version=${VERSION#v}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Export Docker image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: ${{ matrix.platform }}
|
||||||
|
push: false
|
||||||
|
tags: |
|
||||||
|
docmost/docmost:latest
|
||||||
|
docmost/docmost:${{ steps.strip-v.outputs.version }}
|
||||||
|
outputs: type=docker,dest=docmost-${{ matrix.suffix }}.docker.tar
|
||||||
|
cache-from: type=gha,scope=${{ matrix.suffix }}
|
||||||
|
|
||||||
|
- name: Compress image
|
||||||
|
run: gzip docmost-${{ matrix.suffix }}.docker.tar
|
||||||
|
|
||||||
|
- name: Upload image archive
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: docker-image-${{ matrix.suffix }}
|
||||||
|
path: docmost-${{ matrix.suffix }}.docker.tar.gz
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
release:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Download digests
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
pattern: digest-*
|
||||||
|
path: /tmp/digests
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata for tags
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: docmost/docmost
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}},value=${{ env.VERSION }}
|
||||||
|
type=semver,pattern={{major}}.{{minor}},value=${{ env.VERSION }},enable=${{ !contains(env.VERSION, '-') }}
|
||||||
|
type=raw,value=latest,enable=${{ !contains(env.VERSION, '-') }}
|
||||||
|
|
||||||
|
- name: Create manifest list and push
|
||||||
|
working-directory: /tmp/digests
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||||
|
$(printf 'docmost/docmost@sha256:%s ' *)
|
||||||
|
|
||||||
|
- name: Download image archives
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
pattern: docker-image-*
|
||||||
|
path: /tmp/images
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Create GitHub Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: ${{ env.VERSION }}
|
||||||
|
files: |
|
||||||
|
/tmp/images/docmost-amd64.docker.tar.gz
|
||||||
|
/tmp/images/docmost-arm64.docker.tar.gz
|
||||||
|
draft: true
|
||||||
43
.gitignore
vendored
Normal file
43
.gitignore
vendored
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
.env
|
||||||
|
.env.dev
|
||||||
|
.env.prod
|
||||||
|
data
|
||||||
|
# compiled output
|
||||||
|
dist
|
||||||
|
/node_modules
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
/coverage
|
||||||
|
/.nyc_output
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
/.idea
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.c9/
|
||||||
|
*.launch
|
||||||
|
.settings/
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# IDE - VSCode
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
|
||||||
|
.nx
|
||||||
|
.nx/installation
|
||||||
|
.nx/cache
|
||||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "apps/server/src/ee"]
|
||||||
|
path = apps/server/src/ee
|
||||||
|
url = https://github.com/docmost/ee
|
||||||
62
Dockerfile
Normal file
62
Dockerfile
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
FROM node:26-slim AS base
|
||||||
|
LABEL org.opencontainers.image.source="https://github.com/docmost/docmost"
|
||||||
|
|
||||||
|
RUN npm install -g pnpm@11.25.0
|
||||||
|
|
||||||
|
FROM base AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
FROM base AS installer
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends curl bash \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# drop npm and corepack
|
||||||
|
RUN rm -rf /usr/local/lib/node_modules/npm \
|
||||||
|
&& rm -rf /usr/local/lib/node_modules/corepack \
|
||||||
|
&& rm -rf /usr/local/bin/npm \
|
||||||
|
&& rm -rf /usr/local/bin/npx \
|
||||||
|
&& rm -rf /usr/local/bin/corepack \
|
||||||
|
&& rm -rf /root/.npm \
|
||||||
|
&& rm -rf /root/.node-gyp
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy apps
|
||||||
|
COPY --from=builder /app/apps/server/dist /app/apps/server/dist
|
||||||
|
COPY --from=builder /app/apps/client/dist /app/apps/client/dist
|
||||||
|
COPY --from=builder /app/apps/server/package.json /app/apps/server/package.json
|
||||||
|
|
||||||
|
# Copy packages
|
||||||
|
COPY --from=builder /app/packages/editor-ext/dist /app/packages/editor-ext/dist
|
||||||
|
COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-ext/package.json
|
||||||
|
COPY --from=builder /app/packages/base-formula/dist /app/packages/base-formula/dist
|
||||||
|
COPY --from=builder /app/packages/base-formula/package.json /app/packages/base-formula/package.json
|
||||||
|
|
||||||
|
# Copy root package files
|
||||||
|
COPY --from=builder /app/package.json /app/package.json
|
||||||
|
COPY --from=builder /app/pnpm*.yaml /app/
|
||||||
|
|
||||||
|
# Copy patches
|
||||||
|
COPY --from=builder /app/patches /app/patches
|
||||||
|
|
||||||
|
RUN chown -R node:node /app
|
||||||
|
|
||||||
|
USER node
|
||||||
|
|
||||||
|
RUN pnpm install --frozen-lockfile --prod && rm -rf /home/node/.cache/pnpm
|
||||||
|
|
||||||
|
RUN mkdir -p /app/data/storage
|
||||||
|
|
||||||
|
VOLUME ["/app/data/storage"]
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["pnpm", "start"]
|
||||||
661
LICENSE
Normal file
661
LICENSE
Normal file
@@ -0,0 +1,661 @@
|
|||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published
|
||||||
|
by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
62
README.md
Normal file
62
README.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<div align="center">
|
||||||
|
<h1><b>Docmost</b></h1>
|
||||||
|
<p>
|
||||||
|
Open-source collaborative wiki and documentation software.
|
||||||
|
<br />
|
||||||
|
<a href="https://docmost.com"><strong>Website</strong></a> |
|
||||||
|
<a href="https://docmost.com/docs"><strong>Documentation</strong></a> |
|
||||||
|
<a href="https://twitter.com/DocmostHQ"><strong>Twitter / X</strong></a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
To get started with Docmost, please refer to our [documentation](https://docmost.com/docs) or try our [cloud version](https://docmost.com/pricing) .
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Real-time collaboration
|
||||||
|
- Diagrams (Draw.io, Excalidraw and Mermaid)
|
||||||
|
- Spaces
|
||||||
|
- Permissions management
|
||||||
|
- Groups
|
||||||
|
- Comments
|
||||||
|
- Page history
|
||||||
|
- Search
|
||||||
|
- File attachments
|
||||||
|
- Embeds (Airtable, Loom, Miro and more)
|
||||||
|
- Translations (10+ languages)
|
||||||
|
|
||||||
|
### Screenshots
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img alt="home" src="https://docmost.com/screenshots/home.png" width="70%">
|
||||||
|
<img alt="editor" src="https://docmost.com/screenshots/editor.png" width="70%">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
### License
|
||||||
|
Docmost core is licensed under the open-source AGPL 3.0 license.
|
||||||
|
Enterprise features are available under an enterprise license (Enterprise Edition).
|
||||||
|
|
||||||
|
All files in the following directories are licensed under the Docmost Enterprise license defined in `packages/ee/License`.
|
||||||
|
- apps/server/src/ee
|
||||||
|
- apps/client/src/ee
|
||||||
|
- packages/ee
|
||||||
|
|
||||||
|
### Contributing
|
||||||
|
|
||||||
|
See the [development documentation](https://docmost.com/docs/self-hosting/development)
|
||||||
|
|
||||||
|
## Thanks
|
||||||
|
Special thanks to;
|
||||||
|
|
||||||
|
<img width="100" alt="Crowdin" src="https://github.com/user-attachments/assets/a6c3d352-e41b-448d-b6cd-3fbca3109f07" />
|
||||||
|
|
||||||
|
[Crowdin](https://crowdin.com/) for providing access to their localization platform.
|
||||||
|
|
||||||
|
|
||||||
|
<img width="48" alt="Algolia-mark-square-white" src="https://github.com/user-attachments/assets/6ccad04a-9589-4965-b6a1-d5cb1f4f9e94" />
|
||||||
|
|
||||||
|
[Algolia](https://www.algolia.com/) for providing full-text search to the docs.
|
||||||
|
|
||||||
4
apps/client/.dockerignore
Normal file
4
apps/client/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
dist
|
||||||
27
apps/client/.gitignore
vendored
Normal file
27
apps/client/.gitignore
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
.env
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
27
apps/client/README.md
Normal file
27
apps/client/README.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
||||||
|
|
||||||
|
- Configure the top-level `parserOptions` property like this:
|
||||||
|
|
||||||
|
```js
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
sourceType: 'module',
|
||||||
|
project: ['./tsconfig.json', './tsconfig.node.json'],
|
||||||
|
tsconfigRootDir: __dirname,
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
|
||||||
|
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
|
||||||
|
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
|
||||||
36
apps/client/eslint.config.mjs
Normal file
36
apps/client/eslint.config.mjs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import globals from "globals";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
import pluginQuery from "@tanstack/eslint-plugin-query";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ["dist"] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ["**/*.{ts,tsx}"],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
"react-hooks": reactHooks,
|
||||||
|
"react-refresh": reactRefresh,
|
||||||
|
"@tanstack/query": pluginQuery,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
"react-refresh/only-export-components": [
|
||||||
|
"warn",
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
"@typescript-eslint/ban-ts-comment": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": "off",
|
||||||
|
"react-hooks/exhaustive-deps": "off",
|
||||||
|
"@typescript-eslint/no-unused-expressions": "off",
|
||||||
|
"no-useless-escape": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
26
apps/client/index.html
Normal file
26
apps/client/index.html
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32x32.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16x16.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0 user-scalable=no" />
|
||||||
|
<title>Docmost</title>
|
||||||
|
<meta name="theme-color" content="#1f1f1f" media="(prefers-color-scheme: dark)" />
|
||||||
|
<meta name="theme-color" content="#f6f7f9" media="(prefers-color-scheme: light)" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-touch-fullscreen" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Docmost" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<!--meta-tags-->
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
|
||||||
|
<!--window-config-->
|
||||||
|
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
100
apps/client/package.json
Normal file
100
apps/client/package.json
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
{
|
||||||
|
"name": "client",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.96.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\"",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@atlaskit/pragmatic-drag-and-drop": "1.8.1",
|
||||||
|
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "2.1.5",
|
||||||
|
"@atlaskit/pragmatic-drag-and-drop-flourish": "2.0.15",
|
||||||
|
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.1.0",
|
||||||
|
"@atlaskit/pragmatic-drag-and-drop-live-region": "1.3.4",
|
||||||
|
"@casl/react": "5.0.1",
|
||||||
|
"@docmost/base-formula": "workspace:*",
|
||||||
|
"@docmost/editor-ext": "workspace:*",
|
||||||
|
"@excalidraw/excalidraw": "0.18.0-3a5ef40",
|
||||||
|
"@fontsource-variable/inter": "5.3.0",
|
||||||
|
"@mantine/core": "9.3.2",
|
||||||
|
"@mantine/dates": "9.3.2",
|
||||||
|
"@mantine/form": "9.3.2",
|
||||||
|
"@mantine/hooks": "9.3.2",
|
||||||
|
"@mantine/modals": "9.3.2",
|
||||||
|
"@mantine/notifications": "9.3.2",
|
||||||
|
"@mantine/spotlight": "9.3.2",
|
||||||
|
"@slidoapp/emoji-mart": "5.8.7",
|
||||||
|
"@slidoapp/emoji-mart-data": "1.2.4",
|
||||||
|
"@slidoapp/emoji-mart-react": "1.1.5",
|
||||||
|
"@tabler/icons-react": "3.40.0",
|
||||||
|
"@tanstack/react-query": "5.90.17",
|
||||||
|
"@tanstack/react-table": "8.21.3",
|
||||||
|
"@tanstack/react-virtual": "3.14.3",
|
||||||
|
"alfaaz": "1.1.0",
|
||||||
|
"axios": "1.18.1",
|
||||||
|
"blueimp-load-image": "5.16.0",
|
||||||
|
"clsx": "2.1.1",
|
||||||
|
"file-saver": "2.0.5",
|
||||||
|
"highlightjs-sap-abap": "0.3.0",
|
||||||
|
"i18next": "25.10.1",
|
||||||
|
"i18next-http-backend": "3.0.6",
|
||||||
|
"jotai": "2.20.1",
|
||||||
|
"jotai-optics": "0.4.0",
|
||||||
|
"js-cookie": "3.0.7",
|
||||||
|
"jwt-decode": "4.0.0",
|
||||||
|
"katex": "0.16.40",
|
||||||
|
"lowlight": "3.3.0",
|
||||||
|
"mantine-form-zod-resolver": "1.3.0",
|
||||||
|
"mermaid": "11.16.1",
|
||||||
|
"mitt": "3.0.1",
|
||||||
|
"nanoid": "3.3.18",
|
||||||
|
"posthog-js": "1.391.2",
|
||||||
|
"react": "19.2.7",
|
||||||
|
"react-clear-modal": "^2.0.18",
|
||||||
|
"react-dom": "19.2.7",
|
||||||
|
"react-drawio": "1.0.7",
|
||||||
|
"react-error-boundary": "6.1.1",
|
||||||
|
"react-helmet-async": "3.0.0",
|
||||||
|
"react-i18next": "16.5.8",
|
||||||
|
"react-router-dom": "7.18.2",
|
||||||
|
"semver": "7.7.4",
|
||||||
|
"socket.io-client": "4.8.3",
|
||||||
|
"yet-another-react-lightbox": "^3.32.2",
|
||||||
|
"zod": "4.3.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "9.28.0",
|
||||||
|
"@tanstack/eslint-plugin-query": "5.94.4",
|
||||||
|
"@testing-library/jest-dom": "6.6.0",
|
||||||
|
"@testing-library/react": "16.1.0",
|
||||||
|
"@types/blueimp-load-image": "5.16.6",
|
||||||
|
"@types/file-saver": "2.0.7",
|
||||||
|
"@types/js-cookie": "3.0.6",
|
||||||
|
"@types/katex": "0.16.8",
|
||||||
|
"@types/node": "22.19.1",
|
||||||
|
"@types/react": "19.2.17",
|
||||||
|
"@types/react-dom": "19.2.3",
|
||||||
|
"@vitejs/plugin-react": "6.0.1",
|
||||||
|
"eslint": "9.28.0",
|
||||||
|
"eslint-plugin-react": "7.37.5",
|
||||||
|
"eslint-plugin-react-hooks": "7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "0.5.2",
|
||||||
|
"globals": "15.13.0",
|
||||||
|
"jsdom": "25.0.0",
|
||||||
|
"optics-ts": "2.4.1",
|
||||||
|
"postcss": "8.5.25",
|
||||||
|
"postcss-preset-mantine": "1.18.0",
|
||||||
|
"postcss-simple-vars": "7.0.1",
|
||||||
|
"prettier": "3.8.1",
|
||||||
|
"typescript": "5.9.3",
|
||||||
|
"typescript-eslint": "8.57.1",
|
||||||
|
"vite": "8.0.16",
|
||||||
|
"vitest": "4.1.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/client/postcss.config.js
Normal file
14
apps/client/postcss.config.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
"postcss-preset-mantine": {},
|
||||||
|
"postcss-simple-vars": {
|
||||||
|
variables: {
|
||||||
|
"mantine-breakpoint-xs": "36em",
|
||||||
|
"mantine-breakpoint-sm": "48em",
|
||||||
|
"mantine-breakpoint-md": "62em",
|
||||||
|
"mantine-breakpoint-lg": "75em",
|
||||||
|
"mantine-breakpoint-xl": "88em",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
BIN
apps/client/public/icons/app-icon-192x192.png
Normal file
BIN
apps/client/public/icons/app-icon-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
BIN
apps/client/public/icons/app-icon-512x512.png
Normal file
BIN
apps/client/public/icons/app-icon-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
apps/client/public/icons/favicon-16x16.png
Normal file
BIN
apps/client/public/icons/favicon-16x16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 509 B |
BIN
apps/client/public/icons/favicon-32x32.png
Normal file
BIN
apps/client/public/icons/favicon-32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 881 B |
1480
apps/client/public/locales/de-DE/translation.json
Normal file
1480
apps/client/public/locales/de-DE/translation.json
Normal file
File diff suppressed because it is too large
Load Diff
1480
apps/client/public/locales/en-US/translation.json
Normal file
1480
apps/client/public/locales/en-US/translation.json
Normal file
File diff suppressed because it is too large
Load Diff
1480
apps/client/public/locales/es-ES/translation.json
Normal file
1480
apps/client/public/locales/es-ES/translation.json
Normal file
File diff suppressed because it is too large
Load Diff
1480
apps/client/public/locales/fr-FR/translation.json
Normal file
1480
apps/client/public/locales/fr-FR/translation.json
Normal file
File diff suppressed because it is too large
Load Diff
1480
apps/client/public/locales/it-IT/translation.json
Normal file
1480
apps/client/public/locales/it-IT/translation.json
Normal file
File diff suppressed because it is too large
Load Diff
1480
apps/client/public/locales/ja-JP/translation.json
Normal file
1480
apps/client/public/locales/ja-JP/translation.json
Normal file
File diff suppressed because it is too large
Load Diff
1480
apps/client/public/locales/ko-KR/translation.json
Normal file
1480
apps/client/public/locales/ko-KR/translation.json
Normal file
File diff suppressed because it is too large
Load Diff
1480
apps/client/public/locales/nl-NL/translation.json
Normal file
1480
apps/client/public/locales/nl-NL/translation.json
Normal file
File diff suppressed because it is too large
Load Diff
1480
apps/client/public/locales/pt-BR/translation.json
Normal file
1480
apps/client/public/locales/pt-BR/translation.json
Normal file
File diff suppressed because it is too large
Load Diff
1480
apps/client/public/locales/ru-RU/translation.json
Normal file
1480
apps/client/public/locales/ru-RU/translation.json
Normal file
File diff suppressed because it is too large
Load Diff
1480
apps/client/public/locales/uk-UA/translation.json
Normal file
1480
apps/client/public/locales/uk-UA/translation.json
Normal file
File diff suppressed because it is too large
Load Diff
1480
apps/client/public/locales/zh-CN/translation.json
Normal file
1480
apps/client/public/locales/zh-CN/translation.json
Normal file
File diff suppressed because it is too large
Load Diff
30
apps/client/public/manifest.json
Normal file
30
apps/client/public/manifest.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "Docmost",
|
||||||
|
"short_name": "Docmost",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#222",
|
||||||
|
"theme_color": "#222",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "icons/favicon-16x16.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "16x16"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icons/favicon-32x32.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "32x32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icons/app-icon-192x192.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "180x180 192x192"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icons/app-icon-512x512.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
1
apps/client/public/vite.svg
Normal file
1
apps/client/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
202
apps/client/src/App.tsx
Normal file
202
apps/client/src/App.tsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import { lazy, Suspense, useEffect } from "react";
|
||||||
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
|
import Layout from "@/components/layouts/global/layout.tsx";
|
||||||
|
import { Error404 } from "@/components/ui/error-404.tsx";
|
||||||
|
import { isCloud } from "@/lib/config.ts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx";
|
||||||
|
import { useTrackOrigin } from "@/hooks/use-track-origin";
|
||||||
|
|
||||||
|
|
||||||
|
const SetupWorkspace = lazy(() => import("@/pages/auth/setup-workspace.tsx"));
|
||||||
|
const LoginPage = lazy(() => import("@/pages/auth/login"));
|
||||||
|
const Home = lazy(() => import("@/pages/dashboard/home"));
|
||||||
|
const Page = lazy(() => import("@/pages/page/page"));
|
||||||
|
const AccountSettings = lazy(
|
||||||
|
() => import("@/pages/settings/account/account-settings"),
|
||||||
|
);
|
||||||
|
const WorkspaceMembers = lazy(
|
||||||
|
() => import("@/pages/settings/workspace/workspace-members"),
|
||||||
|
);
|
||||||
|
const WorkspaceSettings = lazy(
|
||||||
|
() => import("@/pages/settings/workspace/workspace-settings"),
|
||||||
|
);
|
||||||
|
const Groups = lazy(() => import("@/pages/settings/group/groups"));
|
||||||
|
const GroupInfo = lazy(() => import("./pages/settings/group/group-info"));
|
||||||
|
const Spaces = lazy(() => import("@/pages/settings/space/spaces.tsx"));
|
||||||
|
const AccountPreferences = lazy(
|
||||||
|
() => import("@/pages/settings/account/account-preferences.tsx"),
|
||||||
|
);
|
||||||
|
const SpaceHome = lazy(() => import("@/pages/space/space-home.tsx"));
|
||||||
|
const PageRedirect = lazy(() => import("@/pages/page/page-redirect.tsx"));
|
||||||
|
const InviteSignup = lazy(() => import("@/pages/auth/invite-signup.tsx"));
|
||||||
|
const ForgotPassword = lazy(() => import("@/pages/auth/forgot-password.tsx"));
|
||||||
|
const PasswordReset = lazy(() => import("./pages/auth/password-reset"));
|
||||||
|
const Billing = lazy(() => import("@/ee/billing/pages/billing.tsx"));
|
||||||
|
const CloudLogin = lazy(() => import("@/ee/pages/cloud-login.tsx"));
|
||||||
|
const CreateWorkspace = lazy(() => import("@/ee/pages/create-workspace.tsx"));
|
||||||
|
const Security = lazy(() => import("@/ee/security/pages/security.tsx"));
|
||||||
|
const License = lazy(() => import("@/ee/licence/pages/license.tsx"));
|
||||||
|
const SharedPage = lazy(() => import("@/pages/share/shared-page.tsx"));
|
||||||
|
const PdfRenderPage = lazy(() => import("@/ee/pdf-export/pdf-render-page.tsx"));
|
||||||
|
const Shares = lazy(() => import("@/pages/settings/shares/shares.tsx"));
|
||||||
|
const ShareLayout = lazy(
|
||||||
|
() => import("@/features/share/components/share-layout.tsx"),
|
||||||
|
);
|
||||||
|
const ShareRedirect = lazy(() => import("@/pages/share/share-redirect.tsx"));
|
||||||
|
const PublicSpacePage = lazy(
|
||||||
|
() => import("@/pages/public-space/public-space-page.tsx"),
|
||||||
|
);
|
||||||
|
const PublicSpaceLayout = lazy(
|
||||||
|
() => import("@/features/public-space/components/public-space-layout.tsx"),
|
||||||
|
);
|
||||||
|
const PublicSpaceDirectoryPage = lazy(
|
||||||
|
() => import("@/pages/public-space/public-space-directory-page.tsx"),
|
||||||
|
);
|
||||||
|
const SpacesPage = lazy(() => import("@/pages/spaces/spaces.tsx"));
|
||||||
|
const MfaChallengePage = lazy(() =>
|
||||||
|
import("@/ee/mfa/pages/mfa-challenge-page").then((m) => ({
|
||||||
|
default: m.MfaChallengePage,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const MfaSetupRequiredPage = lazy(() =>
|
||||||
|
import("@/ee/mfa/pages/mfa-setup-required-page").then((m) => ({
|
||||||
|
default: m.MfaSetupRequiredPage,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const SpaceTrash = lazy(() => import("@/pages/space/space-trash.tsx"));
|
||||||
|
const UserApiKeys = lazy(() => import("@/ee/api-key/pages/user-api-keys"));
|
||||||
|
const WorkspaceApiKeys = lazy(
|
||||||
|
() => import("@/ee/api-key/pages/workspace-api-keys"),
|
||||||
|
);
|
||||||
|
const AiSettings = lazy(() => import("@/ee/ai/pages/ai-settings.tsx"));
|
||||||
|
const BasePage = lazy(() => import("@/ee/base/pages/base-page.tsx"));
|
||||||
|
const AuditLogs = lazy(() => import("@/ee/audit/pages/audit-logs.tsx"));
|
||||||
|
const VerifiedPages = lazy(
|
||||||
|
() => import("@/ee/page-verification/pages/verified-pages.tsx"),
|
||||||
|
);
|
||||||
|
const TemplateList = lazy(() => import("@/ee/template/pages/template-list"));
|
||||||
|
const TemplateEditor = lazy(
|
||||||
|
() => import("@/ee/template/pages/template-editor"),
|
||||||
|
);
|
||||||
|
const FavoritesPage = lazy(() => import("@/pages/favorites/favorites-page"));
|
||||||
|
const AiChat = lazy(() => import("@/ee/ai-chat/pages/ai-chat.tsx"));
|
||||||
|
const VerifyEmail = lazy(() => import("@/ee/pages/verify-email.tsx"));
|
||||||
|
const LabelPage = lazy(() => import("@/pages/label/label-page"));
|
||||||
|
const OAuthConsent = lazy(() => import("@/ee/oauth/pages/oauth-consent.tsx"));
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
useRedirectToCloudSelect();
|
||||||
|
useTrackOrigin();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// warm the editor chunk so opening a page doesn't wait on the network
|
||||||
|
const timer = setTimeout(() => import("@/pages/page/page"), 3000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<Routes>
|
||||||
|
<Route index element={<Navigate to="/home" />} />
|
||||||
|
<Route path={"/login"} element={<LoginPage />} />
|
||||||
|
<Route path={"/invites/:invitationId"} element={<InviteSignup />} />
|
||||||
|
<Route path={"/forgot-password"} element={<ForgotPassword />} />
|
||||||
|
<Route path={"/password-reset"} element={<PasswordReset />} />
|
||||||
|
<Route path={"/login/mfa"} element={<MfaChallengePage />} />
|
||||||
|
<Route path={"/login/mfa/setup"} element={<MfaSetupRequiredPage />} />
|
||||||
|
<Route path={"/oauth/consent"} element={<OAuthConsent />} />
|
||||||
|
|
||||||
|
{!isCloud() && (
|
||||||
|
<Route path={"/setup/register"} element={<SetupWorkspace />} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isCloud() && (
|
||||||
|
<>
|
||||||
|
<Route path={"/create"} element={<CreateWorkspace />} />
|
||||||
|
<Route path={"/select"} element={<CloudLogin />} />
|
||||||
|
<Route path={"/verify-email"} element={<VerifyEmail />} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Route element={<ShareLayout />}>
|
||||||
|
<Route
|
||||||
|
path={"/share/:shareId/p/:pageSlug"}
|
||||||
|
element={<SharedPage />}
|
||||||
|
/>
|
||||||
|
<Route path={"/share/p/:pageSlug"} element={<SharedPage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route path={"/docs"} element={<PublicSpaceDirectoryPage />} />
|
||||||
|
<Route element={<PublicSpaceLayout />}>
|
||||||
|
<Route path={"/docs/:spaceSlug"} element={<PublicSpacePage />} />
|
||||||
|
<Route
|
||||||
|
path={"/docs/:spaceSlug/:pageSlug"}
|
||||||
|
element={<PublicSpacePage />}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route path={"/pdf-render/:pageId"} element={<PdfRenderPage />} />
|
||||||
|
<Route path={"/share/:shareId"} element={<ShareRedirect />} />
|
||||||
|
<Route path={"/p/:pageSlug"} element={<PageRedirect />} />
|
||||||
|
|
||||||
|
<Route element={<Layout />}>
|
||||||
|
<Route path={"/home"} element={<Home />} />
|
||||||
|
<Route path={"/ai"} element={<AiChat />} />
|
||||||
|
<Route path={"/ai/chat/:chatId"} element={<AiChat />} />
|
||||||
|
<Route path={"/spaces"} element={<SpacesPage />} />
|
||||||
|
<Route path={"/favorites"} element={<FavoritesPage />} />
|
||||||
|
<Route path={"/labels/:labelName"} element={<LabelPage />} />
|
||||||
|
<Route path={"/templates"} element={<TemplateList />} />
|
||||||
|
<Route
|
||||||
|
path={"/templates/:templateId"}
|
||||||
|
element={<TemplateEditor />}
|
||||||
|
/>
|
||||||
|
<Route path={"/s/:spaceSlug"} element={<SpaceHome />} />
|
||||||
|
<Route path={"/s/:spaceSlug/trash"} element={<SpaceTrash />} />
|
||||||
|
<Route
|
||||||
|
path={"/s/:spaceSlug/p/:pageSlug"}
|
||||||
|
element={<Page />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route path={"/base/:pageId"} element={<BasePage />} />
|
||||||
|
|
||||||
|
<Route path={"/settings"}>
|
||||||
|
<Route path={"account/profile"} element={<AccountSettings />} />
|
||||||
|
<Route
|
||||||
|
path={"account/preferences"}
|
||||||
|
element={<AccountPreferences />}
|
||||||
|
/>
|
||||||
|
<Route path={"account/api-keys"} element={<UserApiKeys />} />
|
||||||
|
<Route
|
||||||
|
path={"account/api-keys/authorized-apps"}
|
||||||
|
element={<UserApiKeys />}
|
||||||
|
/>
|
||||||
|
<Route path={"workspace"} element={<WorkspaceSettings />} />
|
||||||
|
<Route path={"members"} element={<WorkspaceMembers />} />
|
||||||
|
<Route path={"api-keys"} element={<WorkspaceApiKeys />} />
|
||||||
|
<Route path={"groups"} element={<Groups />} />
|
||||||
|
<Route path={"groups/:groupId"} element={<GroupInfo />} />
|
||||||
|
<Route path={"spaces"} element={<Spaces />} />
|
||||||
|
<Route path={"sharing"} element={<Shares />} />
|
||||||
|
<Route path={"security"} element={<Security />} />
|
||||||
|
<Route path={"ai"} element={<AiSettings />} />
|
||||||
|
<Route path={"ai/mcp"} element={<AiSettings />} />
|
||||||
|
<Route path={"audit"} element={<AuditLogs />} />
|
||||||
|
<Route path={"audit/siem"} element={<AuditLogs />} />
|
||||||
|
<Route
|
||||||
|
path={"siem"}
|
||||||
|
element={<Navigate to="/settings/audit/siem" replace />}
|
||||||
|
/>
|
||||||
|
<Route path={"verifications"} element={<VerifiedPages />} />
|
||||||
|
{!isCloud() && <Route path={"license"} element={<License />} />}
|
||||||
|
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route path="*" element={<Error404 />} />
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
apps/client/src/assets/react.svg
Normal file
1
apps/client/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
183
apps/client/src/components/common/avatar-uploader.tsx
Normal file
183
apps/client/src/components/common/avatar-uploader.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import React, { useRef } from "react";
|
||||||
|
import { Menu, Box, Loader } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { IconTrash, IconUpload } from "@tabler/icons-react";
|
||||||
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
|
||||||
|
interface AvatarUploaderProps {
|
||||||
|
currentImageUrl?: string | null;
|
||||||
|
fallbackName?: string;
|
||||||
|
radius?: string | number;
|
||||||
|
size?: string | number;
|
||||||
|
variant?: string;
|
||||||
|
type: AvatarIconType;
|
||||||
|
onUpload: (file: File) => Promise<void>;
|
||||||
|
onRemove: () => Promise<void>;
|
||||||
|
isLoading?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AvatarUploader({
|
||||||
|
currentImageUrl,
|
||||||
|
fallbackName,
|
||||||
|
radius,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
type,
|
||||||
|
onUpload,
|
||||||
|
onRemove,
|
||||||
|
isLoading = false,
|
||||||
|
disabled = false,
|
||||||
|
}: AvatarUploaderProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleFileInputChange = async (
|
||||||
|
event: React.ChangeEvent<HTMLInputElement>,
|
||||||
|
) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file || disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file size (max 10MB)
|
||||||
|
const maxSizeInBytes = 10 * 1024 * 1024;
|
||||||
|
if (file.size > maxSizeInBytes) {
|
||||||
|
notifications.show({
|
||||||
|
message: t("Image exceeds 10MB limit."),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
// Reset the input
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onUpload(file);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
notifications.show({
|
||||||
|
message: t("Failed to upload image"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the input so the same file can be selected again
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUploadClick = () => {
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.click();
|
||||||
|
} else {
|
||||||
|
console.error("File input ref is null!");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionLabel = {
|
||||||
|
[AvatarIconType.AVATAR]: t("Change avatar"),
|
||||||
|
[AvatarIconType.SPACE_ICON]: t("Change space icon"),
|
||||||
|
[AvatarIconType.WORKSPACE_ICON]: t("Change workspace icon"),
|
||||||
|
}[type];
|
||||||
|
|
||||||
|
// Per WCAG 2.5.3 (Label in Name), the accessible name must include the
|
||||||
|
// visible text. When no image is set, the avatar renders the name's
|
||||||
|
// initials, so prepend the name to the action label.
|
||||||
|
const ariaLabel =
|
||||||
|
!currentImageUrl && fallbackName
|
||||||
|
? `${fallbackName} – ${actionLabel}`
|
||||||
|
: actionLabel;
|
||||||
|
|
||||||
|
const handleRemove = async () => {
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onRemove();
|
||||||
|
notifications.show({
|
||||||
|
message: t("Image removed successfully"),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
notifications.show({
|
||||||
|
message: t("Failed to remove image"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={handleFileInputChange}
|
||||||
|
accept="image/png,image/jpeg,image/jpg"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
tabIndex={-1}
|
||||||
|
style={{ display: "none" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Menu shadow="md" width={200} withArrow disabled={disabled || isLoading}>
|
||||||
|
<Menu.Target>
|
||||||
|
<Box style={{ position: "relative", display: "inline-block" }}>
|
||||||
|
<CustomAvatar
|
||||||
|
component="button"
|
||||||
|
size={size}
|
||||||
|
avatarUrl={currentImageUrl}
|
||||||
|
name={fallbackName}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
style={{
|
||||||
|
cursor: disabled || isLoading ? "default" : "pointer",
|
||||||
|
opacity: isLoading ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
radius={radius}
|
||||||
|
variant={variant}
|
||||||
|
type={type}
|
||||||
|
/>
|
||||||
|
{isLoading && (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "50%",
|
||||||
|
left: "50%",
|
||||||
|
transform: "translate(-50%, -50%)",
|
||||||
|
zIndex: 200,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Menu.Target>
|
||||||
|
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconUpload size={16} />}
|
||||||
|
disabled={isLoading || disabled}
|
||||||
|
onClick={handleUploadClick}
|
||||||
|
>
|
||||||
|
{t("Upload image")}
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
|
{currentImageUrl && (
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconTrash size={16} />}
|
||||||
|
color="red"
|
||||||
|
onClick={handleRemove}
|
||||||
|
disabled={isLoading || disabled}
|
||||||
|
>
|
||||||
|
{t("Remove image")}
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
apps/client/src/components/common/copy-button.tsx
Normal file
33
apps/client/src/components/common/copy-button.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// Source: https://github.com/mantinedev/mantine/blob/master/packages/@mantine/core/src/components/CopyButton/CopyButton.tsx - MIT
|
||||||
|
// modified to use the polyfilled clipboard api
|
||||||
|
import React from "react";
|
||||||
|
import { useClipboard } from "@/hooks/use-clipboard";
|
||||||
|
import { useProps } from "@mantine/core";
|
||||||
|
|
||||||
|
interface CopyButtonProps {
|
||||||
|
/** Children callback, provides current status and copy function as an argument */
|
||||||
|
children: (payload: { copied: boolean; copy: () => void }) => React.ReactNode;
|
||||||
|
|
||||||
|
/** Value that is copied to the clipboard when the button is clicked */
|
||||||
|
value: string;
|
||||||
|
|
||||||
|
/** Copied status timeout in ms @default `1000` */
|
||||||
|
timeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
timeout: 1000,
|
||||||
|
} satisfies Partial<CopyButtonProps>;
|
||||||
|
|
||||||
|
export function CopyButton(props: CopyButtonProps) {
|
||||||
|
const { children, timeout, value, ...others } = useProps(
|
||||||
|
"CopyButton",
|
||||||
|
defaultProps,
|
||||||
|
props,
|
||||||
|
);
|
||||||
|
const clipboard = useClipboard({ timeout });
|
||||||
|
const copy = () => clipboard.copy(value);
|
||||||
|
return <>{children({ copy, copied: clipboard.copied, ...others })}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
CopyButton.displayName = "@mantine/core/CopyButton";
|
||||||
40
apps/client/src/components/common/copy.tsx
Normal file
40
apps/client/src/components/common/copy.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { ActionIcon, MantineColor, MantineSize, Tooltip } from "@mantine/core";
|
||||||
|
import { CopyButton } from "@/components/common/copy-button";
|
||||||
|
import { IconCheck, IconCopy } from "@tabler/icons-react";
|
||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
interface CopyProps {
|
||||||
|
text: string;
|
||||||
|
size?: MantineSize;
|
||||||
|
color?: MantineColor;
|
||||||
|
/** Override the accessible name (and tooltip) when not yet copied. Lets callers disambiguate adjacent copy buttons for screen readers. */
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
export default function CopyTextButton({ text, size, label }: CopyProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const copyLabel = label ?? t("Copy");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CopyButton value={text} timeout={2000}>
|
||||||
|
{({ copied, copy }) => (
|
||||||
|
<Tooltip
|
||||||
|
label={copied ? t("Copied") : copyLabel}
|
||||||
|
withArrow
|
||||||
|
position="right"
|
||||||
|
>
|
||||||
|
<ActionIcon
|
||||||
|
color={copied ? "teal" : "gray"}
|
||||||
|
variant="subtle"
|
||||||
|
onClick={copy}
|
||||||
|
size={size}
|
||||||
|
aria-label={copied ? t("Copied") : copyLabel}
|
||||||
|
>
|
||||||
|
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</CopyButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
232
apps/client/src/components/common/export-modal.tsx
Normal file
232
apps/client/src/components/common/export-modal.tsx
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Text,
|
||||||
|
Select,
|
||||||
|
Switch,
|
||||||
|
Divider,
|
||||||
|
Tooltip,
|
||||||
|
Badge,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
exportPage,
|
||||||
|
exportPageToDocx,
|
||||||
|
} from "@/features/page/services/page-service.ts";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ExportFormat } from "@/features/page/types/page.types.ts";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { exportSpace } from "@/features/space/services/space-service";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Feature } from "@/ee/features";
|
||||||
|
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||||
|
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
||||||
|
|
||||||
|
interface ExportModalProps {
|
||||||
|
id: string;
|
||||||
|
type: "space" | "page";
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ExportModal({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: ExportModalProps) {
|
||||||
|
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
|
||||||
|
const [includeChildren, setIncludeChildren] = useState<boolean>(false);
|
||||||
|
const [includeAttachments, setIncludeAttachments] = useState<boolean>(false);
|
||||||
|
const [isExporting, setIsExporting] = useState<boolean>(false);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const upgradeLabel = useUpgradeLabel();
|
||||||
|
const isDocx = format === ExportFormat.Docx;
|
||||||
|
const docxEntitled = useHasFeature(Feature.DOCX_EXPORT);
|
||||||
|
const blockedByLicense = isDocx && !docxEntitled;
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
setIsExporting(true);
|
||||||
|
try {
|
||||||
|
if (type === "page") {
|
||||||
|
if (format === ExportFormat.Docx) {
|
||||||
|
await exportPageToDocx({ pageId: id });
|
||||||
|
} else {
|
||||||
|
await exportPage({
|
||||||
|
pageId: id,
|
||||||
|
format,
|
||||||
|
includeChildren,
|
||||||
|
includeAttachments,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (type === "space") {
|
||||||
|
await exportSpace({ spaceId: id, format, includeAttachments });
|
||||||
|
}
|
||||||
|
notifications.show({
|
||||||
|
message: t("Export successful"),
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
notifications.show({
|
||||||
|
message: "Export failed:" + err.response?.data.message,
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
console.error("export error", err);
|
||||||
|
} finally {
|
||||||
|
setIsExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (format: ExportFormat) => {
|
||||||
|
setFormat(format);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal.Root
|
||||||
|
opened={open}
|
||||||
|
onClose={onClose}
|
||||||
|
size={500}
|
||||||
|
padding="xl"
|
||||||
|
yOffset="10vh"
|
||||||
|
xOffset={0}
|
||||||
|
mah={400}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Modal.Overlay />
|
||||||
|
<Modal.Content style={{ overflow: "hidden" }}>
|
||||||
|
<Modal.Header py={0}>
|
||||||
|
<Modal.Title fw={500}>{t(`Export ${type}`)}</Modal.Title>
|
||||||
|
<Modal.CloseButton aria-label={t("Close")} />
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<div>
|
||||||
|
<Text size="md">{t("Format")}</Text>
|
||||||
|
</div>
|
||||||
|
<ExportFormatSelection
|
||||||
|
format={format}
|
||||||
|
onChange={handleChange}
|
||||||
|
includeDocx={type === "page"}
|
||||||
|
docxEntitled={docxEntitled}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{type === "page" && !isDocx && (
|
||||||
|
<>
|
||||||
|
<Divider my="sm" />
|
||||||
|
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<div>
|
||||||
|
<Text size="md">{t("Include subpages")}</Text>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
onChange={(event) =>
|
||||||
|
setIncludeChildren(event.currentTarget.checked)
|
||||||
|
}
|
||||||
|
checked={includeChildren}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group justify="space-between" wrap="nowrap" mt="md">
|
||||||
|
<div>
|
||||||
|
<Text size="md">{t("Include attachments")}</Text>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
onChange={(event) =>
|
||||||
|
setIncludeAttachments(event.currentTarget.checked)
|
||||||
|
}
|
||||||
|
checked={includeAttachments}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{type === "space" && (
|
||||||
|
<>
|
||||||
|
<Divider my="sm" />
|
||||||
|
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<div>
|
||||||
|
<Text size="md">{t("Include attachments")}</Text>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
onChange={(event) =>
|
||||||
|
setIncludeAttachments(event.currentTarget.checked)
|
||||||
|
}
|
||||||
|
checked={includeAttachments}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="center" mt="md">
|
||||||
|
<Button onClick={onClose} variant="default">
|
||||||
|
{t("Cancel")}
|
||||||
|
</Button>
|
||||||
|
<Tooltip label={upgradeLabel} disabled={!blockedByLicense} withArrow>
|
||||||
|
<Button
|
||||||
|
onClick={handleExport}
|
||||||
|
loading={isExporting}
|
||||||
|
disabled={blockedByLicense}
|
||||||
|
data-disabled={blockedByLicense || undefined}
|
||||||
|
>
|
||||||
|
{t("Export")}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
</Modal.Body>
|
||||||
|
</Modal.Content>
|
||||||
|
</Modal.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExportFormatSelection {
|
||||||
|
format: ExportFormat;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
includeDocx?: boolean;
|
||||||
|
docxEntitled?: boolean;
|
||||||
|
}
|
||||||
|
function ExportFormatSelection({
|
||||||
|
format,
|
||||||
|
onChange,
|
||||||
|
includeDocx,
|
||||||
|
docxEntitled,
|
||||||
|
}: ExportFormatSelection) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const data = [
|
||||||
|
{ value: "markdown", label: "Markdown" },
|
||||||
|
{ value: "html", label: "HTML" },
|
||||||
|
...(includeDocx
|
||||||
|
? [{ value: "docx", label: "Word (.docx)", disabled: !docxEntitled }]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
data={data}
|
||||||
|
defaultValue={format}
|
||||||
|
onChange={onChange}
|
||||||
|
styles={{ wrapper: { maxWidth: 140 }, option: { opacity: 1 } }}
|
||||||
|
comboboxProps={{ width: 200 }}
|
||||||
|
allowDeselect={false}
|
||||||
|
withCheckIcon={false}
|
||||||
|
aria-label={t("Select export format")}
|
||||||
|
renderOption={({ option }) =>
|
||||||
|
option.value === "docx" && !docxEntitled ? (
|
||||||
|
<div>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{option.label}
|
||||||
|
</Text>
|
||||||
|
<Badge size="xs" mt={4}>
|
||||||
|
{t("Enterprise")}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Text size="sm">{option.label}</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
apps/client/src/components/common/no-table-results.tsx
Normal file
20
apps/client/src/components/common/no-table-results.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Table, Text } from "@mantine/core";
|
||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
interface NoTableResultsProps {
|
||||||
|
colSpan: number;
|
||||||
|
text?: string;
|
||||||
|
}
|
||||||
|
export default function NoTableResults({ colSpan, text }: NoTableResultsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={colSpan}>
|
||||||
|
<Text fw={500} c="dimmed" ta="center">
|
||||||
|
{text || t("No results found...")}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
apps/client/src/components/common/page-list-icon.tsx
Normal file
18
apps/client/src/components/common/page-list-icon.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { ThemeIcon } from "@mantine/core";
|
||||||
|
import { IconFileDescription, IconTable } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
icon?: string | null;
|
||||||
|
isBase?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PageListIcon({ icon, isBase }: Props) {
|
||||||
|
if (icon) {
|
||||||
|
return <>{icon}</>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ThemeIcon variant="transparent" color="gray" size={18}>
|
||||||
|
{isBase ? <IconTable size={18} /> : <IconFileDescription size={18} />}
|
||||||
|
</ThemeIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
apps/client/src/components/common/paginate.tsx
Normal file
44
apps/client/src/components/common/paginate.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { Button, Group } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export interface PagePaginationProps {
|
||||||
|
hasPrevPage: boolean;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
onPrev: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Paginate({
|
||||||
|
hasPrevPage,
|
||||||
|
hasNextPage,
|
||||||
|
onPrev,
|
||||||
|
onNext,
|
||||||
|
}: PagePaginationProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (!hasPrevPage && !hasNextPage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group mt="md" justify="flex-end">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="compact-sm"
|
||||||
|
onClick={onPrev}
|
||||||
|
disabled={!hasPrevPage}
|
||||||
|
>
|
||||||
|
{t("Prev")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="compact-sm"
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={!hasNextPage}
|
||||||
|
>
|
||||||
|
{t("Next")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
apps/client/src/components/common/recent-changes.tsx
Normal file
109
apps/client/src/components/common/recent-changes.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import {
|
||||||
|
Text,
|
||||||
|
Group,
|
||||||
|
UnstyledButton,
|
||||||
|
Badge,
|
||||||
|
Table,
|
||||||
|
Button,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx";
|
||||||
|
import { buildPageUrl, getPageTitle } from "@/features/page/page.utils.ts";
|
||||||
|
import { formattedDate } from "@/lib/time.ts";
|
||||||
|
import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts";
|
||||||
|
import { PageListIcon } from "@/components/common/page-list-icon";
|
||||||
|
import { IconFiles } from "@tabler/icons-react";
|
||||||
|
import { EmptyState } from "@/components/ui/empty-state.tsx";
|
||||||
|
import { getSpaceUrl } from "@/lib/config.ts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { getInitialsColor } from "@/lib/get-initials-color.ts";
|
||||||
|
import rowClasses from "@/components/ui/clickable-table-row.module.css";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
spaceId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecentChanges({ spaceId }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data, isLoading, isError, hasNextPage, fetchNextPage, isFetchingNextPage } = useRecentChangesQuery(spaceId);
|
||||||
|
const pages = data?.pages.flatMap((p) => p.items) ?? [];
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <PageListSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return <Text>{t("Failed to fetch recent pages")}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<Table.ScrollContainer minWidth={500}>
|
||||||
|
<Table highlightOnHover verticalSpacing="sm">
|
||||||
|
<Table.Tbody>
|
||||||
|
{pages.map((page) => (
|
||||||
|
<Table.Tr key={page.id} className={rowClasses.row}>
|
||||||
|
<Table.Td>
|
||||||
|
<UnstyledButton
|
||||||
|
className={rowClasses.link}
|
||||||
|
component={Link}
|
||||||
|
to={buildPageUrl(page?.space.slug, page.slugId, page.title)}
|
||||||
|
>
|
||||||
|
<Group wrap="nowrap">
|
||||||
|
<PageListIcon icon={page.icon} isBase={page.isBase} />
|
||||||
|
|
||||||
|
<Text fw={500} size="md" lineClamp={1}>
|
||||||
|
{getPageTitle(page.title, page.isBase, t)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
|
</Table.Td>
|
||||||
|
{!spaceId && (
|
||||||
|
<Table.Td>
|
||||||
|
<Badge
|
||||||
|
color={getInitialsColor(page?.space.name)}
|
||||||
|
variant="light"
|
||||||
|
component={Link}
|
||||||
|
to={getSpaceUrl(page?.space.slug)}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
{page?.space.name}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
)}
|
||||||
|
<Table.Td>
|
||||||
|
<Text
|
||||||
|
c="dimmed"
|
||||||
|
style={{ whiteSpace: "nowrap" }}
|
||||||
|
size="xs"
|
||||||
|
fw={500}
|
||||||
|
>
|
||||||
|
{formattedDate(page.updatedAt)}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Table.ScrollContainer>
|
||||||
|
{hasNextPage && (
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
fullWidth
|
||||||
|
mt="sm"
|
||||||
|
mb="xl"
|
||||||
|
onClick={() => fetchNextPage()}
|
||||||
|
loading={isFetchingNextPage}
|
||||||
|
>
|
||||||
|
{t("Load more")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
icon={IconFiles}
|
||||||
|
title={t("No pages yet")}
|
||||||
|
description={t("Pages you create will show up here.")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
apps/client/src/components/common/search-input.tsx
Normal file
40
apps/client/src/components/common/search-input.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { TextInput, Group } from "@mantine/core";
|
||||||
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
|
import { IconSearch } from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export interface SearchInputProps {
|
||||||
|
placeholder?: string;
|
||||||
|
ariaLabel?: string;
|
||||||
|
debounceDelay?: number;
|
||||||
|
onSearch: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchInput({
|
||||||
|
placeholder,
|
||||||
|
ariaLabel,
|
||||||
|
debounceDelay = 500,
|
||||||
|
onSearch,
|
||||||
|
}: SearchInputProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [value, setValue] = useState("");
|
||||||
|
const [debouncedValue] = useDebouncedValue(value, debounceDelay);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onSearch(debouncedValue);
|
||||||
|
}, [debouncedValue, onSearch]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group mb="sm">
|
||||||
|
<TextInput
|
||||||
|
size="sm"
|
||||||
|
placeholder={placeholder || t("Search...")}
|
||||||
|
aria-label={ariaLabel || placeholder || t("Search")}
|
||||||
|
leftSection={<IconSearch size={16} />}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
apps/client/src/components/common/user-info.tsx
Normal file
24
apps/client/src/components/common/user-info.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Group, Text } from "@mantine/core";
|
||||||
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
|
import React from "react";
|
||||||
|
import { IUser } from '@/features/user/types/user.types.ts';
|
||||||
|
|
||||||
|
interface UserInfoProps {
|
||||||
|
user: Partial<IUser>;
|
||||||
|
size?: string;
|
||||||
|
}
|
||||||
|
export function UserInfo({ user, size }: UserInfoProps) {
|
||||||
|
return (
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<CustomAvatar avatarUrl={user?.avatarUrl} name={user?.name} size={size} />
|
||||||
|
<div>
|
||||||
|
<Text fz="sm" fw={500} lineClamp={1}>
|
||||||
|
{user?.name}
|
||||||
|
</Text>
|
||||||
|
<Text fz="xs" c="dimmed">
|
||||||
|
{user?.email}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
apps/client/src/components/icons/airtable-icon.tsx
Normal file
32
apps/client/src/components/icons/airtable-icon.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { rem } from '@mantine/core';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AirtableIcon({ size }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 256 215"
|
||||||
|
style={{ width: rem(size), height: rem(size) }}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="#ffbf00"
|
||||||
|
d="M114.259 2.701 18.86 42.176c-5.305 2.195-5.25 9.73.089 11.847l95.797 37.989a35.544 35.544 0 0 0 26.208 0l95.799-37.99c5.337-2.115 5.393-9.65.086-11.846L141.442 2.7a35.549 35.549 0 0 0-27.183 0"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#26b5f8"
|
||||||
|
d="M136.35 112.757v94.902c0 4.514 4.55 7.605 8.746 5.942l106.748-41.435a6.39 6.39 0 0 0 4.035-5.941V71.322c0-4.514-4.551-7.604-8.747-5.941l-106.748 41.434a6.392 6.392 0 0 0-4.035 5.942"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#ed3049"
|
||||||
|
d="m111.423 117.654-31.68 15.296-3.217 1.555L9.65 166.548C5.411 168.593 0 165.504 0 160.795V71.72c0-1.704.874-3.175 2.046-4.283a7.266 7.266 0 0 1 1.618-1.213c1.598-.959 3.878-1.215 5.816-.448l101.41 40.18c5.155 2.045 5.56 9.268.533 11.697"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fillOpacity={0.25}
|
||||||
|
d="m111.423 117.654-31.68 15.296L2.045 67.438a7.266 7.266 0 0 1 1.618-1.213c1.598-.959 3.878-1.215 5.816-.448l101.41 40.18c5.155 2.045 5.56 9.268.533 11.697"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
apps/client/src/components/icons/confluence-icon.tsx
Normal file
20
apps/client/src/components/icons/confluence-icon.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { rem } from "@mantine/core";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfluenceIcon({ size }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
style={{ width: rem(size), height: rem(size) }}
|
||||||
|
>
|
||||||
|
<path d="M.87 18.257c-.248.382-.53.875-.763 1.245a.764.764 0 0 0 .255 1.04l4.965 3.054a.764.764 0 0 0 1.058-.26c.199-.332.454-.763.733-1.221 1.967-3.247 3.945-2.853 7.508-1.146l4.957 2.337a.764.764 0 0 0 1.028-.382l2.364-5.346a.764.764 0 0 0-.382-1 599.851 599.851 0 0 1-4.965-2.361C10.911 10.97 5.224 11.185.87 18.257zM23.131 5.743c.249-.405.531-.875.764-1.25a.764.764 0 0 0-.256-1.034L18.675.404a.764.764 0 0 0-1.058.26c-.195.335-.451.763-.734 1.225-1.966 3.246-3.945 2.85-7.508 1.146L4.437.694a.764.764 0 0 0-1.027.382L1.046 6.422a.764.764 0 0 0 .382 1c1.039.49 3.105 1.467 4.965 2.361 6.698 3.246 12.392 3.029 16.738-4.04z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
apps/client/src/components/icons/figma-icon.tsx
Normal file
23
apps/client/src/components/icons/figma-icon.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { rem } from '@mantine/core';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FigmaIcon({ size }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
style={{ width: rem(size), height: rem(size) }}
|
||||||
|
>
|
||||||
|
<g fill="none" fillRule="evenodd" transform="translate(4)">
|
||||||
|
<circle cx={12} cy={12} r={4} fill="#19bcfe" />
|
||||||
|
<path fill="#09cf83" d="M4 24a4 4 0 0 0 4-4v-4H4a4 4 0 1 0 0 8z" />
|
||||||
|
<path fill="#a259ff" d="M4 16h4V8H4a4 4 0 1 0 0 8z" />
|
||||||
|
<path fill="#f24e1e" d="M4 8h4V0H4a4 4 0 1 0 0 8z" />
|
||||||
|
<path fill="#ff7262" d="M12 8H8V0h4a4 4 0 1 1 0 8z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
apps/client/src/components/icons/framer-icon.tsx
Normal file
17
apps/client/src/components/icons/framer-icon.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { rem } from '@mantine/core';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FramerIcon({ size }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
style={{ width: rem(size), height: rem(size) }}
|
||||||
|
>
|
||||||
|
<path d="M4 0h16v8h-8zm0 8h8l8 8H4zm0 8h8v8z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
apps/client/src/components/icons/google-drive-icon.tsx
Normal file
24
apps/client/src/components/icons/google-drive-icon.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { rem } from '@mantine/core';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GoogleDriveIcon({ size }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 87.3 78"
|
||||||
|
style={{ width: rem(size), height: rem(size) }}
|
||||||
|
>
|
||||||
|
<path d="m6.6 66.85 3.85 6.65c.8 1.4 1.95 2.5 3.3 3.3l13.75-23.8h-27.5c0 1.55.4 3.1 1.2 4.5z" fill="#0066da" />
|
||||||
|
<path d="m43.65 25-13.75-23.8c-1.35.8-2.5 1.9-3.3 3.3l-25.4 44a9.06 9.06 0 0 0 -1.2 4.5h27.5z" fill="#00ac47" />
|
||||||
|
<path d="m73.55 76.8c1.35-.8 2.5-1.9 3.3-3.3l1.6-2.75 7.65-13.25c.8-1.4 1.2-2.95 1.2-4.5h-27.502l5.852 11.5z"
|
||||||
|
fill="#ea4335" />
|
||||||
|
<path d="m43.65 25 13.75-23.8c-1.35-.8-2.9-1.2-4.5-1.2h-18.5c-1.6 0-3.15.45-4.5 1.2z" fill="#00832d" />
|
||||||
|
<path d="m59.8 53h-32.3l-13.75 23.8c1.35.8 2.9 1.2 4.5 1.2h50.8c1.6 0 3.15-.45 4.5-1.2z" fill="#2684fc" />
|
||||||
|
<path d="m73.4 26.5-12.7-22c-.8-1.4-1.95-2.5-3.3-3.3l-13.75 23.8 16.15 28h27.45c0-1.55-.4-3.1-1.2-4.5z"
|
||||||
|
fill="#ffba00" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
apps/client/src/components/icons/google-icon.tsx
Normal file
33
apps/client/src/components/icons/google-icon.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { rem } from "@mantine/core";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GoogleIcon({ size }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
preserveAspectRatio="xMidYMid"
|
||||||
|
viewBox="0 0 256 262"
|
||||||
|
style={{ width: rem(size), height: rem(size) }}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="#4285F4"
|
||||||
|
d="M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622 38.755 30.023 2.685.268c24.659-22.774 38.875-56.282 38.875-96.027"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#34A853"
|
||||||
|
d="M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055-34.523 0-63.824-22.773-74.269-54.25l-1.531.13-40.298 31.187-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#FBBC05"
|
||||||
|
d="M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82 0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602l42.356-32.782"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#EB4335"
|
||||||
|
d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0 79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
apps/client/src/components/icons/google-sheets-icon.tsx
Normal file
23
apps/client/src/components/icons/google-sheets-icon.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { rem } from '@mantine/core';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GoogleSheetsIcon({ size }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
style={{ width: rem(size), height: rem(size) }}
|
||||||
|
>
|
||||||
|
<path fill="#43a047" d="M37,45H11c-1.657,0-3-1.343-3-3V6c0-1.657,1.343-3,3-3h19l10,10v29C40,43.657,38.657,45,37,45z"/>
|
||||||
|
<path fill="#c8e6c9" d="M40 13L30 13 30 3z"/>
|
||||||
|
<path fill="#2e7d32" d="M30 13L40 23 40 13z"/>
|
||||||
|
<path
|
||||||
|
fill="#e8f5e9"
|
||||||
|
d="M31,23H17h-2v2v2v2v2v2v2v2h18v-2v-2v-2v-2v-2v-2v-2H31z M17,25h4v2h-4V25z M17,29h4v2h-4V29z M17,33h4v2h-4V33z M31,35h-8v-2h8V35z M31,31h-8v-2h8V31z M31,27h-8v-2h8V27z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
apps/client/src/components/icons/icon-columns-4.tsx
Normal file
27
apps/client/src/components/icons/icon-columns-4.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { rem } from "@mantine/core";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
size?: number | string;
|
||||||
|
stroke?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function IconColumns4({ size = 24, stroke = 2 }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={rem(size)}
|
||||||
|
height={rem(size)}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={stroke}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M3 4a1 1 0 0 1 1 -1h16a1 1 0 0 1 1 1v16a1 1 0 0 1 -1 1h-16a1 1 0 0 1 -1 -1v-16" />
|
||||||
|
<path d="M7.5 3v18" />
|
||||||
|
<path d="M12 3v18" />
|
||||||
|
<path d="M16.5 3v18" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
apps/client/src/components/icons/icon-columns-5.tsx
Normal file
28
apps/client/src/components/icons/icon-columns-5.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { rem } from "@mantine/core";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
size?: number | string;
|
||||||
|
stroke?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function IconColumns5({ size = 24, stroke = 2 }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={rem(size)}
|
||||||
|
height={rem(size)}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={stroke}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M3 4a1 1 0 0 1 1 -1h16a1 1 0 0 1 1 1v16a1 1 0 0 1 -1 1h-16a1 1 0 0 1 -1 -1v-16" />
|
||||||
|
<path d="M6.6 3v18" />
|
||||||
|
<path d="M10.2 3v18" />
|
||||||
|
<path d="M13.8 3v18" />
|
||||||
|
<path d="M17.4 3v18" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
apps/client/src/components/icons/icon-drawio.tsx
Normal file
20
apps/client/src/components/icons/icon-drawio.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { rem } from '@mantine/core';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconDrawio({ size }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="#F08705"
|
||||||
|
style={{ width: rem(size), height: rem(size) }}
|
||||||
|
>
|
||||||
|
<path d="M19.69 13.419h-2.527l-2.667-4.555a1.292 1.292 0 001.035-1.28V4.16c0-.725-.576-1.312-1.302-1.312H9.771c-.726 0-1.312.576-1.312 1.301v3.435c0 .619.426 1.152 1.034 1.28l-2.666 4.555H4.309c-.725 0-1.312.576-1.312 1.301v3.435c0 .725.576 1.312 1.302 1.312h4.458c.726 0 1.312-.576 1.312-1.302v-3.434c0-.726-.576-1.312-1.301-1.312h-.437l2.645-4.523h2.059l2.656 4.523h-.438c-.725 0-1.312.576-1.312 1.301v3.435c0 .725.576 1.312 1.302 1.312H19.7c.726 0 1.312-.576 1.312-1.302v-3.434c0-.726-.576-1.312-1.301-1.312zM24 22.976c0 .565-.459 1.024-1.013 1.024H1.024A1.022 1.022 0 010 22.987V1.024C0 .459.459 0 1.013 0h21.963C23.541 0 24 .459 24 1.013z"></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default IconDrawio;
|
||||||
20
apps/client/src/components/icons/icon-excalidraw.tsx
Normal file
20
apps/client/src/components/icons/icon-excalidraw.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { rem } from "@mantine/core";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconExcalidraw({ size }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="#6965DB"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
style={{ width: rem(size), height: rem(size) }}
|
||||||
|
>
|
||||||
|
<path d="M23.943 19.806a.196.196 0 00-.168-.034c-1.26-1.855-2.873-3.61-4.419-5.315l-.252-.284c-.001-.073-.067-.12-.134-.15l-.084-.084c-.05-.1-.169-.167-.286-.1-.47.234-.907.585-1.327.919-.554.434-1.109.87-1.63 1.354a5.058 5.058 0 00-.588.618c-.084.117-.017.217.084.267-.37.368-.74.736-1.109 1.12a.19.19 0 00-.05.134c0 .05.033.1.067.117l.655.502v.016c.924.92 2.554 2.173 4.285 3.527.251.201.52.402.773.602.117.134.234.285.335.418.05.066.169.084.236.033.033.034.084.067.118.1a.24.24 0 00.1.034.153.153 0 00.135-.066.237.237 0 00.033-.1c.017 0 .017.016.034.016a.192.192 0 00.134-.05l3.058-3.327c.12-.116.014-.267 0-.267zm-7.628-.134l-1.546-1.17-.15-.1c-.035-.017-.068-.05-.102-.067l-.117-.1c.66-.66 1.33-1.308 2-1.956-.488.484-1.463 1.906-1.261 2.373.002 0 .018.042.067.084l1.11.936zm4.1 3.126l-1.277-.97a26.906 26.906 0 00-1.58-1.504c.69.518 1.277.97 1.361 1.053.673.585.638.485 1.093.87l.554.4c-.074.103-.151.148-.151.151zm.336.25l-.034-.016a.913.913 0 00.152-.117zM.587 3.476c.034.217.085.435.118.636.201 1.103.403 2.106.772 2.858l.152.568c.05.217.134.485.219.552a66.769 66.769 0 003.578 2.942.177.177 0 00.219 0s0 .016.016.016a.153.153 0 00.118.05.191.191 0 00.134-.05c1.798-1.989 3.142-3.627 4.1-4.998.068-.066.084-.167.084-.25.067-.067.118-.151.185-.201.067-.067.067-.184 0-.235l-.017-.016c0-.033-.017-.084-.05-.1-.42-.401-.722-.685-1.042-.986a93.555 93.555 0 01-2.352-2.273c-.017-.017-.034-.034-.067-.034-.336-.117-1.025-.234-1.882-.385-1.277-.216-3.008-.517-4.57-.986 0 0-.101 0-.118.017l-.05.05C.05.714.022.707 0 .718c.017.1.017.167.05.284 0 .033.068.301.068.334zm7.191 4.78l-.033.034a.036.036 0 01.033-.034zM6.553 2.238c.101.1.521.502.622.585-.437-.2-1.529-.702-2.034-.869.505.1 1.194.201 1.412.284zM.79 1.403c.252.434.454 1.939.655 3.41-.118-.469-.201-.936-.302-1.372C.992 2.673.84 1.988.638 1.386c.124 0 .152.021.152.017zm-.286-.369c0-.016 0-.033-.017-.033.085 0 .135.017.202.05 0 .006-.145-.017-.185-.017zm23.17-.217c.017-.066-.336-.367-.219-.384.253-.017.253-.401 0-.401-.335.017-.688.1-1.008.15-.587.117-1.192.234-1.78.367a79.696 79.696 0 00-3.949.937c-.403.117-.857.2-1.243.401-.135.067-.118.2-.05.284-.034.017-.051.017-.085.034-.117.017-.218.034-.335.05-.102.017-.152.1-.135.2 0 .017.017.05.017.067-.706.936-1.496 1.923-2.353 2.976-.84.969-1.73 1.989-2.62 3.042-2.84 3.31-6.05 7.07-9.594 10.38a.161.161 0 000 .234c.016.016.033.033.05.033-.05.05-.101.085-.152.134-.033.034-.05.067-.05.1a.364.364 0 00-.067.084c-.067.067-.067.184.017.234.067.066.185.066.235-.017.017-.017.017-.033.033-.033a.265.265 0 01.37 0c.202.217.404.435.588.618l-.42-.35c-.067-.067-.184-.05-.234.016-.068.066-.051.184.016.234l4.469 3.727c.034.034.067.034.118.034a.15.15 0 00.117-.05l.101-.1c.017.016.05.016.067.016.05 0 .084-.016.118-.05 6.049-6.05 10.922-10.614 16.5-14.693.05-.033.067-.1.067-.15.067 0 .118-.05.15-.117 1.026-3.125 1.228-5.9 1.295-7.27 0-.059.016-.038.016-.068.017-.033.017-.05.017-.05a.978.978 0 00-.067-.619zm-10.82 4.915c.268-.301.537-.619.806-.903-1.73 2.273-4.603 5.766-8.67 9.929 2.773-3.059 5.562-6.218 7.864-9.026zM5.14 23.466c-.016-.017-.016-.017 0-.017zm2.504-2.156c.135-.15.27-.284.42-.434 0 0 0 .016.017.016-.224.198-.433.418-.437.418zm.69-.668c.099-.1.14-.173.284-.318.992-1.02 2.017-2.04 3.059-3.076l.016-.016c.252-.2.555-.418.824-.619a228.063 228.063 0 00-4.184 4.029zM14.852 3.91c-.554.719-1.176 1.671-1.697 2.423-1.646 2.374-6.94 8.174-7.057 8.274a1189.647 1189.647 0 01-4.839 4.597l-.1.1c-.085-.1-.085-.25.016-.334C8.652 11.966 13.19 6.133 15.021 3.576c-.05.116-.084.216-.168.334zm2.906 3.427c-.671-.386-.99-.987-.806-1.572l.05-.2a.775.775 0 01.085-.167 1.9 1.9 0 01.756-.703c.016 0 .033 0 .05-.016-.017-.034-.017-.084-.017-.134.017-.1.085-.167.202-.167.202 0 .824.184 1.059.384.067.05.134.117.202.184.084.1.218.268.285.401.034.017.067.184.118.268.033.134.067.284.05.418-.017.016 0 .116-.017.116a1.605 1.605 0 01-.218.619c-.03.03.006.012-.05.067a1.22 1.22 0 01-.32.334 1.49 1.49 0 01-1.26.234 2.191 2.191 0 00-.169-.066zm4.37 1.403c0 .017-.017.05 0 .067-.034 0-.05.017-.085.034a109.886 109.886 0 00-3.915 3.025c1.11-.986 2.218-1.989 3.378-2.975.336-.301.571-.686.638-1.12l.168-1.003v-.033c.085-.201.404-.118.353.1-.004-.001-.173.795-.537 1.905z"></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default IconExcalidraw;
|
||||||
20
apps/client/src/components/icons/icon-mermaid.tsx
Normal file
20
apps/client/src/components/icons/icon-mermaid.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { rem } from "@mantine/core";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconMermaid({ size }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="#FF3670"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
style={{ width: rem(size), height: rem(size) }}
|
||||||
|
>
|
||||||
|
<path d="M23.99 2.115A12.223 12.223 0 0012 10.149 12.223 12.223 0 00.01 2.115a12.23 12.23 0 005.32 10.604 6.562 6.562 0 012.845 5.423v3.754h7.65v-3.754a6.561 6.561 0 012.844-5.423 12.223 12.223 0 005.32-10.604z"></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default IconMermaid;
|
||||||
11
apps/client/src/components/icons/icon-people-circle.tsx
Normal file
11
apps/client/src/components/icons/icon-people-circle.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { ThemeIcon } from "@mantine/core";
|
||||||
|
import React from "react";
|
||||||
|
import { IconUsersGroup } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
export function IconGroupCircle() {
|
||||||
|
return (
|
||||||
|
<ThemeIcon variant="light" size="lg" color="gray" radius="xl">
|
||||||
|
<IconUsersGroup stroke={1.5} />
|
||||||
|
</ThemeIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
apps/client/src/components/icons/index.ts
Normal file
11
apps/client/src/components/icons/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export { AirtableIcon } from "./airtable-icon.tsx";
|
||||||
|
export { FigmaIcon } from "./figma-icon.tsx";
|
||||||
|
export { TypeformIcon } from "./typeform-icon.tsx";
|
||||||
|
export { VimeoIcon } from "./vimeo-icon.tsx";
|
||||||
|
export { MiroIcon } from "./miro-icon.tsx";
|
||||||
|
export { GoogleDriveIcon } from "./google-drive-icon.tsx";
|
||||||
|
export { GoogleSheetsIcon } from "./google-sheets-icon.tsx";
|
||||||
|
export { FramerIcon } from "./framer-icon.tsx";
|
||||||
|
export { LoomIcon } from "./loom-icon.tsx";
|
||||||
|
export { YoutubeIcon } from "./youtube-icon.tsx";
|
||||||
|
|
||||||
19
apps/client/src/components/icons/loom-icon.tsx
Normal file
19
apps/client/src/components/icons/loom-icon.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { rem } from '@mantine/core';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoomIcon({ size }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="#625DF5"
|
||||||
|
style={{ width: rem(size), height: rem(size) }}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M24 10.665h-7.018l6.078-3.509-1.335-2.312-6.078 3.509 3.508-6.077L16.843.94l-3.508 6.077V0h-2.67v7.018L7.156.94 4.844 2.275l3.509 6.077-6.078-3.508L.94 7.156l6.078 3.509H0v2.67h7.017L.94 16.844l1.335 2.313 6.077-3.508-3.509 6.077 2.312 1.335 3.509-6.078V24h2.67v-7.017l3.508 6.077 2.312-1.335-3.509-6.078 6.078 3.509 1.335-2.313-6.077-3.508h7.017v-2.67H24zm-12 4.966a3.645 3.645 0 1 1 0-7.29 3.645 3.645 0 0 1 0 7.29z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
apps/client/src/components/icons/miro-icon.tsx
Normal file
18
apps/client/src/components/icons/miro-icon.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { rem } from '@mantine/core';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MiroIcon({ size }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
style={{ width: rem(size), height: rem(size) }}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M17.392 0H13.9L17 4.808 10.444 0H6.949l3.102 6.3L3.494 0H0l3.05 8.131L0 24h3.494L10.05 6.985 6.949 24h3.494L17 5.494 13.899 24h3.493L24 3.672 17.392 0z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
apps/client/src/components/icons/openid-icon.tsx
Normal file
20
apps/client/src/components/icons/openid-icon.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { rem } from "@mantine/core";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpenIdIcon({ size }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
style={{ width: rem(size), height: rem(size) }}
|
||||||
|
>
|
||||||
|
<path d="M14.54.889l-3.63 1.773v18.17c-4.15-.52-7.27-2.78-7.27-5.5 0-2.58 2.8-4.75 6.63-5.41v-2.31C4.42 8.322 0 11.502 0 15.332c0 3.96 4.74 7.24 10.91 7.78l3.63-1.71V.888m.64 6.724v2.31c1.43.25 2.71.7 3.76 1.31l-1.97 1.11 7.03 1.53-.5-5.21-1.87 1.06c-1.74-1.06-3.96-1.81-6.45-2.11z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
apps/client/src/components/icons/typeform-icon.tsx
Normal file
18
apps/client/src/components/icons/typeform-icon.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { rem } from '@mantine/core';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TypeformIcon({ size }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
style={{ width: rem(size), height: rem(size) }}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M15.502 13.035c-.5 0-.756-.411-.756-.917 0-.505.252-.894.756-.894.513 0 .756.407.756.894-.004.515-.261.917-.756.917Zm-4.888-1.81c.292 0 .414.17.414.317 0 .357-.365.514-1.126.536 0-.442.253-.854.712-.854Zm-3.241 1.81c-.473 0-.67-.384-.67-.917 0-.527.202-.894.67-.894.477 0 .702.38.702.894 0 .537-.234.917-.702.917Zm-3.997-2.334h-.738l1.224 2.808c-.234.519-.36.648-.522.648-.171 0-.333-.138-.45-.259l-.324.43c.22.232.522.366.832.366.387 0 .685-.224.856-.626l1.413-3.371h-.725l-.738 2.012-.828-2.008Zm19.553.523c.36 0 .432.246.432.823v1.516H24v-1.914c0-.689-.473-.988-.91-.988-.386 0-.742.241-.94.688a.901.901 0 0 0-.891-.688c-.365 0-.73.232-.927.666v-.626h-.64v2.857h.64v-1.22c0-.617.324-1.114.765-1.114.36 0 .427.246.427.823v1.516h.64l-.005-1.225c0-.617.329-1.114.77-1.114Zm-5.1-.523h-.324v2.857h.639v-1.095c0-.693.306-1.163.76-1.163.118 0 .217.005.325.05l.099-.676c-.081-.009-.153-.018-.225-.018-.45 0-.774.309-.964.707V10.7h-.31Zm-2.327-.045c-.846 0-1.418.644-1.418 1.458 0 .845.58 1.475 1.418 1.475.85 0 1.431-.648 1.431-1.475-.004-.818-.594-1.458-1.431-1.458Zm-4.852 2.38c-.333 0-.581-.17-.685-.515.847-.036 1.675-.242 1.675-.988 0-.43-.423-.872-1.03-.872-.82 0-1.374.666-1.374 1.457 0 .828.545 1.476 1.36 1.476.567 0 .927-.228 1.21-.559l-.31-.42c-.329.335-.531.42-.846.42Zm-3.151-2.38c-.324 0-.648.188-.774.483v-.438h-.64v3.98h.64v-1.422c.135.205.445.34.72.34.85 0 1.3-.631 1.3-1.48-.004-.841-.445-1.463-1.246-1.463Zm-4.483-1.1H0v.622h1.18v3.38h.67v-3.38h1.166v-.622Zm9.502 1.145h-.383v.572h.383v2.285h.639v-2.285h.621v-.572h-.621v-.447c0-.286.117-.385.382-.385.1 0 .19.027.311.068l.144-.537c-.117-.067-.351-.094-.504-.094-.612 0-.972.367-.972 1.002v.393Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
apps/client/src/components/icons/vimeo-icon.tsx
Normal file
19
apps/client/src/components/icons/vimeo-icon.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { rem } from '@mantine/core';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VimeoIcon({ size }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="#1AB7EA"
|
||||||
|
style={{ width: rem(size), height: rem(size) }}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M23.9765 6.4168c-.105 2.338-1.739 5.5429-4.894 9.6088-3.2679 4.247-6.0258 6.3699-8.2898 6.3699-1.409 0-2.578-1.294-3.553-3.881l-1.9179-7.1138c-.719-2.584-1.488-3.878-2.312-3.878-.179 0-.806.378-1.8809 1.132l-1.129-1.457a315.06 315.06 0 003.501-3.1279c1.579-1.368 2.765-2.085 3.5539-2.159 1.867-.18 3.016 1.1 3.447 3.838.465 2.953.789 4.789.971 5.5069.5389 2.45 1.1309 3.674 1.7759 3.674.502 0 1.256-.796 2.265-2.385 1.004-1.589 1.54-2.797 1.612-3.628.144-1.371-.395-2.061-1.614-2.061-.574 0-1.167.121-1.777.391 1.186-3.8679 3.434-5.7568 6.7619-5.6368 2.4729.06 3.6279 1.664 3.4929 4.7969z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
apps/client/src/components/icons/youtube-icon.tsx
Normal file
19
apps/client/src/components/icons/youtube-icon.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { rem } from '@mantine/core';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function YoutubeIcon({ size }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="#FF0000"
|
||||||
|
style={{ width: rem(size), height: rem(size) }}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
.header {
|
||||||
|
height: 100%;
|
||||||
|
margin-bottom: rem(120px);
|
||||||
|
background-color: var(--mantine-color-body);
|
||||||
|
border-bottom: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||||
|
padding-left: var(--mantine-spacing-md);
|
||||||
|
padding-right: var(--mantine-spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brandIcon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
display: block;
|
||||||
|
line-height: 1;
|
||||||
|
padding: rem(8px) rem(12px);
|
||||||
|
border-radius: var(--mantine-radius-sm);
|
||||||
|
text-decoration: none;
|
||||||
|
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
||||||
|
font-size: var(--mantine-font-size-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
@mixin hover {
|
||||||
|
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||||
|
}
|
||||||
|
}
|
||||||
183
apps/client/src/components/layouts/global/app-header.tsx
Normal file
183
apps/client/src/components/layouts/global/app-header.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Group,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
UnstyledButton,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import classes from "./app-header.module.css";
|
||||||
|
import React from "react";
|
||||||
|
import TopMenu from "@/components/layouts/global/top-menu.tsx";
|
||||||
|
import { Link, useLocation } from "react-router-dom";
|
||||||
|
import { IconSparkles } from "@tabler/icons-react";
|
||||||
|
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
||||||
|
import APP_ROUTE from "@/lib/app-route.ts";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import {
|
||||||
|
desktopSidebarAtom,
|
||||||
|
mobileSidebarAtom,
|
||||||
|
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
|
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||||
|
import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import useTrial from "@/ee/hooks/use-trial.tsx";
|
||||||
|
import { isCloud } from "@/lib/config.ts";
|
||||||
|
import {
|
||||||
|
SearchControl,
|
||||||
|
SearchMobileControl,
|
||||||
|
} from "@/features/search/components/search-control.tsx";
|
||||||
|
import {
|
||||||
|
searchSpotlight,
|
||||||
|
shareSearchSpotlight,
|
||||||
|
} from "@/features/search/constants.ts";
|
||||||
|
import { NotificationPopover } from "@/features/notification/components/notification-popover.tsx";
|
||||||
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
|
|
||||||
|
const links = [
|
||||||
|
{ link: APP_ROUTE.HOME, label: "Home" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AppHeader() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [mobileOpened] = useAtom(mobileSidebarAtom);
|
||||||
|
const toggleMobile = useToggleSidebar(mobileSidebarAtom);
|
||||||
|
|
||||||
|
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
||||||
|
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
|
||||||
|
const { isTrial, trialDaysLeft } = useTrial();
|
||||||
|
const location = useLocation();
|
||||||
|
const toggleAside = useToggleAside();
|
||||||
|
const [workspace] = useAtom(workspaceAtom);
|
||||||
|
const aiChatEnabled = workspace?.settings?.ai?.chat === true;
|
||||||
|
|
||||||
|
const isPageRoute = location.pathname.includes("/p/");
|
||||||
|
|
||||||
|
const items = links.map((link) => (
|
||||||
|
<Link key={link.label} to={link.link} className={classes.link}>
|
||||||
|
{t(link.label)}
|
||||||
|
</Link>
|
||||||
|
));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Group h="100%" px="md" justify="space-between" wrap={"nowrap"}>
|
||||||
|
<Group wrap="nowrap">
|
||||||
|
<Tooltip label={t("Sidebar toggle")}>
|
||||||
|
<SidebarToggle
|
||||||
|
aria-label={t("Sidebar toggle")}
|
||||||
|
opened={mobileOpened}
|
||||||
|
onClick={toggleMobile}
|
||||||
|
hiddenFrom="sm"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip label={t("Sidebar toggle")}>
|
||||||
|
<SidebarToggle
|
||||||
|
aria-label={t("Sidebar toggle")}
|
||||||
|
opened={desktopOpened}
|
||||||
|
onClick={toggleDesktop}
|
||||||
|
visibleFrom="sm"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Link to="/home" className={classes.brand} aria-label="Docmost">
|
||||||
|
<Box hiddenFrom="sm" className={classes.brandIcon}>
|
||||||
|
<img
|
||||||
|
src="/icons/favicon-32x32.png"
|
||||||
|
alt="Docmost"
|
||||||
|
width={22}
|
||||||
|
height={22}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Text
|
||||||
|
size="lg"
|
||||||
|
fw={600}
|
||||||
|
style={{ userSelect: "none" }}
|
||||||
|
visibleFrom="sm"
|
||||||
|
>
|
||||||
|
Docmost
|
||||||
|
</Text>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Group ml={50} gap={5} className={classes.links} visibleFrom="sm">
|
||||||
|
{items}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Group visibleFrom="sm">
|
||||||
|
<SearchControl onClick={searchSpotlight.open} />
|
||||||
|
</Group>
|
||||||
|
<Group hiddenFrom="sm">
|
||||||
|
<SearchMobileControl onSearch={searchSpotlight.open} />
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Group px={"xl"} wrap="nowrap">
|
||||||
|
{aiChatEnabled && (
|
||||||
|
<>
|
||||||
|
<UnstyledButton
|
||||||
|
component={Link}
|
||||||
|
to="/ai"
|
||||||
|
className={classes.link}
|
||||||
|
visibleFrom="sm"
|
||||||
|
onClick={(e: React.MouseEvent) => {
|
||||||
|
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isPageRoute) {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleAside("chat");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("AI Chat")}
|
||||||
|
</UnstyledButton>
|
||||||
|
<Tooltip label={t("AI Chat")} openDelay={250} withArrow>
|
||||||
|
<ActionIcon
|
||||||
|
component={Link}
|
||||||
|
to="/ai"
|
||||||
|
variant="subtle"
|
||||||
|
color="dark"
|
||||||
|
size="sm"
|
||||||
|
hiddenFrom="sm"
|
||||||
|
aria-label={t("AI Chat")}
|
||||||
|
onClick={(e: React.MouseEvent) => {
|
||||||
|
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isPageRoute) {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleAside("chat");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconSparkles size={20} stroke={2} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<NotificationPopover />
|
||||||
|
{isCloud() && isTrial && trialDaysLeft !== 0 && (
|
||||||
|
<Badge
|
||||||
|
variant="light"
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
component={Link}
|
||||||
|
to={APP_ROUTE.SETTINGS.WORKSPACE.BILLING}
|
||||||
|
visibleFrom="xs"
|
||||||
|
>
|
||||||
|
{trialDaysLeft === 1
|
||||||
|
? "1 day left"
|
||||||
|
: `${trialDaysLeft} days left`}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<TopMenu />
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
.header, .navbar, .aside {
|
||||||
|
background-color: light-dark(#f6f7f9, var(--mantine-color-dark-8));
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar, .aside {
|
||||||
|
@media (max-width: $mantine-breakpoint-sm) {
|
||||||
|
width: 350px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.aside {
|
||||||
|
@media (max-width: $mantine-breakpoint-sm) {
|
||||||
|
margin-top: 45px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizeHandle {
|
||||||
|
width: 3px;
|
||||||
|
cursor: col-resize;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
|
||||||
|
&:hover, &:active {
|
||||||
|
width: 5px;
|
||||||
|
background: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-5))
|
||||||
|
}
|
||||||
|
}
|
||||||
101
apps/client/src/components/layouts/global/aside.tsx
Normal file
101
apps/client/src/components/layouts/global/aside.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { ActionIcon, Box, Group, ScrollArea, Title, Tooltip } from "@mantine/core";
|
||||||
|
import { IconX } from "@tabler/icons-react";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
|
import React, { lazy, ReactNode, Suspense, useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||||
|
import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx";
|
||||||
|
|
||||||
|
const CommentListWithTabs = lazy(
|
||||||
|
() => import("@/features/comment/components/comment-list-with-tabs.tsx"),
|
||||||
|
);
|
||||||
|
const TableOfContents = lazy(() =>
|
||||||
|
import(
|
||||||
|
"@/features/editor/components/table-of-contents/table-of-contents.tsx"
|
||||||
|
).then((m) => ({ default: m.TableOfContents })),
|
||||||
|
);
|
||||||
|
const AsideChatPanel = lazy(
|
||||||
|
() => import("@/ee/ai-chat/components/aside-chat-panel"),
|
||||||
|
);
|
||||||
|
const PageDetailsAside = lazy(() =>
|
||||||
|
import("@/features/page-details/components/page-details-aside.tsx").then(
|
||||||
|
(m) => ({ default: m.PageDetailsAside }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function Aside() {
|
||||||
|
const [{ tab, isAsideOpen }, setAsideState] = useAtom(asideStateAtom);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const pageEditor = useAtomValue(pageEditorAtom);
|
||||||
|
const closeAside = () => setAsideState((s) => ({ ...s, isAsideOpen: false }));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAsideOpen) return;
|
||||||
|
document.getElementById(ASIDE_PANEL_ID)?.focus();
|
||||||
|
}, [isAsideOpen, tab]);
|
||||||
|
|
||||||
|
let title: string;
|
||||||
|
let component: ReactNode;
|
||||||
|
|
||||||
|
switch (tab) {
|
||||||
|
case "comments":
|
||||||
|
component = <CommentListWithTabs />;
|
||||||
|
title = "Comments";
|
||||||
|
break;
|
||||||
|
case "toc":
|
||||||
|
component = <TableOfContents editor={pageEditor} />;
|
||||||
|
title = "Table of contents";
|
||||||
|
break;
|
||||||
|
case "chat":
|
||||||
|
component = <AsideChatPanel />;
|
||||||
|
title = "AI Chat";
|
||||||
|
break;
|
||||||
|
case "details":
|
||||||
|
component = <PageDetailsAside />;
|
||||||
|
title = "Details";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
component = null;
|
||||||
|
title = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box p="md" style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||||
|
{component && (
|
||||||
|
<>
|
||||||
|
{tab !== "chat" && (
|
||||||
|
<Group justify="space-between" wrap="nowrap" mb="md">
|
||||||
|
<Title order={2} size="h6" fw={500}>{t(title)}</Title>
|
||||||
|
<Tooltip label={t("Close")} withArrow>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
onClick={closeAside}
|
||||||
|
aria-label={t("Close")}
|
||||||
|
>
|
||||||
|
<IconX size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
{tab === "comments" || tab === "chat" ? (
|
||||||
|
component
|
||||||
|
) : (
|
||||||
|
<ScrollArea
|
||||||
|
style={{ height: "85vh" }}
|
||||||
|
scrollbarSize={5}
|
||||||
|
type="scroll"
|
||||||
|
>
|
||||||
|
<div style={{ paddingBottom: "200px" }}>{component}</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</Suspense>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
174
apps/client/src/components/layouts/global/global-app-shell.tsx
Normal file
174
apps/client/src/components/layouts/global/global-app-shell.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { AppShell, Container } from "@mantine/core";
|
||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import {
|
||||||
|
asideStateAtom,
|
||||||
|
desktopSidebarAtom,
|
||||||
|
mobileSidebarAtom,
|
||||||
|
sidebarWidthAtom,
|
||||||
|
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
|
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
|
||||||
|
|
||||||
|
const AiChatSidebar = React.lazy(
|
||||||
|
() => import("@/ee/ai-chat/components/ai-chat-sidebar.tsx"),
|
||||||
|
);
|
||||||
|
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
|
||||||
|
import Aside from "@/components/layouts/global/aside.tsx";
|
||||||
|
import classes from "./app-shell.module.css";
|
||||||
|
import { useTrialEndAction } from "@/ee/hooks/use-trial-end-action.tsx";
|
||||||
|
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||||
|
import GlobalSidebar from "@/components/layouts/global/global-sidebar.tsx";
|
||||||
|
import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx";
|
||||||
|
import { MAIN_CONTENT_ID, SkipToMain } from "@/components/ui/skip-to-main.tsx";
|
||||||
|
|
||||||
|
export default function GlobalAppShell({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
useTrialEndAction();
|
||||||
|
const [mobileOpened] = useAtom(mobileSidebarAtom);
|
||||||
|
const toggleMobile = useToggleSidebar(mobileSidebarAtom);
|
||||||
|
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
||||||
|
const [{ isAsideOpen, tab: asideTab }] = useAtom(asideStateAtom);
|
||||||
|
const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom);
|
||||||
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
|
const sidebarRef = useRef(null);
|
||||||
|
|
||||||
|
const startResizing = React.useCallback((mouseDownEvent) => {
|
||||||
|
mouseDownEvent.preventDefault();
|
||||||
|
setIsResizing(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stopResizing = React.useCallback(() => {
|
||||||
|
setIsResizing(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resize = React.useCallback(
|
||||||
|
(mouseMoveEvent) => {
|
||||||
|
if (isResizing) {
|
||||||
|
const newWidth =
|
||||||
|
mouseMoveEvent.clientX -
|
||||||
|
sidebarRef.current.getBoundingClientRect().left;
|
||||||
|
if (newWidth < 220) {
|
||||||
|
setSidebarWidth(220);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newWidth > 600) {
|
||||||
|
setSidebarWidth(600);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSidebarWidth(newWidth);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isResizing],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
//https://codesandbox.io/p/sandbox/kz9de
|
||||||
|
window.addEventListener("mousemove", resize);
|
||||||
|
window.addEventListener("mouseup", stopResizing);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("mousemove", resize);
|
||||||
|
window.removeEventListener("mouseup", stopResizing);
|
||||||
|
};
|
||||||
|
}, [resize, stopResizing]);
|
||||||
|
|
||||||
|
const location = useLocation();
|
||||||
|
const isSettingsRoute = location.pathname.startsWith("/settings");
|
||||||
|
const isSpaceRoute = location.pathname.startsWith("/s/");
|
||||||
|
const isAiRoute = location.pathname.startsWith("/ai");
|
||||||
|
const isPageRoute = location.pathname.includes("/p/");
|
||||||
|
const showGlobalSidebar = !isSpaceRoute && !isSettingsRoute && !isAiRoute;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SkipToMain />
|
||||||
|
<AppShell
|
||||||
|
header={{ height: 45 }}
|
||||||
|
navbar={{
|
||||||
|
width: isSpaceRoute ? sidebarWidth : 300,
|
||||||
|
breakpoint: "sm",
|
||||||
|
collapsed: {
|
||||||
|
mobile: !mobileOpened,
|
||||||
|
desktop: !desktopOpened,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
aside={
|
||||||
|
isPageRoute && {
|
||||||
|
width: 350,
|
||||||
|
breakpoint: "sm",
|
||||||
|
collapsed: { mobile: !isAsideOpen, desktop: !isAsideOpen },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
padding="md"
|
||||||
|
>
|
||||||
|
<AppShell.Header px="md" className={classes.header}>
|
||||||
|
<AppHeader />
|
||||||
|
</AppShell.Header>
|
||||||
|
<AppShell.Navbar
|
||||||
|
className={classes.navbar}
|
||||||
|
withBorder={false}
|
||||||
|
ref={sidebarRef}
|
||||||
|
aria-label={
|
||||||
|
isSpaceRoute
|
||||||
|
? t("Space navigation")
|
||||||
|
: isSettingsRoute
|
||||||
|
? t("Settings navigation")
|
||||||
|
: isAiRoute
|
||||||
|
? t("AI navigation")
|
||||||
|
: t("Main navigation")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isSpaceRoute && (
|
||||||
|
<div className={classes.resizeHandle} onMouseDown={startResizing} />
|
||||||
|
)}
|
||||||
|
{isSpaceRoute && <SpaceSidebar />}
|
||||||
|
{isSettingsRoute && <SettingsSidebar />}
|
||||||
|
{isAiRoute && (
|
||||||
|
<React.Suspense fallback={null}>
|
||||||
|
<AiChatSidebar />
|
||||||
|
</React.Suspense>
|
||||||
|
)}
|
||||||
|
{showGlobalSidebar && <GlobalSidebar />}
|
||||||
|
</AppShell.Navbar>
|
||||||
|
<AppShell.Main id={MAIN_CONTENT_ID} tabIndex={-1}>
|
||||||
|
{isSettingsRoute ? (
|
||||||
|
<Container size={900} pb={80}>
|
||||||
|
{children}
|
||||||
|
</Container>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</AppShell.Main>
|
||||||
|
|
||||||
|
{isPageRoute && (
|
||||||
|
<AppShell.Aside
|
||||||
|
id={ASIDE_PANEL_ID}
|
||||||
|
tabIndex={-1}
|
||||||
|
className={classes.aside}
|
||||||
|
p="md"
|
||||||
|
withBorder={false}
|
||||||
|
aria-label={
|
||||||
|
asideTab === "comments"
|
||||||
|
? t("Comments")
|
||||||
|
: asideTab === "toc"
|
||||||
|
? t("Table of contents")
|
||||||
|
: asideTab === "chat"
|
||||||
|
? t("AI Chat")
|
||||||
|
: asideTab === "details"
|
||||||
|
? t("Details")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Aside />
|
||||||
|
</AppShell.Aside>
|
||||||
|
)}
|
||||||
|
</AppShell>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
.navbar {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--mantine-spacing-md);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
padding-bottom: var(--mantine-spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: var(--mantine-font-size-sm);
|
||||||
|
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||||
|
padding-left: var(--mantine-spacing-xs);
|
||||||
|
min-height: 30px;
|
||||||
|
border-radius: var(--mantine-radius-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
@mixin hover {
|
||||||
|
background-color: light-dark(
|
||||||
|
var(--mantine-color-gray-1),
|
||||||
|
var(--mantine-color-dark-6)
|
||||||
|
);
|
||||||
|
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--mantine-primary-color-filled);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-active] {
|
||||||
|
&,
|
||||||
|
& :hover {
|
||||||
|
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6));
|
||||||
|
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-disabled] {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
|
||||||
|
@mixin hover {
|
||||||
|
background-color: transparent;
|
||||||
|
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.linkIcon {
|
||||||
|
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||||
|
margin-right: var(--mantine-spacing-sm);
|
||||||
|
width: rem(16px);
|
||||||
|
height: rem(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionHeader {
|
||||||
|
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
|
||||||
|
font-size: var(--mantine-font-size-xs);
|
||||||
|
color: var(--mantine-color-dimmed);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottomSection {
|
||||||
|
padding-top: var(--mantine-spacing-xs);
|
||||||
|
border-top: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||||
|
}
|
||||||
|
|
||||||
|
.spaceItem {
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--mantine-spacing-sm);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: var(--mantine-font-size-sm);
|
||||||
|
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||||
|
padding-left: var(--mantine-spacing-xs);
|
||||||
|
min-height: 30px;
|
||||||
|
border-radius: var(--mantine-radius-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
@mixin hover {
|
||||||
|
background-color: light-dark(
|
||||||
|
var(--mantine-color-gray-1),
|
||||||
|
var(--mantine-color-dark-6)
|
||||||
|
);
|
||||||
|
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--mantine-primary-color-filled);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
186
apps/client/src/components/layouts/global/global-sidebar.tsx
Normal file
186
apps/client/src/components/layouts/global/global-sidebar.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ScrollArea, Text, Divider, Modal, UnstyledButton, Tooltip } from "@mantine/core";
|
||||||
|
import {
|
||||||
|
IconHome,
|
||||||
|
IconClock,
|
||||||
|
IconStar,
|
||||||
|
IconLayoutGrid,
|
||||||
|
IconSettings,
|
||||||
|
IconUserPlus,
|
||||||
|
IconTemplate,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { Link, useLocation } from "react-router-dom";
|
||||||
|
import classes from "./global-sidebar.module.css";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
||||||
|
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar";
|
||||||
|
import { useFavoritesQuery } from "@/features/favorite/queries/favorite-query";
|
||||||
|
import { getSpaceUrl } from "@/lib/config";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import { WorkspaceInviteForm } from "@/features/workspace/components/members/components/workspace-invite-form";
|
||||||
|
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||||
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types";
|
||||||
|
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||||
|
import { Feature } from "@/ee/features";
|
||||||
|
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
||||||
|
|
||||||
|
export default function GlobalSidebar() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const location = useLocation();
|
||||||
|
const [active, setActive] = useState(location.pathname);
|
||||||
|
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
|
||||||
|
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
|
||||||
|
const hasTemplates = useHasFeature(Feature.TEMPLATES);
|
||||||
|
const upgradeLabel = useUpgradeLabel();
|
||||||
|
const mainNavItems = [
|
||||||
|
{ label: "Home", icon: IconHome, path: "/home" },
|
||||||
|
{ label: "Favorites", icon: IconStar, path: "/favorites" },
|
||||||
|
{ label: "Spaces", icon: IconLayoutGrid, path: "/spaces" },
|
||||||
|
{
|
||||||
|
label: "Templates",
|
||||||
|
icon: IconTemplate,
|
||||||
|
path: "/templates",
|
||||||
|
disabled: !hasTemplates,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const { data: favoriteSpacesData, isPending: isFavoritesPending } = useFavoritesQuery("space");
|
||||||
|
const favoriteSpaces = favoriteSpacesData?.pages.flatMap((p) => p.items) ?? [];
|
||||||
|
const sortedFavoriteSpaces = [...favoriteSpaces]
|
||||||
|
.filter((fav) => fav.space)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const cmp = (a.space!.name ?? "").localeCompare(b.space!.name ?? "", undefined, { sensitivity: "base" });
|
||||||
|
return cmp !== 0 ? cmp : a.id.localeCompare(b.id);
|
||||||
|
});
|
||||||
|
const [inviteOpened, { open: openInvite, close: closeInvite }] =
|
||||||
|
useDisclosure(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActive(location.pathname);
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
const handleNavClick = () => {
|
||||||
|
if (mobileSidebarOpened) {
|
||||||
|
toggleMobileSidebar();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.navbar}>
|
||||||
|
<ScrollArea w="100%" style={{ flex: 1 }}>
|
||||||
|
<div className={classes.section}>
|
||||||
|
{mainNavItems.map((item) =>
|
||||||
|
item.disabled ? (
|
||||||
|
<Tooltip
|
||||||
|
key={item.label}
|
||||||
|
label={upgradeLabel}
|
||||||
|
position="right"
|
||||||
|
withArrow
|
||||||
|
>
|
||||||
|
<UnstyledButton
|
||||||
|
className={classes.link}
|
||||||
|
data-disabled
|
||||||
|
aria-disabled="true"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<item.icon className={classes.linkIcon} stroke={2} />
|
||||||
|
<span>{t(item.label)}</span>
|
||||||
|
</UnstyledButton>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
key={item.label}
|
||||||
|
className={classes.link}
|
||||||
|
data-active={active === item.path || undefined}
|
||||||
|
aria-current={active === item.path ? "page" : undefined}
|
||||||
|
to={item.path}
|
||||||
|
onClick={handleNavClick}
|
||||||
|
>
|
||||||
|
<item.icon className={classes.linkIcon} stroke={2} />
|
||||||
|
<span>{t(item.label)}</span>
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider my="xs" />
|
||||||
|
<div className={classes.section}>
|
||||||
|
<Text component="h2" className={classes.sectionHeader}>{t("Favorite spaces")}</Text>
|
||||||
|
{!isFavoritesPending && sortedFavoriteSpaces.length === 0 ? (
|
||||||
|
<Text size="xs" c="dimmed" pl="xs" py={4}>
|
||||||
|
{t("Favorite spaces appear here")}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{sortedFavoriteSpaces.slice(0, 10).map((fav) => (
|
||||||
|
<Link
|
||||||
|
key={fav.id}
|
||||||
|
className={classes.spaceItem}
|
||||||
|
to={getSpaceUrl(fav.space!.slug)}
|
||||||
|
onClick={handleNavClick}
|
||||||
|
>
|
||||||
|
<CustomAvatar
|
||||||
|
name={fav.space!.name}
|
||||||
|
avatarUrl={fav.space!.logo}
|
||||||
|
type={AvatarIconType.SPACE_ICON}
|
||||||
|
color="initials"
|
||||||
|
variant="filled"
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
|
<Text size="sm" fw={500} lineClamp={1}>
|
||||||
|
{fav.space!.name}
|
||||||
|
</Text>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{sortedFavoriteSpaces.length > 10 && (
|
||||||
|
<Link
|
||||||
|
className={classes.spaceItem}
|
||||||
|
to="/spaces"
|
||||||
|
onClick={handleNavClick}
|
||||||
|
>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t("View all")}
|
||||||
|
</Text>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<div className={classes.bottomSection}>
|
||||||
|
<UnstyledButton
|
||||||
|
className={classes.link}
|
||||||
|
onClick={openInvite}
|
||||||
|
>
|
||||||
|
<IconUserPlus className={classes.linkIcon} stroke={2} />
|
||||||
|
<span>{t("Invite People")}</span>
|
||||||
|
</UnstyledButton>
|
||||||
|
<Link
|
||||||
|
className={classes.link}
|
||||||
|
data-active={active.startsWith("/settings") || undefined}
|
||||||
|
aria-current={active.startsWith("/settings") ? "page" : undefined}
|
||||||
|
to="/settings/account/profile"
|
||||||
|
onClick={handleNavClick}
|
||||||
|
>
|
||||||
|
<IconSettings className={classes.linkIcon} stroke={2} />
|
||||||
|
<span>{t("Settings")}</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
size="550"
|
||||||
|
opened={inviteOpened}
|
||||||
|
onClose={closeInvite}
|
||||||
|
title={t("Invite new members")}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Divider size="xs" mb="xs" />
|
||||||
|
<ScrollArea h="80%">
|
||||||
|
<WorkspaceInviteForm onClose={closeInvite} />
|
||||||
|
</ScrollArea>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { atomWithWebStorage } from "@/lib/jotai-helper.ts";
|
||||||
|
import { atom } from "jotai";
|
||||||
|
|
||||||
|
export const mobileSidebarAtom = atom<boolean>(false);
|
||||||
|
|
||||||
|
export const desktopSidebarAtom = atomWithWebStorage<boolean>(
|
||||||
|
"showSidebar",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const desktopAsideAtom = atom<boolean>(false);
|
||||||
|
|
||||||
|
// Valid `tab` values: "" | "comments" | "toc" | "chat" | "details"
|
||||||
|
type AsideStateType = {
|
||||||
|
tab: string;
|
||||||
|
isAsideOpen: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const asideStateAtom = atom<AsideStateType>({
|
||||||
|
tab: "",
|
||||||
|
isAsideOpen: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sidebarWidthAtom = atomWithWebStorage<number>('sidebarWidth', 300);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { useAtom } from "jotai";
|
||||||
|
|
||||||
|
export function useToggleSidebar(sidebarAtom: any) {
|
||||||
|
const [sidebarState, setSidebarState] = useAtom(sidebarAtom);
|
||||||
|
return () => {
|
||||||
|
setSidebarState(!sidebarState);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
apps/client/src/components/layouts/global/layout.tsx
Normal file
23
apps/client/src/components/layouts/global/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { UserProvider } from "@/features/user/user-provider.tsx";
|
||||||
|
import { Outlet, useParams } from "react-router-dom";
|
||||||
|
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
|
||||||
|
import { PosthogUser } from "@/ee/components/posthog-user.tsx";
|
||||||
|
import { isCloud } from "@/lib/config.ts";
|
||||||
|
import { SearchSpotlight } from "@/features/search/components/search-spotlight.tsx";
|
||||||
|
import React from "react";
|
||||||
|
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
const { spaceSlug } = useParams();
|
||||||
|
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UserProvider>
|
||||||
|
<GlobalAppShell>
|
||||||
|
<Outlet />
|
||||||
|
</GlobalAppShell>
|
||||||
|
{isCloud() && <PosthogUser />}
|
||||||
|
<SearchSpotlight spaceId={space?.id} />
|
||||||
|
</UserProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
203
apps/client/src/components/layouts/global/top-menu.tsx
Normal file
203
apps/client/src/components/layouts/global/top-menu.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import {
|
||||||
|
Group,
|
||||||
|
Menu,
|
||||||
|
Text,
|
||||||
|
UnstyledButton,
|
||||||
|
useMantineColorScheme,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
IconBrightnessFilled,
|
||||||
|
IconBrush,
|
||||||
|
IconCheck,
|
||||||
|
IconChevronDown,
|
||||||
|
IconDeviceDesktop,
|
||||||
|
IconLogout,
|
||||||
|
IconMoon,
|
||||||
|
IconSettings,
|
||||||
|
IconSun,
|
||||||
|
IconUser,
|
||||||
|
IconUserCircle,
|
||||||
|
IconUsers,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import { getSpaceUrl } from "@/lib/config.ts";
|
||||||
|
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||||
|
import { Feature } from "@/ee/features";
|
||||||
|
import { usePersonalSpaceQuery } from "@/ee/personal-space/queries/personal-space-query";
|
||||||
|
import CreatePersonalSpaceModal from "@/ee/personal-space/components/create-personal-space-modal";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import APP_ROUTE from "@/lib/app-route.ts";
|
||||||
|
import useAuth from "@/features/auth/hooks/use-auth.ts";
|
||||||
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||||
|
|
||||||
|
export default function TopMenu() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
|
const { logout } = useAuth();
|
||||||
|
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||||
|
|
||||||
|
const user = currentUser?.user;
|
||||||
|
const workspace = currentUser?.workspace;
|
||||||
|
|
||||||
|
const hasPersonalSpaces = useHasFeature(Feature.PERSONAL_SPACES);
|
||||||
|
const settingEnabled = workspace?.settings?.spaces?.allowPersonal === true;
|
||||||
|
const { data: personalSpace } = usePersonalSpaceQuery(hasPersonalSpaces);
|
||||||
|
const [
|
||||||
|
createOpened,
|
||||||
|
{ open: openCreate, close: closeCreate },
|
||||||
|
] = useDisclosure(false);
|
||||||
|
|
||||||
|
if (!user || !workspace) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Menu width={250} position="bottom-end" withArrow shadow={"lg"}>
|
||||||
|
<Menu.Target>
|
||||||
|
<UnstyledButton>
|
||||||
|
<Group gap={7} wrap={"nowrap"}>
|
||||||
|
<CustomAvatar
|
||||||
|
avatarUrl={workspace?.logo}
|
||||||
|
name={workspace?.name}
|
||||||
|
variant="filled"
|
||||||
|
size="sm"
|
||||||
|
type={AvatarIconType.WORKSPACE_ICON}
|
||||||
|
/>
|
||||||
|
<Text fw={500} size="sm" lh={1} mr={3} lineClamp={1}>
|
||||||
|
{workspace?.name}
|
||||||
|
</Text>
|
||||||
|
<IconChevronDown size={16} />
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Menu.Label>{t("Workspace")}</Menu.Label>
|
||||||
|
|
||||||
|
<Menu.Item
|
||||||
|
component={Link}
|
||||||
|
to={APP_ROUTE.SETTINGS.WORKSPACE.GENERAL}
|
||||||
|
leftSection={<IconSettings size={16} />}
|
||||||
|
>
|
||||||
|
{t("Workspace settings")}
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
|
<Menu.Item
|
||||||
|
component={Link}
|
||||||
|
to={APP_ROUTE.SETTINGS.WORKSPACE.MEMBERS}
|
||||||
|
leftSection={<IconUsers size={16} />}
|
||||||
|
>
|
||||||
|
{t("Manage members")}
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
|
<Menu.Divider />
|
||||||
|
|
||||||
|
<Menu.Label>{t("Account")}</Menu.Label>
|
||||||
|
<Menu.Item component={Link} to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}>
|
||||||
|
<Group wrap={"nowrap"}>
|
||||||
|
<CustomAvatar
|
||||||
|
size={"sm"}
|
||||||
|
avatarUrl={user.avatarUrl}
|
||||||
|
name={user.name}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ width: 190 }}>
|
||||||
|
<Text size="sm" fw={500} lineClamp={1}>
|
||||||
|
{user.name}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" truncate="end">
|
||||||
|
{user.email}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
component={Link}
|
||||||
|
to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}
|
||||||
|
leftSection={<IconUserCircle size={16} />}
|
||||||
|
>
|
||||||
|
{t("My profile")}
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
|
<Menu.Item
|
||||||
|
component={Link}
|
||||||
|
to={APP_ROUTE.SETTINGS.ACCOUNT.PREFERENCES}
|
||||||
|
leftSection={<IconBrush size={16} />}
|
||||||
|
>
|
||||||
|
{t("My preferences")}
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
|
{personalSpace ? (
|
||||||
|
<Menu.Item
|
||||||
|
component={Link}
|
||||||
|
to={getSpaceUrl(personalSpace.slug)}
|
||||||
|
leftSection={<IconUser size={16} />}
|
||||||
|
>
|
||||||
|
{t("Personal space")}
|
||||||
|
</Menu.Item>
|
||||||
|
) : (
|
||||||
|
hasPersonalSpaces &&
|
||||||
|
settingEnabled && (
|
||||||
|
<Menu.Item
|
||||||
|
onClick={openCreate}
|
||||||
|
leftSection={<IconUser size={16} />}
|
||||||
|
>
|
||||||
|
{t("Create personal space")}
|
||||||
|
</Menu.Item>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Menu.Sub>
|
||||||
|
<Menu.Sub.Target>
|
||||||
|
<Menu.Sub.Item leftSection={<IconBrightnessFilled size={16} />}>
|
||||||
|
{t("Theme")}
|
||||||
|
</Menu.Sub.Item>
|
||||||
|
</Menu.Sub.Target>
|
||||||
|
|
||||||
|
<Menu.Sub.Dropdown>
|
||||||
|
<Menu.Item
|
||||||
|
onClick={() => setColorScheme("light")}
|
||||||
|
leftSection={<IconSun size={16} />}
|
||||||
|
rightSection={
|
||||||
|
colorScheme === "light" ? <IconCheck size={16} /> : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("Light")}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
onClick={() => setColorScheme("dark")}
|
||||||
|
leftSection={<IconMoon size={16} />}
|
||||||
|
rightSection={
|
||||||
|
colorScheme === "dark" ? <IconCheck size={16} /> : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("Dark")}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
onClick={() => setColorScheme("auto")}
|
||||||
|
leftSection={<IconDeviceDesktop size={16} />}
|
||||||
|
rightSection={
|
||||||
|
colorScheme === "auto" ? <IconCheck size={16} /> : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("System settings")}
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Sub.Dropdown>
|
||||||
|
</Menu.Sub>
|
||||||
|
|
||||||
|
<Menu.Divider />
|
||||||
|
|
||||||
|
<Menu.Item onClick={logout} leftSection={<IconLogout size={16} />}>
|
||||||
|
{t("Logout")}
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
<CreatePersonalSpaceModal opened={createOpened} onClose={closeCreate} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
apps/client/src/components/settings/app-version.tsx
Normal file
59
apps/client/src/components/settings/app-version.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { useAppVersion } from "@/features/workspace/queries/workspace-query.ts";
|
||||||
|
import { isCloud } from "@/lib/config.ts";
|
||||||
|
import classes from "@/components/settings/settings.module.css";
|
||||||
|
import { Indicator, Text, Tooltip } from "@mantine/core";
|
||||||
|
import React from "react";
|
||||||
|
import semverGt from "semver/functions/gt";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export default function AppVersion() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data: appVersion } = useAppVersion(!isCloud());
|
||||||
|
let hasUpdate = false;
|
||||||
|
try {
|
||||||
|
hasUpdate =
|
||||||
|
appVersion &&
|
||||||
|
parseFloat(appVersion.latestVersion) > 0 &&
|
||||||
|
semverGt(appVersion.latestVersion, appVersion.currentVersion);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.text}>
|
||||||
|
<Tooltip
|
||||||
|
label={t("{{latestVersion}} is available", {
|
||||||
|
latestVersion: `v${appVersion?.latestVersion}`,
|
||||||
|
})}
|
||||||
|
disabled={!hasUpdate}
|
||||||
|
>
|
||||||
|
<Indicator
|
||||||
|
label={t("New update")}
|
||||||
|
color="gray"
|
||||||
|
inline
|
||||||
|
size={16}
|
||||||
|
position="middle-end"
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
disabled={!hasUpdate}
|
||||||
|
onClick={() => {
|
||||||
|
window.open(
|
||||||
|
"https://github.com/docmost/docmost/releases",
|
||||||
|
"_blank",
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
c="dimmed"
|
||||||
|
component="a"
|
||||||
|
mr={45}
|
||||||
|
href="https://github.com/docmost/docmost/releases"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{appVersion?.currentVersion && <>v{appVersion?.currentVersion}</>}
|
||||||
|
</Text>
|
||||||
|
</Indicator>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { atom, WritableAtom } from "jotai";
|
||||||
|
|
||||||
|
export const settingsOriginAtom: WritableAtom<string | null, [string | null], void> = atom(
|
||||||
|
null,
|
||||||
|
(get, set, newValue) => {
|
||||||
|
if (get(settingsOriginAtom) !== newValue) {
|
||||||
|
set(settingsOriginAtom, newValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
108
apps/client/src/components/settings/settings-queries.tsx
Normal file
108
apps/client/src/components/settings/settings-queries.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { queryClient } from "@/main.tsx";
|
||||||
|
import {
|
||||||
|
getBilling,
|
||||||
|
getBillingPlans,
|
||||||
|
} from "@/ee/billing/services/billing-service.ts";
|
||||||
|
import { getSpaces } from "@/features/space/services/space-service.ts";
|
||||||
|
import { getGroups } from "@/features/group/services/group-service.ts";
|
||||||
|
import { QueryParams } from "@/lib/types.ts";
|
||||||
|
import { getWorkspaceMembers } from "@/features/workspace/services/workspace-service.ts";
|
||||||
|
import { getLicenseInfo } from "@/ee/licence/services/license-service.ts";
|
||||||
|
import { getSsoProviders } from "@/ee/security/services/security-service.ts";
|
||||||
|
import { getShares } from "@/features/share/services/share-service.ts";
|
||||||
|
import { getApiKeys } from "@/ee/api-key";
|
||||||
|
import { getAuditLogs } from "@/ee/audit/services/audit-service";
|
||||||
|
import { getVerificationList } from "@/ee/page-verification/services/page-verification-service";
|
||||||
|
import { getScimTokens } from "@/ee/scim/services/scim-token-service";
|
||||||
|
|
||||||
|
export const prefetchWorkspaceMembers = () => {
|
||||||
|
const params: QueryParams = { limit: 100, query: "" };
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["workspaceMembers", params],
|
||||||
|
queryFn: () => getWorkspaceMembers(params),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prefetchSpaces = () => {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["spaces", {}],
|
||||||
|
queryFn: () => getSpaces({}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prefetchGroups = () => {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["groups", {}],
|
||||||
|
queryFn: () => getGroups({}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prefetchBilling = () => {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["billing"],
|
||||||
|
queryFn: () => getBilling(),
|
||||||
|
});
|
||||||
|
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["billing-plans"],
|
||||||
|
queryFn: () => getBillingPlans(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prefetchLicense = () => {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["license"],
|
||||||
|
queryFn: () => getLicenseInfo(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prefetchSsoProviders = () => {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["sso-providers"],
|
||||||
|
queryFn: () => getSsoProviders(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prefetchShares = () => {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["share-list", {}],
|
||||||
|
queryFn: () => getShares({}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prefetchApiKeys = () => {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["api-key-list", {}],
|
||||||
|
queryFn: () => getApiKeys({}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prefetchApiKeyManagement = () => {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["api-key-list", { adminView: true }],
|
||||||
|
queryFn: () => getApiKeys({ adminView: true }),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prefetchAuditLogs = () => {
|
||||||
|
const params = { limit: 50 };
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["audit-logs", params],
|
||||||
|
queryFn: () => getAuditLogs(params),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prefetchVerifiedPages = () => {
|
||||||
|
const params = { limit: 50 };
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["verification-list", params],
|
||||||
|
queryFn: () => getVerificationList(params),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prefetchScimTokens = () => {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["scim-token-list", { cursor: undefined }],
|
||||||
|
queryFn: () => getScimTokens({}),
|
||||||
|
});
|
||||||
|
};
|
||||||
319
apps/client/src/components/settings/settings-sidebar.tsx
Normal file
319
apps/client/src/components/settings/settings-sidebar.tsx
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { Group, Text, ScrollArea, ActionIcon, Tooltip } from "@mantine/core";
|
||||||
|
import {
|
||||||
|
IconUser,
|
||||||
|
IconSettings,
|
||||||
|
IconUsers,
|
||||||
|
IconArrowLeft,
|
||||||
|
IconUsersGroup,
|
||||||
|
IconSpaces,
|
||||||
|
IconBrush,
|
||||||
|
IconCoin,
|
||||||
|
IconLock,
|
||||||
|
IconKey,
|
||||||
|
IconWorld,
|
||||||
|
IconSparkles,
|
||||||
|
IconHistory,
|
||||||
|
IconShieldCheck,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { Link, useLocation } from "react-router-dom";
|
||||||
|
import classes from "./settings.module.css";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { isCloud } from "@/lib/config.ts";
|
||||||
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
|
||||||
|
import { Feature } from "@/ee/features";
|
||||||
|
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
||||||
|
import {
|
||||||
|
prefetchApiKeyManagement,
|
||||||
|
prefetchApiKeys,
|
||||||
|
prefetchBilling,
|
||||||
|
prefetchGroups,
|
||||||
|
prefetchLicense,
|
||||||
|
prefetchScimTokens,
|
||||||
|
prefetchShares,
|
||||||
|
prefetchSpaces,
|
||||||
|
prefetchSsoProviders,
|
||||||
|
prefetchWorkspaceMembers,
|
||||||
|
prefetchAuditLogs,
|
||||||
|
prefetchVerifiedPages,
|
||||||
|
} from "@/components/settings/settings-queries.tsx";
|
||||||
|
import AppVersion from "@/components/settings/app-version.tsx";
|
||||||
|
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
|
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||||
|
import { useSettingsNavigation } from "@/hooks/use-settings-navigation";
|
||||||
|
|
||||||
|
type DataItem = {
|
||||||
|
label: string;
|
||||||
|
icon: React.ElementType;
|
||||||
|
path: string;
|
||||||
|
feature?: string;
|
||||||
|
role?: "admin" | "owner";
|
||||||
|
env?: "cloud" | "selfhosted";
|
||||||
|
};
|
||||||
|
|
||||||
|
type DataGroup = {
|
||||||
|
heading: string;
|
||||||
|
items: DataItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupedData: DataGroup[] = [
|
||||||
|
{
|
||||||
|
heading: "Account",
|
||||||
|
items: [
|
||||||
|
{ label: "Profile", icon: IconUser, path: "/settings/account/profile" },
|
||||||
|
{
|
||||||
|
label: "Preferences",
|
||||||
|
icon: IconBrush,
|
||||||
|
path: "/settings/account/preferences",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "API keys",
|
||||||
|
icon: IconKey,
|
||||||
|
path: "/settings/account/api-keys",
|
||||||
|
feature: Feature.API_KEYS,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "Workspace",
|
||||||
|
items: [
|
||||||
|
{ label: "General", icon: IconSettings, path: "/settings/workspace" },
|
||||||
|
{ label: "Members", icon: IconUsers, path: "/settings/members" },
|
||||||
|
{
|
||||||
|
label: "Billing",
|
||||||
|
icon: IconCoin,
|
||||||
|
path: "/settings/billing",
|
||||||
|
role: "admin",
|
||||||
|
env: "cloud",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Security & SSO",
|
||||||
|
icon: IconLock,
|
||||||
|
path: "/settings/security",
|
||||||
|
feature: Feature.SECURITY_SETTINGS,
|
||||||
|
role: "admin",
|
||||||
|
},
|
||||||
|
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
|
||||||
|
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
|
||||||
|
{ label: "Public sharing", icon: IconWorld, path: "/settings/sharing" },
|
||||||
|
{
|
||||||
|
label: "Verified pages",
|
||||||
|
icon: IconShieldCheck,
|
||||||
|
path: "/settings/verifications",
|
||||||
|
feature: Feature.PAGE_VERIFICATION,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "API management",
|
||||||
|
icon: IconKey,
|
||||||
|
path: "/settings/api-keys",
|
||||||
|
feature: Feature.API_KEYS,
|
||||||
|
role: "admin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "AI settings",
|
||||||
|
icon: IconSparkles,
|
||||||
|
path: "/settings/ai",
|
||||||
|
role: "admin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Audit logs & SIEM",
|
||||||
|
icon: IconHistory,
|
||||||
|
path: "/settings/audit",
|
||||||
|
feature: Feature.AUDIT_LOGS,
|
||||||
|
role: "owner",
|
||||||
|
env: "selfhosted",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "System",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: "License & Edition",
|
||||||
|
icon: IconKey,
|
||||||
|
path: "/settings/license",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SettingsSidebar() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const location = useLocation();
|
||||||
|
const [active, setActive] = useState(location.pathname);
|
||||||
|
const { goBack } = useSettingsNavigation();
|
||||||
|
const { isAdmin, isOwner } = useUserRole();
|
||||||
|
const [entitlements] = useAtom(entitlementAtom);
|
||||||
|
const upgradeLabel = useUpgradeLabel();
|
||||||
|
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
|
||||||
|
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActive(location.pathname);
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
const hasFeature = (f: string) =>
|
||||||
|
entitlements?.features?.includes(f) ?? false;
|
||||||
|
|
||||||
|
const canShowItem = (item: DataItem) => {
|
||||||
|
if (item.env === "cloud" && !isCloud()) return false;
|
||||||
|
if (item.env === "selfhosted" && isCloud()) return false;
|
||||||
|
if (item.role === "admin" && !isAdmin) return false;
|
||||||
|
if (item.role === "owner" && !isOwner) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isItemDisabled = (item: DataItem) => {
|
||||||
|
if (!item.feature) return false;
|
||||||
|
return !hasFeature(item.feature);
|
||||||
|
};
|
||||||
|
|
||||||
|
const menuItems = groupedData.map((group) => {
|
||||||
|
if (group.heading === "System" && (!isAdmin || isCloud())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={group.heading}>
|
||||||
|
<Text c="dimmed" className={classes.linkHeader}>
|
||||||
|
{t(group.heading)}
|
||||||
|
</Text>
|
||||||
|
{group.items.map((item) => {
|
||||||
|
if (!canShowItem(item)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let prefetchHandler: any;
|
||||||
|
switch (item.label) {
|
||||||
|
case "Members":
|
||||||
|
prefetchHandler = prefetchWorkspaceMembers;
|
||||||
|
break;
|
||||||
|
case "Spaces":
|
||||||
|
prefetchHandler = prefetchSpaces;
|
||||||
|
break;
|
||||||
|
case "Groups":
|
||||||
|
prefetchHandler = prefetchGroups;
|
||||||
|
break;
|
||||||
|
case "Billing":
|
||||||
|
prefetchHandler = prefetchBilling;
|
||||||
|
break;
|
||||||
|
case "License & Edition":
|
||||||
|
if (entitlements?.tier !== "free") {
|
||||||
|
prefetchHandler = prefetchLicense;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "Security & SSO":
|
||||||
|
prefetchHandler = () => {
|
||||||
|
prefetchSsoProviders();
|
||||||
|
prefetchScimTokens();
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case "Public sharing":
|
||||||
|
prefetchHandler = prefetchShares;
|
||||||
|
break;
|
||||||
|
case "API keys":
|
||||||
|
prefetchHandler = prefetchApiKeys;
|
||||||
|
break;
|
||||||
|
case "API management":
|
||||||
|
prefetchHandler = prefetchApiKeyManagement;
|
||||||
|
break;
|
||||||
|
case "Audit logs & SIEM":
|
||||||
|
prefetchHandler = prefetchAuditLogs;
|
||||||
|
break;
|
||||||
|
case "Verified pages":
|
||||||
|
prefetchHandler = prefetchVerifiedPages;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDisabled = isItemDisabled(item);
|
||||||
|
|
||||||
|
if (isDisabled) {
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
key={item.label}
|
||||||
|
label={upgradeLabel}
|
||||||
|
position="right"
|
||||||
|
withArrow
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={classes.link}
|
||||||
|
data-disabled
|
||||||
|
role="link"
|
||||||
|
aria-disabled="true"
|
||||||
|
tabIndex={0}
|
||||||
|
style={{
|
||||||
|
opacity: 0.5,
|
||||||
|
cursor: "not-allowed",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<item.icon className={classes.linkIcon} stroke={2} />
|
||||||
|
<span>{t(item.label)}</span>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
onMouseEnter={prefetchHandler}
|
||||||
|
className={classes.link}
|
||||||
|
data-active={active.startsWith(item.path) || undefined}
|
||||||
|
key={item.label}
|
||||||
|
to={item.path}
|
||||||
|
onClick={() => {
|
||||||
|
if (mobileSidebarOpened) {
|
||||||
|
toggleMobileSidebar();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<item.icon className={classes.linkIcon} stroke={2} />
|
||||||
|
<span>{t(item.label)}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.navbar}>
|
||||||
|
<Group className={classes.title} justify="flex-start">
|
||||||
|
<ActionIcon
|
||||||
|
onClick={() => {
|
||||||
|
goBack();
|
||||||
|
if (mobileSidebarOpened) {
|
||||||
|
toggleMobileSidebar();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
variant="transparent"
|
||||||
|
c="gray"
|
||||||
|
aria-label={t("Back")}
|
||||||
|
>
|
||||||
|
<IconArrowLeft stroke={2} />
|
||||||
|
</ActionIcon>
|
||||||
|
<Text fw={500}>{t("Settings")}</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<ScrollArea w="100%">{menuItems}</ScrollArea>
|
||||||
|
|
||||||
|
{!isCloud() && <AppVersion />}
|
||||||
|
|
||||||
|
{isCloud() && (
|
||||||
|
<div className={classes.text}>
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
c="dimmed"
|
||||||
|
component="a"
|
||||||
|
href="mailto:help@docmost.com"
|
||||||
|
>
|
||||||
|
help@docmost.com
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
apps/client/src/components/settings/settings-title.tsx
Normal file
13
apps/client/src/components/settings/settings-title.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Divider, Title } from '@mantine/core';
|
||||||
|
|
||||||
|
export default function SettingsTitle({ title }: { title: string }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Title order={1} size="h3">
|
||||||
|
{title}
|
||||||
|
</Title>
|
||||||
|
<Divider my="md" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
apps/client/src/components/settings/settings.module.css
Normal file
64
apps/client/src/components/settings/settings.module.css
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
.navbar {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--mantine-spacing-md);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
padding-bottom: var(--mantine-spacing-sm);
|
||||||
|
border-bottom: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: var(--mantine-font-size-sm);
|
||||||
|
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||||
|
padding-left: var(--mantine-spacing-xs) ;
|
||||||
|
min-height: 30px;
|
||||||
|
border-radius: var(--mantine-radius-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
@mixin hover {
|
||||||
|
background-color: light-dark(
|
||||||
|
var(--mantine-color-gray-1),
|
||||||
|
var(--mantine-color-dark-6)
|
||||||
|
);
|
||||||
|
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-active] {
|
||||||
|
&,
|
||||||
|
& :hover {
|
||||||
|
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6));
|
||||||
|
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.linkIcon {
|
||||||
|
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||||
|
margin-right: var(--mantine-spacing-sm);
|
||||||
|
width: rem(16px);
|
||||||
|
height: rem(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.linkHeader {
|
||||||
|
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
|
||||||
|
font-size: var(--mantine-font-size-sm);
|
||||||
|
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
padding-left: var(--mantine-spacing-xs) ;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
19
apps/client/src/components/theme-toggle.module.css
Normal file
19
apps/client/src/components/theme-toggle.module.css
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
.dark {
|
||||||
|
@mixin dark {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin light {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.light {
|
||||||
|
@mixin light {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin dark {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
apps/client/src/components/theme-toggle.tsx
Normal file
28
apps/client/src/components/theme-toggle.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Tooltip,
|
||||||
|
useComputedColorScheme,
|
||||||
|
useMantineColorScheme,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { IconMoon, IconSun } from "@tabler/icons-react";
|
||||||
|
import classes from "./theme-toggle.module.css";
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { setColorScheme } = useMantineColorScheme();
|
||||||
|
const computedColorScheme = useComputedColorScheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip label="Toggle Color Scheme">
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
onClick={() => {
|
||||||
|
setColorScheme(computedColorScheme === "light" ? "dark" : "light");
|
||||||
|
}}
|
||||||
|
aria-label="Toggle color scheme"
|
||||||
|
>
|
||||||
|
<IconSun className={classes.light} size={18} stroke={1.5} />
|
||||||
|
<IconMoon className={classes.dark} size={18} stroke={1.5} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
apps/client/src/components/ui/auto-tooltip-text.tsx
Normal file
50
apps/client/src/components/ui/auto-tooltip-text.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { useRef, useState, ReactNode } from "react";
|
||||||
|
import { Text, TextProps, Tooltip } from "@mantine/core";
|
||||||
|
|
||||||
|
type AutoTooltipTextProps = TextProps & {
|
||||||
|
children: ReactNode;
|
||||||
|
tooltipLabel?: string;
|
||||||
|
tooltipProps?: Omit<
|
||||||
|
React.ComponentProps<typeof Tooltip>,
|
||||||
|
"children" | "label"
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AutoTooltipText({
|
||||||
|
children,
|
||||||
|
tooltipLabel,
|
||||||
|
tooltipProps,
|
||||||
|
...textProps
|
||||||
|
}: AutoTooltipTextProps) {
|
||||||
|
const textRef = useRef<HTMLParagraphElement>(null);
|
||||||
|
const [isTruncated, setIsTruncated] = useState(false);
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
const element = textRef.current;
|
||||||
|
if (element) {
|
||||||
|
setIsTruncated(element.scrollWidth > element.clientWidth);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const label = tooltipLabel ?? (typeof children === "string" ? children : "");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
label={label}
|
||||||
|
disabled={!isTruncated || !label}
|
||||||
|
multiline
|
||||||
|
withArrow
|
||||||
|
withinPortal={false}
|
||||||
|
{...tooltipProps}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
ref={textRef}
|
||||||
|
truncate
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
{...textProps}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
apps/client/src/components/ui/card-carousel.module.css
Normal file
68
apps/client/src/components/ui/card-carousel.module.css
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
.root {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--mantine-spacing-md);
|
||||||
|
overflow-x: auto;
|
||||||
|
scroll-snap-type: x mandatory;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
padding: 2px;
|
||||||
|
margin: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track > * {
|
||||||
|
scroll-snap-align: start;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||||
|
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
|
||||||
|
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 120ms ease, background-color 120ms ease, transform 120ms ease;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root:hover .arrow.visible,
|
||||||
|
.arrow.visible:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow:hover {
|
||||||
|
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5));
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow:active {
|
||||||
|
transform: translateY(-50%) scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrowLeft {
|
||||||
|
left: -14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrowRight {
|
||||||
|
right: -14px;
|
||||||
|
}
|
||||||
77
apps/client/src/components/ui/card-carousel.tsx
Normal file
77
apps/client/src/components/ui/card-carousel.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
|
import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import classes from "./card-carousel.module.css";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: ReactNode;
|
||||||
|
ariaLabel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CardCarousel({ children, ariaLabel }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const trackRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||||
|
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||||
|
|
||||||
|
const updateScrollState = useCallback(() => {
|
||||||
|
const el = trackRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const maxScroll = el.scrollWidth - el.clientWidth;
|
||||||
|
setCanScrollLeft(el.scrollLeft > 1);
|
||||||
|
setCanScrollRight(el.scrollLeft < maxScroll - 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateScrollState();
|
||||||
|
const el = trackRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(updateScrollState);
|
||||||
|
observer.observe(el);
|
||||||
|
for (const child of Array.from(el.children)) {
|
||||||
|
observer.observe(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [updateScrollState, children]);
|
||||||
|
|
||||||
|
const scrollBy = (direction: 1 | -1) => {
|
||||||
|
const el = trackRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.scrollBy({ left: direction * el.clientWidth * 0.85, behavior: "smooth" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.root}>
|
||||||
|
<div
|
||||||
|
ref={trackRef}
|
||||||
|
className={classes.track}
|
||||||
|
onScroll={updateScrollState}
|
||||||
|
{...(ariaLabel ? { role: "region", "aria-label": ariaLabel } : {})}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${classes.arrow} ${classes.arrowLeft} ${canScrollLeft ? classes.visible : ""}`}
|
||||||
|
onClick={() => scrollBy(-1)}
|
||||||
|
aria-label={t("Scroll left")}
|
||||||
|
tabIndex={canScrollLeft ? 0 : -1}
|
||||||
|
>
|
||||||
|
<IconChevronLeft size={18} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${classes.arrow} ${classes.arrowRight} ${canScrollRight ? classes.visible : ""}`}
|
||||||
|
onClick={() => scrollBy(1)}
|
||||||
|
aria-label={t("Scroll right")}
|
||||||
|
tabIndex={canScrollRight ? 0 : -1}
|
||||||
|
>
|
||||||
|
<IconChevronRight size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
apps/client/src/components/ui/checkbox-menu-item.tsx
Normal file
12
apps/client/src/components/ui/checkbox-menu-item.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { UnstyledButton } from "@mantine/core";
|
||||||
|
import { type ComponentPropsWithoutRef, forwardRef } from "react";
|
||||||
|
|
||||||
|
// Menu.Item hard-codes role="menuitem"; use as its `component` to restore role="menuitemcheckbox" so aria-checked works.
|
||||||
|
export const CheckboxMenuItem = forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
ComponentPropsWithoutRef<"button">
|
||||||
|
>((props, ref) => (
|
||||||
|
<UnstyledButton ref={ref} {...props} role="menuitemcheckbox" />
|
||||||
|
));
|
||||||
|
|
||||||
|
CheckboxMenuItem.displayName = "CheckboxMenuItem";
|
||||||
29
apps/client/src/components/ui/clickable-table-row.module.css
Normal file
29
apps/client/src/components/ui/clickable-table-row.module.css
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/*
|
||||||
|
* Focus styling for list-style tables (recent changes, favorites, all
|
||||||
|
* spaces, groups, verified pages, shares).
|
||||||
|
*
|
||||||
|
* Per WAI-ARIA Authoring Practices and Adrian Roselli's guidance on table
|
||||||
|
* accessibility (https://adrianroselli.com/2020/02/block-links-cards-clickable-regions-etc.html),
|
||||||
|
* data tables should not be made fully clickable. Only the title cell is the
|
||||||
|
* link, and that link is what receives Tab focus.
|
||||||
|
*
|
||||||
|
* - `.row` adds a subtle background tint when the row contains the focused
|
||||||
|
* element, so keyboard users can see which row they're inspecting.
|
||||||
|
* - `.link` adds a visible :focus-visible outline on the title link itself.
|
||||||
|
*
|
||||||
|
* No stretched-link pseudo here on purpose: absolutely-positioned pseudos
|
||||||
|
* inside table cells cause column reflow on focus in Chromium.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.row:focus-within {
|
||||||
|
background-color: light-dark(
|
||||||
|
var(--mantine-color-gray-1),
|
||||||
|
var(--mantine-color-dark-6)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link:focus-visible {
|
||||||
|
outline: 2px solid var(--mantine-primary-color-filled);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: var(--mantine-radius-sm);
|
||||||
|
}
|
||||||
84
apps/client/src/components/ui/custom-avatar.tsx
Normal file
84
apps/client/src/components/ui/custom-avatar.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Avatar, MantineColor } from "@mantine/core";
|
||||||
|
import { getAvatarUrl } from "@/lib/config.ts";
|
||||||
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||||
|
|
||||||
|
interface CustomAvatarProps {
|
||||||
|
avatarUrl?: string;
|
||||||
|
name: string;
|
||||||
|
color?: string;
|
||||||
|
size?: string | number;
|
||||||
|
radius?: string | number;
|
||||||
|
variant?: string;
|
||||||
|
style?: any;
|
||||||
|
component?: any;
|
||||||
|
type?: AvatarIconType;
|
||||||
|
mt?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// color.shade picks whose FILLED variant (white text on the shade) meets WCAG AA 4.5:1.
|
||||||
|
// Avoids lime/yellow/green/orange, too light even at dark shades.
|
||||||
|
// For non-filled variants, initials text is forced to the .9 shade at render time:
|
||||||
|
// Mantine otherwise caps light-variant placeholder text at .6, dropping contrast to ~3:1.
|
||||||
|
const SAFE_INITIALS_COLORS: MantineColor[] = [
|
||||||
|
"blue.8",
|
||||||
|
"cyan.9",
|
||||||
|
"grape.9",
|
||||||
|
"indigo.8",
|
||||||
|
"pink.8",
|
||||||
|
"red.8",
|
||||||
|
"violet.7",
|
||||||
|
];
|
||||||
|
|
||||||
|
function hashName(input: string) {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < input.length; i += 1) {
|
||||||
|
hash = (hash << 5) - hash + input.charCodeAt(i);
|
||||||
|
hash |= 0;
|
||||||
|
}
|
||||||
|
return Math.abs(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickInitialsColor(name: string) {
|
||||||
|
return SAFE_INITIALS_COLORS[hashName(name) % SAFE_INITIALS_COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeInitialsSource(name: string) {
|
||||||
|
const sanitized = name.replace(/[^\p{L}\p{N}\s]/gu, " ").trim();
|
||||||
|
return sanitized || name;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CustomAvatar = React.forwardRef<
|
||||||
|
HTMLInputElement,
|
||||||
|
CustomAvatarProps
|
||||||
|
>(({ avatarUrl, name, type, color, variant, ...props }: CustomAvatarProps, ref) => {
|
||||||
|
const avatarLink = getAvatarUrl(avatarUrl, type);
|
||||||
|
const isInitials = !color || color === "initials";
|
||||||
|
const pickedColor = isInitials ? pickInitialsColor(name ?? "") : color;
|
||||||
|
const hue = pickedColor.split(".")[0];
|
||||||
|
const initialsSource = sanitizeInitialsSource(name ?? "");
|
||||||
|
|
||||||
|
const resolvedColor = variant === "filled" ? pickedColor : hue;
|
||||||
|
|
||||||
|
const placeholderStyles =
|
||||||
|
isInitials && variant !== "filled"
|
||||||
|
? {
|
||||||
|
placeholder: {
|
||||||
|
color: `var(--mantine-color-${hue}-9)`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Avatar
|
||||||
|
ref={ref}
|
||||||
|
src={avatarLink}
|
||||||
|
name={initialsSource}
|
||||||
|
alt={name}
|
||||||
|
color={resolvedColor}
|
||||||
|
variant={variant}
|
||||||
|
styles={placeholderStyles}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Modal, Button, Group, Divider } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { DestinationPicker } from "./destination-picker";
|
||||||
|
import {
|
||||||
|
DestinationPickerModalProps,
|
||||||
|
DestinationSelection,
|
||||||
|
} from "./destination-picker.types";
|
||||||
|
|
||||||
|
export function DestinationPickerModal({
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
actionLabel,
|
||||||
|
onSelect,
|
||||||
|
loading,
|
||||||
|
excludePageId,
|
||||||
|
pageLimit,
|
||||||
|
initialSpaceId,
|
||||||
|
searchSpacesOnly,
|
||||||
|
}: DestinationPickerModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [selection, setSelection] = useState<DestinationSelection | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!opened) {
|
||||||
|
setSelection(null);
|
||||||
|
}
|
||||||
|
}, [opened]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal.Root
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
size={550}
|
||||||
|
padding="lg"
|
||||||
|
yOffset="10vh"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Modal.Overlay />
|
||||||
|
<Modal.Content>
|
||||||
|
<Modal.Header py={0}>
|
||||||
|
<Modal.Title fw={500}>{title}</Modal.Title>
|
||||||
|
<Modal.CloseButton aria-label={t("Close")} />
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
<DestinationPicker
|
||||||
|
onSelectionChange={setSelection}
|
||||||
|
excludePageId={excludePageId}
|
||||||
|
pageLimit={pageLimit}
|
||||||
|
initialSpaceId={initialSpaceId}
|
||||||
|
searchSpacesOnly={searchSpacesOnly}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider my="md" />
|
||||||
|
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" onClick={onClose}>
|
||||||
|
{t("Close")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => selection && onSelect(selection)}
|
||||||
|
disabled={!selection}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
{actionLabel}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Modal.Body>
|
||||||
|
</Modal.Content>
|
||||||
|
</Modal.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
.searchInput {
|
||||||
|
margin-bottom: var(--mantine-spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollArea {
|
||||||
|
max-height: 50vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: var(--mantine-radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
||||||
|
transition: background-color 150ms ease;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
@mixin hover {
|
||||||
|
background-color: light-dark(
|
||||||
|
var(--mantine-color-gray-0),
|
||||||
|
var(--mantine-color-dark-6)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--mantine-primary-color-filled);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected {
|
||||||
|
background-color: light-dark(
|
||||||
|
var(--mantine-color-blue-0),
|
||||||
|
var(--mantine-color-dark-5)
|
||||||
|
);
|
||||||
|
border-left: 2px solid var(--mantine-primary-color-filled);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spaceRow {
|
||||||
|
composes: row;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageRow {
|
||||||
|
composes: row;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--mantine-radius-sm);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 150ms ease;
|
||||||
|
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||||
|
|
||||||
|
@mixin hover {
|
||||||
|
background-color: light-dark(
|
||||||
|
var(--mantine-color-gray-1),
|
||||||
|
var(--mantine-color-dark-5)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevronExpanded {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadMore {
|
||||||
|
text-align: center;
|
||||||
|
padding: 6px;
|
||||||
|
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||||
|
font-size: var(--mantine-font-size-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
@mixin hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyState {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: var(--mantine-font-size-sm);
|
||||||
|
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchResult {
|
||||||
|
composes: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageTitle {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: var(--mantine-font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spaceName {
|
||||||
|
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||||
|
font-size: var(--mantine-font-size-xs);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconWrapper {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
|
import { ActionIcon, TextInput, ScrollArea, Loader } from "@mantine/core";
|
||||||
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
|
import { IconSearch, IconFileDescription } from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
|
||||||
|
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query";
|
||||||
|
import { ISpace } from "@/features/space/types/space.types";
|
||||||
|
import { IPage } from "@/features/page/types/page.types";
|
||||||
|
import { DestinationSelection } from "./destination-picker.types";
|
||||||
|
import { SpaceRow } from "./space-row";
|
||||||
|
import classes from "./destination-picker.module.css";
|
||||||
|
|
||||||
|
type DestinationPickerProps = {
|
||||||
|
onSelectionChange: (selection: DestinationSelection | null) => void;
|
||||||
|
excludePageId?: string;
|
||||||
|
pageLimit?: number;
|
||||||
|
initialSpaceId?: string;
|
||||||
|
searchSpacesOnly?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DestinationPicker({
|
||||||
|
onSelectionChange,
|
||||||
|
excludePageId,
|
||||||
|
pageLimit = 15,
|
||||||
|
initialSpaceId,
|
||||||
|
searchSpacesOnly,
|
||||||
|
}: DestinationPickerProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [selection, setSelection] = useState<DestinationSelection | null>(null);
|
||||||
|
const [debouncedQuery] = useDebouncedValue(searchQuery, 300);
|
||||||
|
const viewportRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const { data: spacesData, isLoading: spacesLoading } = useGetSpacesQuery({
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchEnabled =
|
||||||
|
!searchSpacesOnly && debouncedQuery && debouncedQuery.length >= 2;
|
||||||
|
|
||||||
|
const { data: searchData, isLoading: searchLoading } =
|
||||||
|
useSearchSuggestionsQuery({
|
||||||
|
query: searchEnabled ? debouncedQuery : "",
|
||||||
|
includePages: true,
|
||||||
|
limit: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isSearching = !!searchEnabled;
|
||||||
|
|
||||||
|
const filteredSpaces = useMemo(() => {
|
||||||
|
const items = spacesData?.items ?? [];
|
||||||
|
if (!searchSpacesOnly || !debouncedQuery) return items;
|
||||||
|
const fold = (s: string) =>
|
||||||
|
s
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[̀-ͯ]/g, "")
|
||||||
|
.toLocaleLowerCase();
|
||||||
|
const term = fold(debouncedQuery);
|
||||||
|
return items.filter((s) => fold(s.name).includes(term));
|
||||||
|
}, [spacesData, searchSpacesOnly, debouncedQuery]);
|
||||||
|
|
||||||
|
const selectedId =
|
||||||
|
selection?.type === "space" ? selection.spaceId : selection?.pageId ?? null;
|
||||||
|
|
||||||
|
const updateSelection = useCallback(
|
||||||
|
(next: DestinationSelection | null) => {
|
||||||
|
setSelection(next);
|
||||||
|
onSelectionChange(next);
|
||||||
|
},
|
||||||
|
[onSelectionChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSearchResultClick = (page: Partial<IPage>) => {
|
||||||
|
if (!page.space || !page.id) return;
|
||||||
|
|
||||||
|
updateSelection({
|
||||||
|
type: "page",
|
||||||
|
spaceId: page.space.id,
|
||||||
|
pageId: page.id,
|
||||||
|
page,
|
||||||
|
space: page.space,
|
||||||
|
});
|
||||||
|
setSearchQuery("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectSpace = useCallback(
|
||||||
|
(space: ISpace) => {
|
||||||
|
updateSelection({ type: "space", spaceId: space.id, space });
|
||||||
|
},
|
||||||
|
[updateSelection],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelectPage = useCallback(
|
||||||
|
(page: Partial<IPage>, space: ISpace) => {
|
||||||
|
if (!page.id) return;
|
||||||
|
updateSelection({
|
||||||
|
type: "page",
|
||||||
|
spaceId: page.spaceId ?? space.id,
|
||||||
|
pageId: page.id,
|
||||||
|
page,
|
||||||
|
space,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[updateSelection],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pre-select space when initialSpaceId is set and spaces have loaded.
|
||||||
|
// Only runs once: skip if user has already made a selection.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialSpaceId || selection) return;
|
||||||
|
const match = spacesData?.items?.find((s) => s.id === initialSpaceId);
|
||||||
|
if (match) {
|
||||||
|
updateSelection({ type: "space", spaceId: match.id, space: match });
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const el = viewportRef.current?.querySelector<HTMLElement>(
|
||||||
|
`[data-space-id="${match.id}"]`,
|
||||||
|
);
|
||||||
|
el?.scrollIntoView({ block: "nearest" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [initialSpaceId, selection, spacesData, updateSelection]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TextInput
|
||||||
|
leftSection={<IconSearch size={16} />}
|
||||||
|
placeholder={
|
||||||
|
searchSpacesOnly
|
||||||
|
? t("Search spaces...")
|
||||||
|
: t("Search pages and spaces...")
|
||||||
|
}
|
||||||
|
aria-label={
|
||||||
|
searchSpacesOnly
|
||||||
|
? t("Search spaces...")
|
||||||
|
: t("Search pages and spaces...")
|
||||||
|
}
|
||||||
|
variant="filled"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.currentTarget.value)}
|
||||||
|
className={classes.searchInput}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScrollArea
|
||||||
|
h="50vh"
|
||||||
|
offsetScrollbars
|
||||||
|
className={classes.scrollArea}
|
||||||
|
viewportRef={viewportRef}
|
||||||
|
>
|
||||||
|
{isSearching ? (
|
||||||
|
searchLoading ? (
|
||||||
|
<div className={classes.emptyState}>
|
||||||
|
<Loader size="xs" />
|
||||||
|
</div>
|
||||||
|
) : searchData?.pages && searchData.pages.length > 0 ? (
|
||||||
|
searchData.pages.map(
|
||||||
|
(page) =>
|
||||||
|
page && (
|
||||||
|
<div
|
||||||
|
key={page.id}
|
||||||
|
className={classes.searchResult}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => handleSearchResultClick(page)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSearchResultClick(page);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={classes.iconWrapper}>
|
||||||
|
{page.icon ? (
|
||||||
|
page.icon
|
||||||
|
) : (
|
||||||
|
<ActionIcon
|
||||||
|
component="div"
|
||||||
|
variant="transparent"
|
||||||
|
c="gray"
|
||||||
|
size={22}
|
||||||
|
>
|
||||||
|
<IconFileDescription size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={classes.pageTitle}>
|
||||||
|
{page.title || t("Untitled")}
|
||||||
|
</div>
|
||||||
|
{page.space && (
|
||||||
|
<div className={classes.spaceName}>
|
||||||
|
{page.space.name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className={classes.emptyState}>{t("No results found")}</div>
|
||||||
|
)
|
||||||
|
) : spacesLoading ? (
|
||||||
|
<div className={classes.emptyState}>
|
||||||
|
<Loader size="xs" />
|
||||||
|
</div>
|
||||||
|
) : filteredSpaces.length === 0 ? (
|
||||||
|
<div className={classes.emptyState}>
|
||||||
|
{searchSpacesOnly && debouncedQuery
|
||||||
|
? t("No spaces found")
|
||||||
|
: t("No results found")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredSpaces.map((space) => (
|
||||||
|
<SpaceRow
|
||||||
|
key={space.id}
|
||||||
|
space={space}
|
||||||
|
limit={pageLimit}
|
||||||
|
selectedId={selectedId}
|
||||||
|
excludePageId={excludePageId}
|
||||||
|
onSelectSpace={handleSelectSpace}
|
||||||
|
onSelectPage={handleSelectPage}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { ISpace } from "@/features/space/types/space.types";
|
||||||
|
import { IPage } from "@/features/page/types/page.types";
|
||||||
|
|
||||||
|
export type DestinationSelection =
|
||||||
|
| { type: "space"; spaceId: string; space: ISpace }
|
||||||
|
| {
|
||||||
|
type: "page";
|
||||||
|
spaceId: string;
|
||||||
|
pageId: string;
|
||||||
|
page: Partial<IPage>;
|
||||||
|
space: Partial<ISpace>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DestinationPickerModalProps = {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title: string;
|
||||||
|
actionLabel: string;
|
||||||
|
onSelect: (selection: DestinationSelection) => void | Promise<void>;
|
||||||
|
loading?: boolean;
|
||||||
|
excludePageId?: string;
|
||||||
|
pageLimit?: number;
|
||||||
|
initialSpaceId?: string;
|
||||||
|
searchSpacesOnly?: boolean;
|
||||||
|
};
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { Loader } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { getSidebarPages } from "@/features/page/services/page-service";
|
||||||
|
import { IPage } from "@/features/page/types/page.types";
|
||||||
|
import { IPagination } from "@/lib/types";
|
||||||
|
import { PageRow } from "./page-row";
|
||||||
|
import classes from "./destination-picker.module.css";
|
||||||
|
|
||||||
|
type PageChildrenProps = {
|
||||||
|
spaceId: string;
|
||||||
|
pageId?: string;
|
||||||
|
depth: number;
|
||||||
|
limit: number;
|
||||||
|
selectedId: string | null;
|
||||||
|
excludePageId?: string;
|
||||||
|
onSelectPage: (page: Partial<IPage>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PageChildren({
|
||||||
|
spaceId,
|
||||||
|
pageId,
|
||||||
|
depth,
|
||||||
|
limit,
|
||||||
|
selectedId,
|
||||||
|
excludePageId,
|
||||||
|
onSelectPage,
|
||||||
|
}: PageChildrenProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { data, isLoading, hasNextPage, fetchNextPage } = useInfiniteQuery({
|
||||||
|
queryKey: ["destination-pages", spaceId, pageId ?? "root"],
|
||||||
|
queryFn: ({ pageParam }) =>
|
||||||
|
getSidebarPages({
|
||||||
|
spaceId,
|
||||||
|
pageId,
|
||||||
|
limit,
|
||||||
|
cursor: pageParam,
|
||||||
|
}),
|
||||||
|
initialPageParam: undefined as string | undefined,
|
||||||
|
getNextPageParam: (lastPage: IPagination<IPage>) =>
|
||||||
|
lastPage.meta?.nextCursor ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pages = data?.pages.flatMap((page) => page.items) ?? [];
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className={classes.emptyState}>
|
||||||
|
<Loader size="xs" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pages.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={classes.emptyState}>
|
||||||
|
{pageId ? t("No pages inside") : t("No pages in this space")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{pages.map((page) => (
|
||||||
|
<PageRow
|
||||||
|
key={page.id}
|
||||||
|
page={page}
|
||||||
|
depth={depth}
|
||||||
|
limit={limit}
|
||||||
|
selectedId={selectedId}
|
||||||
|
excludePageId={excludePageId}
|
||||||
|
onSelect={onSelectPage}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{hasNextPage && (
|
||||||
|
<div
|
||||||
|
className={classes.loadMore}
|
||||||
|
onClick={() => fetchNextPage()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
{t("Load more")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
apps/client/src/components/ui/destination-picker/page-row.tsx
Normal file
116
apps/client/src/components/ui/destination-picker/page-row.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { KeyboardEvent, useState } from "react";
|
||||||
|
import { ActionIcon } from "@mantine/core";
|
||||||
|
import { IconChevronRight, IconFileDescription } from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { IPage } from "@/features/page/types/page.types";
|
||||||
|
import { getPageTitle } from "@/features/page/page.utils";
|
||||||
|
import { PageChildren } from "./page-children";
|
||||||
|
import classes from "./destination-picker.module.css";
|
||||||
|
|
||||||
|
type PageRowProps = {
|
||||||
|
page: Partial<IPage>;
|
||||||
|
depth: number;
|
||||||
|
limit: number;
|
||||||
|
selectedId: string | null;
|
||||||
|
excludePageId?: string;
|
||||||
|
onSelect: (page: Partial<IPage>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PageRow({
|
||||||
|
page,
|
||||||
|
depth,
|
||||||
|
limit,
|
||||||
|
selectedId,
|
||||||
|
excludePageId,
|
||||||
|
onSelect,
|
||||||
|
}: PageRowProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
const isExcluded = page.id === excludePageId;
|
||||||
|
const isSelected = page.id === selectedId;
|
||||||
|
|
||||||
|
const rowClasses = [
|
||||||
|
classes.pageRow,
|
||||||
|
isSelected && classes.selected,
|
||||||
|
isExcluded && classes.disabled,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
const handleSelect = () => {
|
||||||
|
if (!isExcluded) onSelect(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRowKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (e.target !== e.currentTarget) return;
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSelect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={rowClasses}
|
||||||
|
style={{ paddingLeft: depth * 20 + 12 }}
|
||||||
|
role="button"
|
||||||
|
tabIndex={isExcluded ? -1 : 0}
|
||||||
|
aria-disabled={isExcluded || undefined}
|
||||||
|
onClick={handleSelect}
|
||||||
|
onKeyDown={handleRowKeyDown}
|
||||||
|
>
|
||||||
|
{page.hasChildren ? (
|
||||||
|
<ActionIcon
|
||||||
|
className={`${classes.chevron} ${expanded ? classes.chevronExpanded : ""}`}
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size="sm"
|
||||||
|
aria-label={expanded ? t("Collapse") : t("Expand")}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setExpanded(!expanded);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconChevronRight size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
) : (
|
||||||
|
<div style={{ width: 20, flexShrink: 0 }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={classes.iconWrapper}>
|
||||||
|
{page.icon ? (
|
||||||
|
page.icon
|
||||||
|
) : (
|
||||||
|
<ActionIcon
|
||||||
|
component="div"
|
||||||
|
variant="transparent"
|
||||||
|
c="gray"
|
||||||
|
size={22}
|
||||||
|
>
|
||||||
|
<IconFileDescription size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={classes.pageTitle}>
|
||||||
|
{getPageTitle(page.title, page.isBase, t)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded && page.hasChildren && (
|
||||||
|
<PageChildren
|
||||||
|
spaceId={page.spaceId}
|
||||||
|
pageId={page.id}
|
||||||
|
depth={depth + 1}
|
||||||
|
limit={limit}
|
||||||
|
selectedId={selectedId}
|
||||||
|
excludePageId={excludePageId}
|
||||||
|
onSelectPage={onSelect}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
apps/client/src/components/ui/destination-picker/space-row.tsx
Normal file
130
apps/client/src/components/ui/destination-picker/space-row.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { KeyboardEvent, useState } from "react";
|
||||||
|
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||||
|
import { IconChevronRight, IconLock } from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ISpace } from "@/features/space/types/space.types";
|
||||||
|
import { IPage } from "@/features/page/types/page.types";
|
||||||
|
import { SpaceRole } from "@/lib/types";
|
||||||
|
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||||
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types";
|
||||||
|
import { PageChildren } from "./page-children";
|
||||||
|
import classes from "./destination-picker.module.css";
|
||||||
|
|
||||||
|
type SpaceRowProps = {
|
||||||
|
space: ISpace;
|
||||||
|
limit: number;
|
||||||
|
selectedId: string | null;
|
||||||
|
excludePageId?: string;
|
||||||
|
onSelectSpace: (space: ISpace) => void;
|
||||||
|
onSelectPage: (page: Partial<IPage>, space: ISpace) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SpaceRow({
|
||||||
|
space,
|
||||||
|
limit,
|
||||||
|
selectedId,
|
||||||
|
excludePageId,
|
||||||
|
onSelectSpace,
|
||||||
|
onSelectPage,
|
||||||
|
}: SpaceRowProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
const writable =
|
||||||
|
!!space.membership?.role && space.membership.role !== SpaceRole.READER;
|
||||||
|
const isSelected = space.id === selectedId;
|
||||||
|
|
||||||
|
const rowClasses = [
|
||||||
|
classes.spaceRow,
|
||||||
|
isSelected && classes.selected,
|
||||||
|
!writable && classes.disabled,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
const handleSelect = () => {
|
||||||
|
if (writable) onSelectSpace(space);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRowKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (e.target !== e.currentTarget) return;
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSelect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rowContent = (
|
||||||
|
<div
|
||||||
|
className={rowClasses}
|
||||||
|
data-space-id={space.id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={writable ? 0 : -1}
|
||||||
|
aria-disabled={!writable || undefined}
|
||||||
|
onClick={handleSelect}
|
||||||
|
onKeyDown={handleRowKeyDown}
|
||||||
|
>
|
||||||
|
{writable ? (
|
||||||
|
<ActionIcon
|
||||||
|
className={`${classes.chevron} ${expanded ? classes.chevronExpanded : ""}`}
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size="sm"
|
||||||
|
aria-label={expanded ? t("Collapse") : t("Expand")}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setExpanded(!expanded);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconChevronRight size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
) : (
|
||||||
|
<div style={{ width: 20, flexShrink: 0 }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CustomAvatar
|
||||||
|
name={space.name}
|
||||||
|
avatarUrl={space.logo}
|
||||||
|
type={AvatarIconType.SPACE_ICON}
|
||||||
|
size={22}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={classes.pageTitle}>{space.name}</div>
|
||||||
|
|
||||||
|
{!writable && (
|
||||||
|
<IconLock
|
||||||
|
size={14}
|
||||||
|
color="var(--mantine-color-gray-5)"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{writable ? (
|
||||||
|
rowContent
|
||||||
|
) : (
|
||||||
|
<Tooltip
|
||||||
|
label={t("You don't have permission to create pages here")}
|
||||||
|
position="right"
|
||||||
|
withArrow
|
||||||
|
>
|
||||||
|
<div>{rowContent}</div>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{expanded && writable && (
|
||||||
|
<PageChildren
|
||||||
|
spaceId={space.id}
|
||||||
|
depth={1}
|
||||||
|
limit={limit}
|
||||||
|
selectedId={selectedId}
|
||||||
|
excludePageId={excludePageId}
|
||||||
|
onSelectPage={(page) => onSelectPage(page, space)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
apps/client/src/components/ui/document-title.test.tsx
Normal file
45
apps/client/src/components/ui/document-title.test.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, expect, it, beforeEach } from "vitest";
|
||||||
|
import { render } from "@testing-library/react";
|
||||||
|
import { HelmetProvider } from "react-helmet-async";
|
||||||
|
import { DocumentTitle } from "./document-title.tsx";
|
||||||
|
|
||||||
|
const renderTitle = (ui: React.ReactNode) =>
|
||||||
|
render(<HelmetProvider>{ui}</HelmetProvider>);
|
||||||
|
|
||||||
|
describe("DocumentTitle", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
document.head.innerHTML = "<title>Docmost</title>";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends the app name", () => {
|
||||||
|
renderTitle(<DocumentTitle title="Home" />);
|
||||||
|
expect(document.title).toBe("Home - Docmost");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the app name when asked", () => {
|
||||||
|
renderTitle(<DocumentTitle title="My page" withAppName={false} />);
|
||||||
|
expect(document.title).toBe("My page");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the app name without a title", () => {
|
||||||
|
renderTitle(<DocumentTitle />);
|
||||||
|
expect(document.title).toBe("Docmost");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never renders an empty title", () => {
|
||||||
|
renderTitle(<DocumentTitle title="Spaces" />);
|
||||||
|
const titles = Array.from(document.querySelectorAll("head > title"));
|
||||||
|
expect(titles.every((node) => node.textContent !== "")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders extra head children", () => {
|
||||||
|
renderTitle(
|
||||||
|
<DocumentTitle title="Shared">
|
||||||
|
<meta name="robots" content="noindex" />
|
||||||
|
</DocumentTitle>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
document.querySelector('head > meta[name="robots"]')?.getAttribute("content"),
|
||||||
|
).toBe("noindex");
|
||||||
|
});
|
||||||
|
});
|
||||||
29
apps/client/src/components/ui/document-title.tsx
Normal file
29
apps/client/src/components/ui/document-title.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Helmet } from "react-helmet-async";
|
||||||
|
import { getAppName } from "@/lib/config.ts";
|
||||||
|
|
||||||
|
type DocumentTitleProps = {
|
||||||
|
title?: string;
|
||||||
|
withAppName?: boolean;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DocumentTitle({
|
||||||
|
title,
|
||||||
|
withAppName = true,
|
||||||
|
children,
|
||||||
|
}: DocumentTitleProps) {
|
||||||
|
const appName = getAppName();
|
||||||
|
|
||||||
|
let documentTitle = appName;
|
||||||
|
if (title) {
|
||||||
|
documentTitle = withAppName ? `${title} - ${appName}` : title;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Helmet>
|
||||||
|
<title>{documentTitle}</title>
|
||||||
|
{children}
|
||||||
|
</Helmet>
|
||||||
|
);
|
||||||
|
}
|
||||||
162
apps/client/src/components/ui/emoji-picker.tsx
Normal file
162
apps/client/src/components/ui/emoji-picker.tsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import React, { ReactNode, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Popover,
|
||||||
|
Button,
|
||||||
|
useMantineColorScheme,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useClickOutside, useDisclosure, useWindowEvent } from "@mantine/hooks";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
// Load the picker module AND the emoji data in parallel inside the lazy
|
||||||
|
// resolution, then bind the data into the component. React.lazy only finishes
|
||||||
|
// suspending once both are in memory, so the Suspense boundary hides the
|
||||||
|
// Remove button until the Picker can render with real content.
|
||||||
|
const Picker = React.lazy(async () => {
|
||||||
|
const [pickerModule, dataModule] = await Promise.all([
|
||||||
|
import("@slidoapp/emoji-mart-react"),
|
||||||
|
import("@slidoapp/emoji-mart-data"),
|
||||||
|
]);
|
||||||
|
const PickerComp = pickerModule.default;
|
||||||
|
const data = dataModule.default;
|
||||||
|
return {
|
||||||
|
default: (props: any) => <PickerComp {...props} data={data} />,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface EmojiPickerInterface {
|
||||||
|
onEmojiSelect: (emoji: any) => void;
|
||||||
|
icon: ReactNode;
|
||||||
|
removeEmojiAction: () => void;
|
||||||
|
readOnly: boolean;
|
||||||
|
actionIconProps?: {
|
||||||
|
size?: string;
|
||||||
|
variant?: string;
|
||||||
|
c?: string;
|
||||||
|
tabIndex?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmojiPicker({
|
||||||
|
onEmojiSelect,
|
||||||
|
icon,
|
||||||
|
removeEmojiAction,
|
||||||
|
readOnly,
|
||||||
|
actionIconProps,
|
||||||
|
}: EmojiPickerInterface) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [opened, handlers] = useDisclosure(false);
|
||||||
|
const { colorScheme } = useMantineColorScheme();
|
||||||
|
const [target, setTarget] = useState<HTMLElement | null>(null);
|
||||||
|
const [dropdown, setDropdown] = useState<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useClickOutside(
|
||||||
|
() => handlers.close(),
|
||||||
|
["mousedown", "touchstart"],
|
||||||
|
[dropdown, target],
|
||||||
|
);
|
||||||
|
|
||||||
|
// We need this because the default Mantine popover closeOnEscape does not work
|
||||||
|
useWindowEvent("keydown", (event) => {
|
||||||
|
if (opened && event.key === "Escape") {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
handlers.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// emoji-mart's built-in autoFocus calls .focus() without preventScroll, which
|
||||||
|
// makes the browser scroll every scrollable ancestor of the search input to
|
||||||
|
// bring it on screen — including the page editor's scroll container, so the
|
||||||
|
// page jumps to the top whenever the picker is opened from a scrolled-down
|
||||||
|
// position. The search input lives inside the <em-emoji-picker> custom
|
||||||
|
// element's shadow root, so we poll for it after the dropdown mounts and
|
||||||
|
// focus it ourselves with preventScroll.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!opened || !dropdown) return;
|
||||||
|
let cancelled = false;
|
||||||
|
let rafId = 0;
|
||||||
|
const tryFocus = (attempts: number) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const pickerEl = dropdown.querySelector("em-emoji-picker");
|
||||||
|
const input = pickerEl?.shadowRoot?.querySelector<HTMLInputElement>(
|
||||||
|
'input[type="search"]',
|
||||||
|
);
|
||||||
|
if (input) {
|
||||||
|
input.focus({ preventScroll: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (attempts < 60) {
|
||||||
|
rafId = requestAnimationFrame(() => tryFocus(attempts + 1));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rafId = requestAnimationFrame(() => tryFocus(0));
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
};
|
||||||
|
}, [opened, dropdown]);
|
||||||
|
|
||||||
|
const handleEmojiSelect = (emoji) => {
|
||||||
|
onEmojiSelect(emoji);
|
||||||
|
handlers.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveEmoji = () => {
|
||||||
|
removeEmojiAction();
|
||||||
|
handlers.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
opened={opened}
|
||||||
|
onClose={handlers.close}
|
||||||
|
width={332}
|
||||||
|
position="bottom"
|
||||||
|
disabled={readOnly}
|
||||||
|
closeOnEscape={true}
|
||||||
|
>
|
||||||
|
<Popover.Target ref={setTarget}>
|
||||||
|
<ActionIcon
|
||||||
|
c={actionIconProps?.c || "gray"}
|
||||||
|
variant={actionIconProps?.variant || "transparent"}
|
||||||
|
size={actionIconProps?.size}
|
||||||
|
tabIndex={actionIconProps?.tabIndex}
|
||||||
|
onClick={handlers.toggle}
|
||||||
|
aria-label={t("Pick emoji")}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={opened}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</ActionIcon>
|
||||||
|
</Popover.Target>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<Popover.Dropdown bg="000" style={{ border: "none" }} ref={setDropdown}>
|
||||||
|
<Picker
|
||||||
|
onEmojiSelect={handleEmojiSelect}
|
||||||
|
perLine={8}
|
||||||
|
skinTonePosition="search"
|
||||||
|
theme={colorScheme}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
c="gray"
|
||||||
|
size="xs"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
zIndex: 2,
|
||||||
|
bottom: "1rem",
|
||||||
|
right: "1rem",
|
||||||
|
}}
|
||||||
|
onClick={handleRemoveEmoji}
|
||||||
|
>
|
||||||
|
{t("Remove")}
|
||||||
|
</Button>
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Suspense>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EmojiPicker;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user