Compare commits

...
Author SHA1 Message Date
Philipinho 90e3437be7 Merge branch 'main' into feat/oauth 2026-08-26 02:29:34 +01:00
Philip Okugbe b29e130f26 fix: enhance lightbox (#2429)
* fix: close on click outside; smaller icon size

* fix: make ALT more visible
2026-08-26 02:24:02 +01:00
Philip Okugbe 56474e5b70 fix: remove client compression script (#2428) 2026-08-25 17:31:49 +01:00
Philipinho 83d7ee4460 chore: pnpm minor version 2026-08-25 04:34:11 +01:00
Philipinho fc90fbd8c0 chore: update pnpm 2026-08-25 04:31:23 +01:00
Philip Okugbe a0ec93506c chore: compress and lazy load client code (#2425) 2026-08-25 03:29:56 +01:00
Philipinho a415b86193 fix: clean up unaccent usage in index 2026-08-25 03:28:59 +01:00
Philipinho 702f8b7a41 fix: local editor cache 2026-08-25 02:54:38 +01:00
Salihu 95cef61927 fix: rework filtering behaviour (#2413)
* rework filtering behaviour

* fix classification bug
2026-08-25 02:52:24 +01:00
SalihuandPhilipinho 7bd73f77a4 feat: lightbox media display (#2420)
* lightbox media display init

* revert uninteded changes

* load page media more accurately

* restrict lock file changes to installed packages

* revert changes in lock file

* update lockfile

* - support diagrams
- support readonly mode
- use softer backdrop
- other enhancments

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2026-08-25 02:43:19 +01:00
Salihu 3a25336d3d fix: display subpages correctly on first load (#2423) 2026-08-25 01:39:55 +01:00
SalihuandPhilipinho 917195b2f2 feat: enhanced search filters (#2398)
* advanced search filters

* improve labelId filtering in query

* index label ids on typesense

* index label ids on typesense

* filters on AI search

* refactor: detect typesense schema drift from the declared collection schema

* feat: show secondary search filters only when applied

Created by and Labels now stay hidden until they hold a value; a Filter
button at the right end of the bar lists the hidden ones and opens the
picked filter's dropdown immediately.

* fix: keep applied search filters in the order they were added

* feat: add title-only search mode

Adds a titleOnly flag to page search: the Postgres driver matches
titles via trigram-indexed unaccented LIKE ranked by word_similarity,
and a Match filter in the search spotlight switches between everything
and title-only matching.

* refactor: make title-only search a toggle chip

* fix: drop stale results when the search content type changes

* feat: add trigram index on attachment file names

* feat: extend title-only search to attachments

The titleOnly flag now matches attachment file names via the
separator-normalized trigram expression, and the toggle chip stays
available in attachment mode as File name only.

* refactor: keep the title-only label uniform across search modes

* feat: index txt attachments for content search

* feat: pin the current user at the top of the creator filter

* refactor: dim the you suffix in the creator filter

* feat: browse label-filtered pages without a query

Selecting labels now lists their pages newest-first before any text is
typed, on both the Postgres and Typesense drivers.

* fix: match any selected label instead of requiring all

* fix: use a gray spotlight selection instead of primary blue

* feat: include search results in the spotlight tab order

* feat: show last updated time on search results

* fix: stop empty-state flicker while the search query debounces

* feat: browse creator-filtered results without a query

Selecting a creator with an empty query now lists their pages (or
attachments in attachment mode) newest-first, with the same space and
page permission filtering as typed search.

* fix: show Untitled for pages without a title in search results

* quick page sharing fix

* minor search filter fixes

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2026-08-25 01:39:33 +01:00
Philip Okugbe cd597f0161 chore: remove unused npm modules in Docke build (#2422) 2026-08-24 20:32:50 +01:00
Philip Okugbe b7f0b063dc feat(ee): add enterprise ai chat controls (#2421)
* feat(ee): add enterprise ai chat controls

* feat: add comment listing tool to ai chat

* fix: scope comment endpoints to workspace and skip trashed pages
2026-08-24 20:29:21 +01:00
Philipinho ca8e492d30 feat: mcp oauth 2026-08-24 02:15:38 +01:00
Salihu cd9c166927 Merge pull request #2417 from docmost/fix/checkbox-filtering
fix(ee): checkbox filtering
2026-08-23 21:21:02 +01:00
Philip Okugbe 549cf7c005 fix: skip pgvector table check when enabling AI search on turbopuffer (#2416) 2026-08-22 13:20:35 +01:00
Philip Okugbe e14f499f3d fix: pass tls to redis in throttle module (#2415) 2026-08-22 03:46:00 +01:00
Salihu b814bd0f12 fix: checkbox filtering 2026-08-21 19:29:10 +01:00
Salihu b86abd3d40 Revert "fix: checkbox filtering"
This reverts commit 3b858746e3.
2026-08-21 19:23:48 +01:00
Salihu 3b858746e3 fix: checkbox filtering 2026-08-21 19:21:14 +01:00
Philipinho 66b424a3b8 fix: preserve hash in vimeo embed url 2026-08-20 22:25:45 +01:00
Philipinho ab43031375 fix race issue 2026-08-19 21:46:01 +01:00
Michael LohrandPhilipinho 8c2c49ea6d fix: handle empty password, missing port, and TLS in Redis URL parsing (#2021)
* fix: handle empty password, missing port, and TLS in Redis URL parsing
---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2026-08-19 21:05:44 +01:00
Philip Okugbe 8913d20aa0 chore: package updates (#2412) 2026-08-19 19:59:00 +01:00
Philip Okugbe 232beda471 feat(ee): turbopuffer ai vector search driver (#2402)
* feat(ai): add AI_VECTOR_DRIVER and turbopuffer configuration

* feat(ai): carry workspace and target space ids on vector lifecycle events

* feat(ai): add vector driver interface and turbopuffer request helpers

* feat(ai): add pgvector driver behind the vector driver interface

* refactor(ai): route vector reads and writes through the vector driver

* feat(ai): add turbopuffer vector driver

* feat(ai): rebuild turbopuffer namespaces when the embedding model changes

* feat(ai): warm the vector namespace cache on session start

* fix(ai): harden turbopuffer misconfiguration and reset failure paths

* fix(ai): collapse blank-line runs in extracted page text

* fix(ai): skip full re-embed when ai search is re-enabled within the delete grace window

* sync

* fix(ai): store real embedding dimensions instead of serialized vector length

* fix(ai): filter search hits by the page's current space at query time

* fix(ai): retry the page moved-to-space vector patch job

* sync

* feat(ai): pre-warm the vector namespace

* fix(ai): pass AI_VECTOR_DRIVER through the client build config
2026-08-17 21:18:16 +01:00
Philip Okugbe 911c1057d6 feat(server): add global encryption module (AES-256-GCM) (#2400)
Provides an injectable EncryptionService that encrypts/decrypts strings
with AES-256-GCM using a key derived from APP_SECRET with domain
separation.
2026-08-16 12:35:37 +01:00
Philipinho d136864ef1 sync 2026-08-16 11:46:50 +01:00
Philip Okugbe c093c18bf3 fix(client): keep space switcher dropdown inside its popover (#2392) 2026-08-14 02:08:28 +01:00
Philip Okugbe ea59912c7e feat(editor): auto-detect text direction for RTL support (#2389) 2026-08-12 23:48:03 +01:00
Philipinho db3ff54da1 fix base node 2026-08-12 16:47:32 +01:00
Philip Okugbe 9414a38215 feat: page attachments endpoint and modal (#2386)
* feat: page attachments endpoint

* feat: attachments modal
2026-08-12 14:04:33 +01:00
Philipinho 089286f6cf fix: delete pnpm cache 2026-08-12 12:33:34 +01:00
Philip Okugbe 737cd67965 feat: compare two page versions (#2385) 2026-08-12 12:31:02 +01:00
Philip Okugbe a0b2ac6ae3 feat: footnotes (#2384)
* feat: footnotes

* feat: proper DOCX support
2026-08-12 12:30:30 +01:00
Philip Okugbe 7439da2f6e chore: package updates (#2383)
* update

* update versions

* package updates
2026-08-10 17:56:12 +01:00
Philip Okugbe 305fd40686 fix: browser tab showing url instead of page title (#2382) 2026-08-10 17:38:03 +01:00
Philipinho 4bd51d7404 fix: self-reference parent page 2026-08-10 17:37:12 +01:00
Philip Okugbe 89378ee766 chore: package updates (#2378)
* chore: package updates

* remove stale overrides
2026-08-06 23:41:36 +01:00
Philip Okugbe 56ac42767a fix: stale placeholder decorations on empty paragraphs (#2374) 2026-08-02 21:07:50 +01:00
214 changed files with 8186 additions and 2341 deletions
+11 -2
View File
@@ -1,7 +1,7 @@
FROM node:26-slim AS base
LABEL org.opencontainers.image.source="https://github.com/docmost/docmost"
RUN npm install -g pnpm@11.15.1
RUN npm install -g pnpm@11.23.0
FROM base AS builder
@@ -18,6 +18,15 @@ 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
@@ -42,7 +51,7 @@ RUN chown -R node:node /app
USER node
RUN pnpm install --frozen-lockfile --prod
RUN pnpm install --frozen-lockfile --prod && rm -rf /home/node/.cache/pnpm
RUN mkdir -p /app/data/storage
+5 -4
View File
@@ -50,9 +50,9 @@
"katex": "0.16.40",
"lowlight": "3.3.0",
"mantine-form-zod-resolver": "1.3.0",
"mermaid": "11.15.0",
"mermaid": "11.16.1",
"mitt": "3.0.1",
"nanoid": "3.3.8",
"nanoid": "3.3.18",
"posthog-js": "1.391.2",
"react": "19.2.7",
"react-clear-modal": "^2.0.18",
@@ -61,9 +61,10 @@
"react-error-boundary": "6.1.1",
"react-helmet-async": "3.0.0",
"react-i18next": "16.5.8",
"react-router-dom": "7.18.0",
"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": {
@@ -86,7 +87,7 @@
"globals": "15.13.0",
"jsdom": "25.0.0",
"optics-ts": "2.4.1",
"postcss": "8.5.14",
"postcss": "8.5.25",
"postcss-preset-mantine": "1.18.0",
"postcss-simple-vars": "7.0.1",
"prettier": "3.8.1",
@@ -696,7 +696,7 @@
"Upgrade your plan": "Upgrade Ihres Plans",
"Available with a paid license": "Verfügbar mit einer kostenpflichtigen Lizenz",
"Upgrade your license tier.": "Stufen Sie Ihre Lizenz hoch.",
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "KI ist nur in der Docmost Enterprise-Edition verfügbar. Kontaktieren Sie sales@docmost.com.",
"AI is available in the Docmost paid editions. Contact sales@docmost.com.": "KI ist in den kostenpflichtigen Docmost-Editionen verfügbar. Kontaktieren Sie sales@docmost.com.",
"AI & MCP": "KI & MCP",
"AI": "KI",
"MCP": "MCP",
@@ -294,6 +294,7 @@
"Export space": "Export space",
"Export {{type}}": "Export {{type}}",
"File exceeds the {{limit}} attachment limit": "File exceeds the {{limit}} attachment limit",
"Media": "Media",
"Align left": "Align left",
"Align right": "Align right",
"Align center": "Align center",
@@ -387,6 +388,8 @@
"Insert horizontal rule divider": "Insert horizontal rule divider",
"Page break": "Page break",
"Insert a page break for printing.": "Insert a page break for printing.",
"Footnote": "Footnote",
"Insert a footnote reference.": "Insert a footnote reference.",
"Upload any image from your device.": "Upload any image from your device.",
"Upload any video from your device.": "Upload any video from your device.",
"Upload any audio from your device.": "Upload any audio from your device.",
@@ -696,7 +699,7 @@
"Upgrade your plan": "Upgrade your plan",
"Available with a paid license": "Available with a paid license",
"Upgrade your license tier.": "Upgrade your license tier.",
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
"AI is available in the Docmost paid editions. Contact sales@docmost.com.": "AI is available in the Docmost paid editions. Contact sales@docmost.com.",
"AI & MCP": "AI & MCP",
"AI": "AI",
"MCP": "MCP",
@@ -704,9 +707,8 @@
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.",
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
"MCP Server URL": "MCP Server URL",
"Use your API key for authentication. You can manage API keys in your account settings.": "Use your API key for authentication. You can manage API keys in your account settings.",
"Connect with your Docmost account via OAuth when your client supports it, or use an API key from your account settings.": "Connect with your Docmost account via OAuth when your client supports it, or use an API key from your account settings.",
"Supported tools": "Supported tools",
"Your workspace has MCP enabled. Use your API key to connect AI assistants.": "Your workspace has MCP enabled. Use your API key to connect AI assistants.",
"MCP server URL:": "MCP server URL:",
"Learn more": "Learn more",
"Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.",
@@ -1289,5 +1291,55 @@
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
"{{count}} selected_other": "{{count}} selected",
"Compare": "Compare",
"Compare versions": "Compare versions",
"Select version from {{date}}": "Select version from {{date}}",
"Version actions for {{date}}": "Version actions for {{date}}",
"Comparing {{newer}} and {{older}}": "Comparing {{newer}} and {{older}}",
"Exit compare": "Exit compare",
"Search attachments...": "Search attachments...",
"Error loading attachments.": "Error loading attachments.",
"No attachments on this page yet.": "No attachments on this page yet.",
"Uploaded by {{name}}": "Uploaded by {{name}}",
"Download {{name}}": "Download {{name}}",
"Access revoked": "Access revoked",
"Authorize application": "Authorize application",
"{{name}} wants to access {{workspace}}": "{{name}} wants to access {{workspace}}",
"Not you? Switch account": "Not you? Switch account",
"This application will be able to:": "This application will be able to:",
"Write": "Write",
"Invalid authorization request": "Invalid authorization request",
"Authorize": "Authorize",
"Application": "Application",
"Permissions": "Permissions",
"Authorized": "Authorized",
"Revoke access": "Revoke access",
"Revoke access for {{name}}": "Revoke access for {{name}}",
"Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.": "Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.",
"Something went wrong. Please try again.": "Something went wrong. Please try again.",
"Trusted applications": "Trusted applications",
"Remove {{name}}": "Remove {{name}}",
"This origin is already trusted.": "This origin is already trusted.",
"Trusted application name": "Trusted application name",
"Trusted application origin": "Trusted application origin",
"I recognize this application and want to continue": "I recognize this application and want to continue",
"You will be redirected to": "You will be redirected to",
"View content without making changes.": "View content without making changes.",
"Create and modify content.": "Create and modify content.",
"This application is not on your workspace's trusted list. Authorize only if you recognize it.": "This application is not on your workspace's trusted list. Authorize only if you recognize it.",
"Applications with these callback origins are trusted. Members will not see a warning when authorizing them.": "Applications with these callback origins are trusted. Members will not see a warning when authorizing them.",
"Enter the app's callback origin, e.g. https://app.yourcompany.com": "Enter the app's callback origin, e.g. https://app.yourcompany.com",
"Applications and AI assistants you have authorized to access your account.": "Applications and AI assistants you have authorized to access your account.",
"Your workspace has MCP enabled. Connect AI assistants with your Docmost account, or with an API key.": "Your workspace has MCP enabled. Connect AI assistants with your Docmost account, or with an API key.",
"Authorized apps": "Authorized apps",
"No authorized apps yet.": "No authorized apps yet.",
"Workspace knowledge only": "Workspace knowledge only",
"Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.",
"Toggle workspace knowledge only": "Toggle workspace knowledge only",
"Read-only mode": "Read-only mode",
"AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat can search and read workspace content, but cannot create or edit pages.",
"Toggle AI Chat read-only mode": "Toggle AI Chat read-only mode",
"Title only": "Title only",
"you": "you"
}
@@ -696,7 +696,7 @@
"Upgrade your plan": "Mejora tu plan",
"Available with a paid license": "Disponible con una licencia de pago",
"Upgrade your license tier.": "Mejora el nivel de tu licencia.",
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "La IA solo está disponible en la edición empresarial de Docmost. Contacte con sales@docmost.com.",
"AI is available in the Docmost paid editions. Contact sales@docmost.com.": "La IA está disponible en las ediciones de pago de Docmost. Contacte con sales@docmost.com.",
"AI & MCP": "IA y MCP",
"AI": "IA",
"MCP": "MCP",
@@ -696,7 +696,7 @@
"Upgrade your plan": "Mettez à niveau votre forfait",
"Available with a paid license": "Disponible avec une licence payante",
"Upgrade your license tier.": "Mettez à niveau votre niveau de licence.",
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "L'IA n'est disponible que dans l'édition Entreprise de Docmost. Contactez sales@docmost.com.",
"AI is available in the Docmost paid editions. Contact sales@docmost.com.": "L'IA est disponible dans les éditions payantes de Docmost. Contactez sales@docmost.com.",
"AI & MCP": "IA & MCP",
"AI": "IA",
"MCP": "MCP",
@@ -696,7 +696,7 @@
"Upgrade your plan": "Aggiorna il tuo piano",
"Available with a paid license": "Disponibile con una licenza a pagamento",
"Upgrade your license tier.": "Aggiorna il livello della tua licenza.",
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "L'IA è disponibile solo nell'edizione Enterprise di Docmost. Contatta sales@docmost.com.",
"AI is available in the Docmost paid editions. Contact sales@docmost.com.": "L'IA è disponibile nelle edizioni a pagamento di Docmost. Contatta sales@docmost.com.",
"AI & MCP": "IA e MCP",
"AI": "IA",
"MCP": "MCP",
@@ -696,7 +696,7 @@
"Upgrade your plan": "プランをアップグレードする",
"Available with a paid license": "有料ライセンスで利用可能",
"Upgrade your license tier.": "ライセンスタイアをアップグレードしてください。",
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI は Docmost のエンタープライズ版でのみ利用可能です。sales@docmost.com までお問い合わせください。",
"AI is available in the Docmost paid editions. Contact sales@docmost.com.": "AI は Docmost の有料版で利用可能です。sales@docmost.com までお問い合わせください。",
"AI & MCP": "AI と MCP",
"AI": "AI",
"MCP": "MCP",
@@ -696,7 +696,7 @@
"Upgrade your plan": "요금제를 업그레이드하세요",
"Available with a paid license": "유료 라이선스에서만 사용 가능합니다",
"Upgrade your license tier.": "라이선스 등급을 업그레이드하세요.",
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI는 Docmost 엔터프라이즈 에디션에서 제공됩니다. sales@docmost.com으로 문의하세요.",
"AI is available in the Docmost paid editions. Contact sales@docmost.com.": "AI는 Docmost 유료 에디션에서 제공됩니다. sales@docmost.com으로 문의하세요.",
"AI & MCP": "AI 및 MCP",
"AI": "AI",
"MCP": "MCP",
@@ -696,7 +696,7 @@
"Upgrade your plan": "Upgrade je abonnement",
"Available with a paid license": "Beschikbaar met een betaalde licentie",
"Upgrade your license tier.": "Upgrade je licentieniveau.",
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI is alleen beschikbaar in de Docmost Enterprise-editie. Neem contact op met sales@docmost.com.",
"AI is available in the Docmost paid editions. Contact sales@docmost.com.": "AI is beschikbaar in de betaalde edities van Docmost. Neem contact op met sales@docmost.com.",
"AI & MCP": "AI & MCP",
"AI": "AI",
"MCP": "MCP",
@@ -696,7 +696,7 @@
"Upgrade your plan": "Faça upgrade do seu plano",
"Available with a paid license": "Disponível com uma licença paga",
"Upgrade your license tier.": "Faça upgrade do seu nível de licença.",
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "A IA está disponível apenas na edição empresarial do Docmost. Contate sales@docmost.com.",
"AI is available in the Docmost paid editions. Contact sales@docmost.com.": "A IA está disponível nas edições pagas do Docmost. Contate sales@docmost.com.",
"AI & MCP": "IA e MCP",
"AI": "IA",
"MCP": "MCP",
@@ -696,7 +696,7 @@
"Upgrade your plan": "Обновите свой тарифный план",
"Available with a paid license": "Доступно с платной лицензией",
"Upgrade your license tier.": "Обновите уровень вашей лицензии.",
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "ИИ доступен только в корпоративной версии Docmost. Свяжитесь по адресу sales@docmost.com.",
"AI is available in the Docmost paid editions. Contact sales@docmost.com.": "ИИ доступен в платных версиях Docmost. Свяжитесь по адресу sales@docmost.com.",
"AI & MCP": "ИИ и MCP",
"AI": "ИИ",
"MCP": "MCP",
@@ -696,7 +696,7 @@
"Upgrade your plan": "Оновіть свій тарифний план",
"Available with a paid license": "Доступно за платною ліцензією",
"Upgrade your license tier.": "Оновіть рівень своєї ліцензії.",
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "ШІ доступний лише в корпоративній редакції Docmost. Зверніться до sales@docmost.com.",
"AI is available in the Docmost paid editions. Contact sales@docmost.com.": "ШІ доступний у платних редакціях Docmost. Зверніться до sales@docmost.com.",
"AI & MCP": "ШІ та MCP",
"AI": "ШІ",
"MCP": "MCP",
@@ -696,7 +696,7 @@
"Upgrade your plan": "升级您的方案",
"Available with a paid license": "需付费许可才可用",
"Upgrade your license tier.": "升级您的许可等级。",
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI 在 Docmost 企业版中提供。请联系 sales@docmost.com。",
"AI is available in the Docmost paid editions. Contact sales@docmost.com.": "AI 在 Docmost 付费版中提供。请联系 sales@docmost.com。",
"AI & MCP": "AI 与 MCP",
"AI": "AI",
"MCP": "MCP",
+84 -45
View File
@@ -1,60 +1,94 @@
import { lazy, Suspense, useEffect } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import SetupWorkspace from "@/pages/auth/setup-workspace.tsx";
import LoginPage from "@/pages/auth/login";
import Home from "@/pages/dashboard/home";
import Page from "@/pages/page/page";
import AccountSettings from "@/pages/settings/account/account-settings";
import WorkspaceMembers from "@/pages/settings/workspace/workspace-members";
import WorkspaceSettings from "@/pages/settings/workspace/workspace-settings";
import Groups from "@/pages/settings/group/groups";
import GroupInfo from "./pages/settings/group/group-info";
import Spaces from "@/pages/settings/space/spaces.tsx";
import { Error404 } from "@/components/ui/error-404.tsx";
import AccountPreferences from "@/pages/settings/account/account-preferences.tsx";
import SpaceHome from "@/pages/space/space-home.tsx";
import PageRedirect from "@/pages/page/page-redirect.tsx";
import Layout from "@/components/layouts/global/layout.tsx";
import InviteSignup from "@/pages/auth/invite-signup.tsx";
import ForgotPassword from "@/pages/auth/forgot-password.tsx";
import PasswordReset from "./pages/auth/password-reset";
import Billing from "@/ee/billing/pages/billing.tsx";
import CloudLogin from "@/ee/pages/cloud-login.tsx";
import CreateWorkspace from "@/ee/pages/create-workspace.tsx";
import { Error404 } from "@/components/ui/error-404.tsx";
import { isCloud } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
import Security from "@/ee/security/pages/security.tsx";
import License from "@/ee/licence/pages/license.tsx";
import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx";
import SharedPage from "@/pages/share/shared-page.tsx";
import PdfRenderPage from "@/ee/pdf-export/pdf-render-page.tsx";
import Shares from "@/pages/settings/shares/shares.tsx";
import ShareLayout from "@/features/share/components/share-layout.tsx";
import ShareRedirect from "@/pages/share/share-redirect.tsx";
import { useTrackOrigin } from "@/hooks/use-track-origin";
import SpacesPage from "@/pages/spaces/spaces.tsx";
import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page";
import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page";
import SpaceTrash from "@/pages/space/space-trash.tsx";
import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
import BasePage from "@/ee/base/pages/base-page.tsx";
import AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
import VerifiedPages from "@/ee/page-verification/pages/verified-pages.tsx";
import TemplateList from "@/ee/template/pages/template-list";
import TemplateEditor from "@/ee/template/pages/template-editor";
import FavoritesPage from "@/pages/favorites/favorites-page";
import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx";
import VerifyEmail from "@/ee/pages/verify-email.tsx";
import LabelPage from "@/pages/label/label-page";
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 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 />} />
@@ -63,6 +97,7 @@ export default function App() {
<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 />} />
@@ -116,6 +151,10 @@ export default function App() {
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 />} />
@@ -135,6 +174,6 @@ export default function App() {
<Route path="*" element={<Error404 />} />
</Routes>
</>
</Suspense>
);
}
@@ -1,17 +1,30 @@
import { ActionIcon, Box, Group, ScrollArea, Title, Tooltip } from "@mantine/core";
import { IconX } from "@tabler/icons-react";
import CommentListWithTabs from "@/features/comment/components/comment-list-with-tabs.tsx";
import { useAtom } from "jotai";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import React, { ReactNode, useEffect } from "react";
import React, { lazy, ReactNode, Suspense, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx";
import { useAtomValue } from "jotai";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import AsideChatPanel from "@/ee/ai-chat/components/aside-chat-panel";
import { PageDetailsAside } from "@/features/page-details/components/page-details-aside.tsx";
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();
@@ -68,17 +81,19 @@ export default function Aside() {
</Group>
)}
{tab === "comments" || tab === "chat" ? (
component
) : (
<ScrollArea
style={{ height: "85vh" }}
scrollbarSize={5}
type="scroll"
>
<div style={{ paddingBottom: "200px" }}>{component}</div>
</ScrollArea>
)}
<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>
@@ -11,7 +11,10 @@ import {
sidebarWidthAtom,
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
import AiChatSidebar from "@/ee/ai-chat/components/ai-chat-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";
@@ -126,7 +129,11 @@ export default function GlobalAppShell({
)}
{isSpaceRoute && <SpaceSidebar />}
{isSettingsRoute && <SettingsSidebar />}
{isAiRoute && <AiChatSidebar />}
{isAiRoute && (
<React.Suspense fallback={null}>
<AiChatSidebar />
</React.Suspense>
)}
{showGlobalSidebar && <GlobalSidebar />}
</AppShell.Navbar>
<AppShell.Main id={MAIN_CONTENT_ID} tabIndex={-1}>
@@ -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";
@@ -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");
});
});
@@ -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>
);
}
+2 -4
View File
@@ -1,17 +1,15 @@
import { Title, Text, Button, Container, Group } from "@mantine/core";
import classes from "./error-404.module.css";
import { Link } from "react-router-dom";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export function Error404() {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>{t("404 page not found")} - Docmost</title>
</Helmet>
<DocumentTitle title={t("404 page not found")} />
<Container className={classes.root}>
<Title className={classes.title}>{t("404 page not found")}</Title>
<Text c="dimmed" size="lg" ta="center" className={classes.description}>
@@ -0,0 +1,74 @@
import { Badge, Group, Text, Switch, Tooltip } from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
export default function AiChatReadOnly() {
const { t } = useTranslation();
const hasAccess = useHasFeature(Feature.AI_CONTROLS);
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Group gap="xs" align="center">
<Text size="md">{t("Read-only mode")}</Text>
{!hasAccess && (
<Badge variant="light" size="sm" radius="sm">
{t("Enterprise")}
</Badge>
)}
</Group>
<Text size="sm" c="dimmed">
{t(
"AI Chat can search and read workspace content, but cannot create or edit pages.",
)}
</Text>
</div>
<AiChatReadOnlyToggle />
</Group>
);
}
function AiChatReadOnlyToggle() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(
workspace?.settings?.ai?.chatReadOnly,
);
const hasAccess = useHasFeature(Feature.AI_CONTROLS);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
const updatedWorkspace = await updateWorkspace({
aiChatReadOnly: value,
});
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err: any) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
<Switch
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle AI Chat read-only mode")}
/>
</Tooltip>
);
}
@@ -0,0 +1,74 @@
import { Badge, Group, Text, Switch, Tooltip } from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
export default function AiChatWorkspaceKnowledgeOnly() {
const { t } = useTranslation();
const hasAccess = useHasFeature(Feature.AI_CONTROLS);
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Group gap="xs" align="center">
<Text size="md">{t("Workspace knowledge only")}</Text>
{!hasAccess && (
<Badge variant="light" size="sm" radius="sm">
{t("Enterprise")}
</Badge>
)}
</Group>
<Text size="sm" c="dimmed">
{t(
"Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.",
)}
</Text>
</div>
<AiChatWorkspaceKnowledgeOnlyToggle />
</Group>
);
}
function AiChatWorkspaceKnowledgeOnlyToggle() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(
workspace?.settings?.ai?.chatWorkspaceKnowledgeOnly,
);
const hasAccess = useHasFeature(Feature.AI_CONTROLS);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
const updatedWorkspace = await updateWorkspace({
aiChatWorkspaceKnowledgeOnly: value,
});
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err: any) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
<Switch
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle workspace knowledge only")}
/>
</Tooltip>
);
}
@@ -6,7 +6,10 @@ import {
IconFileText,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import ChatInput from "./chat-input";
import { useAtomValue } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useRef } from "react";
import ChatInput, { ChatInputHandle } from "./chat-input";
import type { ChatAttachment, PageMention } from "../types/ai-chat.types";
import classes from "../styles/ai-chat.module.css";
@@ -14,6 +17,7 @@ type Suggestion = {
icon: React.ReactNode;
text: string;
prompt: string;
write?: boolean;
};
const SUGGESTIONS: Suggestion[] = [
@@ -26,6 +30,7 @@ const SUGGESTIONS: Suggestion[] = [
icon: <IconFilePlus size={16} />,
text: "Create a new page",
prompt: "Create a new page titled ",
write: true,
},
{
icon: <IconFileText size={16} />,
@@ -36,6 +41,7 @@ const SUGGESTIONS: Suggestion[] = [
icon: <IconEdit size={16} />,
text: "Update page content",
prompt: "Update the page @",
write: true,
},
];
@@ -47,9 +53,13 @@ type Props = {
export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) {
const { t } = useTranslation();
const workspace = useAtomValue(workspaceAtom);
const writesDisabled = workspace?.settings?.ai?.chatReadOnly === true;
const inputRef = useRef<ChatInputHandle>(null);
const handleSuggestionClick = (prompt: string) => {
onSend(prompt, [], []);
inputRef.current?.prefill(prompt);
};
return (
@@ -62,6 +72,7 @@ export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) {
<div className={classes.emptyStateInput}>
<ChatInput
ref={inputRef}
isStreaming={isStreaming}
onSend={onSend}
onStop={onStop}
@@ -73,7 +84,7 @@ export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) {
<div className={classes.suggestionsSection}>
<h2 className={classes.suggestionsLabel}>{t("Get started")}</h2>
<div className={classes.suggestionsGrid}>
{SUGGESTIONS.map((s) => (
{SUGGESTIONS.filter((s) => !writesDisabled || !s.write).map((s) => (
<button
key={s.text}
type="button"
@@ -1,4 +1,12 @@
import { useCallback, useId, useRef, useEffect, useState } from "react";
import {
forwardRef,
useCallback,
useId,
useImperativeHandle,
useRef,
useEffect,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { IconArrowUp, IconPaperclip, IconPlayerStopFilled, IconX, IconFile, IconPhoto, IconPlus, IconAt, IconFileText } from "@tabler/icons-react";
import { Popover } from "@mantine/core";
@@ -35,6 +43,10 @@ type Props = {
chatId?: string;
};
export type ChatInputHandle = {
prefill: (text: string) => void;
};
function extractMentions(json: any): PageMention[] {
const mentions: PageMention[] = [];
const seen = new Set<string>();
@@ -89,7 +101,7 @@ function editorJsonToText(json: any): string {
return text;
}
export default function ChatInput({
const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({
isStreaming,
onSend,
onStop,
@@ -100,7 +112,7 @@ export default function ChatInput({
variant = "card",
showDisclaimer = true,
chatId,
}: Props) {
}: Props, ref) {
const chatIdRef = useRef(chatId);
chatIdRef.current = chatId;
const { t } = useTranslation();
@@ -255,6 +267,7 @@ export default function ChatInput({
},
content: "",
editable: true,
textDirection: "auto",
immediatelyRender: true,
shouldRerenderOnTransaction: false,
autofocus: autofocus ? "end" : false,
@@ -269,6 +282,19 @@ export default function ChatInput({
}
}, [editor]);
useImperativeHandle(
ref,
() => ({
prefill: (text: string) => {
if (!editor || editor.isDestroyed) return;
editor.commands.clearContent();
editor.commands.insertContent(text);
editor.commands.focus();
},
}),
[editor],
);
const hasContent = !isEmpty || pendingAttachments.some((a) => !a.uploading) || (contextPages?.length ?? 0) > 0;
const wrapperClass = variant === "flat" ? classes.inputWrapperFlat : classes.inputWrapper;
@@ -428,4 +454,6 @@ export default function ChatInput({
)}
</>
);
}
});
export default ChatInput;
@@ -1,7 +1,9 @@
import {
Anchor,
Button,
Group,
List,
Table,
Text,
Switch,
TextInput,
@@ -15,14 +17,33 @@ import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { ITrustedOAuthClient } from "@/features/workspace/types/workspace.types.ts";
import { notifications } from "@mantine/notifications";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { getAppUrl } from "@/lib/config.ts";
import { IconCheck, IconCopy, IconInfoCircle } from "@tabler/icons-react";
import {
IconCheck,
IconCopy,
IconInfoCircle,
IconTrash,
} from "@tabler/icons-react";
import { CopyButton } from "@/components/common/copy-button.tsx";
// Mirrors the server rule: an exact https origin, tolerating only a trailing slash.
function parseTrustedOrigin(value: string): string | null {
const input = value.trim().toLowerCase();
try {
const url = new URL(input);
if (url.protocol !== "https:") return null;
if (input !== url.origin && input !== `${url.origin}/`) return null;
return url.origin;
} catch {
return null;
}
}
export default function McpSettings() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
@@ -30,7 +51,14 @@ export default function McpSettings() {
const hasAccess = useHasFeature(Feature.MCP);
const upgradeLabel = useUpgradeLabel();
const [newClientName, setNewClientName] = useState("");
const [newClientOrigin, setNewClientOrigin] = useState("");
const mcpUrl = `${getAppUrl()}/mcp`;
const storedTrustedClients = workspace?.trustedOauthClients;
const trustedClients = Array.isArray(storedTrustedClients)
? storedTrustedClients
: [];
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
@@ -46,6 +74,51 @@ export default function McpSettings() {
}
};
const saveTrustedClients = async (next: ITrustedOAuthClient[]) => {
try {
const updatedWorkspace = await updateWorkspace({
trustedOauthClients: next,
});
setWorkspace(updatedWorkspace);
return true;
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
return false;
}
};
const handleAddTrustedClient = async () => {
const name = newClientName.trim();
const origin = parseTrustedOrigin(newClientOrigin);
if (!origin) {
notifications.show({
message: t("Enter the app's callback origin, e.g. https://app.yourcompany.com"),
color: "red",
});
return;
}
if (trustedClients.some((client) => client.origin.toLowerCase() === origin)) {
notifications.show({
message: t("This origin is already trusted."),
color: "red",
});
return;
}
if (await saveTrustedClients([...trustedClients, { origin, name }])) {
setNewClientName("");
setNewClientOrigin("");
}
};
const handleRemoveTrustedClient = (origin: string) => {
void saveTrustedClients(
trustedClients.filter((client) => client.origin !== origin),
);
};
return (
<Stack gap="lg">
{!hasAccess && (
@@ -108,7 +181,7 @@ export default function McpSettings() {
</Group>
<Text size="sm" c="dimmed" mt="xs">
{t(
"Use your API key for authentication. You can manage API keys in your account settings.",
"Connect with your Docmost account via OAuth when your client supports it, or use an API key from your account settings.",
)}
</Text>
@@ -149,6 +222,81 @@ export default function McpSettings() {
</List.Item>
</List>
</div>
<div>
<Text size="sm" fw={500} mt="md" mb={4}>
{t("Trusted applications")}
</Text>
<Text size="sm" c="dimmed" mb="xs">
{t(
"Applications with these callback origins are trusted. Members will not see a warning when authorizing them.",
)}
</Text>
{trustedClients.length > 0 && (
<Table verticalSpacing="xs" mb="xs">
<Table.Tbody>
{trustedClients.map((client) => (
<Table.Tr key={client.origin}>
<Table.Td>
<Text size="sm" fw={500}>
{client.name}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{client.origin}
</Text>
</Table.Td>
<Table.Td w={40}>
<ActionIcon
variant="subtle"
color="red"
aria-label={t("Remove {{name}}", {
name: client.name,
})}
onClick={() =>
handleRemoveTrustedClient(client.origin)
}
>
<IconTrash size={16} />
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
<Group gap="xs">
<TextInput
value={newClientName}
onChange={(event) =>
setNewClientName(event.currentTarget.value)
}
placeholder={t("Name")}
aria-label={t("Trusted application name")}
maxLength={64}
style={{ flex: 1 }}
/>
<TextInput
value={newClientOrigin}
onChange={(event) =>
setNewClientOrigin(event.currentTarget.value)
}
placeholder="https://app.yourcompany.com"
aria-label={t("Trusted application origin")}
style={{ flex: 2 }}
/>
<Button
variant="default"
onClick={handleAddTrustedClient}
disabled={!newClientName.trim() || !newClientOrigin.trim()}
>
{t("Add")}
</Button>
</Group>
</div>
</div>
)}
</Stack>
+24 -7
View File
@@ -1,5 +1,3 @@
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import React from "react";
import useUserRole from "@/hooks/use-user-role.tsx";
@@ -7,20 +5,27 @@ import { useTranslation } from "react-i18next";
import EnableAiSearch from "@/ee/ai/components/enable-ai-search.tsx";
import EnableGenerativeAi from "@/ee/ai/components/enable-generative-ai.tsx";
import EnableAiChat from "@/ee/ai-chat/components/enable-ai-chat.tsx";
import AiChatReadOnly from "@/ee/ai-chat/components/ai-chat-read-only.tsx";
import AiChatWorkspaceKnowledgeOnly from "@/ee/ai-chat/components/ai-chat-workspace-knowledge-only.tsx";
import McpSettings from "@/ee/ai/components/mcp-settings.tsx";
import { Alert, Stack, Tabs } from "@mantine/core";
import { Alert, Collapse, Stack, Tabs } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { isCloud } from "@/lib/config.ts";
import { useLocation, useNavigate } from "react-router-dom";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
import { useAtomValue } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
export default function AiSettings() {
const { t } = useTranslation();
const { isAdmin } = useUserRole();
const hasAccess = useHasFeature(Feature.AI);
const upgradeLabel = useUpgradeLabel();
const workspace = useAtomValue(workspaceAtom);
const aiChatEnabled = workspace?.settings?.ai?.chat === true;
const location = useLocation();
const navigate = useNavigate();
@@ -40,9 +45,7 @@ export default function AiSettings() {
return (
<>
<Helmet>
<title>AI settings - {getAppName()}</title>
</Helmet>
<DocumentTitle title="AI settings" />
<SettingsTitle title={t("AI settings")} />
<Tabs color="dark" value={activeTab} onChange={handleTabChange}>
@@ -64,7 +67,7 @@ export default function AiSettings() {
mb="lg"
>
{t(
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
"AI is available in the Docmost paid editions. Contact sales@docmost.com.",
)}
</Alert>
)}
@@ -73,6 +76,20 @@ export default function AiSettings() {
{!isCloud() && <EnableAiSearch />}
<EnableGenerativeAi />
<EnableAiChat />
<Collapse expanded={aiChatEnabled}>
<Stack
gap="md"
pl="md"
ml="xs"
style={{
borderLeft:
"2px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))",
}}
>
<AiChatReadOnly />
<AiChatWorkspaceKnowledgeOnly />
</Stack>
</Collapse>
</Stack>
</Tabs.Panel>
@@ -15,6 +15,14 @@ export interface IAiSearchResponse {
}>;
}
export async function hintVectorCache(): Promise<void> {
try {
await api.post("/ai/vector-cache-hint");
} catch {
// best-effort cache hint
}
}
export async function aiAnswers(
params: IPageSearchParams,
onChunk?: (chunk: { content?: string; sources?: any[] }) => void,
@@ -1,10 +1,9 @@
import React, { useState } from "react";
import { Anchor, Alert, Button, Group, Space, Text } from "@mantine/core";
import { Anchor, Alert, Button, Group, Space, Tabs, Text } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { Helmet } from "react-helmet-async";
import { Trans, useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title";
import { getAppName, getAppUrl } from "@/lib/config";
import { getAppUrl } from "@/lib/config";
import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
import { CreateApiKeyModal } from "@/ee/api-key/components/create-api-key-modal";
import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-modal";
@@ -17,6 +16,9 @@ import { IApiKey } from "@/ee/api-key";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
import { useLocation, useNavigate } from "react-router-dom";
import { AuthorizedAppsPanel } from "@/ee/oauth/components/authorized-apps-panel.tsx";
export default function UserApiKeys() {
const { t } = useTranslation();
@@ -29,6 +31,11 @@ export default function UserApiKeys() {
const { data, isLoading } = useGetApiKeysQuery({ cursor });
const [workspace] = useAtom(workspaceAtom);
const { isAdmin } = useUserRole();
const location = useLocation();
const navigate = useNavigate();
const activeTab = location.pathname.endsWith("/authorized-apps")
? "authorized-apps"
: "api-keys";
const mcpEnabled = workspace?.settings?.ai?.mcp === true;
const restrictToAdmins = workspace?.settings?.api?.restrictToAdmins === true;
const canCreate = !restrictToAdmins || isAdmin;
@@ -47,30 +54,27 @@ export default function UserApiKeys() {
setRevokeModalOpened(true);
};
const handleTabChange = (value: string | null) => {
navigate(
value === "authorized-apps"
? "/settings/account/api-keys/authorized-apps"
: "/settings/account/api-keys",
);
};
return (
<>
<Helmet>
<title>
{t("API keys")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle
title={activeTab === "authorized-apps" ? t("Authorized apps") : t("API keys")}
/>
<SettingsTitle title={t("API keys")} />
<Text size="sm" c="dimmed" mb="md">
<Trans
i18nKey="View the <anchor>API documentation</anchor> for usage details."
components={{
anchor: <Anchor href="https://docmost.com/api-docs" target="_blank" size="sm" />,
}}
/>
</Text>
{mcpEnabled && canCreate && (
{mcpEnabled && (
<Alert variant="light" color="blue" mb="md" p="sm" icon={<IconInfoCircle />}>
<Text size="sm">
{t(
"Your workspace has MCP enabled. Use your API key to connect AI assistants.",
"Your workspace has MCP enabled. Connect AI assistants with your Docmost account, or with an API key.",
)}{" "}
<Anchor
href="https://docmost.com/docs/user-guide/mcp"
@@ -89,37 +93,70 @@ export default function UserApiKeys() {
</Alert>
)}
{canCreate ? (
<Group justify="flex-end" mb="md">
<Button onClick={() => setCreateModalOpened(true)}>
{t("Create API Key")}
</Button>
</Group>
) : restrictToAdmins ? (
<Alert variant="light" color="yellow" mb="md" p="sm" icon={<IconInfoCircle />}>
<Text size="sm">
{t("API key creation is restricted to admins by your workspace administrator.")}
<Tabs color="dark" value={activeTab} onChange={handleTabChange}>
<Tabs.List>
<Tabs.Tab fw={500} value="api-keys">
{t("API keys")}
</Tabs.Tab>
<Tabs.Tab fw={500} value="authorized-apps">
{t("Authorized apps")}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="api-keys" pt="md">
<Group justify="space-between" align="center" mb="md">
<Text size="sm" c="dimmed">
<Trans
i18nKey="View the <anchor>API documentation</anchor> for usage details."
components={{
anchor: <Anchor href="https://docmost.com/api-docs" target="_blank" size="sm" />,
}}
/>
</Text>
</Alert>
) : null}
<ApiKeyTable
apiKeys={data?.items || []}
isLoading={isLoading}
onUpdate={handleUpdate}
onRevoke={handleRevoke}
/>
{canCreate && (
<Button
onClick={() => setCreateModalOpened(true)}
style={{ flexShrink: 0 }}
>
{t("Create API Key")}
</Button>
)}
</Group>
<Space h="md" />
{!canCreate && restrictToAdmins && (
<Alert variant="light" color="yellow" mb="md" p="sm" icon={<IconInfoCircle />}>
<Text size="sm">
{t("API key creation is restricted to admins by your workspace administrator.")}
</Text>
</Alert>
)}
{data?.items.length > 0 && (
<Paginate
hasPrevPage={data?.meta?.hasPrevPage}
hasNextPage={data?.meta?.hasNextPage}
onNext={() => goNext(data?.meta?.nextCursor)}
onPrev={goPrev}
<ApiKeyTable
apiKeys={data?.items || []}
isLoading={isLoading}
onUpdate={handleUpdate}
onRevoke={handleRevoke}
/>
)}
<Space h="md" />
{data?.items.length > 0 && (
<Paginate
hasPrevPage={data?.meta?.hasPrevPage}
hasNextPage={data?.meta?.hasNextPage}
onNext={() => goNext(data?.meta?.nextCursor)}
onPrev={goPrev}
/>
)}
</Tabs.Panel>
<Tabs.Panel value="authorized-apps" pt="md">
<AuthorizedAppsPanel />
</Tabs.Panel>
</Tabs>
<CreateApiKeyModal
opened={createModalOpened}
@@ -1,9 +1,7 @@
import React, { useState } from "react";
import { Anchor, Button, Divider, Group, Space, Text } from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { Trans, useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title";
import { getAppName } from "@/lib/config";
import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
import { CreateApiKeyModal } from "@/ee/api-key/components/create-api-key-modal";
import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-modal";
@@ -15,6 +13,7 @@ import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
import { IApiKey } from "@/ee/api-key";
import useUserRole from '@/hooks/use-user-role.tsx';
import RestrictApiToAdmins from "@/ee/api-key/components/restrict-api-to-admins";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function WorkspaceApiKeys() {
const { t } = useTranslation();
@@ -47,11 +46,7 @@ export default function WorkspaceApiKeys() {
return (
<>
<Helmet>
<title>
{t("API management")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("API management")} />
<SettingsTitle title={t("API management")} />
@@ -10,11 +10,9 @@ import {
Text,
Tooltip,
} from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { IconSettings } from "@tabler/icons-react";
import SettingsTitle from "@/components/settings/settings-title";
import { getAppName } from "@/lib/config";
import Paginate from "@/components/common/paginate";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import {
@@ -26,6 +24,7 @@ import { IAuditLogParams } from "@/ee/audit/types/audit.types";
import { eventFilterOptions } from "@/ee/audit/lib/audit-event-labels";
import AuditLogsTable from "@/ee/audit/components/audit-logs-table";
import useUserRole from "@/hooks/use-user-role";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
type RetentionUnit = "days" | "months" | "years";
@@ -97,11 +96,7 @@ export default function AuditLogs() {
return (
<>
<Helmet>
<title>
{t("Audit log")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Audit log")} />
<SettingsTitle title={t("Audit log")} />
@@ -9,6 +9,7 @@ import {
Text,
UnstyledButton,
Button,
MultiSelect,
} from "@mantine/core";
import { IconPlus, IconTrash } from "@tabler/icons-react";
import {
@@ -52,6 +53,9 @@ const NO_VALUE_OPERATORS: FilterOperator[] = ["isEmpty", "isNotEmpty"];
// stored value so a stale shape isn't sent to the engine.
function valueClass(op: FilterOperator, inputKind: string): string {
if (NO_VALUE_OPERATORS.includes(op)) return "none";
if (inputKind === "choices") {
return op === "any" || op === "none" ? "choicesMulti" : "choicesSingle";
}
if (inputKind === "person") {
return op === "any" || op === "none" ? "personMulti" : "personSingle";
}
@@ -70,6 +74,10 @@ function getOperatorsForType(type: string): FilterOperator[] {
DEFAULT_FILTER_OPERATORS) as FilterOperator[];
}
function isMultiChoice(op: FilterCondition["op"]): boolean {
return op === "any" || op === "none";
}
function FilterValueInput({
condition,
property,
@@ -121,6 +129,32 @@ function FilterValueInput({
const typeOptions = property.typeOptions as SelectTypeOptions | undefined;
const choices = typeOptions?.choices ?? [];
const choiceOptions = choices.map((c) => ({ value: c.id, label: c.name }));
if (isMultiChoice(condition.op)) {
const { value } = condition;
const selected = (
Array.isArray(value) ? value : value ? [value] : []
).filter((id) => choices.some((c) => c.id === id));
return (
<MultiSelect
size="xs"
data={choiceOptions}
comboboxProps={{ withinPortal: false }}
value={selected}
onChange={(values) => onChange(values)}
w={160}
styles={{
pillsList: {
maxHeight: 70,
overflowY: "auto",
},
}}
maxDropdownHeight={220}
/>
);
}
return (
<Select
size="xs"
@@ -199,11 +233,18 @@ export function ViewFilterConfigPopover({
label: p.name,
}));
const [unSaved, setUnSaved] = useState(false)
const [draft, setDraft] = useState<FilterCondition | null>(null);
const [draftConditions, setDraftConditions] =
useState<FilterCondition[]>(conditions);
useEffect(() => {
if (!opened) setDraft(null);
}, [opened]);
if (opened) {
setDraftConditions(conditions);
setDraft(null);
setUnSaved(false)
}
}, [opened, conditions]);
const handleStartDraft = useCallback(() => {
const firstProperty = properties[0];
@@ -216,14 +257,21 @@ export function ViewFilterConfigPopover({
}, [properties]);
const handleSaveDraft = useCallback(() => {
if (!draft) return;
onChange([...conditions, draft]);
const nextConditions = draft
? [...draftConditions, draft]
: draftConditions;
onChange(nextConditions);
setDraft(null);
}, [draft, conditions, onChange]);
setUnSaved(false)
}, [draft, draftConditions, onChange]);
const handleCancelDraft = useCallback(() => {
setDraftConditions(conditions)
setDraft(null);
}, []);
setUnSaved(false)
}, [conditions]);
const handleDraftPropertyChange = useCallback(
(propertyId: string | null) => {
@@ -272,17 +320,19 @@ export function ViewFilterConfigPopover({
const handleRemove = useCallback(
(index: number) => {
onChange(conditions.filter((_, i) => i !== index));
setUnSaved(true);
setDraftConditions((current) => current.filter((_, i) => i !== index));
},
[conditions, onChange],
[],
);
const handlePropertyChange = useCallback(
(index: number, propertyId: string | null) => {
if (!propertyId) return;
const newProperty = properties.find((p) => p.id === propertyId);
onChange(
conditions.map((f, i) => {
setUnSaved(true)
setDraftConditions((current) =>
current.map((f, i) => {
if (i !== index) return f;
if (newProperty) {
const validOperators = getOperatorsForType(newProperty.type);
@@ -302,15 +352,16 @@ export function ViewFilterConfigPopover({
}),
);
},
[conditions, properties, onChange],
[properties],
);
const handleOperatorChange = useCallback(
(index: number, operator: string | null) => {
if (!operator) return;
const op = operator as FilterOperator;
onChange(
conditions.map((f, i) => {
setUnSaved(true)
setDraftConditions((current) =>
current.map((f, i) => {
if (i !== index) return f;
const kind = inputKindForProperty(
properties.find((p) => p.id === f.propertyId),
@@ -320,16 +371,17 @@ export function ViewFilterConfigPopover({
}),
);
},
[conditions, properties, onChange],
[properties],
);
const handleValueChange = useCallback(
(index: number, value: unknown) => {
onChange(
conditions.map((f, i) => (i === index ? { ...f, value } : f)),
setUnSaved(true)
setDraftConditions((current) =>
current.map((f, i) => (i === index ? { ...f, value } : f)),
);
},
[conditions, onChange],
[],
);
return (
@@ -362,13 +414,13 @@ export function ViewFilterConfigPopover({
{t("Filter by")}
</Text>
{conditions.length === 0 && !draft && (
{draftConditions.length === 0 && !draft && (
<Text size="xs" c="dimmed">
{t("No filters applied")}
</Text>
)}
{conditions.map((condition, index) => {
{draftConditions.map((condition, index) => {
const needsValue = !NO_VALUE_OPERATORS.includes(condition.op);
const property = properties.find(
(p) => p.id === condition.propertyId,
@@ -471,14 +523,6 @@ export function ViewFilterConfigPopover({
/>
)}
</Group>
<Group justify="flex-end" gap="xs">
<Button variant="default" size="xs" onClick={handleCancelDraft}>
{t("Cancel")}
</Button>
<Button size="xs" onClick={handleSaveDraft}>
{t("Save")}
</Button>
</Group>
</Stack>
);
})()}
@@ -492,6 +536,20 @@ export function ViewFilterConfigPopover({
{t("Add filter")}
</UnstyledButton>
)}
<Group justify="flex-end" gap="xs">
<Button
variant="default"
size="xs"
onClick={handleCancelDraft}
disabled={!draft && !unSaved}
>
{t("Cancel")}
</Button>
<Button size="xs" onClick={handleSaveDraft} disabled={!draft && !unSaved}>
{t("Save")}
</Button>
</Group>
</Stack>
</Popover.Dropdown>
</Popover>
+2 -5
View File
@@ -1,5 +1,3 @@
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import BillingPlans from "@/ee/billing/components/billing-plans.tsx";
import BillingTrial from "@/ee/billing/components/billing-trial.tsx";
@@ -9,6 +7,7 @@ import React from "react";
import BillingDetails from "@/ee/billing/components/billing-details.tsx";
import { useBillingQuery } from "@/ee/billing/queries/billing-query.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function Billing() {
const { data: billing, isError: isBillingError } = useBillingQuery();
@@ -20,9 +19,7 @@ export default function Billing() {
return (
<>
<Helmet>
<title>Billing - {getAppName()}</title>
</Helmet>
<DocumentTitle title="Billing" />
<SettingsTitle title="Billing" />
<BillingTrial />
+2
View File
@@ -22,4 +22,6 @@ export const Feature = {
PERSONAL_SPACES: 'spaces:personal',
DOCX_EXPORT: 'export:docx',
BASES: 'bases',
OAUTH: 'oauth',
AI_CONTROLS: 'ai:controls',
} as const;
+2 -5
View File
@@ -1,5 +1,3 @@
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import React from "react";
import useUserRole from "@/hooks/use-user-role.tsx";
@@ -9,6 +7,7 @@ import InstallationDetails from "@/ee/licence/components/installation-details.ts
import OssDetails from "@/ee/licence/components/oss-details.tsx";
import { useAtom } from "jotai/index";
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function License() {
const [entitlements] = useAtom(entitlementAtom);
@@ -21,9 +20,7 @@ export default function License() {
return (
<>
<Helmet>
<title>License - {getAppName()}</title>
</Helmet>
<DocumentTitle title="License" />
<SettingsTitle title="License" />
<ActivateLicenseForm />
@@ -0,0 +1,19 @@
import { Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { OAuthGrantsTable } from "@/ee/oauth/components/oauth-grants-table";
import { useOAuthGrantsQuery } from "@/ee/oauth/queries/oauth-query";
export function AuthorizedAppsPanel() {
const { t } = useTranslation();
const { data, isLoading } = useOAuthGrantsQuery();
return (
<>
<Text size="sm" c="dimmed" mb="md">
{t("Applications and AI assistants you have authorized to access your account.")}
</Text>
<OAuthGrantsTable grants={data || []} isLoading={isLoading} />
</>
);
}
@@ -0,0 +1,178 @@
import { ActionIcon, Badge, Group, Skeleton, Table, Text, Tooltip } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import { modals } from "@mantine/modals";
import { useTranslation } from "react-i18next";
import NoTableResults from "@/components/common/no-table-results";
import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts";
import { IOAuthGrant } from "@/ee/oauth/types/oauth.types";
import { useRevokeOAuthGrantMutation } from "@/ee/oauth/queries/oauth-query";
// Callback hosts identify the app; the full URL belongs on the consent screen.
function callbackHosts(redirectUris: string[] = []): string[] {
const hosts = redirectUris.map((uri) => {
try {
const url = new URL(uri);
return url.protocol === "http:" || url.protocol === "https:"
? url.host
: url.protocol;
} catch {
return uri;
}
});
return Array.from(new Set(hosts));
}
type OAuthGrantsTableProps = {
grants: IOAuthGrant[];
isLoading?: boolean;
};
function TableSkeleton() {
return (
<>
{Array.from({ length: 3 }).map((_, i) => (
<Table.Tr key={i}>
<Table.Td>
<Skeleton height={14} width={140} />
</Table.Td>
<Table.Td>
<Group gap={4}>
<Skeleton height={20} width={50} />
<Skeleton height={20} width={55} />
</Group>
</Table.Td>
<Table.Td>
<Skeleton height={14} width={90} />
</Table.Td>
<Table.Td>
<Skeleton height={14} width={90} />
</Table.Td>
<Table.Td>
<Skeleton height={28} width={28} circle />
</Table.Td>
</Table.Tr>
))}
</>
);
}
export function OAuthGrantsTable({ grants, isLoading }: OAuthGrantsTableProps) {
const { t } = useTranslation();
const locale = useDateFnsLocale();
const revokeMutation = useRevokeOAuthGrantMutation();
const scopeMeta: Record<string, { color: string; label: string }> = {
read: { color: "blue", label: t("Read") },
write: { color: "orange", label: t("Write") },
};
const formatDate = (date: string | null) => {
if (!date) return t("Never");
return formatLocalized(date, "MMM dd, yyyy", "PP", locale);
};
const openRevokeModal = (grant: IOAuthGrant) =>
modals.openConfirmModal({
title: t("Revoke access"),
centered: true,
children: (
<Text size="sm">
{t(
"Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.",
{ name: grant.clientName },
)}
</Text>
),
labels: { confirm: t("Revoke access"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => revokeMutation.mutate(grant.id),
});
return (
<Table.ScrollContainer minWidth={600}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Application")}</Table.Th>
<Table.Th>{t("Permissions")}</Table.Th>
<Table.Th>{t("Authorized")}</Table.Th>
<Table.Th>{t("Last used")}</Table.Th>
<Table.Th aria-label={t("Action")} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{isLoading ? (
<TableSkeleton />
) : grants.length > 0 ? (
grants.map((grant) => (
<Table.Tr key={grant.id}>
<Table.Td>
<Text fz="sm" fw={500}>
{grant.clientName}
</Text>
<Text
fz="xs"
c="dimmed"
title={grant.redirectUris?.join("\n")}
style={{ overflowWrap: "anywhere" }}
>
{callbackHosts(grant.redirectUris).join(", ")}
</Text>
</Table.Td>
<Table.Td>
<Group gap={4}>
{grant.scopes.map((scope) => {
const meta = scopeMeta[scope];
if (!meta) return null;
return (
<Badge
key={scope}
variant="light"
color={meta.color}
size="sm"
>
{meta.label}
</Badge>
);
})}
</Group>
</Table.Td>
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatDate(grant.createdAt)}
</Text>
</Table.Td>
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatDate(grant.lastUsedAt)}
</Text>
</Table.Td>
<Table.Td>
<Tooltip label={t("Revoke access")}>
<ActionIcon
variant="subtle"
color="red"
aria-label={t("Revoke access for {{name}}", {
name: grant.clientName,
})}
onClick={() => openRevokeModal(grant)}
>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
</Table.Td>
</Table.Tr>
))
) : (
<NoTableResults colSpan={5} text={t("No authorized apps yet.")} />
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}
@@ -0,0 +1,353 @@
import React, { useEffect, useMemo, useState } from "react";
import {
Alert,
Anchor,
Box,
Button,
Center,
Checkbox,
Container,
Divider,
Group,
Loader,
Paper,
Stack,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
import { notifications } from "@mantine/notifications";
import {
IconAlertTriangle,
IconEye,
IconInfoCircle,
IconPencil,
} from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { RESET } from "jotai/utils";
import { useTranslation } from "react-i18next";
import { useLocation, useSearchParams } from "react-router-dom";
import { AuthLayout } from "@/features/auth/components/auth-layout.tsx";
import classes from "@/features/auth/components/auth.module.css";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
import { UserInfo } from "@/components/common/user-info.tsx";
import useCurrentUser from "@/features/user/hooks/use-current-user";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { logout } from "@/features/auth/services/auth-service";
import { ICurrentUser } from "@/features/user/types/user.types";
import APP_ROUTE from "@/lib/app-route.ts";
import {
approveOAuthAuthorization,
getOAuthAuthorizeInfo,
} from "@/ee/oauth/services/oauth-service";
import {
IApproveAuthorizationPayload,
IAuthorizeParams,
IOAuthAuthorizeInfo,
} from "@/ee/oauth/types/oauth.types";
function loginRedirectUrl(pathname: string, search: string): string {
return `${APP_ROUTE.AUTH.LOGIN}?redirect=${encodeURIComponent(pathname + search)}`;
}
function errorStatus(error: any): number | undefined {
return error?.response?.status;
}
function errorText(error: any): string | undefined {
const data = error?.response?.data;
if (typeof data?.error_description === "string") return data.error_description;
if (Array.isArray(data?.message)) return data.message.join(", ");
return data?.message;
}
const OAUTH_PARAM_NAMES = [
"response_type",
"client_id",
"redirect_uri",
"state",
"code_challenge",
"code_challenge_method",
"scope",
"resource",
];
function pickOAuthParams(params: IAuthorizeParams): IAuthorizeParams {
const picked: IAuthorizeParams = {};
for (const name of OAUTH_PARAM_NAMES) {
if (params[name] !== undefined) {
picked[name] = params[name];
}
}
return picked;
}
export default function OAuthConsent() {
const { t } = useTranslation();
const location = useLocation();
const [searchParams] = useSearchParams();
const params = useMemo<IAuthorizeParams>(
() => Object.fromEntries(searchParams.entries()),
[searchParams],
);
const currentUserQuery = useCurrentUser();
const infoQuery = useQuery({
queryKey: ["oauth-authorize-info", params],
queryFn: () => getOAuthAuthorizeInfo(params),
});
const isUnauthenticated =
errorStatus(currentUserQuery.error) === 401 ||
errorStatus(infoQuery.error) === 401;
useEffect(() => {
if (isUnauthenticated) {
window.location.replace(
loginRedirectUrl(location.pathname, location.search),
);
}
}, [isUnauthenticated, location.pathname, location.search]);
const isLoading =
isUnauthenticated || currentUserQuery.isLoading || infoQuery.isLoading;
return (
<AuthLayout>
<DocumentTitle title={t("Authorize application")} />
<Container size={460} className={classes.container}>
<Box p="xl">
{isLoading ? (
<Center mih={200}>
<Loader />
</Center>
) : infoQuery.data && currentUserQuery.data ? (
<ConsentCard
info={infoQuery.data}
currentUser={currentUserQuery.data}
params={params}
/>
) : (
<InvalidRequestCard
description={errorText(infoQuery.error ?? currentUserQuery.error)}
/>
)}
</Box>
</Container>
</AuthLayout>
);
}
function InvalidRequestCard({ description }: { description?: string }) {
const { t } = useTranslation();
return (
<Stack align="center" gap="sm">
<ThemeIcon size={48} radius="xl" variant="light" color="red">
<IconAlertTriangle size={26} stroke={1.5} />
</ThemeIcon>
<Title order={3} ta="center" fw={600}>
{t("Invalid authorization request")}
</Title>
{description && (
<Text size="sm" c="dimmed" ta="center">
{description}
</Text>
)}
</Stack>
);
}
type ConsentCardProps = {
info: IOAuthAuthorizeInfo;
currentUser: ICurrentUser;
params: IAuthorizeParams;
};
function ConsentCard({ info, currentUser, params }: ConsentCardProps) {
const { t } = useTranslation();
const location = useLocation();
const [, setCurrentUser] = useAtom(currentUserAtom);
const [approvedScopes, setApprovedScopes] = useState<string[]>(
info.scopes.filter((scope) => scope === "read" || scope === "write"),
);
const [submitting, setSubmitting] = useState<"approve" | "deny" | null>(null);
const [acknowledged, setAcknowledged] = useState(false);
const scopeRows = [
{
scope: "read",
icon: <IconEye size={16} stroke={1.5} />,
label: t("Read"),
description: t("View data in your workspace without making changes."),
},
{
scope: "write",
icon: <IconPencil size={16} stroke={1.5} />,
label: t("Write"),
description: t("Create and modify data in your workspace."),
},
];
function toggleScope(scope: string, checked: boolean) {
setApprovedScopes((prev) =>
checked ? [...prev, scope] : prev.filter((item) => item !== scope),
);
}
async function submitDecision(approved: boolean) {
setSubmitting(approved ? "approve" : "deny");
const oauthParams = pickOAuthParams(params);
const payload: IApproveAuthorizationPayload = approved
? { ...oauthParams, approved: true, approvedScopes }
: { ...oauthParams, approved: false };
try {
const res = await approveOAuthAuthorization(payload);
window.location.replace(res.redirectUrl);
} catch (err) {
if (errorStatus(err) === 401) {
window.location.replace(
loginRedirectUrl(location.pathname, location.search),
);
return;
}
setSubmitting(null);
notifications.show({
message: errorText(err) || t("Something went wrong. Please try again."),
color: "red",
});
}
}
async function switchAccount() {
if (submitting !== null) {
return;
}
setCurrentUser(RESET);
try {
await logout();
} finally {
window.location.replace(
loginRedirectUrl(location.pathname, location.search),
);
}
}
return (
<Stack gap="lg">
<Title order={3} ta="center" fw={600}>
{t("{{name}} wants to access {{workspace}}", {
name: info.clientName,
workspace: currentUser.workspace.name,
})}
</Title>
<Paper withBorder radius="md" p="sm">
<Group justify="space-between" wrap="nowrap">
<UserInfo user={currentUser.user} />
<Anchor
component="button"
type="button"
size="xs"
c="dimmed"
disabled={submitting !== null}
onClick={switchAccount}
style={{ whiteSpace: "nowrap" }}
>
{t("Not you? Switch account")}
</Anchor>
</Group>
</Paper>
<Divider />
<Stack gap="sm">
<Text size="sm" fw={500}>
{t("This application will be able to:")}
</Text>
{scopeRows.map((row) => {
const requested = info.scopes.includes(row.scope);
return (
<Checkbox
key={row.scope}
size="sm"
checked={approvedScopes.includes(row.scope)}
disabled={!requested || submitting !== null}
onChange={(event) =>
toggleScope(row.scope, event.currentTarget.checked)
}
label={
<Group gap={6} wrap="nowrap">
{row.icon}
<Text size="sm" fw={500}>
{row.label}
</Text>
</Group>
}
description={row.description}
/>
);
})}
</Stack>
<div>
<Text size="xs" c="dimmed">
{t("You will be redirected to")}
</Text>
<Text size="xs" ff="monospace" style={{ overflowWrap: "anywhere" }}>
{info.redirectUri}
</Text>
</div>
{!info.verified && (
<Alert
variant="light"
color="yellow"
icon={<IconInfoCircle size={16} />}
>
<Stack gap="xs">
<Text size="sm">
{t(
"This application is not on your workspace's trusted list. Authorize only if you recognize it.",
)}
</Text>
<Checkbox
size="sm"
checked={acknowledged}
disabled={submitting !== null}
onChange={(event) =>
setAcknowledged(event.currentTarget.checked)
}
label={t("I recognize this application and want to continue")}
/>
</Stack>
</Alert>
)}
<Group grow>
<Button
variant="default"
onClick={() => submitDecision(false)}
loading={submitting === "deny"}
disabled={submitting === "approve"}
>
{t("Cancel")}
</Button>
<Button
onClick={() => submitDecision(true)}
loading={submitting === "approve"}
disabled={
approvedScopes.length === 0 ||
(!info.verified && !acknowledged) ||
submitting === "deny"
}
>
{t("Authorize")}
</Button>
</Group>
</Stack>
);
}
@@ -0,0 +1,45 @@
import {
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import {
getOAuthGrants,
revokeOAuthGrant,
} from "@/ee/oauth/services/oauth-service";
import { IOAuthGrant } from "@/ee/oauth/types/oauth.types";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
export function useOAuthGrantsQuery(): UseQueryResult<IOAuthGrant[], Error> {
return useQuery({
queryKey: ["oauth-grants"],
queryFn: () => getOAuthGrants(),
staleTime: 0,
gcTime: 0,
});
}
export function useRevokeOAuthGrantMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, string>({
mutationFn: (grantId) => revokeOAuthGrant(grantId),
onSuccess: () => {
notifications.show({ message: t("Access revoked") });
queryClient.invalidateQueries({
predicate: (item) =>
["oauth-grants"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Something went wrong. Please try again."),
color: "red",
});
},
});
}
@@ -0,0 +1,36 @@
import api from "@/lib/api-client";
import {
IApproveAuthorizationPayload,
IAuthorizeParams,
IOAuthAuthorizeInfo,
IOAuthGrant,
} from "@/ee/oauth/types/oauth.types";
export async function getOAuthAuthorizeInfo(
params: IAuthorizeParams,
): Promise<IOAuthAuthorizeInfo> {
const req = await api.post<IOAuthAuthorizeInfo>(
"/oauth/authorize-info",
params,
);
return req.data;
}
export async function approveOAuthAuthorization(
payload: IApproveAuthorizationPayload,
): Promise<{ redirectUrl: string }> {
const req = await api.post<{ redirectUrl: string }>(
"/oauth/authorize",
payload,
);
return req.data;
}
export async function getOAuthGrants(): Promise<IOAuthGrant[]> {
const req = await api.post<IOAuthGrant[]>("/oauth/grants", {});
return req.data;
}
export async function revokeOAuthGrant(grantId: string): Promise<void> {
await api.post("/oauth/grants/revoke", { grantId });
}
@@ -0,0 +1,24 @@
export interface IOAuthAuthorizeInfo {
clientName: string;
redirectUri: string;
scopes: string[];
clientCreatedAt: string;
verified: boolean;
}
export interface IOAuthGrant {
id: string;
clientName: string;
redirectUris: string[];
scopes: string[];
createdAt: string;
lastUsedAt: string | null;
}
export type IAuthorizeParams = Record<string, string>;
export type IApproveAuthorizationPayload = {
[param: string]: unknown;
approved: boolean;
approvedScopes?: string[];
};
@@ -1,17 +1,16 @@
import { useState, useMemo } from "react";
import { Group, MultiSelect, Select, Space, TextInput } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { IconSearch } from "@tabler/icons-react";
import SettingsTitle from "@/components/settings/settings-title";
import { getAppName } from "@/lib/config";
import Paginate from "@/components/common/paginate";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import { useVerificationListQuery } from "@/ee/page-verification/queries/page-verification-query";
import { IVerificationListParams } from "@/ee/page-verification/types/page-verification.types";
import VerificationListTable from "@/ee/page-verification/components/verification-list-table";
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function VerifiedPages() {
const { t } = useTranslation();
@@ -68,11 +67,7 @@ export default function VerifiedPages() {
return (
<>
<Helmet>
<title>
{t("Verified pages")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Verified pages")} />
<SettingsTitle title={t("Verified pages")} />
+2 -7
View File
@@ -1,18 +1,13 @@
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import { CloudLoginForm } from "@/ee/components/cloud-login-form.tsx";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function CloudLogin() {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>
{t("Login")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Login")} />
<CloudLoginForm />
</>
@@ -1,14 +1,11 @@
import { SetupWorkspaceForm } from "@/features/auth/components/setup-workspace-form.tsx";
import { Helmet } from "react-helmet-async";
import React from "react";
import { getAppName } from "@/lib/config.ts";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function CreateWorkspace() {
return (
<>
<Helmet>
<title>Create Workspace - {getAppName()}</title>
</Helmet>
<DocumentTitle title="Create Workspace" />
<SetupWorkspaceForm />
</>
);
@@ -1,5 +1,4 @@
import { Helmet } from "react-helmet-async";
import { getAppName, isCloud } from "@/lib/config.ts";
import { isCloud } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import {
Alert,
@@ -37,6 +36,7 @@ import EnableScim from "@/ee/scim/components/enable-scim";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import Paginate from "@/components/common/paginate";
import { IScimToken } from "@/ee/scim/types/scim-token.types";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
const SCIM_TOKEN_LIMIT = 5;
@@ -64,9 +64,7 @@ export default function Security() {
return (
<>
<Helmet>
<title>Security - {getAppName()}</title>
</Helmet>
<DocumentTitle title="Security" />
<SettingsTitle title={t("Security")} />
<EnforceMfa />
@@ -41,6 +41,7 @@ export default function ReadonlyTemplateEditor({
<EditorProvider
editable={false}
immediatelyRender={true}
textDirection="auto"
extensions={extensions}
content={template.content}
/>
@@ -22,8 +22,6 @@ import { useTranslation } from "react-i18next";
import { useDisclosure, useWindowEvent } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import { Link, useParams } from "react-router-dom";
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config";
import { useEditor, EditorContent } from "@tiptap/react";
import { templateExtensions } from "@/features/editor/extensions/extensions";
import {
@@ -44,6 +42,7 @@ import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
import classes from "./template-editor.module.css";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function TemplateEditor() {
const { t } = useTranslation();
@@ -88,6 +87,7 @@ export default function TemplateEditor() {
const editor = useEditor({
extensions: templateExtensions,
content: "",
textDirection: "auto",
editorProps: {
scrollThreshold: 80,
scrollMargin: 80,
@@ -247,11 +247,7 @@ export default function TemplateEditor() {
return (
<>
<Helmet>
<title>
{t("Edit template")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Edit template")} />
{editorToolbarEnabled && editor && (
<FixedToolbar editor={editor} templateMode />
@@ -13,11 +13,9 @@ import {
} from "@mantine/core";
import { modals } from "@mantine/modals";
import { IconPlus } from "@tabler/icons-react";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useDisclosure } from "@mantine/hooks";
import { getAppName } from "@/lib/config";
import {
useGetTemplatesQuery,
useDeleteTemplateMutation,
@@ -31,6 +29,7 @@ import useUserRole from "@/hooks/use-user-role";
import CreateTemplateModal from "@/ee/template/components/create-template-modal";
import { useAtomValue } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function TemplateList() {
const { t } = useTranslation();
@@ -102,11 +101,7 @@ export default function TemplateList() {
return (
<>
<Helmet>
<title>
{t("Templates")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Templates")} />
<Container size="900" pt="xl">
<Group justify="space-between" mb="xl">
@@ -0,0 +1,59 @@
import { ThemeIcon } from "@mantine/core";
import {
IconFile,
IconFileTypeCsv,
IconFileTypeDocx,
IconFileTypePdf,
IconFileTypePpt,
IconFileTypeXls,
IconFileZip,
IconMovie,
IconMusic,
IconPhoto,
type Icon,
} from "@tabler/icons-react";
const EXT_ICONS: Record<string, { icon: Icon; color: string }> = {
".pdf": { icon: IconFileTypePdf, color: "red" },
".doc": { icon: IconFileTypeDocx, color: "blue" },
".docx": { icon: IconFileTypeDocx, color: "blue" },
".xls": { icon: IconFileTypeXls, color: "teal" },
".xlsx": { icon: IconFileTypeXls, color: "teal" },
".csv": { icon: IconFileTypeCsv, color: "teal" },
".ppt": { icon: IconFileTypePpt, color: "orange" },
".pptx": { icon: IconFileTypePpt, color: "orange" },
".zip": { icon: IconFileZip, color: "gray" },
".rar": { icon: IconFileZip, color: "gray" },
".7z": { icon: IconFileZip, color: "gray" },
".tar": { icon: IconFileZip, color: "gray" },
".gz": { icon: IconFileZip, color: "gray" },
};
const MIME_ICONS: Array<{ prefix: string; icon: Icon; color: string }> = [
{ prefix: "image/", icon: IconPhoto, color: "grape" },
{ prefix: "video/", icon: IconMovie, color: "violet" },
{ prefix: "audio/", icon: IconMusic, color: "pink" },
];
interface AttachmentFileIconProps {
fileExt?: string;
mimeType?: string;
}
export function AttachmentFileIcon({
fileExt,
mimeType,
}: AttachmentFileIconProps) {
const byExt = fileExt ? EXT_ICONS[fileExt.toLowerCase()] : undefined;
const byMime = mimeType
? MIME_ICONS.find((entry) => mimeType.startsWith(entry.prefix))
: undefined;
const { icon: FileIcon, color } = byExt ??
byMime ?? { icon: IconFile, color: "gray" };
return (
<ThemeIcon variant="light" color={color} size={40} radius="md">
<FileIcon size={22} stroke={1.5} />
</ThemeIcon>
);
}
@@ -0,0 +1,191 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ActionIcon,
Anchor,
Center,
Group,
Loader,
Modal,
ScrollArea,
Text,
Tooltip,
} from "@mantine/core";
import { IconDownload } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { SearchInput } from "@/components/common/search-input.tsx";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { usePageAttachmentsQuery } from "@/features/attachments/queries/attachment-query.ts";
import { IPageAttachment } from "@/features/attachments/types/attachment.types.ts";
import { AttachmentFileIcon } from "@/features/attachments/components/attachment-file-icon.tsx";
import { formatBytes } from "@/lib";
import { getFileUrl } from "@/lib/config.ts";
import { formattedDate } from "@/lib/time.ts";
interface PageAttachmentsModalProps {
pageId: string;
open: boolean;
onClose: () => void;
}
export default function PageAttachmentsModal({
pageId,
open,
onClose,
}: PageAttachmentsModalProps) {
const { t } = useTranslation();
return (
<Modal
opened={open}
onClose={onClose}
title={t("Attachments")}
size={800}
closeButtonProps={{ "aria-label": t("Close") }}
>
<PageAttachmentsList pageId={pageId} />
</Modal>
);
}
function PageAttachmentsList({ pageId }: { pageId: string }) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const {
data,
isLoading,
isError,
isFetching,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = usePageAttachmentsQuery(pageId, search);
const attachments = useMemo(
() => data?.pages.flatMap((page) => page.items) ?? [],
[data],
);
const loadMoreRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const sentinel = loadMoreRef.current;
if (!sentinel || !hasNextPage) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !isFetching) {
fetchNextPage();
}
},
{ threshold: 0.1 },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [fetchNextPage, hasNextPage, isFetching]);
const handleSearch = useCallback((value: string) => setSearch(value), []);
return (
<>
<SearchInput
onSearch={handleSearch}
placeholder={t("Search attachments...")}
/>
{isLoading ? (
<Center py="xl">
<Loader size="sm" />
</Center>
) : isError ? (
<Center py="xl">
<Text size="sm" c="dimmed">
{t("Error loading attachments.")}
</Text>
</Center>
) : attachments.length === 0 ? (
<Center py="xl">
<Text size="sm" c="dimmed">
{search
? t("No results found")
: t("No attachments on this page yet.")}
</Text>
</Center>
) : (
<ScrollArea.Autosize mah={480} type="scroll" scrollbarSize={5}>
{attachments.map((attachment) => (
<AttachmentRow key={attachment.id} attachment={attachment} />
))}
{hasNextPage && <div ref={loadMoreRef} style={{ height: 1 }} />}
{isFetchingNextPage && (
<Center py="sm">
<Loader size="sm" />
</Center>
)}
</ScrollArea.Autosize>
)}
</>
);
}
function AttachmentRow({ attachment }: { attachment: IPageAttachment }) {
const { t } = useTranslation();
const fileUrl = getFileUrl(attachment.url);
return (
<Group wrap="nowrap" gap="md" py="sm" pr="xs">
<AttachmentFileIcon
fileExt={attachment.fileExt}
mimeType={attachment.mimeType}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<Anchor
href={fileUrl}
target="_blank"
rel="noopener noreferrer"
size="sm"
fw={500}
c="inherit"
truncate="end"
style={{ display: "block" }}
>
{attachment.fileName}
</Anchor>
<Text size="xs" c="dimmed" mt={2} truncate="end">
{formatBytes(Number(attachment.fileSize))}
{" · "}
{formattedDate(new Date(attachment.createdAt))}
</Text>
</div>
{attachment.creator && (
<Tooltip
label={t("Uploaded by {{name}}", { name: attachment.creator.name })}
withArrow
>
<CustomAvatar
avatarUrl={attachment.creator.avatarUrl}
name={attachment.creator.name}
size="sm"
/>
</Tooltip>
)}
<Tooltip label={t("Download attachment")} withArrow>
<ActionIcon
component="a"
href={fileUrl}
download={attachment.fileName}
target="_blank"
rel="noopener noreferrer"
variant="subtle"
color="gray"
aria-label={t("Download {{name}}", { name: attachment.fileName })}
>
<IconDownload size={18} />
</ActionIcon>
</Tooltip>
</Group>
);
}
@@ -0,0 +1,25 @@
import {
InfiniteData,
keepPreviousData,
useInfiniteQuery,
UseInfiniteQueryResult,
} from "@tanstack/react-query";
import { getPageAttachments } from "@/features/attachments/services/attachment-service.ts";
import { IPageAttachment } from "@/features/attachments/types/attachment.types.ts";
import { IPagination } from "@/lib/types.ts";
export function usePageAttachmentsQuery(
pageId: string,
search?: string,
): UseInfiniteQueryResult<InfiniteData<IPagination<IPageAttachment>, unknown>> {
return useInfiniteQuery({
queryKey: ["page-attachments", pageId, search],
queryFn: ({ pageParam }) =>
getPageAttachments(pageId, { cursor: pageParam, query: search }),
enabled: !!pageId,
gcTime: 0,
placeholderData: keepPreviousData,
initialPageParam: undefined,
getNextPageParam: (lastPage) => lastPage.meta?.nextCursor ?? undefined,
});
}
@@ -3,7 +3,17 @@ import loadImage from "blueimp-load-image";
import {
AvatarIconType,
IAttachment,
IPageAttachment,
} from "@/features/attachments/types/attachment.types.ts";
import { IPagination, QueryParams } from "@/lib/types.ts";
export async function getPageAttachments(
pageId: string,
params?: QueryParams,
): Promise<IPagination<IPageAttachment>> {
const req = await api.post("/pages/attachments", { pageId, ...params });
return req.data;
}
async function compressAndResizeIcon(
file: File,
@@ -1,4 +1,5 @@
export {
getPageAttachments,
uploadIcon,
uploadUserAvatar,
uploadSpaceIcon,
@@ -15,6 +15,15 @@ export interface IAttachment {
deletedAt: string | null;
}
export interface IPageAttachment extends IAttachment {
url: string;
creator: {
id: string;
name: string;
avatarUrl: string | null;
} | null;
}
export enum AvatarIconType {
AVATAR = "avatar",
SPACE_ICON = "space-icon",
@@ -103,6 +103,7 @@ const CommentEditor = forwardRef(
},
content: defaultContent,
editable,
textDirection: "auto",
immediatelyRender: true,
shouldRerenderOnTransaction: false,
autofocus: (autofocus && "end") || false,
@@ -16,6 +16,14 @@ export const showAiMenuAtom = atom(false);
export const showLinkMenuAtom = atom(false);
export type LightboxRequest = {
src: string;
type: "image" | "video";
} | null;
const initialLightboxRequest: LightboxRequest = null;
export const lightboxRequestAtom = atom(initialLightboxRequest);
// Current page's edit mode — initialized from the user's saved preference on
// first load, can be toggled locally without persisting to the server.
export const currentPageEditModeAtom = atom<PageEditMode>(PageEditMode.Edit);
@@ -23,11 +23,21 @@ import {
} from "@/features/comment/atoms/comment-atom";
import { useAtom, useAtomValue } from "jotai";
import { v7 as uuid7 } from "uuid";
import { isCellSelection, isEditorReady, isTextSelected } from "@docmost/editor-ext";
import {
isCellSelection,
isEditorReady,
isTextSelected,
} from "@docmost/editor-ext";
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
import { useTranslation } from "react-i18next";
import { showAiMenuAtom, showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
import { userAtom, workspaceAtom } from "@/features/user/atoms/current-user-atom";
import {
showAiMenuAtom,
showLinkMenuAtom,
} from "@/features/editor/atoms/editor-atoms";
import {
userAtom,
workspaceAtom,
} from "@/features/user/atoms/current-user-atom";
export interface BubbleMenuItem {
name: string;
@@ -217,7 +227,12 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
<ActionIcon.Group>
{items.map((item, index) => (
<Tooltip key={index} label={t(item.name)} withArrow>
<Tooltip
key={index}
label={t(item.name)}
withArrow
withinPortal={false}
>
<ActionIcon
key={index}
variant="default"
@@ -226,7 +241,9 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
aria-label={t(item.name)}
className={clsx({ [classes.active]: item.isActive() })}
style={{ border: "none" }}
onClick={() => isEditorReady(props.editor) && item.command()}
onClick={() =>
isEditorReady(props.editor) && item.command()
}
>
<item.icon style={{ width: rem(16) }} stroke={2} />
</ActionIcon>
@@ -256,7 +273,9 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
radius="6px"
aria-label={t(commentItem.name)}
style={{ border: "none" }}
onClick={() => isEditorReady(props.editor) && commentItem.command()}
onClick={() =>
isEditorReady(props.editor) && commentItem.command()
}
>
<IconMessage size={16} stroke={2} />
</ActionIcon>
@@ -129,8 +129,7 @@ function handleColorKeyNav(
grid: "text" | "highlight",
) {
const cols = COLOR_GRID_COLS;
const total =
grid === "text" ? TEXT_COLORS.length : HIGHLIGHT_COLORS.length;
const total = grid === "text" ? TEXT_COLORS.length : HIGHLIGHT_COLORS.length;
const col = index % cols;
if (e.key === "ArrowRight") {
@@ -163,8 +162,7 @@ function handleColorKeyNav(
if (prev >= 0) {
focusSwatch(grid, prev);
} else if (grid === "highlight") {
const lastRowStart =
Math.floor((TEXT_COLORS.length - 1) / cols) * cols;
const lastRowStart = Math.floor((TEXT_COLORS.length - 1) / cols) * cols;
focusSwatch("text", Math.min(lastRowStart + col, TEXT_COLORS.length - 1));
}
return;
@@ -222,7 +220,7 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
withArrow
>
<Popover.Target>
<Tooltip label={t("Text color")} withArrow>
<Tooltip label={t("Text color")} withArrow withinPortal={false}>
<Button
variant="default"
radius="0"
@@ -247,26 +245,26 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
<Popover.Dropdown onMouseDown={(e) => e.preventDefault()}>
<Stack gap="md" p="2px">
<Box>
<Text size="sm" fw={600} mb="xs">
{t("Text color")}
</Text>
<SimpleGrid cols={5} spacing="xs">
{TEXT_COLORS.map(({ name, color }, index) => {
const applyTextColor = () => {
if (!isEditorReady(editor)) return;
if (name === "Default") {
editor.commands.unsetColor();
} else {
editor
.chain()
.focus()
.setColor(color || "")
.run();
}
setIsOpen(false);
};
return (
<Box>
<Text size="sm" fw={600} mb="xs">
{t("Text color")}
</Text>
<SimpleGrid cols={5} spacing="xs">
{TEXT_COLORS.map(({ name, color }, index) => {
const applyTextColor = () => {
if (!isEditorReady(editor)) return;
if (name === "Default") {
editor.commands.unsetColor();
} else {
editor
.chain()
.focus()
.setColor(color || "")
.run();
}
setIsOpen(false);
};
return (
<Tooltip key={index} label={t(name)} withArrow>
<Box
role="button"
@@ -306,34 +304,34 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
A
</Box>
</Tooltip>
);
})}
</SimpleGrid>
</Box>
);
})}
</SimpleGrid>
</Box>
<Box>
<Text size="sm" fw={600} mb="xs">
{t("Highlight color")}
</Text>
<SimpleGrid cols={5} spacing="xs">
{HIGHLIGHT_COLORS.map(({ name, color }, index) => {
const applyHighlight = () => {
if (!isEditorReady(editor)) return;
if (name === "Default") {
editor.commands.unsetHighlight();
} else {
editor
.chain()
.focus()
.toggleMark("highlight", {
color: color || "",
colorName: name.toLowerCase() || "",
})
.run();
}
setIsOpen(false);
};
return (
<Box>
<Text size="sm" fw={600} mb="xs">
{t("Highlight color")}
</Text>
<SimpleGrid cols={5} spacing="xs">
{HIGHLIGHT_COLORS.map(({ name, color }, index) => {
const applyHighlight = () => {
if (!isEditorReady(editor)) return;
if (name === "Default") {
editor.commands.unsetHighlight();
} else {
editor
.chain()
.focus()
.toggleMark("highlight", {
color: color || "",
colorName: name.toLowerCase() || "",
})
.run();
}
setIsOpen(false);
};
return (
<Tooltip key={index} label={t(name)} withArrow>
<Box
role="button"
@@ -378,37 +376,36 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
)}
</Box>
</Tooltip>
);
})}
</SimpleGrid>
</Box>
);
})}
</SimpleGrid>
</Box>
<Button
variant="default"
fullWidth
data-color-grid="remove"
className={classes.removeColor}
onClick={() => {
if (isEditorReady(editor)) {
editor.commands.unsetColor();
editor.commands.unsetHighlight();
}
setIsOpen(false);
}}
onKeyDown={(e) => {
if (e.key === "ArrowUp") {
e.preventDefault();
const lastRowStart =
Math.floor(
(HIGHLIGHT_COLORS.length - 1) / COLOR_GRID_COLS,
) * COLOR_GRID_COLS;
focusSwatch("highlight", lastRowStart);
}
}}
>
{t("Remove color")}
</Button>
</Stack>
<Button
variant="default"
fullWidth
data-color-grid="remove"
className={classes.removeColor}
onClick={() => {
if (isEditorReady(editor)) {
editor.commands.unsetColor();
editor.commands.unsetHighlight();
}
setIsOpen(false);
}}
onKeyDown={(e) => {
if (e.key === "ArrowUp") {
e.preventDefault();
const lastRowStart =
Math.floor((HIGHLIGHT_COLORS.length - 1) / COLOR_GRID_COLS) *
COLOR_GRID_COLS;
focusSwatch("highlight", lastRowStart);
}
}}
>
{t("Remove color")}
</Button>
</Stack>
</Popover.Dropdown>
</Popover>
);
@@ -10,7 +10,7 @@ export const LinkSelector: FC = () => {
const setShowLinkMenu = useSetAtom(showLinkMenuAtom);
return (
<Tooltip label={t("Add link")} withArrow>
<Tooltip label={t("Add link")} withArrow withinPortal={false}>
<ActionIcon
variant="default"
size="lg"
@@ -91,7 +91,12 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
onChange={setIsOpen}
>
<Menu.Target>
<Tooltip label={t("Text align")} withArrow disabled={isOpen}>
<Tooltip
label={t("Text align")}
withArrow
disabled={isOpen}
withinPortal={false}
>
<Button
variant="default"
style={{ border: "none", height: "34px" }}
@@ -0,0 +1,188 @@
import type { Editor } from "@tiptap/react";
import type { Node as PMNode } from "@tiptap/pm/model";
import Lightbox, { type Slide } from "yet-another-react-lightbox";
import type { LightboxRequest } from "@/features/editor/atoms/editor-atoms";
import { getFileUrl } from "@/lib/config.ts";
import "yet-another-react-lightbox/styles.css";
import Download from "yet-another-react-lightbox/plugins/download";
import Fullscreen from "yet-another-react-lightbox/plugins/fullscreen";
import Video from "yet-another-react-lightbox/plugins/video";
import Zoom from "yet-another-react-lightbox/plugins/zoom";
import { useEffect, useMemo, useState } from "react";
import i18n from "@/i18n.ts";
import { useTranslation } from "react-i18next";
type LightboxViewProps = {
editor: Editor;
open: boolean;
src: string;
type: "image" | "video";
onClose: () => void;
};
function getVideoMimeType(src: string) {
const extension = src.split(/[?#]/, 1)[0].split(".").pop()?.toLowerCase();
switch (extension) {
case "webm":
return "video/webm";
case "ogv":
return "video/ogg";
case "mov":
return "video/quicktime";
case "m4v":
return "video/x-m4v";
default:
return "video/mp4";
}
}
function getFilename(src: string) {
const filename = src.split(/[?#]/, 1)[0].split("/").pop();
if (!filename) return i18n.t("Media");
try {
return decodeURIComponent(filename);
} catch {
return filename;
}
}
function getMedia(rawSrc: string, type?: string, alt?: string): Slide {
const src = getFileUrl(rawSrc);
const filename = getFilename(rawSrc);
if (type === "video") {
return {
type: "video",
sources: [{ src, type: getVideoMimeType(rawSrc) }],
download: { url: src, filename },
};
} else {
return {
type: "image",
src,
alt: alt || undefined,
download: { url: src, filename },
};
}
}
const LIGHTBOX_NODE_TYPES: Record<string, "image" | "video"> = {
image: "image",
video: "video",
drawio: "image",
excalidraw: "image",
};
// video is excluded: clicks there operate the native controls
const CLICK_TO_EXPAND_NODE_TYPES = new Set(["image", "drawio", "excalidraw"]);
export function getLightboxClickRequest(node: PMNode): LightboxRequest {
if (!CLICK_TO_EXPAND_NODE_TYPES.has(node.type.name)) return null;
const src = typeof node.attrs.src === "string" ? node.attrs.src : "";
if (!src) return null;
return { src: getFileUrl(src), type: "image" };
}
function getPageMedia(editor: Editor): Slide[] {
const media: Slide[] = [];
editor.state.doc.descendants((node) => {
const type = LIGHTBOX_NODE_TYPES[node.type.name];
if (!type) return;
const rawSrc = typeof node.attrs.src === "string" ? node.attrs.src : "";
if (!rawSrc) return;
media.push(getMedia(rawSrc, type, node.attrs.alt));
});
return media;
}
export default function LightboxView({
editor,
open,
src,
type,
onClose,
}: LightboxViewProps) {
const { i18n: i18nInstance } = useTranslation();
const selectedSlide = useMemo(
() => getMedia(src, type),
[src, type, i18nInstance.language]
);
const [pageSlides, setPageSlides] = useState<Slide[]>([]);
const [loadedMediaKey, setLoadedMediaKey] = useState<string | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
if (!open) setIsFullscreen(false);
}, [open]);
useEffect(() => {
if (!open) return;
setLoadedMediaKey(null);
const frame = requestAnimationFrame(() => {
const slides = getPageMedia(editor);
setPageSlides(slides);
setLoadedMediaKey(`${type}:${src}`);
});
return () => cancelAnimationFrame(frame);
}, [editor, open, type, src]);
const slides = loadedMediaKey === `${type}:${src}` ? pageSlides : [selectedSlide];
const index = useMemo(() => {
if (!(pageSlides.length > 0)) {
return 0;
}
const idx = slides.findIndex((slide) =>
type === "video"
? "sources" in slide && slide.sources.some((s) => s.src === src)
: "src" in slide && slide.src === src
);
return idx >= 0 ? idx : 0;
}, [slides, src, type]);
return (
<Lightbox
open={open}
close={onClose}
index={index}
slides={slides}
plugins={[Download, Fullscreen, Video, Zoom]}
styles={{
container: { backgroundColor: "rgba(0, 0, 0, 0.8)" },
icon: { width: 24, height: 24 },
toolbar: {
margin: 8,
borderRadius: 8,
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
}}
controller={{ closeOnBackdropClick: !isFullscreen }}
on={{
enterFullscreen: () => setIsFullscreen(true),
exitFullscreen: () => setIsFullscreen(false),
}}
video={{ controls: true, playsInline: true }}
zoom={{
scrollToZoom: true,
maxZoomPixelRatio: 4,
maxZoom: 4,
supports: ["video"],
}}
/>
);
}
@@ -1,6 +1,7 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useSetAtom } from "jotai";
import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import {
@@ -24,6 +25,7 @@ import {
IconDownload,
IconEdit,
IconTrash,
IconZoomIn,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { getDrawioUrl, getFileUrl } from "@/lib/config.ts";
@@ -39,10 +41,12 @@ import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils";
import { IAttachment } from "@/features/attachments/types/attachment.types";
import { modals } from "@mantine/modals";
import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.tsx";
import { lightboxRequestAtom } from "@/features/editor/atoms/editor-atoms";
import classes from "../common/toolbar-menu.module.css";
export function DrawioMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const setLightboxRequest = useSetAtom(lightboxRequestAtom);
const [opened, { open, close }] = useDisclosure(false);
const [initialXML, setInitialXML] = useState<string>("");
const drawioRef = useRef<DrawIoEmbedRef>(null);
@@ -328,6 +332,23 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
<div className={classes.divider} />
<Tooltip position="top" label={t("Expand")} withinPortal={false}>
<ActionIcon
onClick={() =>
editorState?.src &&
setLightboxRequest({
src: getFileUrl(editorState.src),
type: "image",
})
}
size="lg"
aria-label={t("Expand")}
variant="subtle"
>
<IconZoomIn size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Edit")} withinPortal={false}>
<ActionIcon
onClick={handleOpen}
@@ -1,6 +1,7 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useSetAtom } from "jotai";
import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import {
@@ -25,6 +26,7 @@ import {
IconDownload,
IconEdit,
IconTrash,
IconZoomIn,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { getFileUrl } from "@/lib/config.ts";
@@ -37,6 +39,7 @@ import ReactClearModal from "react-clear-modal";
import { useHandleLibrary } from "@excalidraw/excalidraw";
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.tsx";
import { lightboxRequestAtom } from "@/features/editor/atoms/editor-atoms";
import classes from "../common/toolbar-menu.module.css";
const ExcalidrawComponent = lazy(() =>
@@ -47,6 +50,7 @@ const ExcalidrawComponent = lazy(() =>
export function ExcalidrawMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const setLightboxRequest = useSetAtom(lightboxRequestAtom);
const [opened, { open, close }] = useDisclosure(false);
const [excalidrawAPI, setExcalidrawAPI] =
useState<ExcalidrawImperativeAPI>(null);
@@ -359,6 +363,23 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
<div className={classes.divider} />
<Tooltip position="top" label={t("Expand")} withinPortal={false}>
<ActionIcon
onClick={() =>
editorState?.src &&
setLightboxRequest({
src: getFileUrl(editorState.src),
type: "image",
})
}
size="lg"
aria-label={t("Expand")}
variant="subtle"
>
<IconZoomIn size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Edit")} withinPortal={false}>
<ActionIcon
onClick={handleOpen}
@@ -12,6 +12,7 @@ import {
IconMathFunction,
IconRotate2,
IconSitemap,
IconSuperscript,
IconTable,
IconTag,
} from "@tabler/icons-react";
@@ -270,6 +271,12 @@ export const MoreInsertsGroup: FC<Props> = ({ editor, templateMode }) => {
>
{t("Math block")}
</Menu.Item>
<Menu.Item
leftSection={<IconSuperscript size={16} />}
onClick={() => editor.chain().focus().addFootnote().run()}
>
{t("Footnote")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
@@ -1,6 +1,7 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import React, { useCallback, useRef } from "react";
import { useSetAtom } from "jotai";
import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import {
@@ -16,16 +17,19 @@ import {
IconDownload,
IconRefresh,
IconTrash,
IconZoomIn,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { getFileUrl } from "@/lib/config.ts";
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.tsx";
import { lightboxRequestAtom } from "@/features/editor/atoms/editor-atoms";
import classes from "../common/toolbar-menu.module.css";
export function ImageMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null);
const setLightboxRequest = useSetAtom(lightboxRequestAtom);
const editorState = useEditorState({
editor,
@@ -207,6 +211,23 @@ export function ImageMenu({ editor }: EditorMenuProps) {
<div className={classes.divider} />
<Tooltip position="top" label={t("Expand")} withinPortal={false}>
<ActionIcon
onClick={() =>
editorState?.src &&
setLightboxRequest({
src: getFileUrl(editorState.src),
type: "image",
})
}
size="lg"
aria-label={t("Expand")}
variant="subtle"
>
<IconZoomIn size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Download")} withinPortal={false}>
<ActionIcon
onClick={handleDownload}
@@ -30,6 +30,7 @@ import {
IconTag,
IconMoodSmile,
IconRotate2,
IconSuperscript,
} from "@tabler/icons-react";
import {
CommandProps,
@@ -177,6 +178,16 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setPageBreak().run(),
},
{
title: "Footnote",
description: "Insert a footnote reference.",
searchTerms: ["footnote", "reference", "citation", "note"],
icon: IconSuperscript,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).run();
editor.commands.addFootnote();
},
},
{
title: "Image",
description: "Upload any image from your device.",
@@ -13,14 +13,21 @@ import {
import { useTranslation } from "react-i18next";
import { sortPositionKeys } from "@/features/page/tree/utils/utils";
import { useSharedPageSubpages } from "@/features/share/hooks/use-shared-page-subpages";
import { extractPageSlugId } from "@/lib";
export default function SubpagesView(props: NodeViewProps) {
const { editor } = props;
const { spaceSlug, shareId } = useParams();
const { spaceSlug, shareId, pageSlug } = useParams();
const { t } = useTranslation();
//@ts-ignore
const currentPageId = editor.storage.pageId;
// @ts-ignore
const storagePageId = editor.storage.pageId;
const routePageId = extractPageSlugId(pageSlug);
let currentPageId = storagePageId;
if (shareId){
currentPageId = routePageId;
}
// Get subpages from shared tree if we're in a shared context
const sharedSubpages = useSharedPageSubpages(currentPageId);
@@ -40,6 +40,7 @@ export default function TransclusionContent({ content }: Props) {
<EditorProvider
editable={false}
immediatelyRender={true}
textDirection="auto"
extensions={extensions}
content={content as any}
/>
@@ -1,6 +1,7 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback } from "react";
import { useSetAtom } from "jotai";
import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import {
@@ -15,14 +16,17 @@ import {
IconLayoutAlignRight,
IconDownload,
IconTrash,
IconZoomIn,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { getFileUrl } from "@/lib/config.ts";
import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.tsx";
import { lightboxRequestAtom } from "@/features/editor/atoms/editor-atoms";
import classes from "../common/toolbar-menu.module.css";
export function VideoMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const setLightboxRequest = useSetAtom(lightboxRequestAtom);
const editorState = useEditorState({
editor,
@@ -183,6 +187,23 @@ export function VideoMenu({ editor }: EditorMenuProps) {
<div className={classes.divider} />
<Tooltip position="top" label={t("Expand")} withinPortal={false}>
<ActionIcon
onClick={() =>
editorState?.src &&
setLightboxRequest({
src: getFileUrl(editorState.src),
type: "video",
})
}
size="lg"
aria-label={t("Expand")}
variant="subtle"
>
<IconZoomIn size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Download")} withinPortal={false}>
<ActionIcon
onClick={handleDownload}
@@ -1,9 +1,11 @@
import { markInputRule } from "@tiptap/core";
import { StarterKit } from "@tiptap/starter-kit";
import { Document } from "@tiptap/extension-document";
import { Code } from "@tiptap/extension-code";
import { TextAlign } from "@tiptap/extension-text-align";
import { TaskList, TaskItem } from "@tiptap/extension-list";
import { Placeholder, CharacterCount, UndoRedo } from "@tiptap/extensions";
import { CharacterCount, UndoRedo } from "@tiptap/extensions";
import { Placeholder } from "@/features/editor/extensions/placeholder";
import { Superscript } from "@tiptap/extension-superscript";
import SubScript from "@tiptap/extension-subscript";
import { Typography } from "@tiptap/extension-typography";
@@ -62,6 +64,9 @@ import {
TransclusionReference,
TableView,
BaseEmbed as BaseEmbedNode,
Footnotes,
Footnote,
FootnoteReference,
} from "@docmost/editor-ext";
import {
randomElement,
@@ -131,6 +136,7 @@ lowlight.register("scala", scala);
// @ts-ignore
export const mainExtensions = [
StarterKit.configure({
document: false,
heading: false,
undoRedo: false,
link: false,
@@ -142,6 +148,9 @@ export const mainExtensions = [
codeBlock: false,
code: false,
}),
Document.extend({
content: "block+ footnotes?",
}),
// Override TipTap's Code extension to fix the inline code input rule.
// The upstream regex /(^|[^`])`([^`]+)`(?!`)$/ captures the character
// before the opening backtick as part of the match, causing markInputRule
@@ -202,7 +211,8 @@ export const mainExtensions = [
parentName === "tableCell" ||
parentName === "tableHeader" ||
parentName === "callout" ||
parentName === "blockquote"
parentName === "blockquote" ||
parentName === "footnote"
) {
return i18n.t("Write...");
}
@@ -416,6 +426,9 @@ export const mainExtensions = [
}).configure(),
Columns,
Column,
Footnotes,
Footnote,
FootnoteReference,
AutoJoiner.configure({
elementsToJoin: [],
}),
@@ -0,0 +1,64 @@
import { isNodeEmpty } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import { Placeholder as TiptapPlaceholder } from "@tiptap/extensions";
export const Placeholder = TiptapPlaceholder.extend({
addProseMirrorPlugins() {
const editor = this.editor;
const options = this.options;
const dataAttribute = `data-${options.dataAttribute || "placeholder"}`;
return [
new Plugin({
key: new PluginKey("docmostPlaceholder"),
props: {
decorations: (state) => {
if (options.showOnlyWhenEditable && !editor.isEditable) {
return null;
}
const { doc, selection } = state;
const { anchor } = selection;
const decorations: Decoration[] = [];
const isEmptyDoc = editor.isEmpty;
doc.descendants((node, pos) => {
if (!node.type.isTextblock) {
return options.includeChildren;
}
const hasAnchor = anchor >= pos && anchor <= pos + node.nodeSize;
const isEmpty = !node.isLeaf && isNodeEmpty(node);
if ((hasAnchor || !options.showOnlyCurrent) && isEmpty) {
const emptyNodeClass =
typeof options.emptyNodeClass === "function"
? options.emptyNodeClass({ editor, node, pos, hasAnchor })
: options.emptyNodeClass;
const classes = [emptyNodeClass];
if (isEmptyDoc) {
classes.push(options.emptyEditorClass);
}
decorations.push(
Decoration.node(pos, pos + node.nodeSize, {
class: classes.join(" "),
[dataAttribute]:
typeof options.placeholder === "function"
? options.placeholder({ editor, node, pos, hasAnchor })
: options.placeholder,
}),
);
}
return options.includeChildren;
});
return DecorationSet.create(doc, decorations);
},
},
}),
];
},
});
+44 -11
View File
@@ -33,6 +33,7 @@ import { useAtom, useAtomValue } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import {
currentPageEditModeAtom,
lightboxRequestAtom,
pageEditorAtom,
yjsConnectionStatusAtom,
yjsSyncedAtom,
@@ -53,6 +54,9 @@ import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
import PdfMenu from "@/features/editor/components/pdf/pdf-menu.tsx";
import SubpagesMenu from "@/features/editor/components/subpages/subpages-menu.tsx";
import LightboxView, {
getLightboxClickRequest,
} from "@/features/editor/components/common/lightbox-view";
import {
handleFileDrop,
handlePaste,
@@ -184,6 +188,7 @@ function CollabPageEditor({
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
const [lightboxRequest, setLightboxRequest] = useAtom(lightboxRequestAtom);
const [isLocalSynced, setIsLocalSynced] = useState(false);
const [isRemoteSynced, setIsRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
@@ -245,10 +250,22 @@ function CollabPageEditor({
return [...mainExtensions, ...collabExtensions(provider, currentUser.user)];
}, [provider, currentUser?.user]);
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
if (pageData) {
queryClient.setQueryData(["pages", slugId], {
...pageData,
content: newContent,
});
}
}, 3000);
const editor = useEditor(
{
extensions,
editable,
textDirection: "auto",
immediatelyRender: true,
shouldRerenderOnTransaction: false,
editorProps: {
@@ -304,6 +321,22 @@ function CollabPageEditor({
return handleFileDrop(editorRef.current, event, moved, pageId);
},
handleClickOn: (view, _pos, node) => {
if (view.editable) return false;
const request = getLightboxClickRequest(node);
if (!request) return false;
setLightboxRequest(request);
return true;
},
handleDoubleClickOn: (_view, _pos, node) => {
const request = getLightboxClickRequest(node);
if (!request) return false;
setLightboxRequest(request);
return true;
},
},
onCreate({ editor }) {
if (editor) {
@@ -342,17 +375,6 @@ function CollabPageEditor({
},
});
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
if (pageData) {
queryClient.setQueryData(["pages", slugId], {
...pageData,
content: newContent,
});
}
}, 3000);
const handleActiveCommentEvent = (event) => {
const { commentId, resolved } = event.detail;
@@ -385,6 +407,7 @@ function CollabPageEditor({
setActiveCommentId(null);
setShowCommentPopup(false);
setAsideState({ tab: "", isAsideOpen: false });
setLightboxRequest(null);
}, [pageId]);
const isSynced = isLocalSynced && isRemoteSynced;
@@ -458,6 +481,15 @@ function CollabPageEditor({
{editor && !editorIsEditable && (editable || canComment) && (
<ReadonlyBubbleMenu editor={editor} />
)}
{editor && (
<LightboxView
editor={editor}
open={!!lightboxRequest}
src={lightboxRequest?.src ?? ""}
type={lightboxRequest?.type ?? "image"}
onClose={() => setLightboxRequest(null)}
/>
)}
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
{showReadOnlyCommentPopup && (
<CommentDialog editor={editor} pageId={pageId} readOnly />
@@ -484,6 +516,7 @@ function StaticPageEditor({
<EditorProvider
editable={false}
immediatelyRender={true}
textDirection="auto"
extensions={mainExtensions}
content={content}
editorProps={{
@@ -1,15 +1,27 @@
import "@/features/editor/styles/index.css";
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { EditorProvider } from "@tiptap/react";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Editor, EditorProvider } from "@tiptap/react";
import { mainExtensions } from "@/features/editor/extensions/extensions";
import { Document } from "@tiptap/extension-document";
import { Heading, UniqueID } from "@docmost/editor-ext";
import { Text } from "@tiptap/extension-text";
import { Placeholder } from "@tiptap/extension-placeholder";
import { useAtom } from "jotai";
import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import {
lightboxRequestAtom,
readOnlyEditorAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
import { useEditorScroll } from "./hooks/use-editor-scroll";
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
import LightboxView, {
getLightboxClickRequest,
} from "@/features/editor/components/common/lightbox-view";
interface PageEditorProps {
title: string;
@@ -33,6 +45,8 @@ export default function ReadonlyPageEditor({
shareId,
}: PageEditorProps) {
const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom);
const [lightboxRequest, setLightboxRequest] = useAtom(lightboxRequestAtom);
const [contentEditor, setContentEditor] = useState<Editor | null>(null);
const isComponentMounted = useRef(false);
const editorCreated = useRef(false);
@@ -49,6 +63,11 @@ export default function ReadonlyPageEditor({
isComponentMounted.current = true;
}, []);
useEffect(() => {
if (!shareId) return;
setLightboxRequest(null);
}, [pageId, shareId]);
const extensions = useMemo(() => {
const excludedExtensions = new Set([
"uniqueID",
@@ -85,6 +104,7 @@ export default function ReadonlyPageEditor({
<EditorProvider
editable={false}
immediatelyRender={true}
textDirection="auto"
extensions={titleExtensions}
content={title}
></EditorProvider>
@@ -93,8 +113,22 @@ export default function ReadonlyPageEditor({
<EditorProvider
editable={false}
immediatelyRender={true}
textDirection="auto"
extensions={extensions}
content={content}
editorProps={
shareId
? {
handleClickOn: (_view, _pos, node) => {
const request = getLightboxClickRequest(node);
if (!request) return false;
setLightboxRequest(request);
return true;
},
}
: undefined
}
onCreate={({ editor }) => {
if (editor) {
if (pageId) {
@@ -103,12 +137,22 @@ export default function ReadonlyPageEditor({
}
// @ts-ignore
setReadOnlyEditor(editor);
setContentEditor(editor);
handleScrollTo(editor);
editorCreated.current = true;
}
}}
></EditorProvider>
{shareId && contentEditor && (
<LightboxView
editor={contentEditor}
open={!!lightboxRequest}
src={lightboxRequest?.src ?? ""}
type={lightboxRequest?.type ?? "image"}
onClose={() => setLightboxRequest(null)}
/>
)}
<div style={{ paddingBottom: "20vh" }}></div>
</TransclusionLookupProvider>
);
@@ -0,0 +1,26 @@
.ProseMirror sup a.footnote-ref {
color: var(--mantine-primary-color-filled);
text-decoration: none;
cursor: pointer;
font-weight: 600;
}
.ProseMirror sup:has(a.footnote-ref) {
padding: 0 1px;
}
.ProseMirror ol.footnotes {
margin-top: 2rem;
padding-top: 0.75rem;
font-size: 0.875rem;
color: var(--mantine-color-dimmed);
list-style-type: decimal;
}
.ProseMirror ol.footnotes:has(li) {
border-top: 1px solid var(--mantine-color-default-border);
}
.ProseMirror ol.footnotes li p {
margin: 0.15rem 0;
}
@@ -18,3 +18,4 @@
@import "./columns.css";
@import "./status.css";
@import "./base-embed.css";
@import "./footnotes.css";
@@ -38,6 +38,60 @@
line-height: var(--mantine-line-height-md);
}
.media-alt-badge {
position: absolute;
right: 8px;
bottom: 8px;
z-index: 1;
padding: 2px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.03em;
cursor: default;
user-select: none;
background-color: var(--mantine-color-body);
@mixin light {
border: 1px solid var(--mantine-color-gray-3);
color: var(--mantine-color-gray-7);
}
@mixin dark {
border: 1px solid var(--mantine-color-dark-4);
color: var(--mantine-color-dark-1);
}
@media print {
display: none;
}
&::after {
content: attr(data-alt);
display: none;
position: absolute;
right: 0;
bottom: calc(100% + 6px);
width: max-content;
max-width: 320px;
padding: 6px 10px;
border-radius: var(--mantine-radius-default);
font-size: var(--mantine-font-size-sm);
font-weight: 400;
letter-spacing: normal;
line-height: var(--mantine-line-height-sm);
white-space: pre-wrap;
overflow-wrap: break-word;
text-align: start;
background-color: var(--mantine-color-gray-9);
color: var(--mantine-color-white);
}
&:hover::after {
display: block;
}
}
.media-pulse {
animation: media-pulse 1.2s ease-in-out infinite;
@@ -54,7 +54,7 @@
var(--mantine-color-dark-5)
);
font-weight: bold;
text-align: left;
text-align: start;
}
.column-resize-handle {
@@ -86,6 +86,7 @@ export function TitleEditor({
},
editable: editable,
content: title,
textDirection: "auto",
immediatelyRender: true,
shouldRerenderOnTransaction: false,
editorProps: {
@@ -39,6 +39,7 @@ export function useWorkspaceLabelsQuery(query: string, enabled: boolean) {
queryFn: () => getWorkspaceLabels({ type: "page", query, limit: 50 }),
enabled,
staleTime: 30 * 1000,
placeholderData: keepPreviousData
});
}
@@ -6,4 +6,13 @@ export const activeHistoryPrevIdAtom = atom<string>("");
export const highlightChangesAtom = atom<boolean>(true);
export type DiffCounts = { added: number; deleted: number; total: number };
export const diffCountsAtom = atom<DiffCounts | null>(null);
export const diffCountsAtom = atom<DiffCounts | null>(
null as DiffCounts | null,
);
export type ComparePair = { newerId: string; olderId: string };
export const compareModeAtom = atom<boolean>(false);
export const compareSelectionAtom = atom<string[]>([]);
export const comparePairAtom = atom<ComparePair | null>(
null as ComparePair | null,
);
@@ -1,7 +1,7 @@
.history {
display: block;
display: flex;
align-items: center;
width: 100%;
padding: var(--mantine-spacing-md);
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
@mixin hover {
@@ -12,6 +12,28 @@
}
}
.historyButton {
flex: 1;
min-width: 0;
color: inherit;
}
.compareCheckbox {
padding-left: var(--mantine-spacing-xs);
}
.itemMenu {
opacity: 0;
margin-right: var(--mantine-spacing-xs);
}
.history:hover .itemMenu,
.history:focus-within .itemMenu,
.history.active .itemMenu,
.itemMenu[aria-expanded="true"] {
opacity: 1;
}
.historyEditor {
:global(.ProseMirror) {
padding: 0 !important;
@@ -77,3 +99,8 @@
flex: 1;
padding: rem(16px) rem(40px);
}
.compareBanner {
border-bottom: rem(1px) solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
@@ -31,6 +31,7 @@ export function HistoryEditor({
const editor = useEditor({
extensions: mainExtensions,
editable: false,
textDirection: "auto",
});
useEffect(() => {
@@ -170,7 +171,6 @@ export function HistoryEditor({
}
const total = addedCount + deletedCount;
// @ts-ignore
setDiffCounts({ added: addedCount, deleted: deletedCount, total });
editor.setOptions({
@@ -1,10 +1,21 @@
import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core";
import {
Text,
Group,
UnstyledButton,
Avatar,
Tooltip,
ActionIcon,
Checkbox,
Menu,
} from "@mantine/core";
import { IconDots } from "@tabler/icons-react";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { formattedDate } from "@/lib/time";
import classes from "./css/history.module.css";
import clsx from "clsx";
import { IPageHistory } from "@/features/page-history/types/page.types";
import { memo, useCallback } from "react";
import { useTranslation } from "react-i18next";
const MAX_VISIBLE_AVATARS = 5;
@@ -15,6 +26,13 @@ interface HistoryItemProps {
onHover?: (id: string, index: number) => void;
onHoverEnd?: () => void;
isActive: boolean;
compareMode: boolean;
isChecked: boolean;
isCheckboxDisabled: boolean;
canCompare: boolean;
onToggleCompare: (id: string) => void;
onStartCompare: (id: string) => void;
onRestore?: (id: string, index: number) => void;
}
const HistoryItem = memo(function HistoryItem({
@@ -24,10 +42,24 @@ const HistoryItem = memo(function HistoryItem({
onHover,
onHoverEnd,
isActive,
compareMode,
isChecked,
isCheckboxDisabled,
canCompare,
onToggleCompare,
onStartCompare,
onRestore,
}: HistoryItemProps) {
const { t } = useTranslation();
const date = formattedDate(new Date(historyItem.createdAt));
const handleClick = useCallback(() => {
onSelect(historyItem.id, index);
}, [onSelect, historyItem.id, index]);
if (compareMode) {
onToggleCompare(historyItem.id);
} else {
onSelect(historyItem.id, index);
}
}, [compareMode, onToggleCompare, onSelect, historyItem.id, index]);
const handleMouseEnter = useCallback(() => {
onHover?.(historyItem.id, index);
@@ -37,63 +69,115 @@ const HistoryItem = memo(function HistoryItem({
const hasContributors = contributors && contributors.length > 0;
return (
<UnstyledButton
p="xs"
onClick={handleClick}
<div
className={clsx(classes.history, { [classes.active]: isActive })}
onMouseEnter={handleMouseEnter}
onMouseLeave={onHoverEnd}
className={clsx(classes.history, { [classes.active]: isActive })}
>
<Text size="sm">{formattedDate(new Date(historyItem.createdAt))}</Text>
{compareMode && (
<Checkbox
size="xs"
className={classes.compareCheckbox}
checked={isChecked}
disabled={isCheckboxDisabled}
onChange={() => onToggleCompare(historyItem.id)}
aria-label={t("Select version from {{date}}", { date })}
/>
)}
<Group gap={6} wrap="nowrap" mt={4}>
{hasContributors ? (
<>
<Tooltip.Group openDelay={300} closeDelay={100}>
<Avatar.Group spacing={8}>
{contributors.slice(0, MAX_VISIBLE_AVATARS).map((contributor) => (
<Tooltip key={contributor.id} label={contributor.name} withArrow>
<CustomAvatar
size="sm"
avatarUrl={contributor.avatarUrl}
name={contributor.name}
/>
</Tooltip>
))}
{contributors.length > MAX_VISIBLE_AVATARS && (
<Tooltip
withArrow
label={contributors.slice(MAX_VISIBLE_AVATARS).map((c) => (
<div key={c.id}>{c.name}</div>
<UnstyledButton
p="xs"
onClick={handleClick}
className={classes.historyButton}
>
<Text size="sm">{date}</Text>
<Group gap={6} wrap="nowrap" mt={4}>
{hasContributors ? (
<>
<Tooltip.Group openDelay={300} closeDelay={100}>
<Avatar.Group spacing={8}>
{contributors
.slice(0, MAX_VISIBLE_AVATARS)
.map((contributor) => (
<Tooltip
key={contributor.id}
label={contributor.name}
withArrow
>
<CustomAvatar
size="sm"
avatarUrl={contributor.avatarUrl}
name={contributor.name}
/>
</Tooltip>
))}
>
<Avatar size="sm" color="gray">
+{contributors.length - MAX_VISIBLE_AVATARS}
</Avatar>
</Tooltip>
)}
</Avatar.Group>
</Tooltip.Group>
{contributors.length === 1 && (
{contributors.length > MAX_VISIBLE_AVATARS && (
<Tooltip
withArrow
label={contributors
.slice(MAX_VISIBLE_AVATARS)
.map((c) => (
<div key={c.id}>{c.name}</div>
))}
>
<Avatar size="sm" color="gray">
+{contributors.length - MAX_VISIBLE_AVATARS}
</Avatar>
</Tooltip>
)}
</Avatar.Group>
</Tooltip.Group>
{contributors.length === 1 && (
<Text size="sm" c="dimmed" lineClamp={1}>
{contributors[0].name}
</Text>
)}
</>
) : (
<>
<CustomAvatar
size="sm"
avatarUrl={historyItem.lastUpdatedBy?.avatarUrl}
name={historyItem.lastUpdatedBy?.name}
/>
<Text size="sm" c="dimmed" lineClamp={1}>
{contributors[0].name}
{historyItem.lastUpdatedBy?.name}
</Text>
</>
)}
</Group>
</UnstyledButton>
{!compareMode && (
<Menu shadow="md" width={180} position="bottom-end">
<Menu.Target>
<ActionIcon
variant="subtle"
color="gray"
className={classes.itemMenu}
aria-label={t("Version actions for {{date}}", { date })}
onClick={(e) => e.stopPropagation()}
>
<IconDots size={18} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
disabled={!canCompare}
onClick={() => onStartCompare(historyItem.id)}
>
{t("Compare")}
</Menu.Item>
{onRestore && (
<Menu.Item onClick={() => onRestore(historyItem.id, index)}>
{t("Restore")}
</Menu.Item>
)}
</>
) : (
<>
<CustomAvatar
size="sm"
avatarUrl={historyItem.lastUpdatedBy?.avatarUrl}
name={historyItem.lastUpdatedBy?.name}
/>
<Text size="sm" c="dimmed" lineClamp={1}>
{historyItem.lastUpdatedBy?.name}
</Text>
</>
)}
</Group>
</UnstyledButton>
</Menu.Dropdown>
</Menu>
)}
</div>
);
});
@@ -6,8 +6,12 @@ import HistoryItem from "@/features/page-history/components/history-item";
import {
activeHistoryIdAtom,
activeHistoryPrevIdAtom,
compareModeAtom,
comparePairAtom,
compareSelectionAtom,
historyAtoms,
} from "@/features/page-history/atoms/history-atoms";
import { resolveComparePair } from "@/features/page-history/utils/resolve-compare-pair";
import { useAtom, useSetAtom } from "jotai";
import { useCallback, useEffect, useMemo, useRef } from "react";
import {
@@ -32,6 +36,9 @@ function HistoryList({ pageId }: Props) {
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
const setActiveHistoryPrevId = useSetAtom(activeHistoryPrevIdAtom);
const setHistoryModalOpen = useSetAtom(historyAtoms);
const [compareMode, setCompareMode] = useAtom(compareModeAtom);
const [compareSelection, setCompareSelection] = useAtom(compareSelectionAtom);
const setComparePair = useSetAtom(comparePairAtom);
const {
data: pageHistoryData,
@@ -79,10 +86,58 @@ function HistoryList({ pageId }: Props) {
const handleSelect = useCallback(
(id: string, index: number) => {
setComparePair(null);
setActiveHistoryId(id);
setActiveHistoryPrevId(historyItems[index + 1]?.id ?? "");
},
[historyItems, setActiveHistoryId, setActiveHistoryPrevId],
[historyItems, setActiveHistoryId, setActiveHistoryPrevId, setComparePair],
);
const handleToggleCompare = useCallback(
(id: string) => {
setCompareSelection((prev) => {
if (prev.includes(id)) return prev.filter((item) => item !== id);
if (prev.length >= 2) return prev;
return [...prev, id];
});
},
[setCompareSelection],
);
const handleStartCompare = useCallback(
(id: string) => {
setComparePair(null);
setCompareMode(true);
setCompareSelection([id]);
},
[setComparePair, setCompareMode, setCompareSelection],
);
const handleCancelCompare = useCallback(() => {
setCompareMode(false);
setCompareSelection([]);
}, [setCompareMode, setCompareSelection]);
const handleConfirmCompare = useCallback(() => {
const pair = resolveComparePair(historyItems, compareSelection);
if (!pair) return;
setComparePair(pair);
setCompareMode(false);
setCompareSelection([]);
}, [
historyItems,
compareSelection,
setComparePair,
setCompareMode,
setCompareSelection,
]);
const handleRestoreItem = useCallback(
(id: string, index: number) => {
handleSelect(id, index);
confirmRestore(id);
},
[handleSelect, confirmRestore],
);
useEffect(() => {
@@ -138,6 +193,16 @@ function HistoryList({ pageId }: Props) {
onHover={handleHover}
onHoverEnd={clearPrefetchTimeout}
isActive={historyItem.id === activeHistoryId}
compareMode={compareMode}
isChecked={compareSelection.includes(historyItem.id)}
isCheckboxDisabled={
!compareSelection.includes(historyItem.id) &&
compareSelection.length >= 2
}
canCompare={historyItems.length >= 2}
onToggleCompare={handleToggleCompare}
onStartCompare={handleStartCompare}
onRestore={canRestore ? handleRestoreItem : undefined}
/>
))}
{hasNextPage && <div ref={loadMoreRef} style={{ height: 1 }} />}
@@ -148,22 +213,44 @@ function HistoryList({ pageId }: Props) {
)}
</ScrollArea>
{canRestore && (
{compareMode ? (
<>
<Divider />
<Group p="xs" wrap="nowrap">
<Button
variant="default"
size="compact-md"
onClick={() => setHistoryModalOpen(false)}
onClick={handleCancelCompare}
>
{t("Cancel")}
</Button>
<Button size="compact-md" onClick={confirmRestore}>
{t("Restore")}
<Button
size="compact-md"
disabled={compareSelection.length !== 2}
onClick={handleConfirmCompare}
>
{t("Compare")}
</Button>
</Group>
</>
) : (
canRestore && (
<>
<Divider />
<Group p="xs" wrap="nowrap">
<Button
variant="default"
size="compact-md"
onClick={() => setHistoryModalOpen(false)}
>
{t("Cancel")}
</Button>
<Button size="compact-md" onClick={() => confirmRestore()}>
{t("Restore")}
</Button>
</Group>
</>
)
)}
</div>
);
@@ -1,5 +1,6 @@
import {
ActionIcon,
CloseButton,
Group,
Paper,
ScrollArea,
@@ -12,17 +13,20 @@ import { useAtom, useAtomValue } from "jotai";
import {
activeHistoryIdAtom,
activeHistoryPrevIdAtom,
comparePairAtom,
diffCountsAtom,
highlightChangesAtom,
} from "@/features/page-history/atoms/history-atoms";
import HistoryView from "@/features/page-history/components/history-view";
import { useRef } from "react";
import { useMemo, useRef } from "react";
import { IconChevronUp, IconChevronDown } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
useDiffNavigation,
useHistoryReset,
} from "@/features/page-history/hooks";
import { usePageHistoryListQuery } from "@/features/page-history/queries/page-history-query";
import { formattedDate } from "@/lib/time";
interface Props {
pageId: string;
@@ -36,6 +40,28 @@ export default function HistoryModalBody({ pageId }: Props) {
const activeHistoryPrevId = useAtomValue(activeHistoryPrevIdAtom);
const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom);
const diffCounts = useAtomValue(diffCountsAtom);
const [comparePair, setComparePair] = useAtom(comparePairAtom);
const { data: pageHistoryData } = usePageHistoryListQuery(pageId);
const historyItems = useMemo(
() => pageHistoryData?.pages.flatMap((page) => page.items) ?? [],
[pageHistoryData],
);
const compareLabel = useMemo(() => {
if (!comparePair) return null;
const newerItem = historyItems.find(
(item) => item.id === comparePair.newerId,
);
const olderItem = historyItems.find(
(item) => item.id === comparePair.olderId,
);
if (!newerItem || !olderItem) return null;
return t("Comparing {{newer}} and {{older}}", {
newer: formattedDate(new Date(newerItem.createdAt)),
older: formattedDate(new Date(olderItem.createdAt)),
});
}, [comparePair, historyItems, t]);
useHistoryReset(pageId);
const { currentChangeIndex, handlePrevChange, handleNextChange } =
@@ -50,6 +76,25 @@ export default function HistoryModalBody({ pageId }: Props) {
</nav>
<div style={{ position: "relative", flex: 1 }}>
{comparePair && (
<Group
justify="space-between"
wrap="nowrap"
px="md"
py={4}
className={classes.compareBanner}
>
<Text size="sm" fw={500} lineClamp={1}>
{compareLabel ?? t("Compare versions")}
</Text>
<CloseButton
size="sm"
aria-label={t("Exit compare")}
onClick={() => setComparePair(null)}
/>
</Group>
)}
<ScrollArea
h={650}
w="100%"
@@ -57,11 +102,18 @@ export default function HistoryModalBody({ pageId }: Props) {
viewportRef={scrollViewportRef}
>
<div className={classes.sidebarRightSection}>
{activeHistoryId && <HistoryView />}
{comparePair ? (
<HistoryView
historyId={comparePair.newerId}
prevHistoryId={comparePair.olderId}
/>
) : (
activeHistoryId && <HistoryView />
)}
</div>
</ScrollArea>
{activeHistoryId && activeHistoryPrevId && (
{(comparePair || (activeHistoryId && activeHistoryPrevId)) && (
<Paper
shadow="md"
radius="xl"
@@ -166,7 +166,7 @@ export default function HistoryModalMobile({ pageId, pageTitle }: Props) {
<Button variant="default" onClick={() => setHistoryModalOpen(false)}>
{t("Cancel")}
</Button>
<Button onClick={confirmRestore}>{t("Restore")}</Button>
<Button onClick={() => confirmRestore()}>{t("Restore")}</Button>
</Group>
)}
@@ -7,21 +7,29 @@ import {
activeHistoryPrevIdAtom,
} from "@/features/page-history/atoms/history-atoms";
function HistoryView() {
interface Props {
historyId?: string;
prevHistoryId?: string;
}
function HistoryView({ historyId, prevHistoryId }: Props) {
const { t } = useTranslation();
const historyId = useAtomValue(activeHistoryIdAtom);
const prevHistoryId = useAtomValue(activeHistoryPrevIdAtom);
const activeId = useAtomValue(activeHistoryIdAtom);
const activePrevId = useAtomValue(activeHistoryPrevIdAtom);
const resolvedId = historyId ?? activeId;
const resolvedPrevId = prevHistoryId ?? activePrevId;
const {
data,
isLoading: isLoadingCurrent,
isError: isErrorCurrent,
} = usePageHistoryQuery(historyId);
} = usePageHistoryQuery(resolvedId);
const {
data: prevData,
isLoading: isLoadingPrev,
isError: isErrorPrev,
} = usePageHistoryQuery(prevHistoryId);
} = usePageHistoryQuery(resolvedPrevId);
if (isLoadingCurrent || isLoadingPrev) {
return <></>;
@@ -3,22 +3,45 @@ import { useEffect } from "react";
import {
activeHistoryIdAtom,
activeHistoryPrevIdAtom,
compareModeAtom,
comparePairAtom,
compareSelectionAtom,
diffCountsAtom,
} from "@/features/page-history/atoms/history-atoms";
/**
* Resets history state when pageId changes.
* Clears active selection and diff counts.
* Clears active selection, diff counts, and compare state.
* Compare state also resets on unmount so reopening the modal starts clean.
*/
export function useHistoryReset(pageId: string) {
const [, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
const [, setActiveHistoryPrevId] = useAtom(activeHistoryPrevIdAtom);
const [, setDiffCounts] = useAtom(diffCountsAtom);
const [, setCompareMode] = useAtom(compareModeAtom);
const [, setCompareSelection] = useAtom(compareSelectionAtom);
const [, setComparePair] = useAtom(comparePairAtom);
useEffect(() => {
const resetCompare = () => {
setCompareMode(false);
setCompareSelection([]);
setComparePair(null);
};
setActiveHistoryId("");
setActiveHistoryPrevId("");
// @ts-ignore
setDiffCounts(null);
}, [pageId, setActiveHistoryId, setActiveHistoryPrevId, setDiffCounts]);
resetCompare();
return resetCompare;
}, [
pageId,
setActiveHistoryId,
setActiveHistoryPrevId,
setDiffCounts,
setCompareMode,
setCompareSelection,
setComparePair,
]);
}
@@ -1,4 +1,4 @@
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { useAtomValue, useSetAtom } from "jotai";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Text } from "@mantine/core";
@@ -9,7 +9,8 @@ import {
activeHistoryIdAtom,
historyAtoms,
} from "@/features/page-history/atoms/history-atoms";
import { usePageHistoryQuery } from "@/features/page-history/queries/page-history-query";
import { fetchPageHistory } from "@/features/page-history/queries/page-history-query";
import { IPageHistory } from "@/features/page-history/types/page.types";
import {
pageEditorAtom,
titleEditorAtom,
@@ -25,8 +26,6 @@ export function useHistoryRestore() {
const { t } = useTranslation();
const activeHistoryId = useAtomValue(activeHistoryIdAtom);
const { data: activeHistoryData } = usePageHistoryQuery(activeHistoryId);
const mainEditor = useAtomValue(pageEditorAtom);
const mainEditorTitle = useAtomValue(titleEditorAtom);
const setHistoryModalOpen = useSetAtom(historyAtoms);
@@ -40,47 +39,66 @@ export function useHistoryRestore() {
SpaceCaslSubject.Page,
);
const handleRestore = useCallback(() => {
if (!activeHistoryData) return;
if (
!mainEditor ||
mainEditor.isDestroyed ||
!mainEditorTitle ||
mainEditorTitle.isDestroyed
) {
return;
}
const handleRestore = useCallback(
async (historyId: string) => {
let historyData: IPageHistory;
try {
historyData = await fetchPageHistory(historyId);
} catch {
notifications.show({
message: t("Error fetching page data."),
color: "red",
});
return;
}
mainEditorTitle
.chain()
.clearContent()
.setContent(activeHistoryData.title, { emitUpdate: true })
.run();
if (
!mainEditor ||
mainEditor.isDestroyed ||
!mainEditorTitle ||
mainEditorTitle.isDestroyed
) {
return;
}
mainEditor
.chain()
.clearContent()
.setContent(activeHistoryData.content)
.run();
mainEditorTitle
.chain()
.clearContent()
.setContent(historyData.title, { emitUpdate: true })
.run();
setHistoryModalOpen(false);
notifications.show({ message: t("Successfully restored") });
}, [activeHistoryData, mainEditor, mainEditorTitle, setHistoryModalOpen, t]);
mainEditor
.chain()
.clearContent()
.setContent(historyData.content)
.run();
const confirmRestore = useCallback(() => {
modals.openConfirmModal({
title: t("Please confirm your action"),
children: (
<Text size="sm">
{t(
"Are you sure you want to restore this version? Any changes not versioned will be lost.",
)}
</Text>
),
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
onConfirm: handleRestore,
});
}, [t, handleRestore]);
setHistoryModalOpen(false);
notifications.show({ message: t("Successfully restored") });
},
[mainEditor, mainEditorTitle, setHistoryModalOpen, t],
);
const confirmRestore = useCallback(
(historyId?: string) => {
const targetId = historyId ?? activeHistoryId;
if (!targetId) return;
modals.openConfirmModal({
title: t("Please confirm your action"),
children: (
<Text size="sm">
{t(
"Are you sure you want to restore this version? Any changes not versioned will be lost.",
)}
</Text>
),
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
onConfirm: () => handleRestore(targetId),
});
},
[t, handleRestore, activeHistoryId],
);
return { canRestore, confirmRestore };
}
@@ -23,6 +23,14 @@ export function prefetchPageHistory(historyId: string) {
});
}
export function fetchPageHistory(historyId: string): Promise<IPageHistory> {
return queryClient.fetchQuery({
queryKey: ["page-history", historyId],
queryFn: () => getPageHistoryById(historyId),
staleTime: HISTORY_STALE_TIME,
});
}
export function usePageHistoryListQuery(
pageId: string,
): UseInfiniteQueryResult<InfiniteData<IPagination<IPageHistory>, unknown>> {
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { resolveComparePair } from "./resolve-compare-pair";
// list is newest-first, matching usePageHistoryListQuery order
const items = [{ id: "v3" }, { id: "v2" }, { id: "v1" }];
describe("resolveComparePair", () => {
it("orders newer before older regardless of selection order", () => {
expect(resolveComparePair(items, ["v1", "v3"])).toEqual({
newerId: "v3",
olderId: "v1",
});
expect(resolveComparePair(items, ["v3", "v1"])).toEqual({
newerId: "v3",
olderId: "v1",
});
});
it("returns null unless exactly two versions are selected", () => {
expect(resolveComparePair(items, [])).toBeNull();
expect(resolveComparePair(items, ["v1"])).toBeNull();
expect(resolveComparePair(items, ["v1", "v2", "v3"])).toBeNull();
});
it("returns null when a selected id is not in the list", () => {
expect(resolveComparePair(items, ["v1", "missing"])).toBeNull();
});
it("returns null when the same id is selected twice", () => {
expect(resolveComparePair(items, ["v2", "v2"])).toBeNull();
});
});
@@ -0,0 +1,18 @@
import { ComparePair } from "@/features/page-history/atoms/history-atoms";
/**
* Resolves which of the two selected versions is newer using their position
* in the history list (list is newest-first: lower index = newer).
*/
export function resolveComparePair(
historyItems: { id: string }[],
selection: string[],
): ComparePair | null {
if (selection.length !== 2) return null;
const indexA = historyItems.findIndex((item) => item.id === selection[0]);
const indexB = historyItems.findIndex((item) => item.id === selection[1]);
if (indexA === -1 || indexB === -1 || indexA === indexB) return null;
return indexA < indexB
? { newerId: selection[0], olderId: selection[1] }
: { newerId: selection[1], olderId: selection[0] };
}
@@ -11,6 +11,7 @@ import {
IconList,
IconMarkdown,
IconMessage,
IconPaperclip,
IconPrinter,
IconStar,
IconStarFilled,
@@ -42,6 +43,7 @@ import {
import { formattedDate } from "@/lib/time.ts";
import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx";
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
import PageAttachmentsModal from "@/features/attachments/components/page-attachments-modal.tsx";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
import { PageShareModal } from "@/ee/page-permission";
import {
@@ -157,6 +159,10 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
verificationOpened,
{ open: openVerificationModal, close: closeVerificationModal },
] = useDisclosure(false);
const [
attachmentsOpened,
{ open: openAttachmentsModal, close: closeAttachmentsModal },
] = useDisclosure(false);
const [pageEditor] = useAtom(pageEditorAtom);
const pageUpdatedAt = useTimeAgo(page?.updatedAt);
const favoriteIds = useFavoriteIds("page", page?.spaceId);
@@ -293,6 +299,15 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
</Menu.Item>
)}
{!page?.isBase && (
<Menu.Item
leftSection={<IconPaperclip size={16} />}
onClick={openAttachmentsModal}
>
{t("Attachments")}
</Menu.Item>
)}
{!readOnly && !page?.isBase && (
<PageVerificationMenuItem
pageId={page?.id}
@@ -395,6 +410,12 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
opened={verificationOpened}
onClose={closeVerificationModal}
/>
<PageAttachmentsModal
pageId={page.id}
open={attachmentsOpened}
onClose={closeAttachmentsModal}
/>
</>
);
}
@@ -0,0 +1,152 @@
import { ReactNode, useMemo, useState } from "react";
import { Divider, Group, Menu, ScrollArea, Text, TextInput } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { IconCheck, IconSearch } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query";
import { RadioMenuItem } from "@/components/ui/radio-menu-item";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { IUser } from "@/features/user/types/user.types.ts";
import { useAtomValue } from "jotai";
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
type CreatorFilterMenuProps = {
value: string | null;
onChange: (user: IUser | null) => void;
children: ReactNode;
width?: number;
position?:
| "bottom-start"
| "bottom-end"
| "bottom"
| "top-start"
| "top-end"
| "top";
zIndex?: number;
opened?: boolean;
onOpenChange?: (opened: boolean) => void;
};
export function CreatorFilterMenu({
value,
onChange,
children,
width = 280,
position = "bottom-end",
zIndex,
opened,
onOpenChange,
}: CreatorFilterMenuProps) {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(searchQuery, 300);
const { data: suggestion, isLoading } = useSearchSuggestionsQuery({
query: debouncedQuery,
includeUsers: true,
includeGroups: false,
includePages: false,
preload: true,
});
const users: IUser[] = (suggestion?.users as IUser[]) ?? [];
const currentUser = useAtomValue(userAtom);
// pin the signed-in user on top so they never have to search themselves
const displayUsers = useMemo(() => {
if (!currentUser) return users;
const others = users.filter((user) => user.id !== currentUser.id);
const q = debouncedQuery.trim().toLowerCase();
const matchesQuery =
!q ||
currentUser.name?.toLowerCase().includes(q) ||
currentUser.email?.toLowerCase().includes(q);
return matchesQuery ? [currentUser as IUser, ...others] : users;
}, [users, currentUser, debouncedQuery]);
return (
<Menu
shadow="md"
width={width}
position={position}
zIndex={zIndex}
opened={opened}
onChange={onOpenChange}
>
<Menu.Target>{children}</Menu.Target>
<Menu.Dropdown>
<TextInput
placeholder={t("Find a user")}
data-autofocus
autoFocus
leftSection={<IconSearch size={16} />}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
size="sm"
variant="filled"
radius="sm"
styles={{ input: { marginBottom: 8 } }}
/>
<ScrollArea.Autosize mah={280}>
<Menu.Item
component={RadioMenuItem}
aria-checked={!value}
onClick={() => onChange(null)}
>
<Group flex="1" gap="xs">
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{t("Anyone")}
</Text>
</div>
{!value && <IconCheck size={20} aria-hidden />}
</Group>
</Menu.Item>
<Divider my="xs" />
{displayUsers.length === 0 && (
<Text size="xs" c="dimmed" px="xs" py="sm">
{isLoading ? t("Loading...") : t("No users found")}
</Text>
)}
{displayUsers.map((user) => (
<Menu.Item
key={user.id}
component={RadioMenuItem}
aria-checked={value === user.id}
onClick={() => onChange(user)}
>
<Group flex="1" gap="xs">
<CustomAvatar
avatarUrl={user.avatarUrl}
size={20}
name={user.name}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{user.name}
{user.id === currentUser?.id && (
<Text span size="sm" c="dimmed" fw={400}>
{" "}
({t("you")})
</Text>
)}
</Text>
{user.email && (
<Text size="xs" c="dimmed" truncate>
{user.email}
</Text>
)}
</div>
{value === user.id && <IconCheck size={20} aria-hidden />}
</Group>
</Menu.Item>
))}
</ScrollArea.Autosize>
</Menu.Dropdown>
</Menu>
);
}
@@ -0,0 +1,127 @@
import { ReactNode, useMemo, useState } from "react";
import {
Group,
Menu,
ScrollArea,
Text,
TextInput,
useComputedColorScheme,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { IconCheck, IconSearch } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useWorkspaceLabelsQuery } from "@/features/label/queries/label-query.ts";
import { getLabelColor } from "@/features/label/utils/label-colors.ts";
import { CheckboxMenuItem } from "@/components/ui/checkbox-menu-item";
type LabelFilterMenuProps = {
value: string[];
onChange: (labelIds: string[]) => void;
children: ReactNode;
width?: number;
position?:
| "bottom-start"
| "bottom-end"
| "bottom"
| "top-start"
| "top-end"
| "top";
zIndex?: number;
opened?: boolean;
onOpenChange?: (opened: boolean) => void;
};
export function LabelFilterMenu({
value,
onChange,
children,
width = 280,
position = "bottom-end",
zIndex,
opened,
onOpenChange,
}: LabelFilterMenuProps) {
const { t } = useTranslation();
const scheme = useComputedColorScheme("light");
const [searchQuery, setSearchQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(searchQuery, 300);
const { data, isLoading } = useWorkspaceLabelsQuery(debouncedQuery, true);
const labels = data?.items ?? [];
const selectedSet = useMemo(() => new Set(value), [value]);
const toggleLabel = (labelId: string) => {
if (selectedSet.has(labelId)) {
onChange(value.filter((id) => id !== labelId));
} else {
onChange([...value, labelId]);
}
};
return (
<Menu
shadow="md"
width={width}
position={position}
zIndex={zIndex}
opened={opened}
onChange={onOpenChange}
closeOnItemClick={false}
>
<Menu.Target>{children}</Menu.Target>
<Menu.Dropdown>
<TextInput
placeholder={t("Find a label")}
data-autofocus
autoFocus
leftSection={<IconSearch size={16} />}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
size="sm"
variant="filled"
radius="sm"
styles={{ input: { marginBottom: 8 } }}
/>
<ScrollArea.Autosize mah={280}>
{labels.length === 0 && (
<Text size="xs" c="dimmed" px="xs" py="sm">
{isLoading ? t("Loading...") : t("No labels found")}
</Text>
)}
{labels.map((label) => {
const isChecked = selectedSet.has(label.id);
const color = getLabelColor(label.name, scheme);
return (
<Menu.Item
key={label.id}
type="button"
component={CheckboxMenuItem}
aria-checked={isChecked}
onClick={() => toggleLabel(label.id)}
>
<Group flex="1" gap="xs">
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: color.dot,
flexShrink: 0,
}}
/>
<Text size="sm" fw={500} style={{ flex: 1 }} truncate>
{label.name}
</Text>
{isChecked && <IconCheck size={20} aria-hidden />}
</Group>
</Menu.Item>
);
})}
</ScrollArea.Autosize>
</Menu.Dropdown>
</Menu>
);
}
@@ -19,6 +19,7 @@ import {
} from "@/features/search/types/search.types";
import DOMPurify from "dompurify";
import { useTranslation } from "react-i18next";
import { timeAgo } from "@/lib/time.ts";
interface SearchResultItemProps {
result: IPageSearch | IAttachmentSearch;
@@ -26,6 +27,14 @@ interface SearchResultItemProps {
showSpace?: boolean;
}
// Spotlight hardcodes tabIndex={-1} after spreading props; a ref wins and
// React never writes -1 back because the prop value never changes
const makeActionTabbable = (el: HTMLElement | null) => {
if (el) {
el.tabIndex = 0;
}
};
export function SearchResultItem({
result,
isAttachmentResult,
@@ -46,6 +55,7 @@ export function SearchResultItem({
return (
<Spotlight.Action
component={Link}
ref={makeActionTabbable}
//@ts-ignore
to={buildPageUrl(
attachmentResult.space.slug,
@@ -59,8 +69,13 @@ export function SearchResultItem({
<IconFile size={16} />
</Center>
<div style={{ flex: 1 }}>
<Text>{attachmentResult.fileName}</Text>
<div style={{ flex: 1, minWidth: 0 }}>
<Group justify="space-between" wrap="nowrap" gap="xs">
<Text truncate>{attachmentResult.fileName}</Text>
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{timeAgo(attachmentResult.updatedAt)}
</Text>
</Group>
<Text size="xs" opacity={0.6}>
{attachmentResult.space.name} {attachmentResult.page.title}
</Text>
@@ -96,6 +111,7 @@ export function SearchResultItem({
return (
<Spotlight.Action
component={Link}
ref={makeActionTabbable}
//@ts-ignore
to={buildPageUrl(
pageResult.space.slug,
@@ -107,8 +123,13 @@ export function SearchResultItem({
<Group wrap="nowrap" w="100%">
<Center>{getPageIcon(pageResult?.icon)}</Center>
<div style={{ flex: 1 }}>
<Text>{pageResult.title}</Text>
<div style={{ flex: 1, minWidth: 0 }}>
<Group justify="space-between" wrap="nowrap" gap="xs">
<Text truncate>{pageResult.title || t("Untitled")}</Text>
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{timeAgo(pageResult.updatedAt)}
</Text>
</Group>
{showSpace && pageResult.space && (
<Badge variant="light" size="xs" color="gray">
@@ -17,3 +17,10 @@
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-6));
}
}
.filterButtonActive {
color: var(--mantine-color-blue-light-color);
&:hover {
color: var(--mantine-color-blue-light-color);
}
}
@@ -1,4 +1,5 @@
import React, { useState, useEffect } from "react";
import cx from "clsx";
import {
Button,
Menu,
@@ -11,18 +12,24 @@ import {
import {
IconChevronDown,
IconBuilding,
IconPlus,
IconFileDescription,
IconCheck,
IconUser,
IconTag,
IconLetterCase,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import { SpaceFilterMenu } from "@/features/space/components/space-filter-menu";
import { CreatorFilterMenu } from "@/features/search/components/creator-filter-menu";
import { RadioMenuItem } from "@/components/ui/radio-menu-item";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import classes from "./search-spotlight-filters.module.css";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { LabelFilterMenu } from "./label-filter-menu";
interface SearchSpotlightFiltersProps {
onFiltersChange?: (filters: any) => void;
@@ -40,9 +47,17 @@ export function SearchSpotlightFilters({
const { t } = useTranslation();
const hasAttachmentIndexing = useHasFeature(Feature.ATTACHMENT_INDEXING);
const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>(
spaceId || null,
spaceId || null
);
const [contentType, setContentType] = useState<string | null>("page");
const [selectedCreatorId, setSelectedCreatorId] = useState<string | null>(null);
const [selectedCreatorName, setSelectedCreatorName] = useState<string | null>(
null
);
const [selectedLabelIds, setSelectedLabelIds] = useState<string[]>([]);
const [titleOnly, setTitleOnly] = useState(false);
const [openedFilter, setOpenedFilter] = useState<string | null>(null);
const [visibleFilters, setVisibleFilters] = useState<string[]>([]);
const [workspace] = useAtom(workspaceAtom);
const { data: spacesData } = useGetSpacesQuery({ limit: 100 });
@@ -50,15 +65,6 @@ export function SearchSpotlightFilters({
? spacesData?.items.find((space) => space.id === selectedSpaceId)
: null;
useEffect(() => {
if (onFiltersChange) {
onFiltersChange({
spaceId: selectedSpaceId,
contentType,
});
}
}, []);
const contentTypeOptions = [
{ value: "page", label: t("Pages") },
{
@@ -68,38 +74,70 @@ export function SearchSpotlightFilters({
},
];
useEffect(() => {
onFiltersChange?.({
spaceId: selectedSpaceId,
contentType,
creatorId: selectedCreatorId,
labelIds: selectedLabelIds,
titleOnly,
});
}, [
selectedSpaceId,
contentType,
selectedCreatorId,
selectedLabelIds,
titleOnly,
onFiltersChange,
]);
const handleSpaceSelect = (spaceId: string | null) => {
setSelectedSpaceId(spaceId);
};
if (onFiltersChange) {
onFiltersChange({
spaceId: spaceId,
contentType,
});
const handleCreatorSelect = (user: { id: string; name: string } | null) => {
setSelectedCreatorId(user?.id ?? null);
setSelectedCreatorName(user?.name ?? null);
};
const handleLabelsSelect = (labelIds: string[]) => {
setSelectedLabelIds(labelIds);
};
const handleChangeContentType = (value: string) => {
setContentType(value);
if (value === "attachment") {
setSelectedLabelIds([]);
}
};
const handleFilterChange = (filterType: string, value: any) => {
let newSelectedSpaceId = selectedSpaceId;
let newContentType = contentType;
const onDemandFilters = [
{ key: "creator", label: t("Created by"), icon: IconUser, available: true },
{
key: "labels",
label: t("Labels"),
icon: IconTag,
available: contentType !== "attachment",
},
];
switch (filterType) {
case "spaceId":
newSelectedSpaceId = value;
setSelectedSpaceId(value);
break;
case "contentType":
newContentType = value;
setContentType(value);
break;
}
const isFilterVisible = (key: string) => {
if (openedFilter === key) return true;
if (key === "creator") return !!selectedCreatorId;
if (key === "labels")
return contentType !== "attachment" && selectedLabelIds.length > 0;
return false;
};
if (onFiltersChange) {
onFiltersChange({
spaceId: newSelectedSpaceId,
contentType: newContentType,
});
}
const orderedVisibleFilters = visibleFilters.filter(isFilterVisible);
const addableFilters = onDemandFilters.filter(
(filter) => filter.available && !isFilterVisible(filter.key),
);
const revealFilter = (key: string) => {
setVisibleFilters((prev) => [...prev.filter((k) => k !== key), key]);
setOpenedFilter(key);
};
return (
@@ -122,8 +160,17 @@ export function SearchSpotlightFilters({
color="blue"
labelPosition="left"
styles={{
root: { display: "flex", alignItems: "center" },
label: { paddingRight: "8px", fontSize: "13px", fontWeight: 500 },
root: {
display: "flex",
alignItems: "center",
flexShrink: 0,
},
label: {
whiteSpace: "nowrap",
paddingRight: "8px",
fontSize: "13px",
fontWeight: 500,
},
}}
/>
</div>
@@ -181,7 +228,7 @@ export function SearchSpotlightFilters({
onClick={() =>
!option.disabled &&
contentType !== option.value &&
handleFilterChange("contentType", option.value)
handleChangeContentType(option.value)
}
disabled={
option.disabled || (isAiMode && option.value === "attachment")
@@ -195,13 +242,11 @@ export function SearchSpotlightFilters({
{t("Enterprise")}
</Badge>
)}
{!option.disabled &&
isAiMode &&
option.value === "attachment" && (
<Text size="xs" mt={4}>
{t("AI Answers not available for attachments")}
</Text>
)}
{!option.disabled && isAiMode && option.value === "attachment" && (
<Text size="xs" mt={4}>
{t("AI Answers not available for attachments")}
</Text>
)}
</div>
{contentType === option.value && <IconCheck size={20} aria-hidden />}
</Group>
@@ -209,6 +254,126 @@ export function SearchSpotlightFilters({
))}
</Menu.Dropdown>
</Menu>
{!isAiMode && (
<Button
variant={titleOnly ? "light" : "subtle"}
color={titleOnly ? "blue" : "gray"}
size="sm"
radius="xl"
leftSection={<IconLetterCase size={16} />}
className={cx(
classes.filterButton,
titleOnly && classes.filterButtonActive,
)}
fw={500}
aria-pressed={titleOnly}
onClick={() => setTitleOnly(!titleOnly)}
>
{t("Title only")}
</Button>
)}
{!isAiMode &&
orderedVisibleFilters.map((filterKey) => {
if (filterKey === "creator") {
return (
<CreatorFilterMenu
key="creator"
value={selectedCreatorId}
onChange={handleCreatorSelect}
position="bottom-start"
width={250}
zIndex={getDefaultZIndex("max")}
opened={openedFilter === "creator"}
onOpenChange={(opened) =>
setOpenedFilter(opened ? "creator" : null)
}
>
<Button
variant="subtle"
color="gray"
size="sm"
rightSection={<IconChevronDown size={14} />}
leftSection={<IconUser size={16} />}
className={classes.filterButton}
fw={500}
>
{selectedCreatorId
? `${t("Created by")}: ${selectedCreatorName || t("Unknown")}`
: `${t("Created by")}: ${t("Anyone")}`}
</Button>
</CreatorFilterMenu>
);
}
if (filterKey === "labels") {
return (
<LabelFilterMenu
key="labels"
value={selectedLabelIds}
onChange={handleLabelsSelect}
position="bottom-start"
width={250}
zIndex={getDefaultZIndex("max")}
opened={openedFilter === "labels"}
onOpenChange={(opened) =>
setOpenedFilter(opened ? "labels" : null)
}
>
<Button
variant="subtle"
color="gray"
size="sm"
rightSection={<IconChevronDown size={14} />}
leftSection={<IconTag size={16} />}
className={classes.filterButton}
fw={500}
>
{selectedLabelIds.length > 0
? `${t("Labels")} (${selectedLabelIds.length})`
: t("Labels")}
</Button>
</LabelFilterMenu>
);
}
return null;
})}
{!isAiMode && addableFilters.length > 0 && (
<Menu
shadow="md"
width={200}
position="bottom-end"
zIndex={getDefaultZIndex("max")}
>
<Menu.Target>
<Button
variant="subtle"
color="gray"
size="sm"
leftSection={<IconPlus size={16} />}
className={classes.filterButton}
style={{ marginLeft: "auto" }}
fw={500}
>
{t("Filter")}
</Button>
</Menu.Target>
<Menu.Dropdown>
{addableFilters.map((filter) => (
<Menu.Item
key={filter.key}
leftSection={<filter.icon size={16} />}
onClick={() => revealFilter(filter.key)}
>
{filter.label}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
)}
</div>
);
}
@@ -1,7 +1,7 @@
import { Spotlight } from "@mantine/spotlight";
import { IconSearch, IconSparkles } from "@tabler/icons-react";
import { Group, Button, VisuallyHidden } from "@mantine/core";
import React, { useState, useMemo, useEffect } from "react";
import { Group, Button, VisuallyHidden, Text } from "@mantine/core";
import React, { useState, useMemo, useEffect, useCallback } from "react";
import { useDebouncedValue } from "@mantine/hooks";
import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications";
@@ -13,11 +13,16 @@ import { SearchResultItem } from "./search-result-item.tsx";
import { AiSearchResult } from "../../../ee/ai/components/ai-search-result.tsx";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useAtomValue } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { hintVectorCache } from "@/ee/ai/services/ai-search-service.ts";
import { getAiVectorDriver } from "@/lib/config.ts";
interface SearchSpotlightProps {
spaceId?: string;
}
export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
const workspace = useAtomValue(workspaceAtom);
const { t } = useTranslation();
const hasAiFeature = useHasFeature(Feature.AI);
const hasAttachmentIndexing = useHasFeature(Feature.ATTACHMENT_INDEXING);
@@ -26,6 +31,9 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
const [filters, setFilters] = useState<{
spaceId?: string | null;
contentType?: string;
creatorId?: string | null;
labelIds?: string[];
titleOnly?: boolean;
}>({
contentType: "page",
});
@@ -43,10 +51,25 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
params.spaceId = filters.spaceId;
}
if (filters.creatorId) {
params.creatorId = filters.creatorId;
}
if (filters.labelIds?.length) {
params.labelIds = filters.labelIds;
}
if (filters.titleOnly) {
params.titleOnly = true;
}
return params;
}, [debouncedSearchQuery, filters]);
const { data: searchResults, isLoading } = useUnifiedSearch(
const {
data: searchResults,
isFetching,
} = useUnifiedSearch(
searchParams,
!isAiMode // Disable regular search when in AI mode
);
@@ -83,6 +106,11 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
}
}, [aiSearchError, t]);
const isFilterBrowse =
(filters.labelIds?.length ?? 0) > 0 || !!filters.creatorId;
// while the debounce is pending the empty list is not a settled "no results"
const isQuerySettled = query === debouncedSearchQuery;
// Determine result type for rendering
const isAttachmentSearch =
filters.contentType === "attachment" && hasAttachmentIndexing;
@@ -96,10 +124,19 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
/>
));
const handleFiltersChange = (newFilters: any) => {
setFilters(newFilters);
const handleSpotlightOpen = () => {
if (
workspace?.settings?.ai?.search === true &&
getAiVectorDriver() === "turbopuffer"
) {
hintVectorCache();
}
};
const handleFiltersChange = useCallback((newFilters: any) => {
setFilters(newFilters);
}, [setFilters]);
const handleAskClick = () => {
setIsAiMode(!isAiMode);
};
@@ -115,6 +152,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
<Spotlight.Root
size="xl"
maxHeight={600}
onSpotlightOpen={handleSpotlightOpen}
store={searchSpotlightStore}
query={query}
onQueryChange={setQuery}
@@ -167,7 +205,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
? query.length > 0 && !isAiLoading && !aiSearchResult
? t("No answer available")
: ""
: query.length > 0 && !isLoading
: (query.length > 0 || isFilterBrowse) && !isFetching
? resultItems.length === 0
? t("No results found")
: t("{{count}} results found", { count: resultItems.length })
@@ -194,15 +232,28 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
</>
) : (
<>
{query.length === 0 && resultItems.length === 0 && (
{query.length === 0 && !isFilterBrowse && resultItems.length === 0 && (
<Spotlight.Empty>{t("Start typing to search...")}</Spotlight.Empty>
)}
{query.length > 0 && !isLoading && resultItems.length === 0 && (
<Spotlight.Empty>{t("No results found...")}</Spotlight.Empty>
)}
{(query.length > 0 || isFilterBrowse) &&
!isFetching &&
isQuerySettled &&
resultItems.length === 0 && (
<Spotlight.Empty>{t("No results found...")}</Spotlight.Empty>
)}
{resultItems.length > 0 && <>{resultItems}</>}
{(query.length > 0 || isFilterBrowse) &&
isFetching &&
resultItems.length === 0 && (
<Spotlight.Empty>
<Text size="sm" style={{ marginTop: 10 }}>
{t("Searching...")}
</Text>
</Spotlight.Empty>
)}
</>
)}
</Spotlight.ActionsList>
@@ -39,6 +39,20 @@ export function useUnifiedSearch(
return await searchPage(backendParams);
}
},
enabled: !!params.query && enabled,
enabled:
(!!params.query ||
(params.labelIds?.length ?? 0) > 0 ||
!!params.creatorId) &&
enabled,
// keep previous results only within the same search type; page results
// rendered as attachments (or vice versa) crash on missing fields
placeholderData: (previousData, previousQuery) => {
if (!params.query && !params.labelIds?.length && !params.creatorId)
return undefined;
if (previousQuery && previousQuery.queryKey[1] !== searchType) {
return undefined;
}
return previousData;
},
});
}

Some files were not shown because too many files have changed in this diff Show More