Compare commits

..
201 changed files with 7655 additions and 3879 deletions
+11
View File
@@ -51,6 +51,17 @@ DRAWIO_URL=
# Gotenberg URL for server-side PDF export # Gotenberg URL for server-side PDF export
GOTENBERG_URL= GOTENBERG_URL=
# Integration OAuth apps (create one per provider you enable)
INTEGRATION_GITHUB_CLIENT_ID=
INTEGRATION_GITHUB_CLIENT_SECRET=
# Set only for GitHub Enterprise Server, e.g https://github.example.com
INTEGRATION_GITHUB_BASE_URL=
INTEGRATION_GITLAB_CLIENT_ID=
INTEGRATION_GITLAB_CLIENT_SECRET=
# Set only for self-hosted GitLab, e.g https://gitlab.example.com
INTEGRATION_GITLAB_BASE_URL=
DISABLE_TELEMETRY=false DISABLE_TELEMETRY=false
# Allow other sites to embed Docmost in an iframe. # Allow other sites to embed Docmost in an iframe.
+1 -10
View File
@@ -1,7 +1,7 @@
FROM node:26-slim AS base FROM node:26-slim AS base
LABEL org.opencontainers.image.source="https://github.com/docmost/docmost" LABEL org.opencontainers.image.source="https://github.com/docmost/docmost"
RUN npm install -g pnpm@11.23.0 RUN npm install -g pnpm@11.15.1
FROM base AS builder FROM base AS builder
@@ -18,15 +18,6 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends curl bash \ && apt-get install -y --no-install-recommends curl bash \
&& rm -rf /var/lib/apt/lists/* && 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 WORKDIR /app
# Copy apps # Copy apps
+1 -2
View File
@@ -52,7 +52,7 @@
"mantine-form-zod-resolver": "1.3.0", "mantine-form-zod-resolver": "1.3.0",
"mermaid": "11.16.1", "mermaid": "11.16.1",
"mitt": "3.0.1", "mitt": "3.0.1",
"nanoid": "3.3.18", "nanoid": "3.3.17",
"posthog-js": "1.391.2", "posthog-js": "1.391.2",
"react": "19.2.7", "react": "19.2.7",
"react-clear-modal": "^2.0.18", "react-clear-modal": "^2.0.18",
@@ -64,7 +64,6 @@
"react-router-dom": "7.18.2", "react-router-dom": "7.18.2",
"semver": "7.7.4", "semver": "7.7.4",
"socket.io-client": "4.8.3", "socket.io-client": "4.8.3",
"yet-another-react-lightbox": "^3.32.2",
"zod": "4.3.6" "zod": "4.3.6"
}, },
"devDependencies": { "devDependencies": {
@@ -696,7 +696,7 @@
"Upgrade your plan": "Upgrade Ihres Plans", "Upgrade your plan": "Upgrade Ihres Plans",
"Available with a paid license": "Verfügbar mit einer kostenpflichtigen Lizenz", "Available with a paid license": "Verfügbar mit einer kostenpflichtigen Lizenz",
"Upgrade your license tier.": "Stufen Sie Ihre Lizenz hoch.", "Upgrade your license tier.": "Stufen Sie Ihre Lizenz hoch.",
"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 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 & MCP": "KI & MCP", "AI & MCP": "KI & MCP",
"AI": "KI", "AI": "KI",
"MCP": "MCP", "MCP": "MCP",
@@ -14,6 +14,7 @@
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.", "Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.",
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Are you sure you want to remove this user from the space? The user will lose all access to this space.", "Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Are you sure you want to remove this user from the space? The user will lose all access to this space.",
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Are you sure you want to restore this version? Any changes not versioned will be lost.", "Are you sure you want to restore this version? Any changes not versioned will be lost.": "Are you sure you want to restore this version? Any changes not versioned will be lost.",
"Assigned to {{name}}": "Assigned to {{name}}",
"Can become members of groups and spaces in workspace": "Can become members of groups and spaces in workspace", "Can become members of groups and spaces in workspace": "Can become members of groups and spaces in workspace",
"Can create and edit pages in space.": "Can create and edit pages in space.", "Can create and edit pages in space.": "Can create and edit pages in space.",
"Can edit": "Can edit", "Can edit": "Can edit",
@@ -22,6 +23,7 @@
"Can view": "Can view", "Can view": "Can view",
"Can view pages in space but not edit.": "Can view pages in space but not edit.", "Can view pages in space but not edit.": "Can view pages in space but not edit.",
"Cancel": "Cancel", "Cancel": "Cancel",
"Card": "Card",
"Change email": "Change email", "Change email": "Change email",
"Change password": "Change password", "Change password": "Change password",
"Change photo": "Change photo", "Change photo": "Change photo",
@@ -30,6 +32,8 @@
"Choose your preferred interface language.": "Choose your preferred interface language.", "Choose your preferred interface language.": "Choose your preferred interface language.",
"Choose your preferred page width.": "Choose your preferred page width.", "Choose your preferred page width.": "Choose your preferred page width.",
"Confirm": "Confirm", "Confirm": "Confirm",
"Connect to {{name}} to preview": "Connect to {{name}} to preview",
"Connected as {{label}}": "Connected as {{label}}",
"Copy as Markdown": "Copy as Markdown", "Copy as Markdown": "Copy as Markdown",
"Copy link": "Copy link", "Copy link": "Copy link",
"Create": "Create", "Create": "Create",
@@ -41,6 +45,16 @@
"Dark": "Dark", "Dark": "Dark",
"Date": "Date", "Date": "Date",
"Delete": "Delete", "Delete": "Delete",
"Initiative": "Initiative",
"Last modified by {{name}}": "Last modified by {{name}}",
"Open in Slack": "Open in Slack",
"Paid": "Paid",
"Paste as": "Paste as",
"Project": "Project",
"Uninstall {{name}}": "Uninstall {{name}}",
"Disconnect {{name}}": "Disconnect {{name}}",
"This disconnects your {{name}} account. Links are not enriched for you until you reconnect.": "This disconnects your {{name}} account. Links are not enriched for you until you reconnect.",
"This disables the {{name}} integration for the entire workspace. Members' connections are removed and links are not enriched.": "This disables the {{name}} integration for the entire workspace. Members' connections are removed and links are not enriched.",
"Remove from page": "Remove from page", "Remove from page": "Remove from page",
"Base options": "Base options", "Base options": "Base options",
"Delete group": "Delete group", "Delete group": "Delete group",
@@ -150,6 +164,8 @@
"page": "page", "page": "page",
"Page deleted successfully": "Page deleted successfully", "Page deleted successfully": "Page deleted successfully",
"Page history": "Page history", "Page history": "Page history",
"replies": "replies",
"reply": "reply",
"Select version": "Select version", "Select version": "Select version",
"Highlight changes": "Highlight changes", "Highlight changes": "Highlight changes",
"Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.", "Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.",
@@ -184,6 +200,8 @@
"Invitation sent": "Invitation sent", "Invitation sent": "Invitation sent",
"Settings": "Settings", "Settings": "Settings",
"Setup workspace": "Setup workspace", "Setup workspace": "Setup workspace",
"show less": "show less",
"show more": "show more",
"Sign In": "Sign In", "Sign In": "Sign In",
"Sign Up": "Sign Up", "Sign Up": "Sign Up",
"Slug": "Slug", "Slug": "Slug",
@@ -206,7 +224,9 @@
"Theme": "Theme", "Theme": "Theme",
"To change your email, you have to enter your password and new email.": "To change your email, you have to enter your password and new email.", "To change your email, you have to enter your password and new email.": "To change your email, you have to enter your password and new email.",
"Toggle full page width": "Toggle full page width", "Toggle full page width": "Toggle full page width",
"Toggle {{name}} integration": "Toggle {{name}} integration",
"Unable to import pages. Please try again.": "Unable to import pages. Please try again.", "Unable to import pages. Please try again.": "Unable to import pages. Please try again.",
"Unassigned": "Unassigned",
"untitled": "untitled", "untitled": "untitled",
"Untitled": "Untitled", "Untitled": "Untitled",
"Updated successfully": "Updated successfully", "Updated successfully": "Updated successfully",
@@ -294,7 +314,6 @@
"Export space": "Export space", "Export space": "Export space",
"Export {{type}}": "Export {{type}}", "Export {{type}}": "Export {{type}}",
"File exceeds the {{limit}} attachment limit": "File exceeds the {{limit}} attachment limit", "File exceeds the {{limit}} attachment limit": "File exceeds the {{limit}} attachment limit",
"Media": "Media",
"Align left": "Align left", "Align left": "Align left",
"Align right": "Align right", "Align right": "Align right",
"Align center": "Align center", "Align center": "Align center",
@@ -699,7 +718,7 @@
"Upgrade your plan": "Upgrade your plan", "Upgrade your plan": "Upgrade your plan",
"Available with a paid license": "Available with a paid license", "Available with a paid license": "Available with a paid license",
"Upgrade your license tier.": "Upgrade your license tier.", "Upgrade your license tier.": "Upgrade your license tier.",
"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 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 & MCP": "AI & MCP", "AI & MCP": "AI & MCP",
"AI": "AI", "AI": "AI",
"MCP": "MCP", "MCP": "MCP",
@@ -707,11 +726,9 @@
"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.", "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 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", "MCP Server URL": "MCP Server URL",
"Connect AI assistants with your Docmost account via OAuth.": "Connect AI assistants with your Docmost account via OAuth.", "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.",
"Require OAuth": "Require OAuth",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.",
"Toggle require OAuth for MCP": "Toggle require OAuth for MCP",
"Supported tools": "Supported tools", "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:", "MCP server URL:": "MCP server URL:",
"Learn more": "Learn more", "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.", "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.",
@@ -1305,37 +1322,5 @@
"Error loading attachments.": "Error loading attachments.", "Error loading attachments.": "Error loading attachments.",
"No attachments on this page yet.": "No attachments on this page yet.", "No attachments on this page yet.": "No attachments on this page yet.",
"Uploaded by {{name}}": "Uploaded by {{name}}", "Uploaded by {{name}}": "Uploaded by {{name}}",
"Download {{name}}": "Download {{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.",
"Remove {{name}}": "Remove {{name}}",
"Make sure you trust this application before authorizing it.": "Make sure you trust this application before authorizing it.",
"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.",
"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 via OAuth.": "Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.",
"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", "Upgrade your plan": "Mejora tu plan",
"Available with a paid license": "Disponible con una licencia de pago", "Available with a paid license": "Disponible con una licencia de pago",
"Upgrade your license tier.": "Mejora el nivel de tu licencia.", "Upgrade your license tier.": "Mejora el nivel de tu licencia.",
"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 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 & MCP": "IA y MCP", "AI & MCP": "IA y MCP",
"AI": "IA", "AI": "IA",
"MCP": "MCP", "MCP": "MCP",
@@ -696,7 +696,7 @@
"Upgrade your plan": "Mettez à niveau votre forfait", "Upgrade your plan": "Mettez à niveau votre forfait",
"Available with a paid license": "Disponible avec une licence payante", "Available with a paid license": "Disponible avec une licence payante",
"Upgrade your license tier.": "Mettez à niveau votre niveau de licence.", "Upgrade your license tier.": "Mettez à niveau votre niveau de licence.",
"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 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 & MCP": "IA & MCP", "AI & MCP": "IA & MCP",
"AI": "IA", "AI": "IA",
"MCP": "MCP", "MCP": "MCP",
@@ -696,7 +696,7 @@
"Upgrade your plan": "Aggiorna il tuo piano", "Upgrade your plan": "Aggiorna il tuo piano",
"Available with a paid license": "Disponibile con una licenza a pagamento", "Available with a paid license": "Disponibile con una licenza a pagamento",
"Upgrade your license tier.": "Aggiorna il livello della tua licenza.", "Upgrade your license tier.": "Aggiorna il livello della tua licenza.",
"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 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 & MCP": "IA e MCP", "AI & MCP": "IA e MCP",
"AI": "IA", "AI": "IA",
"MCP": "MCP", "MCP": "MCP",
@@ -696,7 +696,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 available in the Docmost paid editions. Contact sales@docmost.com.": "AI は Docmost の有料版で利用可能です。sales@docmost.com までお問い合わせください。", "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI は Docmost のエンタープライズ版でのみ利用可能です。sales@docmost.com までお問い合わせください。",
"AI & MCP": "AI と MCP", "AI & MCP": "AI と MCP",
"AI": "AI", "AI": "AI",
"MCP": "MCP", "MCP": "MCP",
@@ -696,7 +696,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 available in the Docmost paid editions. Contact sales@docmost.com.": "AI는 Docmost 유료 에디션에서 제공됩니다. sales@docmost.com으로 문의하세요.", "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI는 Docmost 엔터프라이즈 에디션에서 제공됩니다. sales@docmost.com으로 문의하세요.",
"AI & MCP": "AI 및 MCP", "AI & MCP": "AI 및 MCP",
"AI": "AI", "AI": "AI",
"MCP": "MCP", "MCP": "MCP",
@@ -696,7 +696,7 @@
"Upgrade your plan": "Upgrade je abonnement", "Upgrade your plan": "Upgrade je abonnement",
"Available with a paid license": "Beschikbaar met een betaalde licentie", "Available with a paid license": "Beschikbaar met een betaalde licentie",
"Upgrade your license tier.": "Upgrade je licentieniveau.", "Upgrade your license tier.": "Upgrade je licentieniveau.",
"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 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 & MCP": "AI & MCP", "AI & MCP": "AI & MCP",
"AI": "AI", "AI": "AI",
"MCP": "MCP", "MCP": "MCP",
@@ -696,7 +696,7 @@
"Upgrade your plan": "Faça upgrade do seu plano", "Upgrade your plan": "Faça upgrade do seu plano",
"Available with a paid license": "Disponível com uma licença paga", "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.", "Upgrade your license tier.": "Faça upgrade do seu nível de licença.",
"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 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 & MCP": "IA e MCP", "AI & MCP": "IA e MCP",
"AI": "IA", "AI": "IA",
"MCP": "MCP", "MCP": "MCP",
@@ -696,7 +696,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 available in the Docmost paid editions. Contact sales@docmost.com.": "ИИ доступен в платных версиях Docmost. Свяжитесь по адресу sales@docmost.com.", "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "ИИ доступен только в корпоративной версии Docmost. Свяжитесь по адресу sales@docmost.com.",
"AI & MCP": "ИИ и MCP", "AI & MCP": "ИИ и MCP",
"AI": "ИИ", "AI": "ИИ",
"MCP": "MCP", "MCP": "MCP",
@@ -696,7 +696,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 available in the Docmost paid editions. Contact sales@docmost.com.": "ШІ доступний у платних редакціях Docmost. Зверніться до sales@docmost.com.", "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "ШІ доступний лише в корпоративній редакції Docmost. Зверніться до sales@docmost.com.",
"AI & MCP": "ШІ та MCP", "AI & MCP": "ШІ та MCP",
"AI": "ШІ", "AI": "ШІ",
"MCP": "MCP", "MCP": "MCP",
@@ -696,7 +696,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 available in the Docmost paid editions. Contact sales@docmost.com.": "AI 在 Docmost 付费版中提供。请联系 sales@docmost.com。", "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI 在 Docmost 企业版中提供。请联系 sales@docmost.com。",
"AI & MCP": "AI 与 MCP", "AI & MCP": "AI 与 MCP",
"AI": "AI", "AI": "AI",
"MCP": "MCP", "MCP": "MCP",
+51 -84
View File
@@ -1,94 +1,63 @@
import { lazy, Suspense, useEffect } from "react";
import { Navigate, Route, Routes } from "react-router-dom"; import { Navigate, Route, Routes } from "react-router-dom";
import Layout from "@/components/layouts/global/layout.tsx"; 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 { 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 { isCloud } from "@/lib/config.ts"; import { isCloud } from "@/lib/config.ts";
import { useTranslation } from "react-i18next"; 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 { 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 { useTrackOrigin } from "@/hooks/use-track-origin";
import SpacesPage from "@/pages/spaces/spaces.tsx";
import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page";
const SetupWorkspace = lazy(() => import("@/pages/auth/setup-workspace.tsx")); import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page";
const LoginPage = lazy(() => import("@/pages/auth/login")); import SpaceTrash from "@/pages/space/space-trash.tsx";
const Home = lazy(() => import("@/pages/dashboard/home")); import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
const Page = lazy(() => import("@/pages/page/page")); import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
const AccountSettings = lazy( import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
() => import("@/pages/settings/account/account-settings"), import Integrations from "@/features/integration/pages/integrations.tsx";
); import Connections from "@/features/integration/pages/connections.tsx";
const WorkspaceMembers = lazy( import SlackLinkPage from "@/features/integration/pages/slack-link.tsx";
() => import("@/pages/settings/workspace/workspace-members"), import BasePage from "@/ee/base/pages/base-page.tsx";
); import AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
const WorkspaceSettings = lazy( import VerifiedPages from "@/ee/page-verification/pages/verified-pages.tsx";
() => import("@/pages/settings/workspace/workspace-settings"), import TemplateList from "@/ee/template/pages/template-list";
); import TemplateEditor from "@/ee/template/pages/template-editor";
const Groups = lazy(() => import("@/pages/settings/group/groups")); import FavoritesPage from "@/pages/favorites/favorites-page";
const GroupInfo = lazy(() => import("./pages/settings/group/group-info")); import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx";
const Spaces = lazy(() => import("@/pages/settings/space/spaces.tsx")); import VerifyEmail from "@/ee/pages/verify-email.tsx";
const AccountPreferences = lazy( import LabelPage from "@/pages/label/label-page";
() => 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() { export default function App() {
const { t } = useTranslation(); const { t } = useTranslation();
useRedirectToCloudSelect(); useRedirectToCloudSelect();
useTrackOrigin(); 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 ( return (
<Suspense fallback={null}> <>
<Routes> <Routes>
<Route index element={<Navigate to="/home" />} /> <Route index element={<Navigate to="/home" />} />
<Route path={"/login"} element={<LoginPage />} /> <Route path={"/login"} element={<LoginPage />} />
@@ -97,7 +66,6 @@ export default function App() {
<Route path={"/password-reset"} element={<PasswordReset />} /> <Route path={"/password-reset"} element={<PasswordReset />} />
<Route path={"/login/mfa"} element={<MfaChallengePage />} /> <Route path={"/login/mfa"} element={<MfaChallengePage />} />
<Route path={"/login/mfa/setup"} element={<MfaSetupRequiredPage />} /> <Route path={"/login/mfa/setup"} element={<MfaSetupRequiredPage />} />
<Route path={"/oauth/consent"} element={<OAuthConsent />} />
{!isCloud() && ( {!isCloud() && (
<Route path={"/setup/register"} element={<SetupWorkspace />} /> <Route path={"/setup/register"} element={<SetupWorkspace />} />
@@ -122,6 +90,7 @@ export default function App() {
<Route path={"/pdf-render/:pageId"} element={<PdfRenderPage />} /> <Route path={"/pdf-render/:pageId"} element={<PdfRenderPage />} />
<Route path={"/share/:shareId"} element={<ShareRedirect />} /> <Route path={"/share/:shareId"} element={<ShareRedirect />} />
<Route path={"/p/:pageSlug"} element={<PageRedirect />} /> <Route path={"/p/:pageSlug"} element={<PageRedirect />} />
<Route path={"/integrations/slack/link"} element={<SlackLinkPage />} />
<Route element={<Layout />}> <Route element={<Layout />}>
<Route path={"/home"} element={<Home />} /> <Route path={"/home"} element={<Home />} />
@@ -151,10 +120,7 @@ export default function App() {
element={<AccountPreferences />} element={<AccountPreferences />}
/> />
<Route path={"account/api-keys"} element={<UserApiKeys />} /> <Route path={"account/api-keys"} element={<UserApiKeys />} />
<Route <Route path={"account/connections"} element={<Connections />} />
path={"account/api-keys/authorized-apps"}
element={<UserApiKeys />}
/>
<Route path={"workspace"} element={<WorkspaceSettings />} /> <Route path={"workspace"} element={<WorkspaceSettings />} />
<Route path={"members"} element={<WorkspaceMembers />} /> <Route path={"members"} element={<WorkspaceMembers />} />
<Route path={"api-keys"} element={<WorkspaceApiKeys />} /> <Route path={"api-keys"} element={<WorkspaceApiKeys />} />
@@ -167,6 +133,7 @@ export default function App() {
<Route path={"ai/mcp"} element={<AiSettings />} /> <Route path={"ai/mcp"} element={<AiSettings />} />
<Route path={"audit"} element={<AuditLogs />} /> <Route path={"audit"} element={<AuditLogs />} />
<Route path={"verifications"} element={<VerifiedPages />} /> <Route path={"verifications"} element={<VerifiedPages />} />
<Route path={"integrations"} element={<Integrations />} />
{!isCloud() && <Route path={"license"} element={<License />} />} {!isCloud() && <Route path={"license"} element={<License />} />}
{isCloud() && <Route path={"billing"} element={<Billing />} />} {isCloud() && <Route path={"billing"} element={<Billing />} />}
</Route> </Route>
@@ -174,6 +141,6 @@ export default function App() {
<Route path="*" element={<Error404 />} /> <Route path="*" element={<Error404 />} />
</Routes> </Routes>
</Suspense> </>
); );
} }
@@ -0,0 +1,22 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function GithubIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
style={{ width: rem(size), height: rem(size) }}
>
<path
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
d="M256 6.3C114.6 6.3 0 120.9 0 262.3c0 113.3 73.3 209 175 242.9 12.8 2.2 17.6-5.4 17.6-12.2 0-6.1-.3-26.2-.3-47.7-64.3 11.8-81-15.7-86.1-30.1-2.9-7.4-15.4-30.1-26.2-36.2-9-4.8-21.8-16.6-.3-17 20.2-.3 34.6 18.6 39.4 26.2 23 38.7 59.8 27.8 74.6 21.1 2.2-16.6 9-27.8 16.3-34.2-57-6.4-116.5-28.5-116.5-126.4 0-27.8 9.9-50.9 26.2-68.8-2.6-6.4-11.5-32.6 2.6-67.8 0 0 21.4-6.7 70.4 26.2 20.5-5.8 42.2-8.6 64-8.6s43.5 2.9 64 8.6c49-33.3 70.4-26.2 70.4-26.2 14.1 35.2 5.1 61.4 2.6 67.8 16.3 17.9 26.2 40.6 26.2 68.8 0 98.2-59.8 120-116.8 126.4 9.3 8 17.3 23.4 17.3 47.4 0 34.2-.3 61.8-.3 70.4 0 6.7 4.8 14.7 17.6 12.2C438.7 471.3 512 375.3 512 262.3c0-141.4-114.6-256-256-256"
/>
</svg>
);
}
@@ -0,0 +1,32 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function GitlabIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="93.97 97.52 192.05 184.99"
style={{ width: rem(size), height: rem(size) }}
>
<path
fill="#e24329"
d="m282.83 170.73-.27-.69-26.14-68.22a6.8 6.8 0 0 0-2.69-3.24 7 7 0 0 0-8 .43 7 7 0 0 0-2.32 3.52l-17.65 54h-71.47l-17.65-54a6.86 6.86 0 0 0-2.32-3.53 7 7 0 0 0-8-.43 6.87 6.87 0 0 0-2.69 3.24L97.44 170l-.26.69a48.54 48.54 0 0 0 16.1 56.1l.09.07.24.17 39.82 29.82 19.7 14.91 12 9.06a8.07 8.07 0 0 0 9.76 0l12-9.06 19.7-14.91 40.06-30 .1-.08a48.56 48.56 0 0 0 16.08-56.04"
/>
<path
fill="#fc6d26"
d="m282.83 170.73-.27-.69a88.3 88.3 0 0 0-35.15 15.8L190 229.25c19.55 14.79 36.57 27.64 36.57 27.64l40.06-30 .1-.08a48.56 48.56 0 0 0 16.1-56.08"
/>
<path
fill="#fca326"
d="m153.43 256.89 19.7 14.91 12 9.06a8.07 8.07 0 0 0 9.76 0l12-9.06 19.7-14.91S209.55 244 190 229.25c-19.55 14.75-36.57 27.64-36.57 27.64"
/>
<path
fill="#fc6d26"
d="M132.58 185.84A88.2 88.2 0 0 0 97.44 170l-.26.69a48.54 48.54 0 0 0 16.1 56.1l.09.07.24.17 39.82 29.82L190 229.21Z"
/>
</svg>
);
}
@@ -0,0 +1,28 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function GoogleDocsIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 47 65"
style={{ width: rem(size), height: rem(size) }}
>
<path
fill="#4285F4"
d="M29.375 0H4.406C1.983 0 0 1.994 0 4.432v56.136C0 63.006 1.983 65 4.406 65h38.188C45.017 65 47 63.006 47 60.568v-42.84L36.719 10.34z"
/>
<path
fill="#A1C2FA"
d="M29.375 0v13.295c0 2.449 1.972 4.432 4.406 4.432H47z"
/>
<path
fill="#F1F1F1"
d="M11.75 47.273h23.5v-2.955h-23.5zm0 5.909h17.625v-2.955H11.75zm0-20.682v2.955h23.5V32.5zm0 8.864h23.5v-2.955h-23.5z"
/>
</svg>
);
}
+8 -2
View File
@@ -1,10 +1,16 @@
export { AirtableIcon } from "./airtable-icon.tsx"; export { AirtableIcon } from "./airtable-icon.tsx";
export { FigmaIcon } from "./figma-icon.tsx"; export { FigmaIcon } from "./figma-icon.tsx";
export { GithubIcon } from "./github-icon.tsx";
export { GitlabIcon } from "./gitlab-icon.tsx";
export { GoogleDocsIcon } from "./google-docs-icon.tsx";
export { GoogleDriveIcon } from "./google-drive-icon.tsx";
export { GoogleSheetsIcon } from "./google-sheets-icon.tsx";
export { JiraIcon } from "./jira-icon.tsx";
export { LinearIcon } from "./linear-icon.tsx";
export { TypeformIcon } from "./typeform-icon.tsx"; export { TypeformIcon } from "./typeform-icon.tsx";
export { VimeoIcon } from "./vimeo-icon.tsx"; export { VimeoIcon } from "./vimeo-icon.tsx";
export { MiroIcon } from "./miro-icon.tsx"; export { MiroIcon } from "./miro-icon.tsx";
export { GoogleDriveIcon } from "./google-drive-icon.tsx"; export { SlackIcon } from "./slack-icon.tsx";
export { GoogleSheetsIcon } from "./google-sheets-icon.tsx";
export { FramerIcon } from "./framer-icon.tsx"; export { FramerIcon } from "./framer-icon.tsx";
export { LoomIcon } from "./loom-icon.tsx"; export { LoomIcon } from "./loom-icon.tsx";
export { YoutubeIcon } from "./youtube-icon.tsx"; export { YoutubeIcon } from "./youtube-icon.tsx";
@@ -0,0 +1,38 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function JiraIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
style={{ width: rem(size), height: rem(size) }}
>
<defs>
<linearGradient id="jira-a" x1="380.896" x2="279.145" y1="390.609" y2="282.574" gradientTransform="matrix(1 0 0 -1 0 514)" gradientUnits="userSpaceOnUse">
<stop offset=".176" stopColor="#0052cc" />
<stop offset="1" stopColor="#2684ff" />
</linearGradient>
<linearGradient id="jira-b" x1="265.965" x2="148.117" y1="270.661" y2="152.607" gradientTransform="matrix(1 0 0 -1 0 514)" gradientUnits="userSpaceOnUse">
<stop offset=".176" stopColor="#0052cc" />
<stop offset="1" stopColor="#2684ff" />
</linearGradient>
</defs>
<path
fill="#2684ff"
d="M490.6 7.4H243.9c0 59.6 49.9 108 111.2 108h45.6v42.2c0 59.6 49.9 108 111.2 108V28.1c.1-11.7-9.2-20.7-21.3-20.7"
/>
<path
fill="url(#jira-a)"
d="M368.7 126.5H121.9c0 59.6 49.9 108 111.2 108h45.6v42.9c0 59.6 49.9 108 111.2 108V147.3c.2-11.1-9.1-20.8-21.2-20.8"
/>
<path
fill="url(#jira-b)"
d="M246.7 246.3H0c0 59.6 49.9 108 111.2 108h45.6v42.2c0 59.6 49.9 108 111.2 108V267.1c.1-11.8-9.9-20.8-21.3-20.8"
/>
</svg>
);
}
@@ -0,0 +1,21 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function LinearIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 100 100"
style={{ width: rem(size), height: rem(size) }}
>
<path
fill="currentColor"
d="M1.22541 61.5228c-.2225-.9485.90748-1.5459 1.59638-.857L39.3342 97.1782c.6889.6889.0915 1.8189-.857 1.5964C20.0515 94.4522 5.54779 79.9485 1.22541 61.5228ZM.00189135 46.8891c-.01764375.2833.08887215.5599.28957165.7606L52.3503 99.7085c.2007.2007.4773.3075.7606.2896 2.3692-.1476 4.6938-.46 6.9624-.9259.7645-.157 1.0301-1.0963.4782-1.6481L2.57595 39.4485c-.55186-.5519-1.49117-.2863-1.648174.4782-.465915 2.2686-.77832 4.5932-.92588465 6.9624ZM4.21093 29.7054c-.16649.3738-.08169.8106.20765 1.1l64.77602 64.776c.2894.2894.7262.3742 1.1.2077 1.7861-.7956 3.5171-1.6927 5.1855-2.684.5521-.328.6373-1.0867.1832-1.5407L8.43566 24.3367c-.45409-.4541-1.21271-.3689-1.54074.1832-.99132 1.6684-1.88843 3.3994-2.68399 5.1855ZM12.6587 18.074c-.3701-.3701-.393-.9637-.0443-1.3541C21.7795 6.45931 35.1114 0 49.9519 0 77.5927 0 100 22.4073 100 50.0481c0 14.8405-6.4593 28.1724-16.7199 37.3375-.3903.3487-.984.3258-1.3542-.0443L12.6587 18.074Z"
/>
</svg>
);
}
+11 -2
View File
@@ -8,11 +8,20 @@ export function MiroIcon({ size }: Props) {
return ( return (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" fill="none"
viewBox="3 2 395 395"
style={{ width: rem(size), height: rem(size) }} style={{ width: rem(size), height: rem(size) }}
> >
<path <path
d="M17.392 0H13.9L17 4.808 10.444 0H6.949l3.102 6.3L3.494 0H0l3.05 8.131L0 24h3.494L10.05 6.985 6.949 24h3.494L17 5.494 13.899 24h3.493L24 3.672 17.392 0z" /> fill="#FFDD33"
d="M3 100.754C3 46.2604 47.2435 2 101.754 2H299.246C353.756 2 398 46.2435 398 100.754V298.246C398 352.756 353.756 397 299.246 397H101.754C47.2435 397 3 352.756 3 298.246V100.754Z"
/>
<path
fill="#1C1C1E"
fillRule="evenodd"
clipRule="evenodd"
d="M265.573 77.3491H229.74L259.629 129.85L193.906 77.3491H158.072L190.934 141.468L122.238 77.3491H86.4041L122.238 159.031L86.4041 322.377H122.238L190.934 147.396L158.072 322.377H193.906L259.629 135.693L229.74 322.377H265.573L331.297 118.232L265.573 77.4335V77.3491Z"
/>
</svg> </svg>
); );
} }
@@ -0,0 +1,32 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function SlackIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
style={{ width: rem(size), height: rem(size) }}
>
<path
d="M107.9 323.6c0 29.7-24 53.8-53.8 53.8S.3 353.4.3 323.6c0-29.7 24-53.8 53.8-53.8h53.8zm26.9 0c0-29.7 24-53.8 53.8-53.8s53.8 24 53.8 53.8V458c0 29.7-24 53.8-53.8 53.8s-53.8-24-53.8-53.8z"
fill="#e01e5a"
/>
<path
d="M188.6 107.7c-29.7 0-53.8-24-53.8-53.8S158.8.1 188.6.1s53.8 24 53.8 53.8v53.8zm0 27.3c29.7 0 53.8 24 53.8 53.8s-24 53.8-53.8 53.8H53.8C24 242.6 0 218.5 0 188.8S24 135 53.8 135z"
fill="#36c5f0"
/>
<path
d="M404.1 188.8c0-29.7 24-53.8 53.8-53.8s53.8 24 53.8 53.8-24 53.8-53.8 53.8h-53.8zm-26.9 0c0 29.7-24 53.8-53.8 53.8-29.7 0-53.8-24-53.8-53.8V54c0-29.7 24-53.8 53.8-53.8s53.8 24 53.8 53.8z"
fill="#2eb67d"
/>
<path
d="M323.4 404.3c29.7 0 53.8 24 53.8 53.8 0 29.7-24 53.8-53.8 53.8-29.7 0-53.8-24-53.8-53.8v-53.8zm0-26.9c-29.7 0-53.8-24-53.8-53.8s24-53.8 53.8-53.8h134.8c29.7 0 53.8 24 53.8 53.8 0 29.7-24 53.8-53.8 53.8z"
fill="#ecb22e"
/>
</svg>
);
}
@@ -1,30 +1,17 @@
import { ActionIcon, Box, Group, ScrollArea, Title, Tooltip } from "@mantine/core"; import { ActionIcon, Box, Group, ScrollArea, Title, Tooltip } from "@mantine/core";
import { IconX } from "@tabler/icons-react"; import { IconX } from "@tabler/icons-react";
import CommentListWithTabs from "@/features/comment/components/comment-list-with-tabs.tsx";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import React, { lazy, ReactNode, Suspense, useEffect } from "react"; import React, { ReactNode, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts"; 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"; 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() { export default function Aside() {
const [{ tab, isAsideOpen }, setAsideState] = useAtom(asideStateAtom); const [{ tab, isAsideOpen }, setAsideState] = useAtom(asideStateAtom);
const { t } = useTranslation(); const { t } = useTranslation();
@@ -81,19 +68,17 @@ export default function Aside() {
</Group> </Group>
)} )}
<Suspense fallback={null}> {tab === "comments" || tab === "chat" ? (
{tab === "comments" || tab === "chat" ? ( component
component ) : (
) : ( <ScrollArea
<ScrollArea style={{ height: "85vh" }}
style={{ height: "85vh" }} scrollbarSize={5}
scrollbarSize={5} type="scroll"
type="scroll" >
> <div style={{ paddingBottom: "200px" }}>{component}</div>
<div style={{ paddingBottom: "200px" }}>{component}</div> </ScrollArea>
</ScrollArea> )}
)}
</Suspense>
</> </>
)} )}
</Box> </Box>
@@ -11,10 +11,7 @@ import {
sidebarWidthAtom, sidebarWidthAtom,
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx"; 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 { AppHeader } from "@/components/layouts/global/app-header.tsx";
import Aside from "@/components/layouts/global/aside.tsx"; import Aside from "@/components/layouts/global/aside.tsx";
import classes from "./app-shell.module.css"; import classes from "./app-shell.module.css";
@@ -129,11 +126,7 @@ export default function GlobalAppShell({
)} )}
{isSpaceRoute && <SpaceSidebar />} {isSpaceRoute && <SpaceSidebar />}
{isSettingsRoute && <SettingsSidebar />} {isSettingsRoute && <SettingsSidebar />}
{isAiRoute && ( {isAiRoute && <AiChatSidebar />}
<React.Suspense fallback={null}>
<AiChatSidebar />
</React.Suspense>
)}
{showGlobalSidebar && <GlobalSidebar />} {showGlobalSidebar && <GlobalSidebar />}
</AppShell.Navbar> </AppShell.Navbar>
<AppShell.Main id={MAIN_CONTENT_ID} tabIndex={-1}> <AppShell.Main id={MAIN_CONTENT_ID} tabIndex={-1}>
@@ -13,6 +13,7 @@ import {
IconKey, IconKey,
IconWorld, IconWorld,
IconSparkles, IconSparkles,
IconPlug,
IconHistory, IconHistory,
IconShieldCheck, IconShieldCheck,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
@@ -74,6 +75,11 @@ const groupedData: DataGroup[] = [
path: "/settings/account/api-keys", path: "/settings/account/api-keys",
feature: Feature.API_KEYS, feature: Feature.API_KEYS,
}, },
{
label: "Connections",
icon: IconPlug,
path: "/settings/account/connections",
},
], ],
}, },
{ {
@@ -125,6 +131,12 @@ const groupedData: DataGroup[] = [
role: "owner", role: "owner",
env: "selfhosted", env: "selfhosted",
}, },
{
label: "Integrations",
icon: IconPlug,
path: "/settings/integrations",
role: "admin",
},
], ],
}, },
{ {
@@ -1,12 +0,0 @@
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";
@@ -1,74 +0,0 @@
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>
);
}
@@ -1,74 +0,0 @@
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,10 +6,7 @@ import {
IconFileText, IconFileText,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useAtomValue } from "jotai"; import ChatInput from "./chat-input";
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 type { ChatAttachment, PageMention } from "../types/ai-chat.types";
import classes from "../styles/ai-chat.module.css"; import classes from "../styles/ai-chat.module.css";
@@ -17,7 +14,6 @@ type Suggestion = {
icon: React.ReactNode; icon: React.ReactNode;
text: string; text: string;
prompt: string; prompt: string;
write?: boolean;
}; };
const SUGGESTIONS: Suggestion[] = [ const SUGGESTIONS: Suggestion[] = [
@@ -30,7 +26,6 @@ const SUGGESTIONS: Suggestion[] = [
icon: <IconFilePlus size={16} />, icon: <IconFilePlus size={16} />,
text: "Create a new page", text: "Create a new page",
prompt: "Create a new page titled ", prompt: "Create a new page titled ",
write: true,
}, },
{ {
icon: <IconFileText size={16} />, icon: <IconFileText size={16} />,
@@ -41,7 +36,6 @@ const SUGGESTIONS: Suggestion[] = [
icon: <IconEdit size={16} />, icon: <IconEdit size={16} />,
text: "Update page content", text: "Update page content",
prompt: "Update the page @", prompt: "Update the page @",
write: true,
}, },
]; ];
@@ -53,13 +47,9 @@ type Props = {
export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) { export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const workspace = useAtomValue(workspaceAtom);
const writesDisabled = workspace?.settings?.ai?.chatReadOnly === true;
const inputRef = useRef<ChatInputHandle>(null);
const handleSuggestionClick = (prompt: string) => { const handleSuggestionClick = (prompt: string) => {
inputRef.current?.prefill(prompt); onSend(prompt, [], []);
}; };
return ( return (
@@ -72,7 +62,6 @@ export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) {
<div className={classes.emptyStateInput}> <div className={classes.emptyStateInput}>
<ChatInput <ChatInput
ref={inputRef}
isStreaming={isStreaming} isStreaming={isStreaming}
onSend={onSend} onSend={onSend}
onStop={onStop} onStop={onStop}
@@ -84,7 +73,7 @@ export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) {
<div className={classes.suggestionsSection}> <div className={classes.suggestionsSection}>
<h2 className={classes.suggestionsLabel}>{t("Get started")}</h2> <h2 className={classes.suggestionsLabel}>{t("Get started")}</h2>
<div className={classes.suggestionsGrid}> <div className={classes.suggestionsGrid}>
{SUGGESTIONS.filter((s) => !writesDisabled || !s.write).map((s) => ( {SUGGESTIONS.map((s) => (
<button <button
key={s.text} key={s.text}
type="button" type="button"
@@ -1,12 +1,4 @@
import { import { useCallback, useId, useRef, useEffect, useState } from "react";
forwardRef,
useCallback,
useId,
useImperativeHandle,
useRef,
useEffect,
useState,
} from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { IconArrowUp, IconPaperclip, IconPlayerStopFilled, IconX, IconFile, IconPhoto, IconPlus, IconAt, IconFileText } from "@tabler/icons-react"; import { IconArrowUp, IconPaperclip, IconPlayerStopFilled, IconX, IconFile, IconPhoto, IconPlus, IconAt, IconFileText } from "@tabler/icons-react";
import { Popover } from "@mantine/core"; import { Popover } from "@mantine/core";
@@ -43,10 +35,6 @@ type Props = {
chatId?: string; chatId?: string;
}; };
export type ChatInputHandle = {
prefill: (text: string) => void;
};
function extractMentions(json: any): PageMention[] { function extractMentions(json: any): PageMention[] {
const mentions: PageMention[] = []; const mentions: PageMention[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
@@ -101,7 +89,7 @@ function editorJsonToText(json: any): string {
return text; return text;
} }
const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({ export default function ChatInput({
isStreaming, isStreaming,
onSend, onSend,
onStop, onStop,
@@ -112,7 +100,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({
variant = "card", variant = "card",
showDisclaimer = true, showDisclaimer = true,
chatId, chatId,
}: Props, ref) { }: Props) {
const chatIdRef = useRef(chatId); const chatIdRef = useRef(chatId);
chatIdRef.current = chatId; chatIdRef.current = chatId;
const { t } = useTranslation(); const { t } = useTranslation();
@@ -282,19 +270,6 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({
} }
}, [editor]); }, [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 hasContent = !isEmpty || pendingAttachments.some((a) => !a.uploading) || (contextPages?.length ?? 0) > 0;
const wrapperClass = variant === "flat" ? classes.inputWrapperFlat : classes.inputWrapper; const wrapperClass = variant === "flat" ? classes.inputWrapperFlat : classes.inputWrapper;
@@ -454,6 +429,4 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({
)} )}
</> </>
); );
}); }
export default ChatInput;
@@ -1,6 +1,5 @@
import { import {
Anchor, Anchor,
Badge,
Group, Group,
List, List,
Text, Text,
@@ -108,11 +107,11 @@ export default function McpSettings() {
</CopyButton> </CopyButton>
</Group> </Group>
<Text size="sm" c="dimmed" mt="xs"> <Text size="sm" c="dimmed" mt="xs">
{t("Connect AI assistants with your Docmost account via OAuth.")} {t(
"Use your API key for authentication. You can manage API keys in your account settings.",
)}
</Text> </Text>
<McpOauthOnlySetting />
<div> <div>
<Text size="sm" fw={500} mt="md" mb={4}> <Text size="sm" fw={500} mt="md" mb={4}>
{t("Supported tools")} {t("Supported tools")}
@@ -155,56 +154,3 @@ export default function McpSettings() {
</Stack> </Stack>
); );
} }
function McpOauthOnlySetting() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.settings?.ai?.mcpOauthOnly);
const hasAccess = useHasFeature(Feature.MCP_CONTROLS);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
const updatedWorkspace = await updateWorkspace({ mcpOauthOnly: value });
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<Group justify="space-between" wrap="nowrap" gap="xl" mt="md">
<div>
<Group gap="xs" align="center">
<Text size="sm" fw={500}>
{t("Require OAuth")}
</Text>
{!hasAccess && (
<Badge variant="light" size="sm" radius="sm">
{t("Enterprise")}
</Badge>
)}
</Group>
<Text size="sm" c="dimmed">
{t(
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.",
)}
</Text>
</div>
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
<Switch
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle require OAuth for MCP")}
/>
</Tooltip>
</Group>
);
}
+2 -22
View File
@@ -5,10 +5,8 @@ import { useTranslation } from "react-i18next";
import EnableAiSearch from "@/ee/ai/components/enable-ai-search.tsx"; import EnableAiSearch from "@/ee/ai/components/enable-ai-search.tsx";
import EnableGenerativeAi from "@/ee/ai/components/enable-generative-ai.tsx"; import EnableGenerativeAi from "@/ee/ai/components/enable-generative-ai.tsx";
import EnableAiChat from "@/ee/ai-chat/components/enable-ai-chat.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 McpSettings from "@/ee/ai/components/mcp-settings.tsx";
import { Alert, Collapse, Stack, Tabs } from "@mantine/core"; import { Alert, Stack, Tabs } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react"; import { IconInfoCircle } from "@tabler/icons-react";
import { useHasFeature } from "@/ee/hooks/use-feature"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features"; import { Feature } from "@/ee/features";
@@ -16,16 +14,12 @@ import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { isCloud } from "@/lib/config.ts"; import { isCloud } from "@/lib/config.ts";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { DocumentTitle } from "@/components/ui/document-title.tsx"; 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() { export default function AiSettings() {
const { t } = useTranslation(); const { t } = useTranslation();
const { isAdmin } = useUserRole(); const { isAdmin } = useUserRole();
const hasAccess = useHasFeature(Feature.AI); const hasAccess = useHasFeature(Feature.AI);
const upgradeLabel = useUpgradeLabel(); const upgradeLabel = useUpgradeLabel();
const workspace = useAtomValue(workspaceAtom);
const aiChatEnabled = workspace?.settings?.ai?.chat === true;
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -67,7 +61,7 @@ export default function AiSettings() {
mb="lg" mb="lg"
> >
{t( {t(
"AI is available in the Docmost paid editions. Contact sales@docmost.com.", "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
)} )}
</Alert> </Alert>
)} )}
@@ -76,20 +70,6 @@ export default function AiSettings() {
{!isCloud() && <EnableAiSearch />} {!isCloud() && <EnableAiSearch />}
<EnableGenerativeAi /> <EnableGenerativeAi />
<EnableAiChat /> <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> </Stack>
</Tabs.Panel> </Tabs.Panel>
@@ -15,14 +15,6 @@ 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( export async function aiAnswers(
params: IPageSearchParams, params: IPageSearchParams,
onChunk?: (chunk: { content?: string; sources?: any[] }) => void, onChunk?: (chunk: { content?: string; sources?: any[] }) => void,
@@ -1,5 +1,5 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Anchor, Alert, Button, Group, Space, Tabs, Text } from "@mantine/core"; import { Anchor, Alert, Button, Group, Space, Text } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react"; import { IconInfoCircle } from "@tabler/icons-react";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title"; import SettingsTitle from "@/components/settings/settings-title";
@@ -17,8 +17,6 @@ import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import { DocumentTitle } from "@/components/ui/document-title.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() { export default function UserApiKeys() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -31,11 +29,6 @@ export default function UserApiKeys() {
const { data, isLoading } = useGetApiKeysQuery({ cursor }); const { data, isLoading } = useGetApiKeysQuery({ cursor });
const [workspace] = useAtom(workspaceAtom); const [workspace] = useAtom(workspaceAtom);
const { isAdmin } = useUserRole(); 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 mcpEnabled = workspace?.settings?.ai?.mcp === true;
const restrictToAdmins = workspace?.settings?.api?.restrictToAdmins === true; const restrictToAdmins = workspace?.settings?.api?.restrictToAdmins === true;
const canCreate = !restrictToAdmins || isAdmin; const canCreate = !restrictToAdmins || isAdmin;
@@ -54,27 +47,26 @@ export default function UserApiKeys() {
setRevokeModalOpened(true); setRevokeModalOpened(true);
}; };
const handleTabChange = (value: string | null) => {
navigate(
value === "authorized-apps"
? "/settings/account/api-keys/authorized-apps"
: "/settings/account/api-keys",
);
};
return ( return (
<> <>
<DocumentTitle <DocumentTitle title={t("API keys")} />
title={activeTab === "authorized-apps" ? t("Authorized apps") : t("API keys")}
/>
<SettingsTitle title={t("API keys")} /> <SettingsTitle title={t("API keys")} />
{mcpEnabled && ( <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 && (
<Alert variant="light" color="blue" mb="md" p="sm" icon={<IconInfoCircle />}> <Alert variant="light" color="blue" mb="md" p="sm" icon={<IconInfoCircle />}>
<Text size="sm"> <Text size="sm">
{t( {t(
"Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.", "Your workspace has MCP enabled. Use your API key to connect AI assistants.",
)}{" "} )}{" "}
<Anchor <Anchor
href="https://docmost.com/docs/user-guide/mcp" href="https://docmost.com/docs/user-guide/mcp"
@@ -93,70 +85,37 @@ export default function UserApiKeys() {
</Alert> </Alert>
)} )}
<Tabs color="dark" value={activeTab} onChange={handleTabChange}> {canCreate ? (
<Tabs.List> <Group justify="flex-end" mb="md">
<Tabs.Tab fw={500} value="api-keys"> <Button onClick={() => setCreateModalOpened(true)}>
{t("API keys")} {t("Create API Key")}
</Tabs.Tab> </Button>
<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>
{canCreate && (
<Button
onClick={() => setCreateModalOpened(true)}
style={{ flexShrink: 0 }}
>
{t("Create API Key")}
</Button>
)}
</Group> </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.")}
</Text>
</Alert>
) : null}
{!canCreate && restrictToAdmins && ( <ApiKeyTable
<Alert variant="light" color="yellow" mb="md" p="sm" icon={<IconInfoCircle />}> apiKeys={data?.items || []}
<Text size="sm"> isLoading={isLoading}
{t("API key creation is restricted to admins by your workspace administrator.")} onUpdate={handleUpdate}
</Text> onRevoke={handleRevoke}
</Alert> />
)}
<ApiKeyTable <Space h="md" />
apiKeys={data?.items || []}
isLoading={isLoading} {data?.items.length > 0 && (
onUpdate={handleUpdate} <Paginate
onRevoke={handleRevoke} hasPrevPage={data?.meta?.hasPrevPage}
hasNextPage={data?.meta?.hasNextPage}
onNext={() => goNext(data?.meta?.nextCursor)}
onPrev={goPrev}
/> />
)}
<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 <CreateApiKeyModal
opened={createModalOpened} opened={createModalOpened}
@@ -9,7 +9,6 @@ import {
Text, Text,
UnstyledButton, UnstyledButton,
Button, Button,
MultiSelect,
} from "@mantine/core"; } from "@mantine/core";
import { IconPlus, IconTrash } from "@tabler/icons-react"; import { IconPlus, IconTrash } from "@tabler/icons-react";
import { import {
@@ -53,9 +52,6 @@ const NO_VALUE_OPERATORS: FilterOperator[] = ["isEmpty", "isNotEmpty"];
// stored value so a stale shape isn't sent to the engine. // stored value so a stale shape isn't sent to the engine.
function valueClass(op: FilterOperator, inputKind: string): string { function valueClass(op: FilterOperator, inputKind: string): string {
if (NO_VALUE_OPERATORS.includes(op)) return "none"; if (NO_VALUE_OPERATORS.includes(op)) return "none";
if (inputKind === "choices") {
return op === "any" || op === "none" ? "choicesMulti" : "choicesSingle";
}
if (inputKind === "person") { if (inputKind === "person") {
return op === "any" || op === "none" ? "personMulti" : "personSingle"; return op === "any" || op === "none" ? "personMulti" : "personSingle";
} }
@@ -74,10 +70,6 @@ function getOperatorsForType(type: string): FilterOperator[] {
DEFAULT_FILTER_OPERATORS) as FilterOperator[]; DEFAULT_FILTER_OPERATORS) as FilterOperator[];
} }
function isMultiChoice(op: FilterCondition["op"]): boolean {
return op === "any" || op === "none";
}
function FilterValueInput({ function FilterValueInput({
condition, condition,
property, property,
@@ -129,32 +121,6 @@ function FilterValueInput({
const typeOptions = property.typeOptions as SelectTypeOptions | undefined; const typeOptions = property.typeOptions as SelectTypeOptions | undefined;
const choices = typeOptions?.choices ?? []; const choices = typeOptions?.choices ?? [];
const choiceOptions = choices.map((c) => ({ value: c.id, label: c.name })); 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 ( return (
<Select <Select
size="xs" size="xs"
@@ -233,18 +199,11 @@ export function ViewFilterConfigPopover({
label: p.name, label: p.name,
})); }));
const [unSaved, setUnSaved] = useState(false)
const [draft, setDraft] = useState<FilterCondition | null>(null); const [draft, setDraft] = useState<FilterCondition | null>(null);
const [draftConditions, setDraftConditions] =
useState<FilterCondition[]>(conditions);
useEffect(() => { useEffect(() => {
if (opened) { if (!opened) setDraft(null);
setDraftConditions(conditions); }, [opened]);
setDraft(null);
setUnSaved(false)
}
}, [opened, conditions]);
const handleStartDraft = useCallback(() => { const handleStartDraft = useCallback(() => {
const firstProperty = properties[0]; const firstProperty = properties[0];
@@ -257,21 +216,14 @@ export function ViewFilterConfigPopover({
}, [properties]); }, [properties]);
const handleSaveDraft = useCallback(() => { const handleSaveDraft = useCallback(() => {
const nextConditions = draft if (!draft) return;
? [...draftConditions, draft] onChange([...conditions, draft]);
: draftConditions;
onChange(nextConditions);
setDraft(null); setDraft(null);
setUnSaved(false) }, [draft, conditions, onChange]);
}, [draft, draftConditions, onChange]);
const handleCancelDraft = useCallback(() => { const handleCancelDraft = useCallback(() => {
setDraftConditions(conditions)
setDraft(null); setDraft(null);
setUnSaved(false) }, []);
}, [conditions]);
const handleDraftPropertyChange = useCallback( const handleDraftPropertyChange = useCallback(
(propertyId: string | null) => { (propertyId: string | null) => {
@@ -320,19 +272,17 @@ export function ViewFilterConfigPopover({
const handleRemove = useCallback( const handleRemove = useCallback(
(index: number) => { (index: number) => {
setUnSaved(true); onChange(conditions.filter((_, i) => i !== index));
setDraftConditions((current) => current.filter((_, i) => i !== index));
}, },
[], [conditions, onChange],
); );
const handlePropertyChange = useCallback( const handlePropertyChange = useCallback(
(index: number, propertyId: string | null) => { (index: number, propertyId: string | null) => {
if (!propertyId) return; if (!propertyId) return;
const newProperty = properties.find((p) => p.id === propertyId); const newProperty = properties.find((p) => p.id === propertyId);
setUnSaved(true) onChange(
setDraftConditions((current) => conditions.map((f, i) => {
current.map((f, i) => {
if (i !== index) return f; if (i !== index) return f;
if (newProperty) { if (newProperty) {
const validOperators = getOperatorsForType(newProperty.type); const validOperators = getOperatorsForType(newProperty.type);
@@ -352,16 +302,15 @@ export function ViewFilterConfigPopover({
}), }),
); );
}, },
[properties], [conditions, properties, onChange],
); );
const handleOperatorChange = useCallback( const handleOperatorChange = useCallback(
(index: number, operator: string | null) => { (index: number, operator: string | null) => {
if (!operator) return; if (!operator) return;
const op = operator as FilterOperator; const op = operator as FilterOperator;
setUnSaved(true) onChange(
setDraftConditions((current) => conditions.map((f, i) => {
current.map((f, i) => {
if (i !== index) return f; if (i !== index) return f;
const kind = inputKindForProperty( const kind = inputKindForProperty(
properties.find((p) => p.id === f.propertyId), properties.find((p) => p.id === f.propertyId),
@@ -371,17 +320,16 @@ export function ViewFilterConfigPopover({
}), }),
); );
}, },
[properties], [conditions, properties, onChange],
); );
const handleValueChange = useCallback( const handleValueChange = useCallback(
(index: number, value: unknown) => { (index: number, value: unknown) => {
setUnSaved(true) onChange(
setDraftConditions((current) => conditions.map((f, i) => (i === index ? { ...f, value } : f)),
current.map((f, i) => (i === index ? { ...f, value } : f)),
); );
}, },
[], [conditions, onChange],
); );
return ( return (
@@ -414,13 +362,13 @@ export function ViewFilterConfigPopover({
{t("Filter by")} {t("Filter by")}
</Text> </Text>
{draftConditions.length === 0 && !draft && ( {conditions.length === 0 && !draft && (
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{t("No filters applied")} {t("No filters applied")}
</Text> </Text>
)} )}
{draftConditions.map((condition, index) => { {conditions.map((condition, index) => {
const needsValue = !NO_VALUE_OPERATORS.includes(condition.op); const needsValue = !NO_VALUE_OPERATORS.includes(condition.op);
const property = properties.find( const property = properties.find(
(p) => p.id === condition.propertyId, (p) => p.id === condition.propertyId,
@@ -523,6 +471,14 @@ export function ViewFilterConfigPopover({
/> />
)} )}
</Group> </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> </Stack>
); );
})()} })()}
@@ -536,20 +492,6 @@ export function ViewFilterConfigPopover({
{t("Add filter")} {t("Add filter")}
</UnstyledButton> </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> </Stack>
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </Popover>
+1 -3
View File
@@ -22,7 +22,5 @@ export const Feature = {
PERSONAL_SPACES: 'spaces:personal', PERSONAL_SPACES: 'spaces:personal',
DOCX_EXPORT: 'export:docx', DOCX_EXPORT: 'export:docx',
BASES: 'bases', BASES: 'bases',
OAUTH: 'oauth', INTEGRATIONS: 'integrations',
AI_CONTROLS: 'ai:controls',
MCP_CONTROLS: 'mcp:controls',
} as const; } as const;
@@ -1,19 +0,0 @@
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} />
</>
);
}
@@ -1,178 +0,0 @@
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>
);
}
@@ -1,333 +0,0 @@
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,
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 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"
py="xs"
icon={<IconAlertTriangle size={16} />}
>
{t("Make sure you trust this application before authorizing it.")}
</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 || submitting === "deny"}
>
{t("Authorize")}
</Button>
</Group>
</Stack>
);
}
@@ -1,45 +0,0 @@
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",
});
},
});
}
@@ -1,36 +0,0 @@
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 });
}
@@ -1,24 +0,0 @@
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[];
};
@@ -16,14 +16,6 @@ export const showAiMenuAtom = atom(false);
export const showLinkMenuAtom = 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 // Current page's edit mode — initialized from the user's saved preference on
// first load, can be toggled locally without persisting to the server. // first load, can be toggled locally without persisting to the server.
export const currentPageEditModeAtom = atom<PageEditMode>(PageEditMode.Edit); export const currentPageEditModeAtom = atom<PageEditMode>(PageEditMode.Edit);
@@ -23,21 +23,11 @@ import {
} from "@/features/comment/atoms/comment-atom"; } from "@/features/comment/atoms/comment-atom";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { v7 as uuid7 } from "uuid"; import { v7 as uuid7 } from "uuid";
import { import { isCellSelection, isEditorReady, isTextSelected } from "@docmost/editor-ext";
isCellSelection,
isEditorReady,
isTextSelected,
} from "@docmost/editor-ext";
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx"; import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import { showAiMenuAtom, showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
showAiMenuAtom, import { userAtom, workspaceAtom } from "@/features/user/atoms/current-user-atom";
showLinkMenuAtom,
} from "@/features/editor/atoms/editor-atoms";
import {
userAtom,
workspaceAtom,
} from "@/features/user/atoms/current-user-atom";
export interface BubbleMenuItem { export interface BubbleMenuItem {
name: string; name: string;
@@ -227,12 +217,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
<ActionIcon.Group> <ActionIcon.Group>
{items.map((item, index) => ( {items.map((item, index) => (
<Tooltip <Tooltip key={index} label={t(item.name)} withArrow>
key={index}
label={t(item.name)}
withArrow
withinPortal={false}
>
<ActionIcon <ActionIcon
key={index} key={index}
variant="default" variant="default"
@@ -241,9 +226,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
aria-label={t(item.name)} aria-label={t(item.name)}
className={clsx({ [classes.active]: item.isActive() })} className={clsx({ [classes.active]: item.isActive() })}
style={{ border: "none" }} style={{ border: "none" }}
onClick={() => onClick={() => isEditorReady(props.editor) && item.command()}
isEditorReady(props.editor) && item.command()
}
> >
<item.icon style={{ width: rem(16) }} stroke={2} /> <item.icon style={{ width: rem(16) }} stroke={2} />
</ActionIcon> </ActionIcon>
@@ -273,9 +256,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
radius="6px" radius="6px"
aria-label={t(commentItem.name)} aria-label={t(commentItem.name)}
style={{ border: "none" }} style={{ border: "none" }}
onClick={() => onClick={() => isEditorReady(props.editor) && commentItem.command()}
isEditorReady(props.editor) && commentItem.command()
}
> >
<IconMessage size={16} stroke={2} /> <IconMessage size={16} stroke={2} />
</ActionIcon> </ActionIcon>
@@ -129,7 +129,8 @@ function handleColorKeyNav(
grid: "text" | "highlight", grid: "text" | "highlight",
) { ) {
const cols = COLOR_GRID_COLS; 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; const col = index % cols;
if (e.key === "ArrowRight") { if (e.key === "ArrowRight") {
@@ -162,7 +163,8 @@ function handleColorKeyNav(
if (prev >= 0) { if (prev >= 0) {
focusSwatch(grid, prev); focusSwatch(grid, prev);
} else if (grid === "highlight") { } 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)); focusSwatch("text", Math.min(lastRowStart + col, TEXT_COLORS.length - 1));
} }
return; return;
@@ -220,7 +222,7 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
withArrow withArrow
> >
<Popover.Target> <Popover.Target>
<Tooltip label={t("Text color")} withArrow withinPortal={false}> <Tooltip label={t("Text color")} withArrow>
<Button <Button
variant="default" variant="default"
radius="0" radius="0"
@@ -245,26 +247,26 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
<Popover.Dropdown onMouseDown={(e) => e.preventDefault()}> <Popover.Dropdown onMouseDown={(e) => e.preventDefault()}>
<Stack gap="md" p="2px"> <Stack gap="md" p="2px">
<Box> <Box>
<Text size="sm" fw={600} mb="xs"> <Text size="sm" fw={600} mb="xs">
{t("Text color")} {t("Text color")}
</Text> </Text>
<SimpleGrid cols={5} spacing="xs"> <SimpleGrid cols={5} spacing="xs">
{TEXT_COLORS.map(({ name, color }, index) => { {TEXT_COLORS.map(({ name, color }, index) => {
const applyTextColor = () => { const applyTextColor = () => {
if (!isEditorReady(editor)) return; if (!isEditorReady(editor)) return;
if (name === "Default") { if (name === "Default") {
editor.commands.unsetColor(); editor.commands.unsetColor();
} else { } else {
editor editor
.chain() .chain()
.focus() .focus()
.setColor(color || "") .setColor(color || "")
.run(); .run();
} }
setIsOpen(false); setIsOpen(false);
}; };
return ( return (
<Tooltip key={index} label={t(name)} withArrow> <Tooltip key={index} label={t(name)} withArrow>
<Box <Box
role="button" role="button"
@@ -304,34 +306,34 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
A A
</Box> </Box>
</Tooltip> </Tooltip>
); );
})} })}
</SimpleGrid> </SimpleGrid>
</Box> </Box>
<Box> <Box>
<Text size="sm" fw={600} mb="xs"> <Text size="sm" fw={600} mb="xs">
{t("Highlight color")} {t("Highlight color")}
</Text> </Text>
<SimpleGrid cols={5} spacing="xs"> <SimpleGrid cols={5} spacing="xs">
{HIGHLIGHT_COLORS.map(({ name, color }, index) => { {HIGHLIGHT_COLORS.map(({ name, color }, index) => {
const applyHighlight = () => { const applyHighlight = () => {
if (!isEditorReady(editor)) return; if (!isEditorReady(editor)) return;
if (name === "Default") { if (name === "Default") {
editor.commands.unsetHighlight(); editor.commands.unsetHighlight();
} else { } else {
editor editor
.chain() .chain()
.focus() .focus()
.toggleMark("highlight", { .toggleMark("highlight", {
color: color || "", color: color || "",
colorName: name.toLowerCase() || "", colorName: name.toLowerCase() || "",
}) })
.run(); .run();
} }
setIsOpen(false); setIsOpen(false);
}; };
return ( return (
<Tooltip key={index} label={t(name)} withArrow> <Tooltip key={index} label={t(name)} withArrow>
<Box <Box
role="button" role="button"
@@ -376,36 +378,37 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
)} )}
</Box> </Box>
</Tooltip> </Tooltip>
); );
})} })}
</SimpleGrid> </SimpleGrid>
</Box> </Box>
<Button <Button
variant="default" variant="default"
fullWidth fullWidth
data-color-grid="remove" data-color-grid="remove"
className={classes.removeColor} className={classes.removeColor}
onClick={() => { onClick={() => {
if (isEditorReady(editor)) { if (isEditorReady(editor)) {
editor.commands.unsetColor(); editor.commands.unsetColor();
editor.commands.unsetHighlight(); editor.commands.unsetHighlight();
} }
setIsOpen(false); setIsOpen(false);
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "ArrowUp") { if (e.key === "ArrowUp") {
e.preventDefault(); e.preventDefault();
const lastRowStart = const lastRowStart =
Math.floor((HIGHLIGHT_COLORS.length - 1) / COLOR_GRID_COLS) * Math.floor(
COLOR_GRID_COLS; (HIGHLIGHT_COLORS.length - 1) / COLOR_GRID_COLS,
focusSwatch("highlight", lastRowStart); ) * COLOR_GRID_COLS;
} focusSwatch("highlight", lastRowStart);
}} }
> }}
{t("Remove color")} >
</Button> {t("Remove color")}
</Stack> </Button>
</Stack>
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </Popover>
); );
@@ -10,7 +10,7 @@ export const LinkSelector: FC = () => {
const setShowLinkMenu = useSetAtom(showLinkMenuAtom); const setShowLinkMenu = useSetAtom(showLinkMenuAtom);
return ( return (
<Tooltip label={t("Add link")} withArrow withinPortal={false}> <Tooltip label={t("Add link")} withArrow>
<ActionIcon <ActionIcon
variant="default" variant="default"
size="lg" size="lg"
@@ -91,12 +91,7 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
onChange={setIsOpen} onChange={setIsOpen}
> >
<Menu.Target> <Menu.Target>
<Tooltip <Tooltip label={t("Text align")} withArrow disabled={isOpen}>
label={t("Text align")}
withArrow
disabled={isOpen}
withinPortal={false}
>
<Button <Button
variant="default" variant="default"
style={{ border: "none", height: "34px" }} style={{ border: "none", height: "34px" }}
@@ -5,6 +5,10 @@ import { uploadPdfAction } from "../pdf/upload-pdf-action";
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts"; import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts"; import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
import { Editor } from "@tiptap/core"; import { Editor } from "@tiptap/core";
import { matchIntegrationLink } from "@docmost/editor-ext";
import { integrationPasteMenuKey } from "@/features/editor/extensions/integration-paste-menu";
import { queryClient } from "@/main.tsx";
import { Integration } from "@/features/integration/types/integration.types";
import { import {
getAttachmentInfo, getAttachmentInfo,
uploadFile, uploadFile,
@@ -22,6 +26,16 @@ const ATTACHMENT_NODE_TYPES = [
const ATTACHMENT_URL_RE = /\/api\/files\/([0-9a-f-]+)\//; const ATTACHMENT_URL_RE = /\/api\/files\/([0-9a-f-]+)\//;
// Only installed providers get card treatment; anything else pastes as an
// ordinary link. The cache is prefetched when the page editor mounts;
// a cold cache also means ordinary link.
function isIntegrationInstalled(provider: string): boolean {
const installed = queryClient.getQueryData<Integration[]>([
"installed-integrations",
]);
return Boolean(installed?.some((i) => i.type === provider));
}
export const handlePaste = ( export const handlePaste = (
editor: Editor, editor: Editor,
event: ClipboardEvent, event: ClipboardEvent,
@@ -30,6 +44,59 @@ export const handlePaste = (
) => { ) => {
const clipboardData = event.clipboardData.getData("text/plain"); const clipboardData = event.clipboardData.getData("text/plain");
const integrationMatch = matchIntegrationLink(clipboardData.trim());
if (
integrationMatch &&
editor.state.selection.empty &&
isIntegrationInstalled(integrationMatch.provider)
) {
event.preventDefault();
const pastedUrl = clipboardData.trim();
editor
.chain()
.focus()
.setIntegrationLink({
url: pastedUrl,
provider: integrationMatch.provider,
})
// Anchor the "Paste as" menu to the inserted node, in the SAME
// transaction: BubbleMenu ignores meta-only transactions (it only
// re-evaluates when the doc or selection changed). Locate the node via
// the range this transaction's own steps touched, never by url, so a
// duplicate of the same link elsewhere in the doc can't steal the menu.
.command(({ tr }) => {
let start: number | null = null;
let end: number | null = null;
tr.mapping.maps.forEach((map, index) => {
const rest = tr.mapping.slice(index + 1);
map.forEach((_oldStart, _oldEnd, newStart, newEnd) => {
const mappedStart = rest.map(newStart, -1);
const mappedEnd = rest.map(newEnd, 1);
start = start === null ? mappedStart : Math.min(start, mappedStart);
end = end === null ? mappedEnd : Math.max(end, mappedEnd);
});
});
if (start === null || end === null) return true;
let pastedPos: number | null = null;
tr.doc.nodesBetween(
start,
Math.min(end, tr.doc.content.size),
(node, pos) => {
if (node.type.name === "integrationLink") {
pastedPos = pos;
}
},
);
if (pastedPos !== null) {
tr.setMeta(integrationPasteMenuKey, { pos: pastedPos });
}
return true;
})
.run();
return true;
}
if (INTERNAL_LINK_REGEX.test(clipboardData)) { if (INTERNAL_LINK_REGEX.test(clipboardData)) {
// we have to do this validation here to allow the default link extension to takeover if needs be // we have to do this validation here to allow the default link extension to takeover if needs be
event.preventDefault(); event.preventDefault();
@@ -1,188 +0,0 @@
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,7 +1,6 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useSetAtom } from "jotai";
import { Node as PMNode } from "@tiptap/pm/model"; import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext"; import { isEditorReady } from "@docmost/editor-ext";
import { import {
@@ -25,7 +24,6 @@ import {
IconDownload, IconDownload,
IconEdit, IconEdit,
IconTrash, IconTrash,
IconZoomIn,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getDrawioUrl, getFileUrl } from "@/lib/config.ts"; import { getDrawioUrl, getFileUrl } from "@/lib/config.ts";
@@ -41,12 +39,10 @@ import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils";
import { IAttachment } from "@/features/attachments/types/attachment.types"; import { IAttachment } from "@/features/attachments/types/attachment.types";
import { modals } from "@mantine/modals"; import { modals } from "@mantine/modals";
import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.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"; import classes from "../common/toolbar-menu.module.css";
export function DrawioMenu({ editor }: EditorMenuProps) { export function DrawioMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const setLightboxRequest = useSetAtom(lightboxRequestAtom);
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const [initialXML, setInitialXML] = useState<string>(""); const [initialXML, setInitialXML] = useState<string>("");
const drawioRef = useRef<DrawIoEmbedRef>(null); const drawioRef = useRef<DrawIoEmbedRef>(null);
@@ -332,23 +328,6 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
<div className={classes.divider} /> <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}> <Tooltip position="top" label={t("Edit")} withinPortal={false}>
<ActionIcon <ActionIcon
onClick={handleOpen} onClick={handleOpen}
@@ -1,7 +1,6 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react"; import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useSetAtom } from "jotai";
import { Node as PMNode } from "@tiptap/pm/model"; import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext"; import { isEditorReady } from "@docmost/editor-ext";
import { import {
@@ -26,7 +25,6 @@ import {
IconDownload, IconDownload,
IconEdit, IconEdit,
IconTrash, IconTrash,
IconZoomIn,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getFileUrl } from "@/lib/config.ts"; import { getFileUrl } from "@/lib/config.ts";
@@ -39,7 +37,6 @@ import ReactClearModal from "react-clear-modal";
import { useHandleLibrary } from "@excalidraw/excalidraw"; import { useHandleLibrary } from "@excalidraw/excalidraw";
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts"; import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.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"; import classes from "../common/toolbar-menu.module.css";
const ExcalidrawComponent = lazy(() => const ExcalidrawComponent = lazy(() =>
@@ -50,7 +47,6 @@ const ExcalidrawComponent = lazy(() =>
export function ExcalidrawMenu({ editor }: EditorMenuProps) { export function ExcalidrawMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const setLightboxRequest = useSetAtom(lightboxRequestAtom);
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const [excalidrawAPI, setExcalidrawAPI] = const [excalidrawAPI, setExcalidrawAPI] =
useState<ExcalidrawImperativeAPI>(null); useState<ExcalidrawImperativeAPI>(null);
@@ -363,23 +359,6 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
<div className={classes.divider} /> <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}> <Tooltip position="top" label={t("Edit")} withinPortal={false}>
<ActionIcon <ActionIcon
onClick={handleOpen} onClick={handleOpen}
@@ -1,7 +1,6 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import React, { useCallback, useRef } from "react"; import React, { useCallback, useRef } from "react";
import { useSetAtom } from "jotai";
import { Node as PMNode } from "@tiptap/pm/model"; import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext"; import { isEditorReady } from "@docmost/editor-ext";
import { import {
@@ -17,19 +16,16 @@ import {
IconDownload, IconDownload,
IconRefresh, IconRefresh,
IconTrash, IconTrash,
IconZoomIn,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getFileUrl } from "@/lib/config.ts"; import { getFileUrl } from "@/lib/config.ts";
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx"; import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.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"; import classes from "../common/toolbar-menu.module.css";
export function ImageMenu({ editor }: EditorMenuProps) { export function ImageMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const setLightboxRequest = useSetAtom(lightboxRequestAtom);
const editorState = useEditorState({ const editorState = useEditorState({
editor, editor,
@@ -211,23 +207,6 @@ export function ImageMenu({ editor }: EditorMenuProps) {
<div className={classes.divider} /> <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}> <Tooltip position="top" label={t("Download")} withinPortal={false}>
<ActionIcon <ActionIcon
onClick={handleDownload} onClick={handleDownload}
@@ -0,0 +1,49 @@
// Light-scheme text per hue, measured to pass 4.5:1 on the light-variant
// badge background; hexes are darkened .9 shades for hues whose scale
// never gets dark enough.
const BADGE_TEXT_LIGHT: Record<string, string> = {
dark: "var(--mantine-color-dark-9)",
gray: "var(--mantine-color-gray-9)",
red: "var(--mantine-color-red-9)",
pink: "var(--mantine-color-pink-9)",
grape: "var(--mantine-color-grape-9)",
violet: "var(--mantine-color-violet-9)",
indigo: "var(--mantine-color-indigo-9)",
blue: "var(--mantine-color-blue-9)",
cyan: "var(--mantine-color-cyan-9)",
teal: "var(--mantine-color-teal-9)",
green: "#277c38",
lime: "#4e7e0b",
yellow: "#ad5900",
orange: "#c3410e",
};
export function badgeTextColor(color?: string): string | undefined {
if (!color) return undefined;
const light = BADGE_TEXT_LIGHT[color];
if (!light) return undefined;
// Dark scheme keeps Mantine's own light-variant text.
return `light-dark(${light}, var(--mantine-color-${color}-light-color))`;
}
export function toBadgeColor(raw?: string): string {
if (!raw) return "gray";
const hex = raw.toLowerCase().replace("#", "");
if (/^[0-9a-f]{6}$/.test(hex)) {
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2 / 255;
if (max - min < 30) return l > 0.6 ? "gray" : "dark";
if (r > g && r > b) return g > 160 ? "orange" : "red";
if (g > r && g > b) return r > 160 ? "lime" : "green";
if (b > r && b > g) return r > 100 ? "violet" : "blue";
if (r > 200 && g > 200) return "yellow";
if (r > 200 && b > 200) return "pink";
if (g > 200 && b > 200) return "cyan";
return "gray";
}
return raw;
}
@@ -0,0 +1,65 @@
.card {
max-width: 100%;
cursor: pointer;
transition: background-color 150ms ease;
margin: 4px 0;
}
.card:hover {
background-color: var(--mantine-color-gray-0);
}
:global([data-mantine-color-scheme="dark"]) .card:hover {
background-color: var(--mantine-color-dark-5);
}
.thumbnail {
display: block;
width: 100%;
max-height: 320px;
object-fit: cover;
background-color: var(--mantine-color-gray-0);
}
:global([data-mantine-color-scheme="dark"]) .thumbnail {
background-color: var(--mantine-color-dark-6);
}
.mention {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 100%;
vertical-align: text-bottom;
text-decoration: none;
color: inherit;
border-radius: var(--mantine-radius-sm);
}
:global(.node-integrationMention) .mention {
border-bottom: none !important;
font-weight: 400;
}
.mention:hover {
background-color: var(--mantine-color-gray-0);
}
:global([data-mantine-color-scheme="dark"]) .mention:hover {
background-color: var(--mantine-color-dark-5);
}
.mentionText {
text-decoration: underline;
text-decoration-color: var(--mantine-color-gray-4);
text-underline-offset: 3px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 340px;
font-weight: 500;
}
.mentionIcon {
flex-shrink: 0;
}
@@ -0,0 +1,525 @@
import { NodeViewWrapper } from "@tiptap/react";
import {
Card,
Group,
Text,
Badge,
Avatar,
Skeleton,
Anchor,
Stack,
Button,
} from "@mantine/core";
import { useCallback, useState, memo } from "react";
import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications";
import { describeIntegrationLink } from "@docmost/editor-ext";
import { getIntegrationIcon } from "@/features/integration/components/integration-icons";
import { getOAuthAuthorizeUrl } from "@/features/integration/services/integration-service";
import { timeAgo } from "@/lib/time";
import { useUnfurl } from "./use-unfurl";
import { badgeTextColor, toBadgeColor } from "./badge-color";
import classes from "./integration-link-view.module.css";
const SLACK_TEXT_CLAMP_LINES = 4;
function SlackMessageCard({
url,
unfurlData,
}: {
url: string;
unfurlData: Record<string, any>;
}) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const meta = unfurlData.metadata ?? {};
const postedAt = meta.ts ? new Date(parseFloat(meta.ts) * 1000) : null;
const text: string = unfurlData.description ?? "";
const isLong =
text.length > 280 || text.split("\n").length > SLACK_TEXT_CLAMP_LINES;
const footer = [
meta.replyCount
? `${meta.replyCount} ${meta.replyCount === 1 ? t("reply") : t("replies")}`
: null,
unfurlData.status,
meta.teamName,
]
.filter(Boolean)
.join(" • ");
return (
<NodeViewWrapper data-drag-handle="">
<Card className={classes.card} withBorder padding="sm" radius="sm">
<Group gap="sm" wrap="nowrap" align="flex-start">
<Avatar
src={unfurlData.authorAvatarUrl}
size={28}
radius="xl"
style={{ flexShrink: 0 }}
>
{(unfurlData.author ?? "?").charAt(0)}
</Avatar>
<Stack gap={4} style={{ flex: 1, minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600} truncate>
{unfurlData.author}
</Text>
{postedAt && (
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{timeAgo(postedAt)}
</Text>
)}
</Group>
{text && (
<Text
size="sm"
lineClamp={expanded ? undefined : SLACK_TEXT_CLAMP_LINES}
style={{ whiteSpace: "pre-wrap" }}
>
{text}
</Text>
)}
{isLong && (
<Text
size="xs"
fw={600}
role="button"
tabIndex={0}
aria-expanded={expanded}
style={{ cursor: "pointer", width: "fit-content" }}
onClick={() => setExpanded((v) => !v)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setExpanded((v) => !v);
}
}}
>
{expanded ? t("show less") : t("show more")}
</Text>
)}
{footer && (
<Text size="xs" c="dimmed" truncate>
{footer}
</Text>
)}
</Stack>
<Anchor
href={url}
target="_blank"
rel="noopener"
aria-label={t("Open in Slack")}
style={{ flexShrink: 0, lineHeight: 0 }}
>
{getIntegrationIcon("slack", 18)}
</Anchor>
</Group>
</Card>
</NodeViewWrapper>
);
}
function JiraIssueCard({
url,
unfurlData,
}: {
url: string;
unfurlData: Record<string, any>;
}) {
const { t } = useTranslation();
const meta = unfurlData.metadata ?? {};
const infoLine = [
meta.issueKey,
unfurlData.author
? t("Assigned to {{name}}", { name: unfurlData.author })
: t("Unassigned"),
meta.updatedAt
? t("Updated {{time}}", { time: timeAgo(new Date(meta.updatedAt)) })
: null,
]
.filter(Boolean)
.join(" • ");
return (
<NodeViewWrapper data-drag-handle="">
<Card
className={classes.card}
withBorder
padding="sm"
radius="sm"
component="a"
href={url}
target="_blank"
rel="noopener"
style={{ textDecoration: "none", color: "inherit" }}
>
<Group gap="sm" wrap="nowrap">
{unfurlData.authorAvatarUrl ? (
<Avatar
src={unfurlData.authorAvatarUrl}
size={28}
radius="xl"
style={{ flexShrink: 0 }}
/>
) : (
<div style={{ flexShrink: 0 }}>{getIntegrationIcon("jira", 28)}</div>
)}
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={600} truncate>
{unfurlData.title}
</Text>
{unfurlData.status && (
<Badge
size="xs"
variant="light"
color={toBadgeColor(unfurlData.statusColor)}
c={badgeTextColor(toBadgeColor(unfurlData.statusColor))}
style={{ flexShrink: 0 }}
>
{unfurlData.status}
</Badge>
)}
</Group>
<Group gap={4} wrap="nowrap">
{meta.issueTypeIconUrl && (
<img
src={meta.issueTypeIconUrl}
width={14}
height={14}
alt=""
style={{ flexShrink: 0 }}
/>
)}
<Text size="xs" c="dimmed" truncate>
{infoLine}
</Text>
</Group>
</Stack>
<div style={{ flexShrink: 0, alignSelf: "center" }}>
{getIntegrationIcon("jira", 18)}
</div>
</Group>
</Card>
</NodeViewWrapper>
);
}
function FigmaFileCard({
url,
unfurlData,
}: {
url: string;
unfurlData: Record<string, any>;
}) {
const { t } = useTranslation();
// Figma thumbnail links are pre-signed and expire; drop the preview rather
// than render a broken image.
const [thumbnailFailed, setThumbnailFailed] = useState(false);
const meta = unfurlData.metadata ?? {};
const thumbnailUrl: string | undefined = meta.thumbnailUrl;
const showThumbnail = Boolean(thumbnailUrl) && !thumbnailFailed;
const subtitle = [
unfurlData.author
? t("Last modified by {{name}}", { name: unfurlData.author })
: unfurlData.description,
meta.lastModified ? timeAgo(new Date(meta.lastModified)) : null,
]
.filter(Boolean)
.join(" • ");
return (
<NodeViewWrapper data-drag-handle="">
<Card
className={classes.card}
withBorder
padding="sm"
radius="sm"
component="a"
href={url}
target="_blank"
rel="noopener"
style={{ textDecoration: "none", color: "inherit" }}
>
{showThumbnail && (
<Card.Section withBorder>
<img
src={thumbnailUrl}
alt=""
loading="lazy"
className={classes.thumbnail}
onError={() => setThumbnailFailed(true)}
/>
</Card.Section>
)}
<Group gap="sm" wrap="nowrap" mt={showThumbnail ? "sm" : undefined}>
<Avatar
src={unfurlData.authorAvatarUrl}
size={28}
radius="xl"
style={{ flexShrink: 0 }}
>
{(unfurlData.author ?? unfurlData.title ?? "F").charAt(0)}
</Avatar>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{unfurlData.title}
</Text>
{subtitle && (
<Text size="xs" c="dimmed" truncate>
{subtitle}
</Text>
)}
</Stack>
<div style={{ flexShrink: 0, alignSelf: "center" }}>
{getIntegrationIcon("figma", 18)}
</div>
</Group>
</Card>
</NodeViewWrapper>
);
}
function IntegrationLinkView(props: any) {
const { node } = props;
const { url, provider } = node.attrs;
const { t } = useTranslation();
const unfurl = useUnfurl(url);
const [connecting, setConnecting] = useState(false);
const needsConnection =
unfurl.state === "needsConnection" ? unfurl.needsConnection : null;
const handleConnect = useCallback(
async (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (!needsConnection) return;
setConnecting(true);
try {
const result = await getOAuthAuthorizeUrl({
integrationId: needsConnection.integrationId,
returnPath: window.location.pathname,
});
window.location.href = result.authorizationUrl;
} catch (error) {
setConnecting(false);
notifications.show({
message:
error?.["response"]?.data?.message ||
t("Failed to start OAuth connection"),
color: "red",
});
}
},
[needsConnection, t],
);
if (needsConnection) {
return (
<NodeViewWrapper data-drag-handle="">
<Card
className={classes.card}
withBorder
padding="sm"
radius="sm"
component="a"
href={url}
target="_blank"
rel="noopener"
style={{ textDecoration: "none", color: "inherit" }}
>
<Group gap="sm" wrap="nowrap">
<div style={{ flexShrink: 0 }}>
{getIntegrationIcon(provider, 28)}
</div>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{needsConnection.title}
</Text>
{needsConnection.description && (
<Text size="xs" c="dimmed" lineClamp={1}>
{needsConnection.description}
</Text>
)}
</Stack>
{needsConnection.oauthConnect ? (
<Button
size="xs"
variant="filled"
color="dark"
loading={connecting}
onClick={handleConnect}
style={{ flexShrink: 0 }}
>
{t("Connect to {{name}} to preview", {
name: needsConnection.integrationName,
})}
</Button>
) : (
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{t("Use")} <code>/docmost help</code> {t("in")}{" "}
{needsConnection.integrationName} {t("to link your account.")}
</Text>
)}
</Group>
</Card>
</NodeViewWrapper>
);
}
if (unfurl.state === "loading") {
return (
<NodeViewWrapper data-drag-handle="">
<Card className={classes.card} withBorder padding="sm" radius="sm">
<Group gap="sm">
<Skeleton circle height={24} />
<Stack gap={4} style={{ flex: 1 }}>
<Skeleton height={14} width="60%" />
<Skeleton height={10} width="80%" />
</Stack>
</Group>
</Card>
</NodeViewWrapper>
);
}
if (unfurl.state !== "loaded") {
// anonymous or error: no third-party data, only what the url itself says
const described = describeIntegrationLink(url);
if (described) {
return (
<NodeViewWrapper data-drag-handle="">
<Card
className={classes.card}
withBorder
padding="sm"
radius="sm"
component="a"
href={url}
target="_blank"
rel="noopener"
style={{ textDecoration: "none", color: "inherit" }}
>
<Group gap="sm" wrap="nowrap">
<div style={{ flexShrink: 0 }}>
{getIntegrationIcon(provider, 28)}
</div>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{described.title}
</Text>
{described.description && (
<Text size="xs" c="dimmed" lineClamp={1}>
{described.description}
</Text>
)}
</Stack>
</Group>
</Card>
</NodeViewWrapper>
);
}
return (
<NodeViewWrapper data-drag-handle="">
<Card className={classes.card} withBorder padding="sm" radius="sm">
<Anchor href={url} target="_blank" rel="noopener" size="sm">
{url}
</Anchor>
</Card>
</NodeViewWrapper>
);
}
const unfurlData = unfurl.data;
// metadata.ts marks legacy message unfurls stored before metadata.type existed.
const slackMeta = provider === "slack" ? unfurlData.metadata : null;
if (slackMeta?.type === "message" || (slackMeta && !slackMeta.type && slackMeta.ts)) {
return <SlackMessageCard url={url} unfurlData={unfurlData} />;
}
if (provider === "jira" && unfurlData.metadata?.issueKey) {
return <JiraIssueCard url={url} unfurlData={unfurlData} />;
}
if (provider === "figma") {
return <FigmaFileCard url={url} unfurlData={unfurlData} />;
}
return (
<NodeViewWrapper data-drag-handle="">
<Card
className={classes.card}
withBorder
padding="sm"
radius="sm"
component="a"
href={url}
target="_blank"
rel="noopener"
style={{ textDecoration: "none", color: "inherit" }}
>
<Group gap="sm" wrap="nowrap">
{unfurlData.authorAvatarUrl ? (
<Avatar src={unfurlData.authorAvatarUrl} size={28} radius="xl" style={{ flexShrink: 0 }} />
) : (
<div style={{ flexShrink: 0 }}>{getIntegrationIcon(provider, 28)}</div>
)}
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={600} truncate>
{unfurlData.title}
</Text>
{unfurlData.status && (
<Badge
size="xs"
variant="light"
color={toBadgeColor(unfurlData.statusColor)}
c={badgeTextColor(toBadgeColor(unfurlData.statusColor))}
style={{ flexShrink: 0 }}
>
{unfurlData.status}
</Badge>
)}
</Group>
{unfurlData.description && (
<Text size="xs" c="dimmed" lineClamp={1}>
{unfurlData.description}
</Text>
)}
</Stack>
{provider && (
<div style={{ flexShrink: 0, alignSelf: "center" }}>
{getIntegrationIcon(provider, 18)}
</div>
)}
</Group>
</Card>
</NodeViewWrapper>
);
}
export default memo(IntegrationLinkView);
@@ -0,0 +1,253 @@
import { NodeViewWrapper } from "@tiptap/react";
import {
Avatar,
Badge,
Button,
Group,
HoverCard,
Stack,
Text,
} from "@mantine/core";
import { memo, useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications";
import { describeIntegrationLink } from "@docmost/editor-ext";
import { getIntegrationIcon } from "@/features/integration/components/integration-icons";
import { getOAuthAuthorizeUrl } from "@/features/integration/services/integration-service";
import { useUnfurl } from "./use-unfurl";
import { badgeTextColor, toBadgeColor } from "./badge-color";
import classes from "./integration-link-view.module.css";
function shortUrl(url: string): string {
try {
const parsed = new URL(url);
return `${parsed.host}${parsed.pathname}`;
} catch {
return url;
}
}
function IntegrationMentionView(props: any) {
const { node } = props;
const { url, provider } = node.attrs;
const { t } = useTranslation();
const unfurl = useUnfurl(url);
const data = unfurl.state === "loaded" ? unfurl.data : null;
const needsConnection =
unfurl.state === "needsConnection" ? unfurl.needsConnection : null;
const [connecting, setConnecting] = useState(false);
const handleConnect = useCallback(
async (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (!needsConnection) return;
setConnecting(true);
try {
const result = await getOAuthAuthorizeUrl({
integrationId: needsConnection.integrationId,
returnPath: window.location.pathname,
});
window.location.href = result.authorizationUrl;
} catch (error) {
setConnecting(false);
notifications.show({
message:
error?.["response"]?.data?.message ||
t("Failed to start OAuth connection"),
color: "red",
});
}
},
[needsConnection, t],
);
const meta = data?.metadata ?? {};
const isSlackMessage =
provider === "slack" && (meta.type === "message" || (!meta.type && meta.ts));
const issueNumber = meta.iid ?? meta.number;
const typeLabel =
meta.type === "project"
? t("Project")
: meta.type === "initiative"
? t("Initiative")
: null;
const statusBadge = data?.status ? (
<Badge
size="xs"
variant="light"
color={toBadgeColor(data.statusColor)}
c={badgeTextColor(toBadgeColor(data.statusColor))}
className={classes.mentionIcon}
>
{data.status}
</Badge>
) : null;
let content;
if (!data) {
// anonymous / loading / error / needs-connection: a compact link chip.
// With no unfurl response, fall back to what the url text itself says.
content = (
<>
{getIntegrationIcon(provider, 14)}
<span className={classes.mentionText}>
{needsConnection?.title ??
describeIntegrationLink(url)?.title ??
shortUrl(url)}
</span>
</>
);
} else if (isSlackMessage) {
content = (
<>
<Avatar
src={data.authorAvatarUrl}
size={16}
radius="xl"
className={classes.mentionIcon}
>
{(data.author ?? "?").charAt(0)}
</Avatar>
{data.author && (
<Text component="span" size="sm" c="dimmed">
{data.author}
</Text>
)}
<span className={classes.mentionText}>
{(data.description ?? "").split("\n")[0] || shortUrl(url)}
</span>
{getIntegrationIcon("slack", 14)}
{data.status && (
<Badge
size="xs"
variant="light"
color="gray"
c={badgeTextColor("gray")}
tt="none"
className={classes.mentionIcon}
>
{data.status}
</Badge>
)}
</>
);
} else if (meta.issueKey) {
// Jira: type icon leads, provider icon trails.
content = (
<>
{meta.issueTypeIconUrl ? (
<img
src={meta.issueTypeIconUrl}
width={14}
height={14}
alt=""
className={classes.mentionIcon}
/>
) : (
getIntegrationIcon(provider, 14)
)}
<Text component="span" size="sm" c="dimmed">
{meta.issueKey}
</Text>
<span className={classes.mentionText}>{data.title}</span>
{statusBadge}
{meta.issueTypeIconUrl && getIntegrationIcon(provider, 14)}
</>
);
} else if (issueNumber) {
content = (
<>
{getIntegrationIcon(provider, 14)}
<Text component="span" size="sm" c="dimmed">
#{issueNumber}
</Text>
<span className={classes.mentionText}>{data.title}</span>
{statusBadge}
</>
);
} else if (typeLabel) {
content = (
<>
{getIntegrationIcon(provider, 14)}
<Text component="span" size="sm" c="dimmed">
{typeLabel}
</Text>
<span className={classes.mentionText}>{data.title}</span>
{statusBadge}
</>
);
} else {
content = (
<>
{getIntegrationIcon(provider, 14)}
<span className={classes.mentionText}>{data.title || shortUrl(url)}</span>
{statusBadge}
</>
);
}
const anchor = (
<a
href={url}
target="_blank"
rel="noopener"
title={url}
className={classes.mention}
>
{content}
</a>
);
return (
<NodeViewWrapper as="span" style={{ display: "inline" }}>
{needsConnection ? (
<HoverCard
position="bottom-start"
shadow="md"
radius="md"
openDelay={150}
closeDelay={150}
>
<HoverCard.Target>{anchor}</HoverCard.Target>
<HoverCard.Dropdown p="sm">
<Stack gap="xs">
<Group gap={6} wrap="nowrap">
{getIntegrationIcon(provider, 16)}
<Text size="sm" fw={500}>
{needsConnection.integrationName}
</Text>
</Group>
{needsConnection.oauthConnect ? (
<Button
size="xs"
variant="filled"
color="dark"
leftSection={getIntegrationIcon(provider, 16)}
loading={connecting}
onClick={handleConnect}
>
{t("Connect to {{name}} to preview", {
name: needsConnection.integrationName,
})}
</Button>
) : (
<Text size="xs" c="dimmed">
{t("Use")} <code>/docmost help</code> {t("in")}{" "}
{needsConnection.integrationName}{" "}
{t("to link your account.")}
</Text>
)}
</Stack>
</HoverCard.Dropdown>
</HoverCard>
) : (
anchor
)}
</NodeViewWrapper>
);
}
export default memo(IntegrationMentionView);
@@ -0,0 +1,205 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback, useEffect, useState } from "react";
import {
Button,
Paper,
Stack,
Text,
VisuallyHidden,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
import { integrationPasteMenuKey } from "@/features/editor/extensions/integration-paste-menu";
const INTEGRATION_NODE_TYPES = ["integrationLink", "integrationMention"];
type PasteTarget = "card" | "mention" | "url";
const PASTE_OPTIONS: { target: PasteTarget; label: string }[] = [
{ target: "card", label: "Card" },
{ target: "mention", label: "Mention" },
{ target: "url", label: "URL" },
];
export function IntegrationPasteMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const [selectedIndex, setSelectedIndex] = useState(0);
const menuState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) return null;
return integrationPasteMenuKey.getState(ctx.editor.state) ?? null;
},
});
const findTarget = useCallback(() => {
const state = integrationPasteMenuKey.getState(editor.state);
if (!state) return null;
const node = editor.state.doc.nodeAt(state.pos);
if (!node || !INTEGRATION_NODE_TYPES.includes(node.type.name)) return null;
return { node, pos: state.pos };
}, [editor]);
const shouldShow = useCallback(() => Boolean(findTarget()), [findTarget]);
const getReferencedVirtualElement = useCallback(() => {
const target = findTarget();
if (!target) return undefined;
const dom = editor.view.nodeDOM(target.pos) as HTMLElement | null;
const domRect =
dom?.getBoundingClientRect?.() ??
posToDOMRect(
editor.view,
target.pos,
target.pos + target.node.nodeSize,
);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}, [editor, findTarget]);
const dismiss = useCallback(() => {
editor.view.dispatch(
editor.state.tr.setMeta(integrationPasteMenuKey, null),
);
}, [editor]);
const convert = useCallback(
(target: PasteTarget) => {
const found = findTarget();
if (!found) {
dismiss();
return;
}
const { node, pos } = found;
const attrs = { ...node.attrs };
const from = pos;
const to = pos + node.nodeSize;
const isBlock = node.type.name === "integrationLink";
// Always replace, even when the node is already in the requested form:
// the menu closes via the doc change, and BubbleMenu never re-evaluates
// on meta-only transactions, so a bare dismiss would leave it stuck.
let content: Record<string, any>;
if (target === "card") {
content = { type: "integrationLink", attrs };
} else if (target === "mention") {
const mention = { type: "integrationMention", attrs };
content = isBlock
? {
type: "paragraph",
content: [mention, { type: "text", text: " " }],
}
: mention;
} else {
const linkText = {
type: "text",
text: attrs.url,
marks: [{ type: "link", attrs: { href: attrs.url } }],
};
content = isBlock
? { type: "paragraph", content: [linkText] }
: linkText;
}
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.deleteRange({ from, to })
.insertContentAt(from, content)
.run();
},
[editor, findTarget, dismiss],
);
useEffect(() => {
if (menuState) setSelectedIndex(0);
}, [menuState]);
// DOM focus stays in the editor (as with the slash menu); keys are handled
// here. Capture phase, because ProseMirror itself consumes arrow keys next
// to atom nodes (gap cursor / node selection) before they would bubble.
useEffect(() => {
if (!menuState) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
dismiss();
return;
}
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
event.stopPropagation();
const delta = event.key === "ArrowDown" ? 1 : -1;
setSelectedIndex(
(prev) =>
(prev + delta + PASTE_OPTIONS.length) % PASTE_OPTIONS.length,
);
return;
}
if (event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
convert(PASTE_OPTIONS[selectedIndex].target);
}
};
window.addEventListener("keydown", onKeyDown, true);
return () => window.removeEventListener("keydown", onKeyDown, true);
}, [menuState, dismiss, convert, selectedIndex]);
return (
<BaseBubbleMenu
editor={editor}
pluginKey="integration-paste-menu"
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{ placement: "bottom-start", flip: true }}
shouldShow={shouldShow}
>
{/* Content is gated on the plugin state too: meta-only dismissals
(Escape) are invisible to BubbleMenu's update cycle. */}
{menuState ? (
<Paper
shadow="md"
radius="md"
withBorder
p={4}
miw={140}
role="listbox"
aria-label={t("Paste as")}
aria-activedescendant={`integration-paste-option-${PASTE_OPTIONS[selectedIndex].target}`}
>
<VisuallyHidden role="status" aria-live="polite" aria-atomic="true">
{t(PASTE_OPTIONS[selectedIndex].label)}
</VisuallyHidden>
<Text size="xs" c="dimmed" px={8} py={4}>
{t("Paste as")}
</Text>
<Stack gap={2}>
{PASTE_OPTIONS.map((option, index) => (
<Button
key={option.target}
id={`integration-paste-option-${option.target}`}
role="option"
aria-selected={index === selectedIndex}
variant={index === selectedIndex ? "light" : "subtle"}
color="gray"
size="compact-sm"
fullWidth
justify="flex-start"
onMouseEnter={() => setSelectedIndex(index)}
onClick={() => convert(option.target)}
>
{t(option.label)}
</Button>
))}
</Stack>
</Paper>
) : null}
</BaseBubbleMenu>
);
}
@@ -0,0 +1,119 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createStore, Provider } from "jotai";
import { ReactNode } from "react";
import { useUnfurl } from "./use-unfurl";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { unfurlUrl } from "@/features/integration/services/integration-service";
import {
UnfurlNeedsConnection,
UnfurlResult,
} from "@/features/integration/types/integration.types";
vi.mock("@/features/integration/services/integration-service", () => ({
unfurlUrl: vi.fn(),
}));
const mockedUnfurlUrl = vi.mocked(unfurlUrl);
const ISSUE_URL = "https://github.com/acme/repo/issues/42";
const loadedResult: UnfurlResult = {
title: "Fix race condition in file watcher",
url: ISSUE_URL,
provider: "github",
status: "open",
};
const needsConnectionResult: UnfurlNeedsConnection = {
needsConnection: true,
integrationId: "int-1",
integrationType: "github",
integrationName: "GitHub",
oauthConnect: true,
title: "GitHub link",
description: "github.com/acme/repo/issues/42",
};
function createWrapper(loggedIn: boolean) {
const store = createStore();
if (loggedIn) {
store.set(currentUserAtom, { user: { id: "user-1" } } as any);
}
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return ({ children }: { children: ReactNode }) => (
<Provider store={store}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</Provider>
);
}
describe("useUnfurl", () => {
beforeEach(() => {
localStorage.clear();
vi.resetAllMocks();
});
it("never fetches for anonymous viewers and reports anonymous", () => {
const { result } = renderHook(() => useUnfurl(ISSUE_URL), {
wrapper: createWrapper(false),
});
expect(result.current.state).toBe("anonymous");
expect(mockedUnfurlUrl).not.toHaveBeenCalled();
});
it("starts loading then exposes the unfurl result", async () => {
mockedUnfurlUrl.mockResolvedValue(loadedResult);
const { result } = renderHook(() => useUnfurl(ISSUE_URL), {
wrapper: createWrapper(true),
});
expect(result.current.state).toBe("loading");
await waitFor(() => expect(result.current.state).toBe("loaded"));
expect(
result.current.state === "loaded" && result.current.data,
).toEqual(loadedResult);
expect(mockedUnfurlUrl).toHaveBeenCalledWith({ url: ISSUE_URL });
});
it("maps a needsConnection response without treating it as an error", async () => {
mockedUnfurlUrl.mockResolvedValue(needsConnectionResult);
const { result } = renderHook(() => useUnfurl(ISSUE_URL), {
wrapper: createWrapper(true),
});
await waitFor(() =>
expect(result.current.state).toBe("needsConnection"),
);
expect(
result.current.state === "needsConnection" &&
result.current.needsConnection,
).toEqual(needsConnectionResult);
});
it("maps a null result (no matching provider) to error", async () => {
mockedUnfurlUrl.mockResolvedValue(null);
const { result } = renderHook(() => useUnfurl(ISSUE_URL), {
wrapper: createWrapper(true),
});
await waitFor(() => expect(result.current.state).toBe("error"));
});
it("maps a rejected request to error", async () => {
mockedUnfurlUrl.mockRejectedValue(new Error("boom"));
const { result } = renderHook(() => useUnfurl(ISSUE_URL), {
wrapper: createWrapper(true),
});
await waitFor(() => expect(result.current.state).toBe("error"));
});
});
@@ -0,0 +1,47 @@
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { unfurlUrl } from "@/features/integration/services/integration-service";
import {
UnfurlNeedsConnection,
UnfurlResult,
} from "@/features/integration/types/integration.types";
const UNFURL_STALE_TIME = 5 * 60 * 1000; // mirrors the server-side Redis TTL
export type UnfurlState =
| { state: "anonymous" }
| { state: "loading" }
| { state: "error" }
| { state: "needsConnection"; needsConnection: UnfurlNeedsConnection }
| { state: "loaded"; data: UnfurlResult };
// Resolves the unfurl per viewer at render time. Nothing is written back into
// the document, so third-party permissions are enforced on every view:
// unconnected viewers get needsConnection and anonymous viewers never fetch.
export function useUnfurl(url: string): UnfurlState {
const currentUser = useAtomValue(currentUserAtom);
const isAuthenticated = Boolean(currentUser?.user);
const query = useQuery({
queryKey: ["unfurl", url],
queryFn: () => unfurlUrl({ url }),
enabled: isAuthenticated && Boolean(url),
staleTime: UNFURL_STALE_TIME,
retry: false,
});
if (!isAuthenticated || !url) {
return { state: "anonymous" };
}
if (query.isPending) {
return { state: "loading" };
}
if (query.isError || !query.data) {
return { state: "error" };
}
if ("needsConnection" in query.data) {
return { state: "needsConnection", needsConnection: query.data };
}
return { state: "loaded", data: query.data };
}
@@ -13,21 +13,14 @@ import {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { sortPositionKeys } from "@/features/page/tree/utils/utils"; import { sortPositionKeys } from "@/features/page/tree/utils/utils";
import { useSharedPageSubpages } from "@/features/share/hooks/use-shared-page-subpages"; import { useSharedPageSubpages } from "@/features/share/hooks/use-shared-page-subpages";
import { extractPageSlugId } from "@/lib";
export default function SubpagesView(props: NodeViewProps) { export default function SubpagesView(props: NodeViewProps) {
const { editor } = props; const { editor } = props;
const { spaceSlug, shareId, pageSlug } = useParams(); const { spaceSlug, shareId } = useParams();
const { t } = useTranslation(); const { t } = useTranslation();
// @ts-ignore //@ts-ignore
const storagePageId = editor.storage.pageId; const currentPageId = 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 // Get subpages from shared tree if we're in a shared context
const sharedSubpages = useSharedPageSubpages(currentPageId); const sharedSubpages = useSharedPageSubpages(currentPageId);
@@ -1,7 +1,6 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback } from "react"; import { useCallback } from "react";
import { useSetAtom } from "jotai";
import { Node as PMNode } from "@tiptap/pm/model"; import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext"; import { isEditorReady } from "@docmost/editor-ext";
import { import {
@@ -16,17 +15,14 @@ import {
IconLayoutAlignRight, IconLayoutAlignRight,
IconDownload, IconDownload,
IconTrash, IconTrash,
IconZoomIn,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getFileUrl } from "@/lib/config.ts"; import { getFileUrl } from "@/lib/config.ts";
import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.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"; import classes from "../common/toolbar-menu.module.css";
export function VideoMenu({ editor }: EditorMenuProps) { export function VideoMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const setLightboxRequest = useSetAtom(lightboxRequestAtom);
const editorState = useEditorState({ const editorState = useEditorState({
editor, editor,
@@ -187,23 +183,6 @@ export function VideoMenu({ editor }: EditorMenuProps) {
<div className={classes.divider} /> <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}> <Tooltip position="top" label={t("Download")} withinPortal={false}>
<ActionIcon <ActionIcon
onClick={handleDownload} onClick={handleDownload}
@@ -57,6 +57,8 @@ import {
Indent, Indent,
UniqueID, UniqueID,
SharedStorage, SharedStorage,
IntegrationLink,
IntegrationMention,
Columns, Columns,
Column, Column,
Status, Status,
@@ -95,6 +97,9 @@ import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-v
import EmbedView from "@/features/editor/components/embed/embed-view.tsx"; import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
import PdfView from "@/features/editor/components/pdf/pdf-view.tsx"; import PdfView from "@/features/editor/components/pdf/pdf-view.tsx";
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx"; import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
import IntegrationLinkView from "@/features/editor/components/integration-link/integration-link-view.tsx";
import IntegrationMentionView from "@/features/editor/components/integration-link/integration-mention-view.tsx";
import { IntegrationPasteMenuExtension } from "@/features/editor/extensions/integration-paste-menu";
import TransclusionView from "@/features/editor/components/transclusion/transclusion-view.tsx"; import TransclusionView from "@/features/editor/components/transclusion/transclusion-view.tsx";
import TransclusionReferenceView from "@/features/editor/components/transclusion/transclusion-reference-view.tsx"; import TransclusionReferenceView from "@/features/editor/components/transclusion/transclusion-reference-view.tsx";
import { BaseEmbedView } from "@/features/editor/components/base-embed/base-embed-view.tsx"; import { BaseEmbedView } from "@/features/editor/components/base-embed/base-embed-view.tsx";
@@ -387,6 +392,13 @@ export const mainExtensions = [
Subpages.configure({ Subpages.configure({
view: SubpagesView, view: SubpagesView,
}), }),
IntegrationLink.configure({
view: IntegrationLinkView,
}),
IntegrationMention.configure({
view: IntegrationMentionView,
}),
IntegrationPasteMenuExtension,
Status.configure({ Status.configure({
view: StatusView, view: StatusView,
}), }),
@@ -0,0 +1,44 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
export type IntegrationPasteMenuState = { pos: number } | null;
export const integrationPasteMenuKey = new PluginKey<IntegrationPasteMenuState>(
"integrationPasteMenu",
);
// Holds the position of a just-pasted integration node so the "Paste as"
// menu can anchor to it. Any other edit or selection change dismisses it.
export const IntegrationPasteMenuExtension = Extension.create({
name: "integrationPasteMenu",
addProseMirrorPlugins() {
return [
new Plugin({
key: integrationPasteMenuKey,
state: {
init: (): IntegrationPasteMenuState => null,
apply(tr, prev): IntegrationPasteMenuState {
const meta = tr.getMeta(integrationPasteMenuKey);
if (meta !== undefined) return meta;
if (!prev) return null;
// Clicking or typing elsewhere dismisses.
if (tr.selectionSet) return null;
// Structural follow-ups (unique-id assignment, trailing node)
// keep the menu anchored: remap and re-validate the position.
if (tr.docChanged) {
const pos = tr.mapping.map(prev.pos);
const node = tr.doc.nodeAt(pos);
const isIntegrationNode =
node &&
(node.type.name === "integrationLink" ||
node.type.name === "integrationMention");
return isIntegrationNode ? { pos } : null;
}
return prev;
},
},
}),
];
},
});
+26 -42
View File
@@ -33,7 +33,6 @@ import { useAtom, useAtomValue } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { import {
currentPageEditModeAtom, currentPageEditModeAtom,
lightboxRequestAtom,
pageEditorAtom, pageEditorAtom,
yjsConnectionStatusAtom, yjsConnectionStatusAtom,
yjsSyncedAtom, yjsSyncedAtom,
@@ -54,9 +53,6 @@ import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
import VideoMenu from "@/features/editor/components/video/video-menu.tsx"; import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
import PdfMenu from "@/features/editor/components/pdf/pdf-menu.tsx"; import PdfMenu from "@/features/editor/components/pdf/pdf-menu.tsx";
import SubpagesMenu from "@/features/editor/components/subpages/subpages-menu.tsx"; import SubpagesMenu from "@/features/editor/components/subpages/subpages-menu.tsx";
import LightboxView, {
getLightboxClickRequest,
} from "@/features/editor/components/common/lightbox-view";
import { import {
handleFileDrop, handleFileDrop,
handlePaste, handlePaste,
@@ -79,6 +75,8 @@ import { useEditorScroll } from "./hooks/use-editor-scroll";
import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu"; import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu";
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu"; import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx"; import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
import { IntegrationPasteMenu } from "@/features/editor/components/integration-link/integration-paste-menu.tsx";
import { getInstalledIntegrations } from "@/features/integration/services/integration-service";
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context"; import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
@@ -188,7 +186,6 @@ function CollabPageEditor({
const [, setActiveCommentId] = useAtom(activeCommentIdAtom); const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom); const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom); const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
const [lightboxRequest, setLightboxRequest] = useAtom(lightboxRequestAtom);
const [isLocalSynced, setIsLocalSynced] = useState(false); const [isLocalSynced, setIsLocalSynced] = useState(false);
const [isRemoteSynced, setIsRemoteSynced] = useState(false); const [isRemoteSynced, setIsRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom( const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
@@ -250,17 +247,6 @@ function CollabPageEditor({
return [...mainExtensions, ...collabExtensions(provider, currentUser.user)]; return [...mainExtensions, ...collabExtensions(provider, currentUser.user)];
}, [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( const editor = useEditor(
{ {
extensions, extensions,
@@ -321,22 +307,6 @@ function CollabPageEditor({
return handleFileDrop(editorRef.current, event, moved, pageId); 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 }) { onCreate({ editor }) {
if (editor) { if (editor) {
@@ -358,6 +328,18 @@ function CollabPageEditor({
[pageId, editable, extensions], [pageId, editable, extensions],
); );
useEffect(() => {
// Warm the cache the paste handler reads to decide whether a pasted
// integration url becomes a card or stays an ordinary link. gcTime
// Infinity: no observer holds this query, and the default 5-minute gc
// would evict it mid-session, silently downgrading pastes to plain links.
queryClient.prefetchQuery({
queryKey: ["installed-integrations"],
queryFn: getInstalledIntegrations,
gcTime: Infinity,
});
}, []);
useLayoutEffect(() => { useLayoutEffect(() => {
if (editor && !editor.isDestroyed) { if (editor && !editor.isDestroyed) {
// @ts-ignore // @ts-ignore
@@ -375,6 +357,17 @@ 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 handleActiveCommentEvent = (event) => {
const { commentId, resolved } = event.detail; const { commentId, resolved } = event.detail;
@@ -407,7 +400,6 @@ function CollabPageEditor({
setActiveCommentId(null); setActiveCommentId(null);
setShowCommentPopup(false); setShowCommentPopup(false);
setAsideState({ tab: "", isAsideOpen: false }); setAsideState({ tab: "", isAsideOpen: false });
setLightboxRequest(null);
}, [pageId]); }, [pageId]);
const isSynced = isLocalSynced && isRemoteSynced; const isSynced = isLocalSynced && isRemoteSynced;
@@ -476,20 +468,12 @@ function CollabPageEditor({
<ExcalidrawMenu editor={editor} /> <ExcalidrawMenu editor={editor} />
<DrawioMenu editor={editor} /> <DrawioMenu editor={editor} />
<ColumnsMenu editor={editor} /> <ColumnsMenu editor={editor} />
<IntegrationPasteMenu editor={editor} />
</div> </div>
)} )}
{editor && !editorIsEditable && (editable || canComment) && ( {editor && !editorIsEditable && (editable || canComment) && (
<ReadonlyBubbleMenu editor={editor} /> <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} />} {showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
{showReadOnlyCommentPopup && ( {showReadOnlyCommentPopup && (
<CommentDialog editor={editor} pageId={pageId} readOnly /> <CommentDialog editor={editor} pageId={pageId} readOnly />
@@ -1,27 +1,15 @@
import "@/features/editor/styles/index.css"; import "@/features/editor/styles/index.css";
import React, { import React, { useCallback, useEffect, useMemo, useRef } from "react";
useCallback, import { EditorProvider } from "@tiptap/react";
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Editor, EditorProvider } from "@tiptap/react";
import { mainExtensions } from "@/features/editor/extensions/extensions"; import { mainExtensions } from "@/features/editor/extensions/extensions";
import { Document } from "@tiptap/extension-document"; import { Document } from "@tiptap/extension-document";
import { Heading, UniqueID } from "@docmost/editor-ext"; import { Heading, UniqueID } from "@docmost/editor-ext";
import { Text } from "@tiptap/extension-text"; import { Text } from "@tiptap/extension-text";
import { Placeholder } from "@tiptap/extension-placeholder"; import { Placeholder } from "@tiptap/extension-placeholder";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
lightboxRequestAtom,
readOnlyEditorAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
import { useEditorScroll } from "./hooks/use-editor-scroll"; import { useEditorScroll } from "./hooks/use-editor-scroll";
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context"; import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
import LightboxView, {
getLightboxClickRequest,
} from "@/features/editor/components/common/lightbox-view";
interface PageEditorProps { interface PageEditorProps {
title: string; title: string;
@@ -45,8 +33,6 @@ export default function ReadonlyPageEditor({
shareId, shareId,
}: PageEditorProps) { }: PageEditorProps) {
const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom); const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom);
const [lightboxRequest, setLightboxRequest] = useAtom(lightboxRequestAtom);
const [contentEditor, setContentEditor] = useState<Editor | null>(null);
const isComponentMounted = useRef(false); const isComponentMounted = useRef(false);
const editorCreated = useRef(false); const editorCreated = useRef(false);
@@ -63,11 +49,6 @@ export default function ReadonlyPageEditor({
isComponentMounted.current = true; isComponentMounted.current = true;
}, []); }, []);
useEffect(() => {
if (!shareId) return;
setLightboxRequest(null);
}, [pageId, shareId]);
const extensions = useMemo(() => { const extensions = useMemo(() => {
const excludedExtensions = new Set([ const excludedExtensions = new Set([
"uniqueID", "uniqueID",
@@ -116,19 +97,6 @@ export default function ReadonlyPageEditor({
textDirection="auto" textDirection="auto"
extensions={extensions} extensions={extensions}
content={content} content={content}
editorProps={
shareId
? {
handleClickOn: (_view, _pos, node) => {
const request = getLightboxClickRequest(node);
if (!request) return false;
setLightboxRequest(request);
return true;
},
}
: undefined
}
onCreate={({ editor }) => { onCreate={({ editor }) => {
if (editor) { if (editor) {
if (pageId) { if (pageId) {
@@ -137,22 +105,12 @@ export default function ReadonlyPageEditor({
} }
// @ts-ignore // @ts-ignore
setReadOnlyEditor(editor); setReadOnlyEditor(editor);
setContentEditor(editor);
handleScrollTo(editor); handleScrollTo(editor);
editorCreated.current = true; editorCreated.current = true;
} }
}} }}
></EditorProvider> ></EditorProvider>
{shareId && contentEditor && (
<LightboxView
editor={contentEditor}
open={!!lightboxRequest}
src={lightboxRequest?.src ?? ""}
type={lightboxRequest?.type ?? "image"}
onClose={() => setLightboxRequest(null)}
/>
)}
<div style={{ paddingBottom: "20vh" }}></div> <div style={{ paddingBottom: "20vh" }}></div>
</TransclusionLookupProvider> </TransclusionLookupProvider>
); );
@@ -38,60 +38,6 @@
line-height: var(--mantine-line-height-md); 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 { .media-pulse {
animation: media-pulse 1.2s ease-in-out infinite; animation: media-pulse 1.2s ease-in-out infinite;
@@ -0,0 +1,93 @@
import { Group, Text, Button, Box } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { IntegrationDefinition, UserConnection } from "../types/integration.types";
import { getIntegrationIcon } from "./integration-icons";
type ConnectionRowProps = {
definition: IntegrationDefinition;
connection?: UserConnection;
onConnect: (type: string) => void;
onDisconnect: (integrationId: string) => void;
disconnectingId?: string;
};
export default function ConnectionRow({
definition,
connection,
onConnect,
onDisconnect,
disconnectingId,
}: ConnectionRowProps) {
const { t } = useTranslation();
const connectedLabel =
connection?.providerDisplayName || connection?.providerUserId;
return (
<Box
py="sm"
px="xs"
style={{
borderBottom: "1px solid var(--mantine-color-default-border)",
}}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
{getIntegrationIcon(definition.type, 28)}
<div>
<Text size="sm" fw={500}>
{definition.name}
</Text>
<Text size="xs" c="dimmed">
{definition.description}
</Text>
</div>
</Group>
<Group gap="sm" wrap="nowrap" style={{ flexShrink: 0 }}>
{connection ? (
<>
{connection.invalidatedAt ? (
<>
<Text size="xs" c="orange">
{t("Connection expired")}
</Text>
<Button
size="xs"
variant="light"
color="orange"
onClick={() => onConnect(definition.type)}
>
{t("Reconnect")}
</Button>
</>
) : (
<Text size="xs" c="green">
{connectedLabel
? t("Connected as {{label}}", { label: connectedLabel })
: t("Connected")}
</Text>
)}
<Button
size="xs"
variant="subtle"
color="red"
onClick={() => onDisconnect(connection.integrationId)}
loading={disconnectingId === connection.integrationId}
>
{t("Disconnect")}
</Button>
</>
) : (
<Button
size="xs"
variant="light"
onClick={() => onConnect(definition.type)}
>
{t("Connect")}
</Button>
)}
</Group>
</Group>
</Box>
);
}
@@ -0,0 +1,30 @@
import { ReactNode } from "react";
import {
FigmaIcon,
GithubIcon,
GitlabIcon,
GoogleDocsIcon,
JiraIcon,
LinearIcon,
SlackIcon,
} from "@/components/icons";
import { IconPuzzle } from "@tabler/icons-react";
const integrationIconMap: Record<string, (size: number) => ReactNode> = {
github: (size) => <GithubIcon size={size} />,
gitlab: (size) => <GitlabIcon size={size} />,
slack: (size) => <SlackIcon size={size} />,
linear: (size) => <LinearIcon size={size} />,
jira: (size) => <JiraIcon size={size} />,
figma: (size) => <FigmaIcon size={size} />,
google_docs: (size) => <GoogleDocsIcon size={size} />,
};
export function getIntegrationIcon(
type: string,
size: number,
): ReactNode {
const renderIcon = integrationIconMap[type];
if (renderIcon) return renderIcon(size);
return <IconPuzzle size={size} stroke={1.5} />;
}
@@ -0,0 +1,66 @@
import { Box, Group, Skeleton, Stack } from "@mantine/core";
const TITLE_WIDTHS = [64, 52, 60, 44, 58, 96, 56];
const DESCRIPTION_WIDTHS = [300, 250, 320, 180, 290, 270, 260];
type IntegrationListSkeletonProps = {
rows?: number;
withBadges?: boolean;
};
export default function IntegrationListSkeleton({
rows = 7,
withBadges = true,
}: IntegrationListSkeletonProps) {
return (
<Stack gap={0} aria-hidden="true">
{Array.from({ length: rows }, (_, index) => (
<Box
key={index}
py="sm"
px="xs"
style={{
borderBottom: "1px solid var(--mantine-color-default-border)",
}}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
<Skeleton height={28} circle style={{ flexShrink: 0 }} />
<Stack gap={2} style={{ minWidth: 0 }}>
<Group gap="xs" wrap="nowrap" h={20}>
<Skeleton
height={12}
width={TITLE_WIDTHS[index % TITLE_WIDTHS.length]}
radius="xs"
/>
{withBadges && (
<>
<Skeleton height={16} width={52} radius="xl" />
<Skeleton height={16} width={52} radius="xl" />
</>
)}
</Group>
<Group h={17}>
<Skeleton
height={10}
width={
DESCRIPTION_WIDTHS[index % DESCRIPTION_WIDTHS.length]
}
maw="100%"
radius="xs"
/>
</Group>
</Stack>
</Group>
<Skeleton
height={30}
width={64}
radius="sm"
style={{ flexShrink: 0 }}
/>
</Group>
</Box>
))}
</Stack>
);
}
@@ -0,0 +1,93 @@
import {
Group,
Text,
Badge,
Button,
Box,
Stack,
Tooltip,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import {
IntegrationDefinition,
Integration,
} from "../types/integration.types";
import { getIntegrationIcon } from "./integration-icons";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
type IntegrationRowProps = {
definition: IntegrationDefinition;
installation?: Integration;
onInstall: (type: string) => void;
onUninstall: (integrationId: string) => void;
};
export default function IntegrationRow({
definition,
installation,
onInstall,
onUninstall,
}: IntegrationRowProps) {
const { t } = useTranslation();
const isInstalled = !!installation;
const hasAccess = useHasFeature(Feature.INTEGRATIONS);
const locked = !!definition.requiresLicense && !hasAccess;
const upgradeLabel = useUpgradeLabel();
return (
<Box
py="sm"
px="xs"
style={{
borderBottom: "1px solid var(--mantine-color-default-border)",
}}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
{getIntegrationIcon(definition.type, 28)}
<Stack gap={2} style={{ minWidth: 0 }}>
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={500}>
{definition.name}
</Text>
{locked && (
<Badge size="xs" variant="light" color="violet">
{t("Paid")}
</Badge>
)}
</Group>
<Text size="xs" c="dimmed" truncate>
{definition.description}
</Text>
</Stack>
</Group>
<Group gap="sm" wrap="nowrap" style={{ flexShrink: 0 }}>
{isInstalled ? (
<Button
size="xs"
variant="subtle"
color="red"
onClick={() => onUninstall(installation.id)}
>
{t("Uninstall")}
</Button>
) : (
<Tooltip label={upgradeLabel} disabled={!locked}>
<Button
size="xs"
variant="light"
disabled={locked}
onClick={() => onInstall(definition.type)}
>
{t("Install")}
</Button>
</Tooltip>
)}
</Group>
</Group>
</Box>
);
}
@@ -0,0 +1,134 @@
import { Text, Alert, Stack } from "@mantine/core";
import { modals } from "@mantine/modals";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications";
import { getAppName } from "@/lib/config";
import SettingsTitle from "@/components/settings/settings-title";
import ConnectionRow from "../components/connection-row";
import IntegrationListSkeleton from "../components/integration-list-skeleton";
import {
useAvailableIntegrations,
useInstalledIntegrations,
useMyConnections,
useDisconnectIntegration,
} from "../queries/integration-query";
import * as integrationService from "../services/integration-service";
export default function Connections() {
const { t } = useTranslation();
const { data: available, isLoading: loadingAvailable } =
useAvailableIntegrations();
const { data: installed, isLoading: loadingInstalled } =
useInstalledIntegrations();
const { data: myConnections, isLoading: loadingConnections } =
useMyConnections();
const disconnectMutation = useDisconnectIntegration();
const isLoading = loadingAvailable || loadingInstalled || loadingConnections;
const handleConnect = async (type: string) => {
const integration = installed?.find((i) => i.type === type);
if (!integration) return;
try {
// Workspace-scoped providers default the OAuth return to the admin
// integrations page; members connecting here must come back here.
const result = await integrationService.getOAuthAuthorizeUrl({
integrationId: integration.id,
returnPath: "/settings/account/connections",
});
window.location.href = result.authorizationUrl;
} catch (error) {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to start OAuth connection"),
color: "red",
});
}
};
const handleDisconnect = (integrationId: string) => {
const installation = installed?.find((i) => i.id === integrationId);
const name =
available?.find((d) => d.type === installation?.type)?.name ??
installation?.type ??
"";
modals.openConfirmModal({
title: t("Disconnect {{name}}", { name }),
centered: true,
children: (
<Text size="sm">
{t(
"This disconnects your {{name}} account. Links are not enriched for you until you reconnect.",
{ name },
)}
</Text>
),
labels: { confirm: t("Disconnect"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => disconnectMutation.mutate({ integrationId }),
});
};
// Only the row being disconnected shows a loader; isPending alone is shared by every row.
const disconnectingId = disconnectMutation.isPending
? disconnectMutation.variables?.integrationId
: undefined;
const error = new URLSearchParams(window.location.search).get("error");
return (
<>
<Helmet>
<title>
{t("Connections")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Connections")} />
<Text size="sm" c="dimmed" mb="md">
{t("Manage the apps you have connected to your account.")}
</Text>
{error === "oauth_failed" && (
<Alert color="red" mb="md">
{t("OAuth connection failed. Please try again.")}
</Alert>
)}
{isLoading ? (
<IntegrationListSkeleton rows={3} withBadges={false} />
) : !available?.length ? (
<Text c="dimmed" size="sm">
{t("No integrations available.")}
</Text>
) : (
<Stack gap={0}>
{available
.filter((def) => {
if (!def.capabilities.includes("oauth")) return false;
return installed?.some((i) => i.type === def.type);
})
.map((def) => {
const connection = myConnections?.find(
(c) => c.type === def.type,
);
return (
<ConnectionRow
key={def.type}
definition={def}
connection={connection}
onConnect={handleConnect}
onDisconnect={handleDisconnect}
disconnectingId={disconnectingId}
/>
);
})}
</Stack>
)}
</>
);
}
@@ -0,0 +1,126 @@
import { Text, Alert, Stack } from "@mantine/core";
import { modals } from "@mantine/modals";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { useCallback } from "react";
import { getAppName } from "@/lib/config";
import SettingsTitle from "@/components/settings/settings-title";
import IntegrationRow from "../components/integration-row";
import IntegrationListSkeleton from "../components/integration-list-skeleton";
import {
useAvailableIntegrations,
useInstalledIntegrations,
useInstallIntegration,
useUninstallIntegration,
} from "../queries/integration-query";
import { getOAuthInstallUrl } from "../services/integration-service";
import { notifications } from "@mantine/notifications";
export default function Integrations() {
const { t } = useTranslation();
const { data: available, isLoading: loadingAvailable } =
useAvailableIntegrations();
const { data: installed, isLoading: loadingInstalled } =
useInstalledIntegrations();
const installMutation = useInstallIntegration();
const uninstallMutation = useUninstallIntegration();
const handleInstall = useCallback(
async (type: string) => {
const definition = available?.find((d) => d.type === type);
// OAuth providers only become installed once the admin's OAuth
// callback succeeds; a cancelled or failed flow persists nothing.
if (definition?.capabilities?.includes("oauth")) {
try {
const { authorizationUrl } = await getOAuthInstallUrl({ type });
window.location.href = authorizationUrl;
} catch (err: any) {
notifications.show({
message:
err?.response?.data?.message ?? t("Failed to start installation"),
color: "red",
});
}
return;
}
installMutation.mutate({ type });
},
[installMutation, available, t],
);
const handleUninstall = useCallback(
(integrationId: string) => {
const installation = installed?.find((i) => i.id === integrationId);
const name =
available?.find((d) => d.type === installation?.type)?.name ??
installation?.type ??
"";
modals.openConfirmModal({
title: t("Uninstall {{name}}", { name }),
centered: true,
children: (
<Text size="sm">
{t(
"This disables the {{name}} integration for the entire workspace. Members' connections are removed and links are not enriched.",
{ name },
)}
</Text>
),
labels: { confirm: t("Uninstall"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => uninstallMutation.mutate({ integrationId }),
});
},
[uninstallMutation, installed, available, t],
);
const isLoading = loadingAvailable || loadingInstalled;
const error = new URLSearchParams(window.location.search).get("error");
return (
<>
<Helmet>
<title>
{t("Integrations")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Integrations")} />
<Text size="sm" c="dimmed" mb="md">
{t("Manage workspace integrations.")}
</Text>
{error === "oauth_failed" && (
<Alert color="red" mb="md">
{t("OAuth connection failed. Please try again.")}
</Alert>
)}
{isLoading ? (
<IntegrationListSkeleton />
) : !available?.length ? (
<Text c="dimmed" size="sm">
{t("No integrations available.")}
</Text>
) : (
<Stack gap={0}>
{available.map((def) => {
const installation = installed?.find((i) => i.type === def.type);
return (
<IntegrationRow
key={def.type}
definition={def}
installation={installation}
onInstall={handleInstall}
onUninstall={handleUninstall}
/>
);
})}
</Stack>
)}
</>
);
}
@@ -0,0 +1,112 @@
import { useEffect, useState } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
import { Alert, Button, Card, Group, Loader, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useAtomValue } from "jotai";
import {
decodeSlackLinkState,
confirmSlackLink,
SlackLinkStateInfo,
} from "../services/slack-link-service";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import APP_ROUTE from "@/lib/app-route";
export default function SlackLinkPage() {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const state = searchParams.get("state");
const currentUser = useAtomValue(currentUserAtom);
const [info, setInfo] = useState<SlackLinkStateInfo | null>(null);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [done, setDone] = useState(false);
useEffect(() => {
if (!currentUser) {
const redirectPath = window.location.pathname + window.location.search;
navigate(`${APP_ROUTE.AUTH.LOGIN}?redirect=${encodeURIComponent(redirectPath)}`);
return;
}
if (!state) {
setError(t("Missing state parameter"));
return;
}
decodeSlackLinkState(state)
.then(setInfo)
.catch((e) => setError(e?.response?.data?.message ?? e.message));
}, [state, t, currentUser, navigate]);
async function onConfirm() {
if (!state) return;
setSubmitting(true);
try {
await confirmSlackLink(state);
setDone(true);
} catch (e: any) {
setError(e?.response?.data?.message ?? e.message);
} finally {
setSubmitting(false);
}
}
if (done) {
return (
<Card maw={500} mx="auto" mt={80} p="lg">
<Stack>
<Text fw={600}>{t("Connected")}</Text>
<Text c="dimmed">{t("You can close this tab and return to Slack.")}</Text>
</Stack>
</Card>
);
}
if (error) {
return (
<Card maw={500} mx="auto" mt={80} p="lg">
<Alert color="red" title={t("Could not link account")}>
{error}
</Alert>
</Card>
);
}
if (!info || !currentUser) {
return (
<div style={{ display: "flex", justifyContent: "center", marginTop: 80 }}>
<Loader />
</div>
);
}
return (
<Card maw={500} mx="auto" mt={80} p="lg">
<Stack>
<Text fw={600}>{t("Link your Docmost account to Slack")}</Text>
<Text>
{t("Connect Docmost account")}{" "}
<b>{currentUser.user.email}</b>{" "}
{t("to Slack user")} <b>@{info.slackUserName}</b>
{info.slackTeamName && (
<>
{" "}
{t("in")} <b>{info.slackTeamName}</b>
</>
)}
?
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={() => window.close()}>
{t("Cancel")}
</Button>
<Button onClick={onConfirm} loading={submitting}>
{t("Confirm")}
</Button>
</Group>
</Stack>
</Card>
);
}
@@ -0,0 +1,100 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications";
import * as integrationService from "../services/integration-service";
export function useAvailableIntegrations() {
return useQuery({
queryKey: ["available-integrations"],
queryFn: integrationService.getAvailableIntegrations,
});
}
export function useInstalledIntegrations() {
return useQuery({
queryKey: ["installed-integrations"],
queryFn: integrationService.getInstalledIntegrations,
});
}
export function useInstallIntegration() {
const qc = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: integrationService.installIntegration,
onSuccess: () => {
notifications.show({ message: t("Integration installed successfully") });
qc.invalidateQueries({ queryKey: ["installed-integrations"] });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to install integration"),
color: "red",
});
},
});
}
export function useUninstallIntegration() {
const qc = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: integrationService.uninstallIntegration,
onSuccess: () => {
notifications.show({
message: t("Integration uninstalled successfully"),
});
qc.invalidateQueries({ queryKey: ["installed-integrations"] });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to uninstall integration"),
color: "red",
});
},
});
}
export function useMyConnections() {
return useQuery({
queryKey: ["my-connections"],
queryFn: integrationService.getMyConnections,
});
}
export function useConnectionStatus(integrationId: string | undefined) {
return useQuery({
queryKey: ["integration-connection", integrationId],
queryFn: () =>
integrationService.getConnectionStatus({
integrationId: integrationId!,
}),
enabled: !!integrationId,
});
}
export function useDisconnectIntegration() {
const qc = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: integrationService.disconnectIntegration,
onSuccess: (_data, variables) => {
notifications.show({ message: t("Integration disconnected") });
qc.invalidateQueries({
queryKey: ["integration-connection", variables.integrationId],
});
qc.invalidateQueries({ queryKey: ["my-connections"] });
// removeQueries, not invalidate: refetchOnMount false leaves invalidated inactive queries unrefreshed
qc.removeQueries({ queryKey: ["unfurl"] });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to disconnect integration"),
color: "red",
});
},
});
}
@@ -0,0 +1,92 @@
import api from "@/lib/api-client";
import {
IntegrationDefinition,
Integration,
ConnectionStatus,
UserConnection,
UnfurlResult,
UnfurlNeedsConnection,
} from "../types/integration.types";
export async function getAvailableIntegrations(): Promise<
IntegrationDefinition[]
> {
const req = await api.post<IntegrationDefinition[]>(
"/integrations/available",
);
return req.data;
}
export async function getInstalledIntegrations(): Promise<Integration[]> {
const req = await api.post<Integration[]>("/integrations/list");
return req.data;
}
export async function installIntegration(data: {
type: string;
}): Promise<Integration> {
const req = await api.post<Integration>("/integrations/install", data);
return req.data;
}
export async function uninstallIntegration(data: {
integrationId: string;
}): Promise<void> {
await api.post("/integrations/uninstall", data);
}
export async function getMyConnections(): Promise<UserConnection[]> {
const req = await api.post<UserConnection[]>("/integrations/connections/mine");
return req.data;
}
export async function getConnectionStatus(data: {
integrationId: string;
}): Promise<ConnectionStatus> {
const req = await api.post<ConnectionStatus>(
"/integrations/connection/status",
data,
);
return req.data;
}
export async function getOAuthAuthorizeUrl(data: {
integrationId: string;
returnPath?: string;
}): Promise<{ authorizationUrl: string }> {
const req = await api.post<{ authorizationUrl: string }>(
"/integrations/oauth/authorize",
data,
);
return req.data;
}
/**
* For workspace-scoped providers: returns the authorize URL WITHOUT creating
* the integration row. The row is created atomically when the OAuth callback
* succeeds; a cancelled OAuth leaves no half-installed state.
*/
export async function getOAuthInstallUrl(data: {
type: string;
}): Promise<{ authorizationUrl: string }> {
const req = await api.post<{ authorizationUrl: string }>(
"/integrations/oauth/install",
data,
);
return req.data;
}
export async function disconnectIntegration(data: {
integrationId: string;
}): Promise<void> {
await api.post("/integrations/oauth/disconnect", data);
}
export async function unfurlUrl(data: {
url: string;
}): Promise<UnfurlResult | UnfurlNeedsConnection | null> {
const req = await api.post<{
data: UnfurlResult | UnfurlNeedsConnection | null;
}>("/integrations/unfurl", data);
return req.data.data;
}
@@ -0,0 +1,23 @@
import api from "@/lib/api-client";
export type SlackLinkStateInfo = {
slackUserName: string;
slackUserId: string;
slackTeamId: string;
slackTeamName: string | null;
integrationWorkspaceId: string | undefined;
};
export async function decodeSlackLinkState(
state: string,
): Promise<SlackLinkStateInfo> {
const req = await api.post<SlackLinkStateInfo>(
"/integrations/slack/link/state",
{ state },
);
return req.data;
}
export async function confirmSlackLink(state: string): Promise<void> {
await api.post("/integrations/slack/link", { state });
}
@@ -0,0 +1,69 @@
export type IntegrationCapability = "oauth" | "unfurl" | "actions";
export type OAuthConfig = {
authUrl: string;
tokenUrl: string;
scopes: string[];
connectionScope?: 'workspace' | 'user';
};
export type IntegrationDefinition = {
type: string;
name: string;
description: string;
icon: string;
capabilities: IntegrationCapability[];
oauth?: OAuthConfig;
requiresLicense?: boolean;
};
export type Integration = {
id: string;
workspaceId: string;
type: string;
settings: Record<string, any> | null;
installedById: string | null;
createdAt: string;
updatedAt: string;
};
export type ConnectionStatus = {
connected: boolean;
providerUserId?: string;
};
export type UserConnection = {
integrationId: string;
type: string;
providerUserId: string | null;
providerDisplayName: string | null;
connectedAt: string;
invalidatedAt: string | null;
};
export type UnfurlResult = {
title: string;
description?: string;
url: string;
provider: string;
providerIcon?: string;
status?: string;
statusColor?: string;
author?: string;
authorAvatarUrl?: string;
metadata?: Record<string, any>;
};
// Returned when the link's provider needs a per-user connection the
// requesting user has not authorized yet.
export type UnfurlNeedsConnection = {
needsConnection: true;
integrationId: string;
integrationType: string;
integrationName: string;
// false for workspace-scoped providers (Slack): linking happens on the
// provider's side, so no Docmost-initiated OAuth button.
oauthConnect: boolean;
title: string;
description?: string;
};
@@ -39,7 +39,6 @@ export function useWorkspaceLabelsQuery(query: string, enabled: boolean) {
queryFn: () => getWorkspaceLabels({ type: "page", query, limit: 50 }), queryFn: () => getWorkspaceLabels({ type: "page", query, limit: 50 }),
enabled, enabled,
staleTime: 30 * 1000, staleTime: 30 * 1000,
placeholderData: keepPreviousData
}); });
} }
@@ -1,152 +0,0 @@
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>
);
}
@@ -1,127 +0,0 @@
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,7 +19,6 @@ import {
} from "@/features/search/types/search.types"; } from "@/features/search/types/search.types";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { timeAgo } from "@/lib/time.ts";
interface SearchResultItemProps { interface SearchResultItemProps {
result: IPageSearch | IAttachmentSearch; result: IPageSearch | IAttachmentSearch;
@@ -27,14 +26,6 @@ interface SearchResultItemProps {
showSpace?: boolean; 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({ export function SearchResultItem({
result, result,
isAttachmentResult, isAttachmentResult,
@@ -55,7 +46,6 @@ export function SearchResultItem({
return ( return (
<Spotlight.Action <Spotlight.Action
component={Link} component={Link}
ref={makeActionTabbable}
//@ts-ignore //@ts-ignore
to={buildPageUrl( to={buildPageUrl(
attachmentResult.space.slug, attachmentResult.space.slug,
@@ -69,13 +59,8 @@ export function SearchResultItem({
<IconFile size={16} /> <IconFile size={16} />
</Center> </Center>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1 }}>
<Group justify="space-between" wrap="nowrap" gap="xs"> <Text>{attachmentResult.fileName}</Text>
<Text truncate>{attachmentResult.fileName}</Text>
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{timeAgo(attachmentResult.updatedAt)}
</Text>
</Group>
<Text size="xs" opacity={0.6}> <Text size="xs" opacity={0.6}>
{attachmentResult.space.name} {attachmentResult.page.title} {attachmentResult.space.name} {attachmentResult.page.title}
</Text> </Text>
@@ -111,7 +96,6 @@ export function SearchResultItem({
return ( return (
<Spotlight.Action <Spotlight.Action
component={Link} component={Link}
ref={makeActionTabbable}
//@ts-ignore //@ts-ignore
to={buildPageUrl( to={buildPageUrl(
pageResult.space.slug, pageResult.space.slug,
@@ -123,13 +107,8 @@ export function SearchResultItem({
<Group wrap="nowrap" w="100%"> <Group wrap="nowrap" w="100%">
<Center>{getPageIcon(pageResult?.icon)}</Center> <Center>{getPageIcon(pageResult?.icon)}</Center>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1 }}>
<Group justify="space-between" wrap="nowrap" gap="xs"> <Text>{pageResult.title}</Text>
<Text truncate>{pageResult.title || t("Untitled")}</Text>
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{timeAgo(pageResult.updatedAt)}
</Text>
</Group>
{showSpace && pageResult.space && ( {showSpace && pageResult.space && (
<Badge variant="light" size="xs" color="gray"> <Badge variant="light" size="xs" color="gray">
@@ -17,10 +17,3 @@
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-6)); 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,5 +1,4 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import cx from "clsx";
import { import {
Button, Button,
Menu, Menu,
@@ -12,24 +11,18 @@ import {
import { import {
IconChevronDown, IconChevronDown,
IconBuilding, IconBuilding,
IconPlus,
IconFileDescription, IconFileDescription,
IconCheck, IconCheck,
IconUser,
IconTag,
IconLetterCase,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useGetSpacesQuery } from "@/features/space/queries/space-query"; import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import { SpaceFilterMenu } from "@/features/space/components/space-filter-menu"; 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 { RadioMenuItem } from "@/components/ui/radio-menu-item";
import { useHasFeature } from "@/ee/hooks/use-feature"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features"; import { Feature } from "@/ee/features";
import classes from "./search-spotlight-filters.module.css"; import classes from "./search-spotlight-filters.module.css";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { LabelFilterMenu } from "./label-filter-menu";
interface SearchSpotlightFiltersProps { interface SearchSpotlightFiltersProps {
onFiltersChange?: (filters: any) => void; onFiltersChange?: (filters: any) => void;
@@ -47,17 +40,9 @@ export function SearchSpotlightFilters({
const { t } = useTranslation(); const { t } = useTranslation();
const hasAttachmentIndexing = useHasFeature(Feature.ATTACHMENT_INDEXING); const hasAttachmentIndexing = useHasFeature(Feature.ATTACHMENT_INDEXING);
const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>( const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>(
spaceId || null spaceId || null,
); );
const [contentType, setContentType] = useState<string | null>("page"); 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 [workspace] = useAtom(workspaceAtom);
const { data: spacesData } = useGetSpacesQuery({ limit: 100 }); const { data: spacesData } = useGetSpacesQuery({ limit: 100 });
@@ -65,6 +50,15 @@ export function SearchSpotlightFilters({
? spacesData?.items.find((space) => space.id === selectedSpaceId) ? spacesData?.items.find((space) => space.id === selectedSpaceId)
: null; : null;
useEffect(() => {
if (onFiltersChange) {
onFiltersChange({
spaceId: selectedSpaceId,
contentType,
});
}
}, []);
const contentTypeOptions = [ const contentTypeOptions = [
{ value: "page", label: t("Pages") }, { value: "page", label: t("Pages") },
{ {
@@ -74,70 +68,38 @@ export function SearchSpotlightFilters({
}, },
]; ];
useEffect(() => {
onFiltersChange?.({
spaceId: selectedSpaceId,
contentType,
creatorId: selectedCreatorId,
labelIds: selectedLabelIds,
titleOnly,
});
}, [
selectedSpaceId,
contentType,
selectedCreatorId,
selectedLabelIds,
titleOnly,
onFiltersChange,
]);
const handleSpaceSelect = (spaceId: string | null) => { const handleSpaceSelect = (spaceId: string | null) => {
setSelectedSpaceId(spaceId); setSelectedSpaceId(spaceId);
};
const handleCreatorSelect = (user: { id: string; name: string } | null) => { if (onFiltersChange) {
setSelectedCreatorId(user?.id ?? null); onFiltersChange({
setSelectedCreatorName(user?.name ?? null); spaceId: spaceId,
}; contentType,
});
const handleLabelsSelect = (labelIds: string[]) => {
setSelectedLabelIds(labelIds);
};
const handleChangeContentType = (value: string) => {
setContentType(value);
if (value === "attachment") {
setSelectedLabelIds([]);
} }
}; };
const onDemandFilters = [ const handleFilterChange = (filterType: string, value: any) => {
{ key: "creator", label: t("Created by"), icon: IconUser, available: true }, let newSelectedSpaceId = selectedSpaceId;
{ let newContentType = contentType;
key: "labels",
label: t("Labels"),
icon: IconTag,
available: contentType !== "attachment",
},
];
const isFilterVisible = (key: string) => { switch (filterType) {
if (openedFilter === key) return true; case "spaceId":
if (key === "creator") return !!selectedCreatorId; newSelectedSpaceId = value;
if (key === "labels") setSelectedSpaceId(value);
return contentType !== "attachment" && selectedLabelIds.length > 0; break;
return false; case "contentType":
}; newContentType = value;
setContentType(value);
break;
}
const orderedVisibleFilters = visibleFilters.filter(isFilterVisible); if (onFiltersChange) {
const addableFilters = onDemandFilters.filter( onFiltersChange({
(filter) => filter.available && !isFilterVisible(filter.key), spaceId: newSelectedSpaceId,
); contentType: newContentType,
});
const revealFilter = (key: string) => { }
setVisibleFilters((prev) => [...prev.filter((k) => k !== key), key]);
setOpenedFilter(key);
}; };
return ( return (
@@ -160,17 +122,8 @@ export function SearchSpotlightFilters({
color="blue" color="blue"
labelPosition="left" labelPosition="left"
styles={{ styles={{
root: { root: { display: "flex", alignItems: "center" },
display: "flex", label: { paddingRight: "8px", fontSize: "13px", fontWeight: 500 },
alignItems: "center",
flexShrink: 0,
},
label: {
whiteSpace: "nowrap",
paddingRight: "8px",
fontSize: "13px",
fontWeight: 500,
},
}} }}
/> />
</div> </div>
@@ -228,7 +181,7 @@ export function SearchSpotlightFilters({
onClick={() => onClick={() =>
!option.disabled && !option.disabled &&
contentType !== option.value && contentType !== option.value &&
handleChangeContentType(option.value) handleFilterChange("contentType", option.value)
} }
disabled={ disabled={
option.disabled || (isAiMode && option.value === "attachment") option.disabled || (isAiMode && option.value === "attachment")
@@ -242,11 +195,13 @@ export function SearchSpotlightFilters({
{t("Enterprise")} {t("Enterprise")}
</Badge> </Badge>
)} )}
{!option.disabled && isAiMode && option.value === "attachment" && ( {!option.disabled &&
<Text size="xs" mt={4}> isAiMode &&
{t("AI Answers not available for attachments")} option.value === "attachment" && (
</Text> <Text size="xs" mt={4}>
)} {t("AI Answers not available for attachments")}
</Text>
)}
</div> </div>
{contentType === option.value && <IconCheck size={20} aria-hidden />} {contentType === option.value && <IconCheck size={20} aria-hidden />}
</Group> </Group>
@@ -254,126 +209,6 @@ export function SearchSpotlightFilters({
))} ))}
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </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> </div>
); );
} }
@@ -1,7 +1,7 @@
import { Spotlight } from "@mantine/spotlight"; import { Spotlight } from "@mantine/spotlight";
import { IconSearch, IconSparkles } from "@tabler/icons-react"; import { IconSearch, IconSparkles } from "@tabler/icons-react";
import { Group, Button, VisuallyHidden, Text } from "@mantine/core"; import { Group, Button, VisuallyHidden } from "@mantine/core";
import React, { useState, useMemo, useEffect, useCallback } from "react"; import React, { useState, useMemo, useEffect } from "react";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
@@ -13,16 +13,11 @@ import { SearchResultItem } from "./search-result-item.tsx";
import { AiSearchResult } from "../../../ee/ai/components/ai-search-result.tsx"; import { AiSearchResult } from "../../../ee/ai/components/ai-search-result.tsx";
import { useHasFeature } from "@/ee/hooks/use-feature"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features"; 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 { interface SearchSpotlightProps {
spaceId?: string; spaceId?: string;
} }
export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
const workspace = useAtomValue(workspaceAtom);
const { t } = useTranslation(); const { t } = useTranslation();
const hasAiFeature = useHasFeature(Feature.AI); const hasAiFeature = useHasFeature(Feature.AI);
const hasAttachmentIndexing = useHasFeature(Feature.ATTACHMENT_INDEXING); const hasAttachmentIndexing = useHasFeature(Feature.ATTACHMENT_INDEXING);
@@ -31,9 +26,6 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
const [filters, setFilters] = useState<{ const [filters, setFilters] = useState<{
spaceId?: string | null; spaceId?: string | null;
contentType?: string; contentType?: string;
creatorId?: string | null;
labelIds?: string[];
titleOnly?: boolean;
}>({ }>({
contentType: "page", contentType: "page",
}); });
@@ -51,25 +43,10 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
params.spaceId = filters.spaceId; 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; return params;
}, [debouncedSearchQuery, filters]); }, [debouncedSearchQuery, filters]);
const { const { data: searchResults, isLoading } = useUnifiedSearch(
data: searchResults,
isFetching,
} = useUnifiedSearch(
searchParams, searchParams,
!isAiMode // Disable regular search when in AI mode !isAiMode // Disable regular search when in AI mode
); );
@@ -106,11 +83,6 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
} }
}, [aiSearchError, t]); }, [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 // Determine result type for rendering
const isAttachmentSearch = const isAttachmentSearch =
filters.contentType === "attachment" && hasAttachmentIndexing; filters.contentType === "attachment" && hasAttachmentIndexing;
@@ -124,18 +96,9 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
/> />
)); ));
const handleSpotlightOpen = () => { const handleFiltersChange = (newFilters: any) => {
if (
workspace?.settings?.ai?.search === true &&
getAiVectorDriver() === "turbopuffer"
) {
hintVectorCache();
}
};
const handleFiltersChange = useCallback((newFilters: any) => {
setFilters(newFilters); setFilters(newFilters);
}, [setFilters]); };
const handleAskClick = () => { const handleAskClick = () => {
setIsAiMode(!isAiMode); setIsAiMode(!isAiMode);
@@ -152,7 +115,6 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
<Spotlight.Root <Spotlight.Root
size="xl" size="xl"
maxHeight={600} maxHeight={600}
onSpotlightOpen={handleSpotlightOpen}
store={searchSpotlightStore} store={searchSpotlightStore}
query={query} query={query}
onQueryChange={setQuery} onQueryChange={setQuery}
@@ -205,7 +167,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
? query.length > 0 && !isAiLoading && !aiSearchResult ? query.length > 0 && !isAiLoading && !aiSearchResult
? t("No answer available") ? t("No answer available")
: "" : ""
: (query.length > 0 || isFilterBrowse) && !isFetching : query.length > 0 && !isLoading
? resultItems.length === 0 ? resultItems.length === 0
? t("No results found") ? t("No results found")
: t("{{count}} results found", { count: resultItems.length }) : t("{{count}} results found", { count: resultItems.length })
@@ -232,28 +194,15 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
</> </>
) : ( ) : (
<> <>
{query.length === 0 && !isFilterBrowse && resultItems.length === 0 && ( {query.length === 0 && resultItems.length === 0 && (
<Spotlight.Empty>{t("Start typing to search...")}</Spotlight.Empty> <Spotlight.Empty>{t("Start typing to search...")}</Spotlight.Empty>
)} )}
{(query.length > 0 || isFilterBrowse) && {query.length > 0 && !isLoading && resultItems.length === 0 && (
!isFetching && <Spotlight.Empty>{t("No results found...")}</Spotlight.Empty>
isQuerySettled && )}
resultItems.length === 0 && (
<Spotlight.Empty>{t("No results found...")}</Spotlight.Empty>
)}
{resultItems.length > 0 && <>{resultItems}</>} {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> </Spotlight.ActionsList>
@@ -39,20 +39,6 @@ export function useUnifiedSearch(
return await searchPage(backendParams); return await searchPage(backendParams);
} }
}, },
enabled: enabled: !!params.query && 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;
},
}); });
} }
@@ -36,9 +36,6 @@ export interface IPageSearchParams {
query: string; query: string;
spaceId?: string; spaceId?: string;
shareId?: string; shareId?: string;
creatorId?: string;
labelIds?: string[];
titleOnly?: boolean;
} }
export interface IAttachmentSearch { export interface IAttachmentSearch {
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useState } from "react";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { Group, Select, SelectProps, Text } from "@mantine/core"; import { Group, Select, SelectProps, Text } from "@mantine/core";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts"; import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
@@ -14,7 +14,6 @@ interface SpaceSelectProps {
width?: number; width?: number;
opened?: boolean; opened?: boolean;
clearable?: boolean; clearable?: boolean;
withinPortal?: boolean;
} }
const renderSelectOption: SelectProps["renderOption"] = ({ option }) => ( const renderSelectOption: SelectProps["renderOption"] = ({ option }) => (
@@ -42,7 +41,6 @@ export function SpaceSelect({
width, width,
opened, opened,
clearable, clearable,
withinPortal = true,
}: SpaceSelectProps) { }: SpaceSelectProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [searchValue, setSearchValue] = useState(""); const [searchValue, setSearchValue] = useState("");
@@ -52,13 +50,9 @@ export function SpaceSelect({
limit: 50, limit: 50,
}); });
const [data, setData] = useState([]); const [data, setData] = useState([]);
const fetchedSpaces = useRef(new Map<string, ISpace>());
useEffect(() => { useEffect(() => {
if (spaces) { if (spaces) {
spaces.items.forEach((space: ISpace) =>
fetchedSpaces.current.set(space.slug, space),
);
const spaceData = spaces?.items const spaceData = spaces?.items
.filter((space: ISpace) => space.slug !== value) .filter((space: ISpace) => space.slug !== value)
.map((space: ISpace) => { .map((space: ISpace) => {
@@ -89,19 +83,14 @@ export function SpaceSelect({
onSearchChange={setSearchValue} onSearchChange={setSearchValue}
clearable={clearable} clearable={clearable}
variant="filled" variant="filled"
onChange={(slug) => { onChange={(slug) =>
// options accumulate across fetches; resolve against everything onChange(spaces.items?.find((item) => item.slug === slug))
// fetched, not just the latest query result }
const space = slug && fetchedSpaces.current.get(slug);
if (space) {
onChange(space);
}
}}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
nothingFoundMessage={t("No space found")} nothingFoundMessage={t("No space found")}
limit={50} limit={50}
checkIconPosition="right" checkIconPosition="right"
comboboxProps={{ width, withinPortal, position: "bottom", keepMounted: false, dropdownPadding: 0 }} comboboxProps={{ width, withinPortal: true, position: "bottom", keepMounted: false, dropdownPadding: 0 }}
dropdownOpened={opened} dropdownOpened={opened}
/> />
); );
@@ -54,9 +54,7 @@ import {
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import { searchSpotlight } from "@/features/search/constants"; import { searchSpotlight } from "@/features/search/constants";
const TemplatePickerModal = React.lazy( import TemplatePickerModal from "@/ee/template/components/template-picker-modal";
() => import("@/ee/template/components/template-picker-modal"),
);
import { useHasFeature } from "@/ee/hooks/use-feature"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label"; import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { Feature } from "@/ee/features"; import { Feature } from "@/ee/features";
@@ -408,13 +406,11 @@ function SpaceMenu({
{hasTemplates && templatePickerOpened && ( {hasTemplates && templatePickerOpened && (
<ErrorBoundary fallbackRender={() => null}> <ErrorBoundary fallbackRender={() => null}>
<React.Suspense fallback={null}> <TemplatePickerModal
<TemplatePickerModal opened={templatePickerOpened}
opened={templatePickerOpened} onClose={closeTemplatePicker}
onClose={closeTemplatePicker} initialSpaceId={spaceId}
initialSpaceId={spaceId} />
/>
</React.Suspense>
</ErrorBoundary> </ErrorBoundary>
)} )}
</> </>
@@ -70,7 +70,6 @@ export function SwitchSpace({
onChange={(space) => handleSelect(space.slug)} onChange={(space) => handleSelect(space.slug)}
width={300} width={300}
opened={true} opened={true}
withinPortal={false}
/> />
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </Popover>
@@ -25,9 +25,6 @@ export interface IWorkspace {
generativeAi?: boolean; generativeAi?: boolean;
disablePublicSharing?: boolean; disablePublicSharing?: boolean;
mcpEnabled?: boolean; mcpEnabled?: boolean;
aiChatReadOnly?: boolean;
aiChatWorkspaceKnowledgeOnly?: boolean;
mcpOauthOnly?: boolean;
trashRetentionDays?: number; trashRetentionDays?: number;
restrictApiToAdmins?: boolean; restrictApiToAdmins?: boolean;
allowMemberTemplates?: boolean; allowMemberTemplates?: boolean;
@@ -53,10 +50,7 @@ export interface IWorkspaceAiSettings {
search?: boolean; search?: boolean;
generative?: boolean; generative?: boolean;
mcp?: boolean; mcp?: boolean;
mcpOauthOnly?: boolean;
chat?: boolean; chat?: boolean;
chatReadOnly?: boolean;
chatWorkspaceKnowledgeOnly?: boolean;
} }
export interface IWorkspaceSharingSettings { export interface IWorkspaceSharingSettings {
-2
View File
@@ -76,8 +76,6 @@ function redirectToLogin() {
APP_ROUTE.AUTH.MFA_CHALLENGE, APP_ROUTE.AUTH.MFA_CHALLENGE,
APP_ROUTE.AUTH.MFA_SETUP_REQUIRED, APP_ROUTE.AUTH.MFA_SETUP_REQUIRED,
"/invites", "/invites",
// the oauth consent page redirects to login itself, preserving its query string
"/oauth/consent",
]; ];
if (!exemptPaths.some((path) => window.location.pathname.startsWith(path))) { if (!exemptPaths.some((path) => window.location.pathname.startsWith(path))) {
const redirectTo = window.location.pathname; const redirectTo = window.location.pathname;
+1
View File
@@ -27,6 +27,7 @@ const APP_ROUTE = {
SPACES: "/settings/spaces", SPACES: "/settings/spaces",
BILLING: "/settings/billing", BILLING: "/settings/billing",
SECURITY: "/settings/security", SECURITY: "/settings/security",
INTEGRATIONS: "/settings/integrations",
}, },
}, },
}; };
-4
View File
@@ -43,10 +43,6 @@ export function isCloud(): boolean {
return castToBoolean(getConfigValue("CLOUD")); return castToBoolean(getConfigValue("CLOUD"));
} }
export function getAiVectorDriver(): string {
return getConfigValue("AI_VECTOR_DRIVER");
}
export function getAvatarUrl( export function getAvatarUrl(
avatarUrl: string, avatarUrl: string,
type: AvatarIconType = AvatarIconType.AVATAR, type: AvatarIconType = AvatarIconType.AVATAR,
-28
View File
@@ -33,31 +33,3 @@
color: var(--mantine-color-dimmed); color: var(--mantine-color-dimmed);
-webkit-text-fill-color: var(--mantine-color-dimmed); -webkit-text-fill-color: var(--mantine-color-dimmed);
} }
/* Spotlight's selected action ships as primary-filled blue with white text,
* but our custom action children (gray badge, dimmed snippet, gray icons)
* keep their light-surface colors on it and drop below WCAG AA 4.5:1
* (WCAG 1.4.3). Use a gray selection one step above the gray-0/dark-6
* hover instead - it matches the app's selection styling (we use no blue
* fills) and keeps every child at its already-passing resting contrast.
*/
.mantine-Spotlight-action[data-selected] {
background-color: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-5)
);
color: var(--mantine-color-text);
--action-description-color: var(--mantine-color-dimmed);
--action-description-opacity: 1;
}
/* Result actions are tab stops (tabIndex restored via ref); give keyboard
* focus the same gray treatment as arrow-key selection so both navigation
* modes read identically (WCAG 2.4.7 focus visible).
*/
.mantine-Spotlight-action:focus-visible {
background-color: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-5)
);
}
-2
View File
@@ -16,7 +16,6 @@ export default defineConfig(({ mode }) => {
BILLING_TRIAL_DAYS, BILLING_TRIAL_DAYS,
POSTHOG_HOST, POSTHOG_HOST,
POSTHOG_KEY, POSTHOG_KEY,
AI_VECTOR_DRIVER,
} = loadEnv(mode, envPath, ""); } = loadEnv(mode, envPath, "");
return { return {
@@ -32,7 +31,6 @@ export default defineConfig(({ mode }) => {
BILLING_TRIAL_DAYS, BILLING_TRIAL_DAYS,
POSTHOG_HOST, POSTHOG_HOST,
POSTHOG_KEY, POSTHOG_KEY,
AI_VECTOR_DRIVER,
}, },
APP_VERSION: JSON.stringify(process.env.npm_package_version), APP_VERSION: JSON.stringify(process.env.npm_package_version),
}, },
+10 -12
View File
@@ -40,34 +40,32 @@
"@clickhouse/client": "1.18.2", "@clickhouse/client": "1.18.2",
"@docmost/base-formula": "workspace:*", "@docmost/base-formula": "workspace:*",
"@docmost/pdf-inspector": "1.9.6", "@docmost/pdf-inspector": "1.9.6",
"@fastify/cookie": "11.1.2", "@fastify/cookie": "11.0.2",
"@fastify/multipart": "10.1.1", "@fastify/multipart": "10.0.0",
"@fastify/static": "10.1.3", "@fastify/static": "10.1.2",
"@jmondi/oauth2-server": "^4.3.7",
"@keyv/redis": "5.1.6", "@keyv/redis": "5.1.6",
"@langchain/core": "1.1.46", "@langchain/core": "1.1.46",
"@langchain/textsplitters": "1.0.1", "@langchain/textsplitters": "1.0.1",
"@modelcontextprotocol/sdk": "1.30.0", "@modelcontextprotocol/sdk": "1.30.0",
"@nest-lab/throttler-storage-redis": "1.2.0", "@nest-lab/throttler-storage-redis": "1.2.0",
"@nestjs-labs/nestjs-ioredis": "11.0.4", "@nestjs-labs/nestjs-ioredis": "11.0.4",
"@nestjs/bullmq": "11.0.5", "@nestjs/bullmq": "11.0.4",
"@nestjs/cache-manager": "3.1.3", "@nestjs/cache-manager": "3.1.3",
"@nestjs/common": "11.2.1", "@nestjs/common": "11.1.28",
"@nestjs/config": "4.0.4", "@nestjs/config": "4.0.4",
"@nestjs/core": "11.2.1", "@nestjs/core": "11.1.27",
"@nestjs/event-emitter": "3.1.0", "@nestjs/event-emitter": "3.1.0",
"@nestjs/jwt": "11.0.2", "@nestjs/jwt": "11.0.2",
"@nestjs/mapped-types": "2.1.1", "@nestjs/mapped-types": "2.1.1",
"@nestjs/passport": "11.0.5", "@nestjs/passport": "11.0.5",
"@nestjs/platform-fastify": "11.2.1", "@nestjs/platform-fastify": "11.1.28",
"@nestjs/platform-socket.io": "11.2.1", "@nestjs/platform-socket.io": "11.1.28",
"@nestjs/schedule": "6.1.3", "@nestjs/schedule": "6.1.3",
"@nestjs/terminus": "11.1.1", "@nestjs/terminus": "11.1.1",
"@nestjs/throttler": "6.5.0", "@nestjs/throttler": "6.5.0",
"@nestjs/websockets": "11.2.1", "@nestjs/websockets": "11.1.28",
"@node-saml/passport-saml": "5.1.0", "@node-saml/passport-saml": "5.1.0",
"@socket.io/redis-adapter": "8.3.0", "@socket.io/redis-adapter": "8.3.0",
"@turbopuffer/turbopuffer": "^2.8.0",
"ai": "6.0.134", "ai": "6.0.134",
"ai-sdk-ollama": "3.8.1", "ai-sdk-ollama": "3.8.1",
"bcrypt": "6.0.0", "bcrypt": "6.0.0",
@@ -121,7 +119,7 @@
"tmp-promise": "3.0.3", "tmp-promise": "3.0.3",
"typesense": "3.0.5", "typesense": "3.0.5",
"undici": "7.29.0", "undici": "7.29.0",
"ws": "8.21.3", "ws": "8.21.0",
"yauzl": "3.4.0", "yauzl": "3.4.0",
"zod": "4.3.6" "zod": "4.3.6"
}, },
+2 -13
View File
@@ -22,8 +22,7 @@ import { TelemetryModule } from './integrations/telemetry/telemetry.module';
import { RedisModule } from '@nestjs-labs/nestjs-ioredis'; import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
import { RedisConfigService } from './integrations/redis/redis-config.service'; import { RedisConfigService } from './integrations/redis/redis-config.service';
import { CacheModule } from '@nestjs/cache-manager'; import { CacheModule } from '@nestjs/cache-manager';
import KeyvRedis, { defaultReconnectStrategy } from '@keyv/redis'; import KeyvRedis from '@keyv/redis';
import { parseRedisUrl } from './common/helpers';
import { LoggerModule } from './common/logger/logger.module'; import { LoggerModule } from './common/logger/logger.module';
import { ClsModule } from 'nestjs-cls'; import { ClsModule } from 'nestjs-cls';
import { NoopAuditModule } from './integrations/audit/audit.module'; import { NoopAuditModule } from './integrations/audit/audit.module';
@@ -63,20 +62,10 @@ try {
isGlobal: true, isGlobal: true,
useFactory: async (environmentService: EnvironmentService) => { useFactory: async (environmentService: EnvironmentService) => {
const redisUrl = environmentService.getRedisUrl(); const redisUrl = environmentService.getRedisUrl();
const { family, tls } = parseRedisUrl(redisUrl);
return { return {
ttl: 5 * 1000, ttl: 5 * 1000,
stores: [ stores: [new KeyvRedis(redisUrl)],
new KeyvRedis({
url: redisUrl,
socket: {
family,
reconnectStrategy: defaultReconnectStrategy,
...tls,
},
}),
],
}; };
}, },
inject: [EnvironmentService], inject: [EnvironmentService],
@@ -66,7 +66,6 @@ export class CollaborationGateway {
password: this.redisConfig.password, password: this.redisConfig.password,
db: this.redisConfig.db, db: this.redisConfig.db,
family: this.redisConfig.family, family: this.redisConfig.family,
tls: this.redisConfig.tls,
retryStrategy: createRetryStrategy(), retryStrategy: createRetryStrategy(),
}), }),
serverId: `collab-${os?.hostname()}-${nanoid(10)}`, serverId: `collab-${os?.hostname()}-${nanoid(10)}`,

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