Compare commits

..
243 changed files with 10869 additions and 8352 deletions
-11
View File
@@ -51,17 +51,6 @@ 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 -1
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.25.0
FROM base AS builder FROM base AS builder
+1
View File
@@ -21,6 +21,7 @@
"@docmost/base-formula": "workspace:*", "@docmost/base-formula": "workspace:*",
"@docmost/editor-ext": "workspace:*", "@docmost/editor-ext": "workspace:*",
"@excalidraw/excalidraw": "0.18.0-3a5ef40", "@excalidraw/excalidraw": "0.18.0-3a5ef40",
"@fontsource-variable/inter": "5.3.0",
"@mantine/core": "9.3.2", "@mantine/core": "9.3.2",
"@mantine/dates": "9.3.2", "@mantine/dates": "9.3.2",
"@mantine/form": "9.3.2", "@mantine/form": "9.3.2",
@@ -708,9 +708,9 @@
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP ist nur in der Docmost Enterprise-Edition verfügbar. Kontaktieren Sie sales@docmost.com.", "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP ist nur in der Docmost Enterprise-Edition verfügbar. Kontaktieren Sie sales@docmost.com.",
"MCP Server URL": "MCP-Server-URL", "MCP Server URL": "MCP-Server-URL",
"Connect AI assistants with your Docmost account via OAuth.": "Verbinde AI-Assistenten über OAuth mit deinem Docmost-Konto.", "Connect AI assistants with your Docmost account via OAuth.": "Verbinde AI-Assistenten über OAuth mit deinem Docmost-Konto.",
"Enforce OAuth": "Enforce OAuth", "Enforce OAuth": "OAuth erzwingen",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI-Assistenten müssen sich über OAuth mit einem Docmost-Konto verbinden. API-Schlüssel können nicht mit dem MCP-Server verwendet werden.", "AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI-Assistenten müssen sich über OAuth mit einem Docmost-Konto verbinden. API-Schlüssel können nicht mit dem MCP-Server verwendet werden.",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP", "Toggle enforce OAuth for MCP": "OAuth-Erzwingung für MCP umschalten",
"Supported tools": "Unterstützte Tools", "Supported tools": "Unterstützte Tools",
"MCP server URL:": "MCP-Server-URL:", "MCP server URL:": "MCP-Server-URL:",
"Learn more": "Mehr erfahren", "Learn more": "Mehr erfahren",
+140 -21
View File
@@ -14,7 +14,6 @@
"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",
@@ -23,7 +22,6 @@
"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",
@@ -32,8 +30,6 @@
"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",
@@ -45,16 +41,6 @@
"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",
@@ -164,8 +150,6 @@
"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.",
@@ -200,8 +184,6 @@
"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",
@@ -224,9 +206,7 @@
"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",
@@ -813,6 +793,10 @@
"Removed page restriction": "Removed page restriction", "Removed page restriction": "Removed page restriction",
"Added page permission": "Added page permission", "Added page permission": "Added page permission",
"Removed page permission": "Removed page permission", "Removed page permission": "Removed page permission",
"Changed page permission": "Changed page permission",
"Requested password reset": "Requested password reset",
"Created template": "Created template",
"Deleted template": "Deleted template",
"day": "day", "day": "day",
"days": "days", "days": "days",
"week": "week", "week": "week",
@@ -900,6 +884,9 @@
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold> returned a page for revision", "<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold> returned a page for revision",
"Page verification expires soon": "Page verification expires soon", "Page verification expires soon": "Page verification expires soon",
"Page verification has expired": "Page verification has expired", "Page verification has expired": "Page verification has expired",
"SIEM destination <bold>{{name}}</bold> is failing": "SIEM destination <bold>{{name}}</bold> is failing",
"SIEM destination <bold>{{name}}</bold> was disabled after 24 hours of failures": "SIEM destination <bold>{{name}}</bold> was disabled after 24 hours of failures",
"SIEM destination <bold>{{name}}</bold> recovered": "SIEM destination <bold>{{name}}</bold> recovered",
"Verifying your email": "Verifying your email", "Verifying your email": "Verifying your email",
"Please wait...": "Please wait...", "Please wait...": "Please wait...",
"Verification failed. The link may have expired.": "Verification failed. The link may have expired.", "Verification failed. The link may have expired.": "Verification failed. The link may have expired.",
@@ -1357,5 +1344,137 @@
"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.", "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", "Toggle AI Chat read-only mode": "Toggle AI Chat read-only mode",
"Title only": "Title only", "Title only": "Title only",
"you": "you" "you": "you",
"Allow public spaces": "Allow public spaces",
"Space admins can publish their spaces to the web.": "Space admins can publish their spaces to the web.",
"Toggle allow public spaces": "Toggle allow public spaces",
"Publish space to the web": "Publish space to the web",
"Anyone on the internet will be able to read every page in this space, except restricted pages. Are you sure?": "Anyone on the internet will be able to read every page in this space, except restricted pages. Are you sure?",
"Make this space publicly readable by anyone on the internet.": "Make this space publicly readable by anyone on the internet.",
"Toggle publish space to the web": "Toggle publish space to the web",
"Allow search engines to index": "Allow search engines to index",
"Let public pages in this space appear in search engine results.": "Let public pages in this space appear in search engine results.",
"Toggle search engine indexing": "Toggle search engine indexing",
"Public space link": "Public space link",
"Copy public space link": "Copy public space link",
"Renaming the space slug will break public links.": "Renaming the space slug will break public links.",
"Failed to update space": "Failed to update space",
"This space is public": "This space is public",
"Anyone on the internet can read the pages in this space, except restricted pages.": "Anyone on the internet can read the pages in this space, except restricted pages.",
"Open public site": "Open public site",
"Public": "Public",
"This space has no public pages yet.": "This space has no public pages yet.",
"On this page": "On this page",
"Previous": "Previous",
"Next": "Next",
"Show hidden pages": "Show hidden pages",
"Page navigation": "Page navigation",
"Toggle sidebar": "Toggle sidebar",
"Toggle table of contents": "Toggle table of contents",
"Appearance": "Appearance",
"Forest": "Forest",
"Violet": "Violet",
"Light mode color": "Light mode color",
"Dark mode color": "Dark mode color",
"Choose the primary color of the public docs site.": "Choose the primary color of the public docs site.",
"Show page author": "Show page author",
"Display the page creator's name on public pages.": "Display the page creator's name on public pages.",
"Toggle show page author": "Toggle show page author",
"Show last updated": "Show last updated",
"Display when each page was last updated.": "Display when each page was last updated.",
"Toggle show last updated": "Toggle show last updated",
"Open public page": "Open public page",
"Toggle color scheme": "Toggle color scheme",
"Ember": "Ember",
"Rose": "Rose",
"Documentation": "Documentation",
"All published spaces.": "All published spaces.",
"No public spaces yet.": "No public spaces yet.",
"Show public directory": "Show public directory",
"List published spaces at /docs for anyone to browse.": "List published spaces at /docs for anyone to browse.",
"Toggle show public directory": "Toggle show public directory",
"Show in public directory": "Show in public directory",
"List this space in the public directory at /docs.": "List this space in the public directory at /docs.",
"Toggle show in public directory": "Toggle show in public directory",
"Open public space link": "Open public space link",
"Disable public spaces": "Disable public spaces",
"Space admins will be able to make their spaces publicly readable by anyone on the internet. Are you sure?": "Space admins will be able to make their spaces publicly readable by anyone on the internet. Are you sure?",
"This will immediately unpublish every published space. Re-enabling later will not republish them. Are you sure?": "This will immediately unpublish every published space. Re-enabling later will not republish them. Are you sure?",
"Allow": "Allow",
"Shared pages": "Shared pages",
"Published spaces": "Published spaces",
"Spaces published to the web will appear here": "Spaces published to the web will appear here",
"No published spaces": "No published spaces",
"Published by": "Published by",
"Published at": "Published at",
"Open space": "Open space",
"Unpublish": "Unpublish",
"Unpublish space": "Unpublish space",
"This space will no longer be publicly accessible. Are you sure?": "This space will no longer be publicly accessible. Are you sure?",
"More options for {{name}}": "More options for {{name}}",
"Edit page": "Edit page",
"Sign in": "Sign in",
"Open app": "Open app",
"No spaces match your search.": "No spaces match your search.",
"Welcome to our documentation": "Welcome to our documentation",
"Guides, references and answers across all our published spaces.": "Guides, references and answers across all our published spaces.",
"Guides, references and answers across all our spaces.": "Guides, references and answers across all our spaces.",
"Search documentation...": "Search documentation...",
"1 published space": "1 published space",
"{{count}} published spaces": "{{count}} published spaces",
"Actions": "Actions",
"Add destination": "Add destination",
"Are you sure you want to delete the destination": "Are you sure you want to delete the destination",
"Audit logs": "Audit logs",
"Audit logs & SIEM": "Audit logs & SIEM",
"Auth header name": "Auth header name",
"Auth header prefix": "Auth header prefix",
"Body format": "Body format",
"Created SIEM destination": "Created SIEM destination",
"Datadog site": "Datadog site",
"Defaults to this instance's hostname": "Defaults to this instance's hostname",
"Delete destination": "Delete destination",
"Deleted SIEM destination": "Deleted SIEM destination",
"Destination created": "Destination created",
"Destination deleted": "Destination deleted",
"Destination updated": "Destination updated",
"Disabled": "Disabled",
"Edit destination": "Edit destination",
"Endpoint URL": "Endpoint URL",
"Failing": "Failing",
"Failing since {{time}}": "Failing since {{time}}",
"HEC token": "HEC token",
"HEC URL": "HEC URL",
"Healthy": "Healthy",
"Hide advanced options": "Hide advanced options",
"Host": "Host",
"Index": "Index",
"Insecure: connections can be intercepted.": "Insecure: connections can be intercepted.",
"JSON array": "JSON array",
"Last delivered": "Last delivered",
"Last error": "Last error",
"Leave empty to use the token's default index": "Leave empty to use the token's default index",
"Maximum of {{limit}} destinations reached": "Maximum of {{limit}} destinations reached",
"Could not load SIEM destinations: {{message}}": "Could not load SIEM destinations: {{message}}",
"No destinations yet": "No destinations yet",
"Preset": "Preset",
"Retry now": "Retry now",
"Retry scheduled": "Retry scheduled",
"Send test event": "Send test event",
"Sent in the auth header below. Leave empty if your receiver does not need one.": "Sent in the auth header below. Leave empty if your receiver does not need one.",
"Service": "Service",
"Show advanced options": "Show advanced options",
"SIEM": "SIEM",
"SIEM streaming": "SIEM streaming",
"SIEM streaming requires an Enterprise license.": "SIEM streaming requires an Enterprise license.",
"Source": "Source",
"Sourcetype": "Sourcetype",
"Tags": "Tags",
"Test connection": "Test connection",
"Test event delivered successfully.": "Test event delivered successfully.",
"Test the connection before saving.": "Test the connection before saving.",
"Test event delivered to {{name}}": "Test event delivered to {{name}}",
"Updated SIEM destination": "Updated SIEM destination",
"Verify TLS certificate": "Verify TLS certificate",
"e.g. Splunk prod": "e.g. Splunk prod"
} }
@@ -708,9 +708,9 @@
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP solo está disponible en la edición empresarial de Docmost. Contacte con sales@docmost.com.", "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP solo está disponible en la edición empresarial de Docmost. Contacte con sales@docmost.com.",
"MCP Server URL": "URL del servidor MCP", "MCP Server URL": "URL del servidor MCP",
"Connect AI assistants with your Docmost account via OAuth.": "Conecta asistentes de IA con tu cuenta de Docmost mediante OAuth.", "Connect AI assistants with your Docmost account via OAuth.": "Conecta asistentes de IA con tu cuenta de Docmost mediante OAuth.",
"Enforce OAuth": "Enforce OAuth", "Enforce OAuth": "Exigir OAuth",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "Los asistentes de IA deben conectarse con una cuenta de Docmost mediante OAuth. No se pueden usar claves API con el servidor MCP.", "AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "Los asistentes de IA deben conectarse con una cuenta de Docmost mediante OAuth. No se pueden usar claves API con el servidor MCP.",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP", "Toggle enforce OAuth for MCP": "Activar o desactivar la exigencia de OAuth para MCP",
"Supported tools": "Herramientas compatibles", "Supported tools": "Herramientas compatibles",
"MCP server URL:": "URL del servidor MCP:", "MCP server URL:": "URL del servidor MCP:",
"Learn more": "Más información", "Learn more": "Más información",
@@ -708,9 +708,9 @@
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP n'est disponible que dans l'édition Entreprise de Docmost. Contactez sales@docmost.com.", "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP n'est disponible que dans l'édition Entreprise de Docmost. Contactez sales@docmost.com.",
"MCP Server URL": "URL du serveur MCP", "MCP Server URL": "URL du serveur MCP",
"Connect AI assistants with your Docmost account via OAuth.": "Connectez des assistants IA à votre compte Docmost via OAuth.", "Connect AI assistants with your Docmost account via OAuth.": "Connectez des assistants IA à votre compte Docmost via OAuth.",
"Enforce OAuth": "Enforce OAuth", "Enforce OAuth": "Imposer OAuth",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "Les assistants IA doivent se connecter avec un compte Docmost via OAuth. Les clés API ne peuvent pas être utilisées avec le serveur MCP.", "AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "Les assistants IA doivent se connecter avec un compte Docmost via OAuth. Les clés API ne peuvent pas être utilisées avec le serveur MCP.",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP", "Toggle enforce OAuth for MCP": "Activer ou désactiver limposition dOAuth pour MCP",
"Supported tools": "Outils pris en charge", "Supported tools": "Outils pris en charge",
"MCP server URL:": "URL du serveur MCP :", "MCP server URL:": "URL du serveur MCP :",
"Learn more": "En savoir plus", "Learn more": "En savoir plus",
@@ -708,9 +708,9 @@
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP è disponibile solo nell'edizione Enterprise di Docmost. Contatta sales@docmost.com.", "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP è disponibile solo nell'edizione Enterprise di Docmost. Contatta sales@docmost.com.",
"MCP Server URL": "URL del server MCP", "MCP Server URL": "URL del server MCP",
"Connect AI assistants with your Docmost account via OAuth.": "Connetti gli assistenti AI al tuo account Docmost tramite OAuth.", "Connect AI assistants with your Docmost account via OAuth.": "Connetti gli assistenti AI al tuo account Docmost tramite OAuth.",
"Enforce OAuth": "Enforce OAuth", "Enforce OAuth": "Rendi obbligatorio OAuth",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "Gli assistenti AI devono connettersi con un account Docmost tramite OAuth. Le chiavi API non possono essere utilizzate con il server MCP.", "AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "Gli assistenti AI devono connettersi con un account Docmost tramite OAuth. Le chiavi API non possono essere utilizzate con il server MCP.",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP", "Toggle enforce OAuth for MCP": "Attiva/disattiva l'obbligo di OAuth per MCP",
"Supported tools": "Strumenti supportati", "Supported tools": "Strumenti supportati",
"MCP server URL:": "URL del server MCP:", "MCP server URL:": "URL del server MCP:",
"Learn more": "Scopri di più", "Learn more": "Scopri di più",
@@ -708,9 +708,9 @@
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP は Docmost のエンタープライズ版でのみ利用可能です。sales@docmost.com までお問い合わせください。", "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP は Docmost のエンタープライズ版でのみ利用可能です。sales@docmost.com までお問い合わせください。",
"MCP Server URL": "MCP サーバーの URL", "MCP Server URL": "MCP サーバーの URL",
"Connect AI assistants with your Docmost account via OAuth.": "OAuth を使用して AI アシスタントを Docmost アカウントに接続します。", "Connect AI assistants with your Docmost account via OAuth.": "OAuth を使用して AI アシスタントを Docmost アカウントに接続します。",
"Enforce OAuth": "Enforce OAuth", "Enforce OAuth": "OAuth を必須化",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI アシスタントは OAuth を使用して Docmost アカウントに接続する必要があります。MCP サーバーでは API キーは使用できません。", "AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI アシスタントは OAuth を使用して Docmost アカウントに接続する必要があります。MCP サーバーでは API キーは使用できません。",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP", "Toggle enforce OAuth for MCP": "MCP の OAuth 必須化を切り替え",
"Supported tools": "サポートされているツール", "Supported tools": "サポートされているツール",
"MCP server URL:": "MCP サーバーの URL:", "MCP server URL:": "MCP サーバーの URL:",
"Learn more": "詳細を見る", "Learn more": "詳細を見る",
@@ -1191,7 +1191,7 @@
"Default value": "デフォルト値", "Default value": "デフォルト値",
"Delete property": "プロパティを削除", "Delete property": "プロパティを削除",
"Delete view": "ビューを削除", "Delete view": "ビューを削除",
"Delete {{count}} rows?_one": "Delete 1 row?", "Delete {{count}} rows?_one": "1行を削除しますか?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?", "Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "降順", "Descending": "降順",
"Discard": "破棄", "Discard": "破棄",
@@ -1291,9 +1291,9 @@
"Value": "値", "Value": "値",
"View updated for everyone": "ビューが全員向けに更新されました", "View updated for everyone": "ビューが全員向けに更新されました",
"You have unsaved changes. Do you want to discard them?": "未保存の変更があります。破棄しますか?", "You have unsaved changes. Do you want to discard them?": "未保存の変更があります。破棄しますか?",
"{{count}} rows deleted_one": "1 row deleted", "{{count}} rows deleted_one": "1行を削除しました",
"{{count}} rows deleted_other": "{{count}} rows deleted", "{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected", "{{count}} selected_one": "1件を選択中",
"{{count}} selected_other": "{{count}} selected", "{{count}} selected_other": "{{count}} selected",
"Compare": "比較", "Compare": "比較",
"Compare versions": "バージョンを比較", "Compare versions": "バージョンを比較",
@@ -708,9 +708,9 @@
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP는 Docmost 엔터프라이즈 에디션에서만 제공됩니다. sales@docmost.com으로 문의하세요.", "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP는 Docmost 엔터프라이즈 에디션에서만 제공됩니다. sales@docmost.com으로 문의하세요.",
"MCP Server URL": "MCP 서버 URL", "MCP Server URL": "MCP 서버 URL",
"Connect AI assistants with your Docmost account via OAuth.": "OAuth를 통해 AI 도우미를 Docmost 계정에 연결합니다.", "Connect AI assistants with your Docmost account via OAuth.": "OAuth를 통해 AI 도우미를 Docmost 계정에 연결합니다.",
"Enforce OAuth": "Enforce OAuth", "Enforce OAuth": "OAuth 강제",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI 도우미는 OAuth를 통해 Docmost 계정에 연결해야 합니다. MCP 서버에서는 API 키를 사용할 수 없습니다.", "AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI 도우미는 OAuth를 통해 Docmost 계정에 연결해야 합니다. MCP 서버에서는 API 키를 사용할 수 없습니다.",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP", "Toggle enforce OAuth for MCP": "MCP에 대해 OAuth 강제 전환",
"Supported tools": "지원되는 도구", "Supported tools": "지원되는 도구",
"MCP server URL:": "MCP 서버 URL:", "MCP server URL:": "MCP 서버 URL:",
"Learn more": "자세히 알아보기", "Learn more": "자세히 알아보기",
@@ -708,9 +708,9 @@
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP is alleen beschikbaar in de Docmost Enterprise-editie. Neem contact op met sales@docmost.com.", "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP is alleen beschikbaar in de Docmost Enterprise-editie. Neem contact op met sales@docmost.com.",
"MCP Server URL": "MCP-server-URL", "MCP Server URL": "MCP-server-URL",
"Connect AI assistants with your Docmost account via OAuth.": "Verbind AI-assistenten met je Docmost-account via OAuth.", "Connect AI assistants with your Docmost account via OAuth.": "Verbind AI-assistenten met je Docmost-account via OAuth.",
"Enforce OAuth": "Enforce OAuth", "Enforce OAuth": "OAuth afdwingen",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI-assistenten moeten verbinding maken met een Docmost-account via OAuth. API-sleutels kunnen niet worden gebruikt met de MCP-server.", "AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI-assistenten moeten verbinding maken met een Docmost-account via OAuth. API-sleutels kunnen niet worden gebruikt met de MCP-server.",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP", "Toggle enforce OAuth for MCP": "OAuth afdwingen voor MCP in- of uitschakelen",
"Supported tools": "Ondersteunde tools", "Supported tools": "Ondersteunde tools",
"MCP server URL:": "MCP-server-URL:", "MCP server URL:": "MCP-server-URL:",
"Learn more": "Meer informatie", "Learn more": "Meer informatie",
@@ -708,9 +708,9 @@
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "O MCP está disponível apenas na edição empresarial do Docmost. Contate sales@docmost.com.", "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "O MCP está disponível apenas na edição empresarial do Docmost. Contate sales@docmost.com.",
"MCP Server URL": "URL do servidor MCP", "MCP Server URL": "URL do servidor MCP",
"Connect AI assistants with your Docmost account via OAuth.": "Conecte assistentes de IA à sua conta do Docmost via OAuth.", "Connect AI assistants with your Docmost account via OAuth.": "Conecte assistentes de IA à sua conta do Docmost via OAuth.",
"Enforce OAuth": "Enforce OAuth", "Enforce OAuth": "Exigir OAuth",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "Os assistentes de IA devem se conectar com uma conta do Docmost via OAuth. Chaves de API não podem ser usadas com o servidor MCP.", "AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "Os assistentes de IA devem se conectar com uma conta do Docmost via OAuth. Chaves de API não podem ser usadas com o servidor MCP.",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP", "Toggle enforce OAuth for MCP": "Ativar/desativar exigência de OAuth para MCP",
"Supported tools": "Ferramentas compatíveis", "Supported tools": "Ferramentas compatíveis",
"MCP server URL:": "URL do servidor MCP:", "MCP server URL:": "URL do servidor MCP:",
"Learn more": "Saiba mais", "Learn more": "Saiba mais",
@@ -708,9 +708,9 @@
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP доступен только в корпоративной версии Docmost. Свяжитесь по адресу sales@docmost.com.", "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP доступен только в корпоративной версии Docmost. Свяжитесь по адресу sales@docmost.com.",
"MCP Server URL": "URL сервера MCP", "MCP Server URL": "URL сервера MCP",
"Connect AI assistants with your Docmost account via OAuth.": "Подключайте AI-помощников к вашей учетной записи Docmost через OAuth.", "Connect AI assistants with your Docmost account via OAuth.": "Подключайте AI-помощников к вашей учетной записи Docmost через OAuth.",
"Enforce OAuth": "Enforce OAuth", "Enforce OAuth": "Сделать OAuth обязательным",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI-помощники должны подключаться к учетной записи Docmost через OAuth. Ключи API нельзя использовать с MCP-сервером.", "AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI-помощники должны подключаться к учетной записи Docmost через OAuth. Ключи API нельзя использовать с MCP-сервером.",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP", "Toggle enforce OAuth for MCP": "Переключить обязательное использование OAuth для MCP",
"Supported tools": "Поддерживаемые инструменты", "Supported tools": "Поддерживаемые инструменты",
"MCP server URL:": "URL сервера MCP:", "MCP server URL:": "URL сервера MCP:",
"Learn more": "Подробнее", "Learn more": "Подробнее",
@@ -708,9 +708,9 @@
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP доступний лише в корпоративній редакції Docmost. Зверніться до sales@docmost.com.", "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP доступний лише в корпоративній редакції Docmost. Зверніться до sales@docmost.com.",
"MCP Server URL": "URL сервера MCP", "MCP Server URL": "URL сервера MCP",
"Connect AI assistants with your Docmost account via OAuth.": "Підключайте AI-асистентів до свого облікового запису Docmost через OAuth.", "Connect AI assistants with your Docmost account via OAuth.": "Підключайте AI-асистентів до свого облікового запису Docmost через OAuth.",
"Enforce OAuth": "Enforce OAuth", "Enforce OAuth": "Зробити OAuth обов’язковим",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI-асистенти повинні підключатися до облікового запису Docmost через OAuth. Ключі API не можна використовувати з MCP-сервером.", "AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI-асистенти повинні підключатися до облікового запису Docmost через OAuth. Ключі API не можна використовувати з MCP-сервером.",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP", "Toggle enforce OAuth for MCP": "Увімкнути обов’язковий OAuth для MCP",
"Supported tools": "Підтримувані інструменти", "Supported tools": "Підтримувані інструменти",
"MCP server URL:": "URL сервера MCP:", "MCP server URL:": "URL сервера MCP:",
"Learn more": "Дізнатися більше", "Learn more": "Дізнатися більше",
@@ -708,9 +708,9 @@
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP 仅在 Docmost 企业版中提供。请联系 sales@docmost.com。", "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP 仅在 Docmost 企业版中提供。请联系 sales@docmost.com。",
"MCP Server URL": "MCP 服务器 URL", "MCP Server URL": "MCP 服务器 URL",
"Connect AI assistants with your Docmost account via OAuth.": "通过 OAuth 将 AI 助手连接到你的 Docmost 账户。", "Connect AI assistants with your Docmost account via OAuth.": "通过 OAuth 将 AI 助手连接到你的 Docmost 账户。",
"Enforce OAuth": "Enforce OAuth", "Enforce OAuth": "强制使用 OAuth",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI 助手必须通过 OAuth 使用 Docmost 账户连接。MCP 服务器不能使用 API 密钥。", "AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI 助手必须通过 OAuth 使用 Docmost 账户连接。MCP 服务器不能使用 API 密钥。",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP", "Toggle enforce OAuth for MCP": "切换 MCP 的强制使用 OAuth 设置",
"Supported tools": "支持的工具", "Supported tools": "支持的工具",
"MCP server URL:": "MCP 服务器 URL", "MCP server URL:": "MCP 服务器 URL",
"Learn more": "了解更多", "Learn more": "了解更多",
+23 -12
View File
@@ -44,6 +44,15 @@ const ShareLayout = lazy(
() => import("@/features/share/components/share-layout.tsx"), () => import("@/features/share/components/share-layout.tsx"),
); );
const ShareRedirect = lazy(() => import("@/pages/share/share-redirect.tsx")); const ShareRedirect = lazy(() => import("@/pages/share/share-redirect.tsx"));
const PublicSpacePage = lazy(
() => import("@/pages/public-space/public-space-page.tsx"),
);
const PublicSpaceLayout = lazy(
() => import("@/features/public-space/components/public-space-layout.tsx"),
);
const PublicSpaceDirectoryPage = lazy(
() => import("@/pages/public-space/public-space-directory-page.tsx"),
);
const SpacesPage = lazy(() => import("@/pages/spaces/spaces.tsx")); const SpacesPage = lazy(() => import("@/pages/spaces/spaces.tsx"));
const MfaChallengePage = lazy(() => const MfaChallengePage = lazy(() =>
import("@/ee/mfa/pages/mfa-challenge-page").then((m) => ({ import("@/ee/mfa/pages/mfa-challenge-page").then((m) => ({
@@ -75,15 +84,6 @@ const AiChat = lazy(() => import("@/ee/ai-chat/pages/ai-chat.tsx"));
const VerifyEmail = lazy(() => import("@/ee/pages/verify-email.tsx")); const VerifyEmail = lazy(() => import("@/ee/pages/verify-email.tsx"));
const LabelPage = lazy(() => import("@/pages/label/label-page")); const LabelPage = lazy(() => import("@/pages/label/label-page"));
const OAuthConsent = lazy(() => import("@/ee/oauth/pages/oauth-consent.tsx")); const OAuthConsent = lazy(() => import("@/ee/oauth/pages/oauth-consent.tsx"));
const Integrations = lazy(
() => import("@/features/integration/pages/integrations.tsx"),
);
const Connections = lazy(
() => import("@/features/integration/pages/connections.tsx"),
);
const SlackLinkPage = lazy(
() => import("@/features/integration/pages/slack-link.tsx"),
);
export default function App() { export default function App() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -128,10 +128,18 @@ export default function App() {
<Route path={"/share/p/:pageSlug"} element={<SharedPage />} /> <Route path={"/share/p/:pageSlug"} element={<SharedPage />} />
</Route> </Route>
<Route path={"/docs"} element={<PublicSpaceDirectoryPage />} />
<Route element={<PublicSpaceLayout />}>
<Route path={"/docs/:spaceSlug"} element={<PublicSpacePage />} />
<Route
path={"/docs/:spaceSlug/:pageSlug"}
element={<PublicSpacePage />}
/>
</Route>
<Route path={"/pdf-render/:pageId"} element={<PdfRenderPage />} /> <Route path={"/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 />} />
@@ -165,7 +173,6 @@ export default function App() {
path={"account/api-keys/authorized-apps"} path={"account/api-keys/authorized-apps"}
element={<UserApiKeys />} element={<UserApiKeys />}
/> />
<Route path={"account/connections"} element={<Connections />} />
<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 />} />
@@ -177,8 +184,12 @@ export default function App() {
<Route path={"ai"} element={<AiSettings />} /> <Route path={"ai"} element={<AiSettings />} />
<Route path={"ai/mcp"} element={<AiSettings />} /> <Route path={"ai/mcp"} element={<AiSettings />} />
<Route path={"audit"} element={<AuditLogs />} /> <Route path={"audit"} element={<AuditLogs />} />
<Route path={"audit/siem"} element={<AuditLogs />} />
<Route
path={"siem"}
element={<Navigate to="/settings/audit/siem" replace />}
/>
<Route path={"verifications"} element={<VerifiedPages />} /> <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>
@@ -1,22 +0,0 @@
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>
);
}
@@ -1,32 +0,0 @@
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>
);
}
@@ -1,28 +0,0 @@
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>
);
}
+2 -8
View File
@@ -1,16 +1,10 @@
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 { SlackIcon } from "./slack-icon.tsx"; export { GoogleDriveIcon } from "./google-drive-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";
@@ -1,38 +0,0 @@
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>
);
}
@@ -1,21 +0,0 @@
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>
);
}
+2 -11
View File
@@ -8,20 +8,11 @@ export function MiroIcon({ size }: Props) {
return ( return (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24"
viewBox="3 2 395 395"
style={{ width: rem(size), height: rem(size) }} style={{ width: rem(size), height: rem(size) }}
> >
<path <path
fill="#FFDD33" 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" />
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>
); );
} }
@@ -1,32 +0,0 @@
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>
);
}
@@ -13,7 +13,6 @@ import {
IconKey, IconKey,
IconWorld, IconWorld,
IconSparkles, IconSparkles,
IconPlug,
IconHistory, IconHistory,
IconShieldCheck, IconShieldCheck,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
@@ -75,11 +74,6 @@ 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",
},
], ],
}, },
{ {
@@ -124,19 +118,13 @@ const groupedData: DataGroup[] = [
role: "admin", role: "admin",
}, },
{ {
label: "Audit log", label: "Audit logs & SIEM",
icon: IconHistory, icon: IconHistory,
path: "/settings/audit", path: "/settings/audit",
feature: Feature.AUDIT_LOGS, feature: Feature.AUDIT_LOGS,
role: "owner", role: "owner",
env: "selfhosted", env: "selfhosted",
}, },
{
label: "Integrations",
icon: IconPlug,
path: "/settings/integrations",
role: "admin",
},
], ],
}, },
{ {
@@ -231,7 +219,7 @@ export default function SettingsSidebar() {
case "API management": case "API management":
prefetchHandler = prefetchApiKeyManagement; prefetchHandler = prefetchApiKeyManagement;
break; break;
case "Audit log": case "Audit logs & SIEM":
prefetchHandler = prefetchAuditLogs; prefetchHandler = prefetchAuditLogs;
break; break;
case "Verified pages": case "Verified pages":
@@ -22,6 +22,7 @@ export const auditEventLabels: Record<string, string> = {
"user.role_changed": "Changed user role", "user.role_changed": "Changed user role",
"user.password_changed": "Changed password", "user.password_changed": "Changed password",
"user.password_reset": "Reset password", "user.password_reset": "Reset password",
"user.password_reset_requested": "Requested password reset",
"user.updated": "Updated user", "user.updated": "Updated user",
"user.deactivated": "Deactivated user", "user.deactivated": "Deactivated user",
"user.activated": "Activated user", "user.activated": "Activated user",
@@ -62,6 +63,7 @@ export const auditEventLabels: Record<string, string> = {
"page.restriction_removed": "Removed page restriction", "page.restriction_removed": "Removed page restriction",
"page.permission_added": "Added page permission", "page.permission_added": "Added page permission",
"page.permission_removed": "Removed page permission", "page.permission_removed": "Removed page permission",
"page.permission_role_changed": "Changed page permission",
"page.verification_created": "Created page verification", "page.verification_created": "Created page verification",
"page.verification_updated": "Updated page verification", "page.verification_updated": "Updated page verification",
"page.verification_removed": "Removed page verification", "page.verification_removed": "Removed page verification",
@@ -79,6 +81,13 @@ export const auditEventLabels: Record<string, string> = {
"license.activated": "Activated license", "license.activated": "Activated license",
"license.removed": "Removed license", "license.removed": "Removed license",
"siem_destination.created": "Created SIEM destination",
"siem_destination.updated": "Updated SIEM destination",
"siem_destination.deleted": "Deleted SIEM destination",
"template.created": "Created template",
"template.deleted": "Deleted template",
}; };
export function getEventLabel(event: string): string { export function getEventLabel(event: string): string {
@@ -105,6 +114,10 @@ export const eventFilterOptions: EventGroup[] = [
{ value: "user.activated", label: "Activated user" }, { value: "user.activated", label: "Activated user" },
{ value: "user.role_changed", label: "Changed user role" }, { value: "user.role_changed", label: "Changed user role" },
{ value: "user.password_changed", label: "Changed password" }, { value: "user.password_changed", label: "Changed password" },
{
value: "user.password_reset_requested",
label: "Requested password reset",
},
{ value: "user.mfa_enabled", label: "Enabled MFA" }, { value: "user.mfa_enabled", label: "Enabled MFA" },
{ value: "user.mfa_disabled", label: "Disabled MFA" }, { value: "user.mfa_disabled", label: "Disabled MFA" },
], ],
@@ -147,6 +160,10 @@ export const eventFilterOptions: EventGroup[] = [
{ value: "page.restriction_removed", label: "Removed page restriction" }, { value: "page.restriction_removed", label: "Removed page restriction" },
{ value: "page.permission_added", label: "Added page permission" }, { value: "page.permission_added", label: "Added page permission" },
{ value: "page.permission_removed", label: "Removed page permission" }, { value: "page.permission_removed", label: "Removed page permission" },
{
value: "page.permission_role_changed",
label: "Changed page permission",
},
{ value: "page.verification_created", label: "Created page verification" }, { value: "page.verification_created", label: "Created page verification" },
{ value: "page.verification_updated", label: "Updated page verification" }, { value: "page.verification_updated", label: "Updated page verification" },
{ value: "page.verification_removed", label: "Removed page verification" }, { value: "page.verification_removed", label: "Removed page verification" },
@@ -193,4 +210,19 @@ export const eventFilterOptions: EventGroup[] = [
{ value: "license.removed", label: "Removed license" }, { value: "license.removed", label: "Removed license" },
], ],
}, },
{
group: "SIEM",
items: [
{ value: "siem_destination.created", label: "Created SIEM destination" },
{ value: "siem_destination.updated", label: "Updated SIEM destination" },
{ value: "siem_destination.deleted", label: "Deleted SIEM destination" },
],
},
{
group: "Template",
items: [
{ value: "template.created", label: "Created template" },
{ value: "template.deleted", label: "Deleted template" },
],
},
]; ];
+143 -111
View File
@@ -7,10 +7,12 @@ import {
Popover, Popover,
Select, Select,
Space, Space,
Tabs,
Text, Text,
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
import { IconSettings } from "@tabler/icons-react"; import { IconSettings } from "@tabler/icons-react";
import SettingsTitle from "@/components/settings/settings-title"; import SettingsTitle from "@/components/settings/settings-title";
import Paginate from "@/components/common/paginate"; import Paginate from "@/components/common/paginate";
@@ -23,6 +25,7 @@ import {
import { IAuditLogParams } from "@/ee/audit/types/audit.types"; import { IAuditLogParams } from "@/ee/audit/types/audit.types";
import { eventFilterOptions } from "@/ee/audit/lib/audit-event-labels"; import { eventFilterOptions } from "@/ee/audit/lib/audit-event-labels";
import AuditLogsTable from "@/ee/audit/components/audit-logs-table"; import AuditLogsTable from "@/ee/audit/components/audit-logs-table";
import SiemStreamingPanel from "@/ee/siem/components/siem-streaming-panel";
import useUserRole from "@/hooks/use-user-role"; import useUserRole from "@/hooks/use-user-role";
import { DocumentTitle } from "@/components/ui/document-title.tsx"; import { DocumentTitle } from "@/components/ui/document-title.tsx";
@@ -48,6 +51,8 @@ export default function AuditLogs() {
const { t } = useTranslation(); const { t } = useTranslation();
const { isOwner } = useUserRole(); const { isOwner } = useUserRole();
const { cursor, goNext, goPrev, resetCursor } = useCursorPaginate(); const { cursor, goNext, goPrev, resetCursor } = useCursorPaginate();
const location = useLocation();
const navigate = useNavigate();
const [eventFilter, setEventFilter] = useState<string | null>(null); const [eventFilter, setEventFilter] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
@@ -85,6 +90,8 @@ export default function AuditLogs() {
const { data, isLoading } = useAuditLogsQuery(params); const { data, isLoading } = useAuditLogsQuery(params);
const activeTab = location.pathname.endsWith("/siem") ? "siem" : "audit";
if (!isOwner) { if (!isOwner) {
return null; return null;
} }
@@ -94,125 +101,150 @@ export default function AuditLogs() {
resetCursor(); resetCursor();
}; };
const handleTabChange = (value: string | null) => {
if (value === "siem") {
navigate("/settings/audit/siem");
} else {
navigate("/settings/audit");
}
};
return ( return (
<> <>
<DocumentTitle title={t("Audit log")} /> <DocumentTitle title={t("Audit logs & SIEM")} />
<SettingsTitle title={t("Audit log")} /> <SettingsTitle title={t("Audit logs & SIEM")} />
<Group mb="md" gap="sm"> <Tabs color="dark" value={activeTab} onChange={handleTabChange}>
<Select <Tabs.List>
placeholder={t("Filter by event")} <Tabs.Tab fw={500} value="audit">
data={eventFilterOptions.map((group) => ({ {t("Audit logs")}
group: t(group.group), </Tabs.Tab>
items: group.items.map((item) => ({ <Tabs.Tab fw={500} value="siem">
value: item.value, {t("SIEM")}
label: t(item.label), </Tabs.Tab>
})), </Tabs.List>
}))}
value={eventFilter}
onChange={handleEventChange}
clearable
searchable
w={220}
size="sm"
/>
<Popover <Tabs.Panel value="audit" pt="md">
position="bottom-end" <Group mb="md" gap="sm">
shadow="md" <Select
width={260} placeholder={t("Filter by event")}
withArrow data={eventFilterOptions.map((group) => ({
opened={settingsOpen} group: t(group.group),
onChange={(opened) => { items: group.items.map((item) => ({
if (!opened) resetRetentionForm(); value: item.value,
setSettingsOpen(opened); label: t(item.label),
}} })),
> }))}
<Popover.Target> value={eventFilter}
<Tooltip label={t("Audit settings")}> onChange={handleEventChange}
<ActionIcon variant="default" size="input-sm" ml="auto" onClick={() => setSettingsOpen((o) => !o)}> clearable
<IconSettings size={16} /> searchable
</ActionIcon> w={220}
</Tooltip> size="sm"
</Popover.Target> />
<Popover.Dropdown>
<Text fz="sm" fw={500} mb={4}>
{t("Retention")}
</Text>
<Text fz="xs" c="dimmed" mb="sm">
{t("Logs older than this period are automatically deleted.")}
</Text>
<Group gap="xs" wrap="nowrap" mb="sm">
<NumberInput
value={retentionAmount}
onChange={(val) => setRetentionAmount(val)}
min={1}
hideControls
size="sm"
w={60}
/>
<Select
data={[
{ value: "days", label: t("days") },
{ value: "months", label: t("months") },
{ value: "years", label: t("years") },
]}
value={retentionUnit}
onChange={(value) => {
if (value === "days" || value === "months" || value === "years") {
setRetentionUnit(value);
}
}}
size="sm"
style={{ flex: 1 }}
comboboxProps={{ withinPortal: false }}
/>
</Group>
<Group gap="xs" grow>
<Button
size="xs"
variant="default"
onClick={() => {
resetRetentionForm();
setSettingsOpen(false);
}}
>
{t("Cancel")}
</Button>
<Button
size="xs"
onClick={() => {
const num = typeof retentionAmount === "number" ? retentionAmount : 1;
const clamped = Math.max(1, num);
setRetentionAmount(clamped);
const days = retentionToDays(clamped, retentionUnit);
if (days !== currentDays) {
updateRetention.mutate({ auditRetentionDays: days });
}
setSettingsOpen(false);
}}
loading={updateRetention.isPending}
>
{t("Save")}
</Button>
</Group>
</Popover.Dropdown>
</Popover>
</Group>
<AuditLogsTable items={data?.items} isLoading={isLoading} /> <Popover
position="bottom-end"
shadow="md"
width={260}
withArrow
opened={settingsOpen}
onChange={(opened) => {
if (!opened) resetRetentionForm();
setSettingsOpen(opened);
}}
>
<Popover.Target>
<Tooltip label={t("Audit settings")}>
<ActionIcon variant="default" size="input-sm" ml="auto" onClick={() => setSettingsOpen((o) => !o)}>
<IconSettings size={16} />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
<Text fz="sm" fw={500} mb={4}>
{t("Retention")}
</Text>
<Text fz="xs" c="dimmed" mb="sm">
{t("Logs older than this period are automatically deleted.")}
</Text>
<Group gap="xs" wrap="nowrap" mb="sm">
<NumberInput
value={retentionAmount}
onChange={(val) => setRetentionAmount(val)}
min={1}
hideControls
size="sm"
w={60}
/>
<Select
data={[
{ value: "days", label: t("days") },
{ value: "months", label: t("months") },
{ value: "years", label: t("years") },
]}
value={retentionUnit}
onChange={(value) => {
if (value === "days" || value === "months" || value === "years") {
setRetentionUnit(value);
}
}}
size="sm"
style={{ flex: 1 }}
comboboxProps={{ withinPortal: false }}
/>
</Group>
<Group gap="xs" grow>
<Button
size="xs"
variant="default"
onClick={() => {
resetRetentionForm();
setSettingsOpen(false);
}}
>
{t("Cancel")}
</Button>
<Button
size="xs"
onClick={() => {
const num = typeof retentionAmount === "number" ? retentionAmount : 1;
const clamped = Math.max(1, num);
setRetentionAmount(clamped);
const days = retentionToDays(clamped, retentionUnit);
if (days !== currentDays) {
updateRetention.mutate({ auditRetentionDays: days });
}
setSettingsOpen(false);
}}
loading={updateRetention.isPending}
>
{t("Save")}
</Button>
</Group>
</Popover.Dropdown>
</Popover>
</Group>
<Space h="md" /> <AuditLogsTable items={data?.items} isLoading={isLoading} />
{data?.items && data.items.length > 0 && ( <Space h="md" />
<Paginate
hasPrevPage={data?.meta?.hasPrevPage} {data?.items && data.items.length > 0 && (
hasNextPage={data?.meta?.hasNextPage} <Paginate
onNext={() => goNext(data?.meta?.nextCursor)} hasPrevPage={data?.meta?.hasPrevPage}
onPrev={goPrev} hasNextPage={data?.meta?.hasNextPage}
/> onNext={() => goNext(data?.meta?.nextCursor)}
)} onPrev={goPrev}
/>
)}
</Tabs.Panel>
<Tabs.Panel value="siem" pt="md">
<SiemStreamingPanel />
</Tabs.Panel>
</Tabs>
</> </>
); );
} }
+2 -1
View File
@@ -22,8 +22,9 @@ export const Feature = {
PERSONAL_SPACES: 'spaces:personal', PERSONAL_SPACES: 'spaces:personal',
DOCX_EXPORT: 'export:docx', DOCX_EXPORT: 'export:docx',
BASES: 'bases', BASES: 'bases',
INTEGRATIONS: 'integrations',
OAUTH: 'oauth', OAUTH: 'oauth',
AI_CONTROLS: 'ai:controls', AI_CONTROLS: 'ai:controls',
MCP_CONTROLS: 'mcp:controls', MCP_CONTROLS: 'mcp:controls',
PUBLIC_SPACE_APPEARANCE: 'public-space:appearance',
SIEM: 'siem',
} as const; } as const;
@@ -82,6 +82,7 @@ export default function SpacePublicSharingToggle({
checked={checked} checked={checked}
onChange={handleChange} onChange={handleChange}
disabled={isDisabled} disabled={isDisabled}
size={"xs"}
aria-label={t("Toggle space public sharing")} aria-label={t("Toggle space public sharing")}
/> />
</Tooltip> </Tooltip>
@@ -53,6 +53,7 @@ export default function SpaceViewerCommentsToggle({
checked={checked} checked={checked}
onChange={handleChange} onChange={handleChange}
disabled={isDisabled} disabled={isDisabled}
size={"xs"}
aria-label={t("Toggle viewer comments")} aria-label={t("Toggle viewer comments")}
/> />
</Tooltip> </Tooltip>
@@ -0,0 +1,44 @@
import { Button, Group, Modal, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ISiemDestination } from "@/ee/siem/types/siem.types";
import { useDeleteSiemDestinationMutation } from "@/ee/siem/queries/siem-query";
interface DeleteDestinationModalProps {
opened: boolean;
onClose: () => void;
destination: ISiemDestination | null;
}
export function DeleteDestinationModal({ opened, onClose, destination }: DeleteDestinationModalProps) {
const { t } = useTranslation();
const deleteMutation = useDeleteSiemDestinationMutation();
const handleDelete = async () => {
if (!destination) return;
await deleteMutation.mutateAsync({ destinationId: destination.id });
onClose();
};
return (
<Modal
opened={opened}
onClose={onClose}
title={t("Delete destination")}
size="md"
closeButtonProps={{ "aria-label": t("Close") }}
>
<Stack gap="md">
<Text>
{t("Are you sure you want to delete the destination")}{" "}
<strong>{destination?.name}</strong>?
</Text>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>{t("Cancel")}</Button>
<Button color="red" onClick={handleDelete} loading={deleteMutation.isPending}>
{t("Delete")}
</Button>
</Group>
</Stack>
</Modal>
);
}
@@ -0,0 +1,300 @@
import { useEffect, useState } from "react";
import {
Alert,
Button,
Collapse,
Group,
Modal,
PasswordInput,
Select,
Stack,
Switch,
Text,
TextInput,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import { IconAlertCircle, IconCheck } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { isCloud } from "@/lib/config.ts";
import { DATADOG_SITES, ISiemDestination, ISiemTestResult } from "@/ee/siem/types/siem.types";
import {
useCreateSiemDestinationMutation,
useTestSiemDestinationMutation,
useUpdateSiemDestinationMutation,
} from "@/ee/siem/queries/siem-query";
import {
DestinationFormValues,
initialValues,
toPayload,
validateForm,
} from "@/ee/siem/lib/destination-form";
import { DESTINATION_TYPE_LABELS } from "./destination-table";
interface DestinationFormModalProps {
opened: boolean;
onClose: () => void;
destination?: ISiemDestination | null;
}
function connectionKey(values: DestinationFormValues): string {
const { type, config, secrets } = toPayload(values);
return JSON.stringify({ type, config, secrets });
}
export function DestinationFormModal({ opened, onClose, destination }: DestinationFormModalProps) {
const { t } = useTranslation();
const isEdit = Boolean(destination);
const hasSecrets = destination?.hasSecrets ?? {};
const [advancedOpen, setAdvancedOpen] = useState(false);
const [testState, setTestState] = useState<{ result: ISiemTestResult | null; testedPayloadKey: string | null }>({
result: null,
testedPayloadKey: null,
});
const createMutation = useCreateSiemDestinationMutation();
const updateMutation = useUpdateSiemDestinationMutation();
const testMutation = useTestSiemDestinationMutation();
const form = useForm<DestinationFormValues>({
initialValues: initialValues(destination),
validate: (values) => validateForm(values, hasSecrets),
});
useEffect(() => {
if (opened) {
form.setValues(initialValues(destination));
form.resetDirty();
// eslint-disable-next-line react-hooks/set-state-in-effect
setTestState({ result: null, testedPayloadKey: null });
setAdvancedOpen(false);
}
}, [opened, destination?.id]);
const handleSubmit = async (values: DestinationFormValues) => {
const payload = toPayload(values);
try {
if (destination) {
await updateMutation.mutateAsync({
destinationId: destination.id,
name: payload.name,
config: payload.config,
secrets: payload.secrets,
enabled: payload.enabled,
});
} else {
await createMutation.mutateAsync(payload);
}
onClose();
} catch {}
};
const handleTest = async () => {
if (form.validate().hasErrors) return;
const payload = toPayload(form.values);
const testedPayloadKey = connectionKey(form.values);
setTestState((prev) => ({ ...prev, testedPayloadKey }));
try {
const result = await testMutation.mutateAsync({
type: payload.type,
config: payload.config,
secrets: payload.secrets,
destinationId: destination?.id,
});
setTestState({ result, testedPayloadKey });
} catch {
setTestState({ result: null, testedPayloadKey });
}
};
const type = form.values.type;
const showTls = type !== "datadog";
const currentPayloadKey = connectionKey(form.values);
const showTestResult = testState.result !== null && testState.testedPayloadKey === currentPayloadKey;
const connectionChanged = currentPayloadKey !== connectionKey(initialValues(destination));
const testPassed = showTestResult && testState.result.delivered;
const requiresTest = (!isEdit || connectionChanged) && !testPassed;
return (
<Modal
opened={opened}
onClose={onClose}
title={isEdit ? t("Edit destination") : t("Add destination")}
size="lg"
closeButtonProps={{ "aria-label": t("Close") }}
>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Select
label={t("Preset")}
data={[
{ value: "splunk_hec", label: DESTINATION_TYPE_LABELS.splunk_hec },
{ value: "datadog", label: DESTINATION_TYPE_LABELS.datadog },
{ value: "http", label: DESTINATION_TYPE_LABELS.http },
]}
allowDeselect={false}
disabled={isEdit}
{...form.getInputProps("type")}
/>
<TextInput
label={t("Name")}
placeholder={t("e.g. Splunk prod")}
required
data-autofocus
{...form.getInputProps("name")}
/>
{type === "splunk_hec" && (
<>
<TextInput
label={t("HEC URL")}
placeholder="https://splunk.example.com:8088"
required
{...form.getInputProps("url")}
/>
<PasswordInput
label={t("HEC token")}
required={!hasSecrets.token}
{...form.getInputProps("token")}
/>
</>
)}
{type === "datadog" && (
<>
<Select
label={t("Datadog site")}
data={DATADOG_SITES.map((site) => ({ value: site, label: site }))}
allowDeselect={false}
{...form.getInputProps("site")}
/>
<PasswordInput
label={t("API key")}
required={!hasSecrets.apiKey}
{...form.getInputProps("apiKey")}
/>
</>
)}
{type === "http" && (
<>
<TextInput
label={t("Endpoint URL")}
placeholder="https://collector.example.com/docmost"
required
{...form.getInputProps("url")}
/>
<PasswordInput
label={t("Token")}
description={t("Sent in the auth header below. Leave empty if your receiver does not need one.")}
{...form.getInputProps("token")}
/>
</>
)}
<Button
variant="subtle"
size="compact-sm"
onClick={() => setAdvancedOpen((open) => !open)}
style={{ alignSelf: "flex-start" }}
>
{advancedOpen ? t("Hide advanced options") : t("Show advanced options")}
</Button>
<Collapse expanded={advancedOpen}>
<Stack gap="md">
{type === "splunk_hec" && (
<>
<TextInput
label={t("Index")}
description={t("Leave empty to use the token's default index")}
{...form.getInputProps("index")}
/>
<Group grow>
<TextInput label={t("Source")} {...form.getInputProps("source")} />
<TextInput label={t("Sourcetype")} {...form.getInputProps("sourcetype")} />
</Group>
<TextInput
label={t("Host")}
description={t("Defaults to this instance's hostname")}
{...form.getInputProps("host")}
/>
</>
)}
{type === "datadog" && (
<>
<TextInput label={t("Service")} {...form.getInputProps("service")} />
<TextInput label={t("Tags")} placeholder="env:prod,team:security" {...form.getInputProps("tags")} />
</>
)}
{type === "http" && (
<>
<Group grow>
<TextInput label={t("Auth header name")} {...form.getInputProps("authHeaderName")} />
<TextInput label={t("Auth header prefix")} {...form.getInputProps("authHeaderPrefix")} />
</Group>
<Select
label={t("Body format")}
data={[
{ value: "json", label: t("JSON array") },
{ value: "ndjson", label: "NDJSON" },
]}
allowDeselect={false}
{...form.getInputProps("format")}
/>
</>
)}
{showTls && (
<>
{!isCloud() && (
<Switch
label={t("Verify TLS certificate")}
description={
form.values.rejectUnauthorized
? undefined
: t("Insecure: connections can be intercepted.")
}
{...form.getInputProps("rejectUnauthorized", { type: "checkbox" })}
/>
)}
</>
)}
<Switch label={t("Enabled")} {...form.getInputProps("enabled", { type: "checkbox" })} />
</Stack>
</Collapse>
{showTestResult && (
<Alert
color={testState.result.delivered ? "green" : "red"}
icon={testState.result.delivered ? <IconCheck size={16} /> : <IconAlertCircle size={16} />}
>
{testState.result.delivered ? t("Test event delivered successfully.") : testState.result.error}
</Alert>
)}
<Group justify="space-between" mt="md">
<Group gap="sm">
<Button variant="default" onClick={handleTest} loading={testMutation.isPending}>
{t("Test connection")}
</Button>
{requiresTest && (
<Text size="xs" c="dimmed">
{t("Test the connection before saving.")}
</Text>
)}
</Group>
<Group>
<Button variant="default" onClick={onClose}>{t("Cancel")}</Button>
<Button type="submit" disabled={requiresTest} loading={createMutation.isPending || updateMutation.isPending}>
{isEdit ? t("Save") : t("Create")}
</Button>
</Group>
</Group>
</Stack>
</form>
</Modal>
);
}
@@ -0,0 +1,15 @@
import { Badge } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ISiemDestination } from "@/ee/siem/types/siem.types";
export function DestinationStatusBadge({ destination }: { destination: ISiemDestination }) {
const { t } = useTranslation();
if (!destination.enabled) {
return <Badge color="gray" variant="light">{t("Disabled")}</Badge>;
}
if (destination.status === "failing") {
return <Badge color="red" variant="light">{t("Failing")}</Badge>;
}
return <Badge color="green" variant="light">{t("Healthy")}</Badge>;
}
@@ -0,0 +1,155 @@
import { ActionIcon, Menu, Switch, Table, Text, Tooltip } from "@mantine/core";
import {
IconDots,
IconEdit,
IconPlugConnected,
IconRefresh,
IconTrash,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { formattedDate, timeAgo } from "@/lib/time.ts";
import { ISiemDestination, SiemDestinationType } from "@/ee/siem/types/siem.types";
import { DestinationStatusBadge } from "./destination-status-badge";
export const DESTINATION_TYPE_LABELS: Record<SiemDestinationType, string> = {
splunk_hec: "Splunk HEC",
datadog: "Datadog",
http: "Generic HTTP",
};
interface DestinationTableProps {
destinations?: ISiemDestination[];
isLoading?: boolean;
onEdit: (destination: ISiemDestination) => void;
onTest: (destination: ISiemDestination) => void;
onRetry: (destination: ISiemDestination) => void;
onDelete: (destination: ISiemDestination) => void;
onToggle: (destination: ISiemDestination, enabled: boolean) => void;
}
export function DestinationTable({
destinations,
isLoading,
onEdit,
onTest,
onRetry,
onDelete,
onToggle,
}: DestinationTableProps) {
const { t } = useTranslation();
return (
<Table.ScrollContainer minWidth={760}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Name")}</Table.Th>
<Table.Th>{t("Type")}</Table.Th>
<Table.Th>{t("Enabled")}</Table.Th>
<Table.Th>{t("Status")}</Table.Th>
<Table.Th>{t("Last delivered")}</Table.Th>
<Table.Th>{t("Last error")}</Table.Th>
<Table.Th aria-label={t("Actions")} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{destinations && destinations.length > 0 ? (
destinations.map((destination) => (
<Table.Tr key={destination.id}>
<Table.Td>
<Text fz="sm" fw={500}>{destination.name}</Text>
</Table.Td>
<Table.Td>
<Text fz="sm">{DESTINATION_TYPE_LABELS[destination.type]}</Text>
</Table.Td>
<Table.Td>
<Switch
size="sm"
checked={destination.enabled}
onChange={(event) => onToggle(destination, event.currentTarget.checked)}
aria-label={t("Enabled")}
/>
</Table.Td>
<Table.Td>
<DestinationStatusBadge destination={destination} />
{destination.failingSince &&
(destination.status === "failing" ||
!destination.enabled) && (
<Text
fz="xs"
c="dimmed"
mt={4}
style={{ whiteSpace: "nowrap" }}
>
{t("Failing since {{time}}", {
time: formattedDate(
new Date(destination.failingSince),
),
})}
</Text>
)}
</Table.Td>
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{destination.lastDeliveredAt
? timeAgo(new Date(destination.lastDeliveredAt))
: t("Never")}
</Text>
</Table.Td>
<Table.Td>
{destination.lastError ? (
<Tooltip label={destination.lastError} multiline w={320}>
<Text fz="xs" c="red" lineClamp={2} style={{ maxWidth: 260 }}>
{destination.lastError}
</Text>
</Tooltip>
) : (
<Text fz="xs" c="dimmed"></Text>
)}
</Table.Td>
<Table.Td>
<Menu shadow="md" width={200}>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" aria-label={t("Actions")}>
<IconDots size={18} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<IconEdit size={16} />} onClick={() => onEdit(destination)}>
{t("Edit")}
</Menu.Item>
<Menu.Item leftSection={<IconPlugConnected size={16} />} onClick={() => onTest(destination)}>
{t("Send test event")}
</Menu.Item>
<Menu.Item
leftSection={<IconRefresh size={16} />}
onClick={() => onRetry(destination)}
disabled={!destination.nextAttemptAt}
>
{t("Retry now")}
</Menu.Item>
<Menu.Divider />
<Menu.Item color="red" leftSection={<IconTrash size={16} />} onClick={() => onDelete(destination)}>
{t("Delete")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
))
) : (
!isLoading && (
<Table.Tr>
<Table.Td colSpan={7}>
<Text fz="sm" c="dimmed" ta="center" py="md">
{t("No destinations yet")}
</Text>
</Table.Td>
</Table.Tr>
)
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}
@@ -0,0 +1,129 @@
import { useState } from "react";
import { Alert, Button, Group, Tooltip } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { IconAlertCircle, IconInfoCircle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import useUserRole from "@/hooks/use-user-role";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import {
ISiemDestination,
SIEM_MAX_DESTINATIONS_PER_WORKSPACE,
} from "@/ee/siem/types/siem.types";
import {
useRetrySiemDestinationMutation,
useSiemDestinationsQuery,
extractErrorMessage,
useTestSiemDestinationMutation,
useUpdateSiemDestinationMutation,
} from "@/ee/siem/queries/siem-query";
import { DestinationTable } from "@/ee/siem/components/destination-table";
import { DestinationFormModal } from "@/ee/siem/components/destination-form-modal";
import { DeleteDestinationModal } from "@/ee/siem/components/delete-destination-modal";
export default function SiemStreamingPanel() {
const { t } = useTranslation();
const { isOwner } = useUserRole();
const hasFeature = useHasFeature(Feature.SIEM);
const { data, isLoading, isError, error } = useSiemDestinationsQuery(hasFeature);
const updateMutation = useUpdateSiemDestinationMutation();
const retryMutation = useRetrySiemDestinationMutation();
const testMutation = useTestSiemDestinationMutation();
const [formOpened, setFormOpened] = useState(false);
const [deleteOpened, setDeleteOpened] = useState(false);
const [selected, setSelected] = useState<ISiemDestination | null>(null);
if (!isOwner) {
return null;
}
const atDestinationLimit =
(data?.length ?? 0) >= SIEM_MAX_DESTINATIONS_PER_WORKSPACE;
const handleTest = async (destination: ISiemDestination) => {
const result = await testMutation
.mutateAsync({
type: destination.type,
config: destination.config as unknown as Record<string, unknown>,
destinationId: destination.id,
})
.catch(() => null);
if (!result) return;
notifications.show({
message: result.delivered
? t("Test event delivered to {{name}}", { name: destination.name })
: result.error,
color: result.delivered ? "green" : "red",
});
};
return (
<>
{!hasFeature && (
<Alert icon={<IconInfoCircle size={16} />} color="yellow" mb="md">
{t("SIEM streaming requires an Enterprise license.")}
</Alert>
)}
<Group justify="flex-end" mb="md">
<Tooltip
label={t("Maximum of {{limit}} destinations reached", {
limit: SIEM_MAX_DESTINATIONS_PER_WORKSPACE,
})}
disabled={!atDestinationLimit}
>
<span>
<Button
onClick={() => {
setSelected(null);
setFormOpened(true);
}}
disabled={!hasFeature || atDestinationLimit}
>
{t("Add destination")}
</Button>
</span>
</Tooltip>
</Group>
{isError && (
<Alert icon={<IconAlertCircle size={16} />} color="red" mb="md">
{t("Could not load SIEM destinations: {{message}}", {
message: extractErrorMessage(error),
})}
</Alert>
)}
{hasFeature && !isError && (
<DestinationTable
destinations={data}
isLoading={isLoading}
onEdit={(destination) => {
setSelected(destination);
setFormOpened(true);
}}
onTest={handleTest}
onRetry={(destination) => retryMutation.mutate({ destinationId: destination.id })}
onDelete={(destination) => {
setSelected(destination);
setDeleteOpened(true);
}}
onToggle={(destination, enabled) =>
updateMutation.mutate({ destinationId: destination.id, enabled })
}
/>
)}
<DestinationFormModal
opened={formOpened}
onClose={() => setFormOpened(false)}
destination={selected}
/>
<DeleteDestinationModal
opened={deleteOpened}
onClose={() => setDeleteOpened(false)}
destination={selected}
/>
</>
);
}
@@ -0,0 +1,179 @@
import {
DATADOG_SITES,
ISiemDestination,
ISiemDestinationInput,
SiemDestinationType,
} from "@/ee/siem/types/siem.types";
export type DestinationFormValues = {
name: string;
type: SiemDestinationType;
url: string;
token: string;
apiKey: string;
authHeaderName: string;
authHeaderPrefix: string;
format: "json" | "ndjson";
index: string;
source: string;
sourcetype: string;
host: string;
site: string;
service: string;
tags: string;
rejectUnauthorized: boolean;
enabled: boolean;
};
export const DEFAULT_FORM_VALUES: DestinationFormValues = {
name: "",
type: "splunk_hec",
url: "",
token: "",
apiKey: "",
authHeaderName: "Authorization",
authHeaderPrefix: "Bearer ",
format: "json",
index: "",
source: "docmost",
sourcetype: "docmost:audit",
host: "",
site: DATADOG_SITES[0],
service: "docmost",
tags: "",
rejectUnauthorized: true,
enabled: true,
};
const HEADER_NAME_RE = /^[A-Za-z0-9-]+$/;
export const SECRET_MASK = "********";
export function initialValues(
destination?: ISiemDestination | null,
): DestinationFormValues {
if (!destination) return { ...DEFAULT_FORM_VALUES };
const config = destination.config as Record<string, any>;
const tls = config.tls ?? {};
return {
...DEFAULT_FORM_VALUES,
name: destination.name,
type: destination.type,
enabled: destination.enabled,
token: destination.hasSecrets?.token ? SECRET_MASK : "",
apiKey: destination.hasSecrets?.apiKey ? SECRET_MASK : "",
url: config.url ?? "",
authHeaderName: config.authHeaderName ?? DEFAULT_FORM_VALUES.authHeaderName,
authHeaderPrefix: config.authHeaderPrefix ?? DEFAULT_FORM_VALUES.authHeaderPrefix,
format: config.format ?? "json",
index: config.index ?? "",
source: config.source ?? DEFAULT_FORM_VALUES.source,
sourcetype: config.sourcetype ?? DEFAULT_FORM_VALUES.sourcetype,
host: config.host ?? "",
site: config.site ?? DEFAULT_FORM_VALUES.site,
service: config.service ?? DEFAULT_FORM_VALUES.service,
tags: config.tags ?? "",
rejectUnauthorized: tls.rejectUnauthorized ?? true,
};
}
function isValidUrl(value: string): boolean {
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
}
export function validateForm(
values: DestinationFormValues,
hasSecrets: Record<string, boolean> = {},
): Partial<Record<keyof DestinationFormValues, string>> {
const errors: Partial<Record<keyof DestinationFormValues, string>> = {};
if (!values.name.trim()) errors.name = "Name is required";
if (values.type !== "datadog" && !isValidUrl(values.url.trim())) {
errors.url = "Enter a valid http(s) URL";
}
if (values.type === "splunk_hec") {
if (!values.token && !hasSecrets.token) {
errors.token = "HEC token is required";
}
try {
const url = new URL(values.url.trim());
const path = url.pathname.replace(/\/+$/, "");
if (path !== "" && path !== "/services/collector" && path !== "/services/collector/event") {
errors.url = "Enter the HEC base URL or the /services/collector/event endpoint";
}
} catch {}
}
if (values.type === "datadog") {
if (!(DATADOG_SITES as readonly string[]).includes(values.site)) {
errors.site = "Select a Datadog site";
}
if (!values.apiKey && !hasSecrets.apiKey) errors.apiKey = "API key is required";
}
if (values.type === "http") {
if (!HEADER_NAME_RE.test(values.authHeaderName.trim())) {
errors.authHeaderName = "Use letters, digits and hyphens only";
}
}
return errors;
}
function enteredSecret(key: string, value: string): Record<string, string> {
const trimmed = value.trim();
return trimmed && trimmed !== SECRET_MASK ? { [key]: trimmed } : {};
}
export function toPayload(values: DestinationFormValues): ISiemDestinationInput {
const tls = { rejectUnauthorized: values.rejectUnauthorized };
let config: Record<string, unknown>;
let secrets: Record<string, string>;
switch (values.type) {
case "splunk_hec":
config = {
url: values.url.trim(),
index: values.index.trim(),
source: values.source.trim() || "docmost",
sourcetype: values.sourcetype.trim() || "docmost:audit",
host: values.host.trim(),
tls,
};
secrets = enteredSecret("token", values.token);
break;
case "datadog":
config = {
site: values.site,
service: values.service.trim() || "docmost",
tags: values.tags.trim(),
};
secrets = enteredSecret("apiKey", values.apiKey);
break;
default:
config = {
url: values.url.trim(),
authHeaderName: values.authHeaderName.trim(),
authHeaderPrefix: values.authHeaderPrefix,
format: values.format,
tls,
};
secrets = enteredSecret("token", values.token);
}
return {
name: values.name.trim(),
type: values.type,
config,
secrets: Object.fromEntries(Object.entries(secrets).filter(([, v]) => v !== "")),
enabled: values.enabled,
};
}
@@ -0,0 +1,115 @@
import {
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import {
createSiemDestination,
deleteSiemDestination,
getSiemDestinations,
retrySiemDestination,
testSiemDestination,
updateSiemDestination,
} from "@/ee/siem/services/siem-service";
import {
ISiemDestination,
ISiemDestinationInput,
ISiemTestResult,
ITestSiemDestinationInput,
IUpdateSiemDestinationInput,
} from "@/ee/siem/types/siem.types";
export const SIEM_DESTINATIONS_KEY = ["siem-destinations"];
export function extractErrorMessage(error: Error): string {
const data = (error as any)?.response?.data;
const message = data?.message ?? error.message;
return Array.isArray(message) ? message.join(", ") : String(message);
}
function showError(error: Error) {
notifications.show({ message: extractErrorMessage(error), color: "red" });
}
function isForbidden(error: unknown): boolean {
return (error as { response?: { status?: number } })?.response?.status === 403;
}
export function useSiemDestinationsQuery(
enabled = true,
): UseQueryResult<ISiemDestination[], Error> {
return useQuery({
queryKey: SIEM_DESTINATIONS_KEY,
queryFn: getSiemDestinations,
enabled,
retry: (failureCount, error) => !isForbidden(error) && failureCount < 2,
refetchInterval: (query) => (query.state.status === "error" ? false : 15_000),
});
}
function useInvalidateDestinations() {
const queryClient = useQueryClient();
return () => queryClient.invalidateQueries({ queryKey: SIEM_DESTINATIONS_KEY });
}
export function useCreateSiemDestinationMutation() {
const { t } = useTranslation();
const invalidate = useInvalidateDestinations();
return useMutation<ISiemDestination, Error, ISiemDestinationInput>({
mutationFn: createSiemDestination,
onSuccess: () => {
notifications.show({ message: t("Destination created") });
invalidate();
},
onError: showError,
});
}
export function useUpdateSiemDestinationMutation() {
const { t } = useTranslation();
const invalidate = useInvalidateDestinations();
return useMutation<ISiemDestination, Error, IUpdateSiemDestinationInput>({
mutationFn: updateSiemDestination,
onSuccess: () => {
notifications.show({ message: t("Destination updated") });
invalidate();
},
onError: showError,
});
}
export function useDeleteSiemDestinationMutation() {
const { t } = useTranslation();
const invalidate = useInvalidateDestinations();
return useMutation<void, Error, { destinationId: string }>({
mutationFn: deleteSiemDestination,
onSuccess: () => {
notifications.show({ message: t("Destination deleted") });
invalidate();
},
onError: showError,
});
}
export function useRetrySiemDestinationMutation() {
const { t } = useTranslation();
const invalidate = useInvalidateDestinations();
return useMutation<void, Error, { destinationId: string }>({
mutationFn: retrySiemDestination,
onSuccess: () => {
notifications.show({ message: t("Retry scheduled") });
invalidate();
},
onError: showError,
});
}
export function useTestSiemDestinationMutation() {
return useMutation<ISiemTestResult, Error, ITestSiemDestinationInput>({
mutationFn: testSiemDestination,
onError: showError,
});
}
@@ -0,0 +1,46 @@
import api from "@/lib/api-client";
import {
ISiemDestination,
ISiemDestinationInput,
ISiemTestResult,
ITestSiemDestinationInput,
IUpdateSiemDestinationInput,
} from "@/ee/siem/types/siem.types";
export async function getSiemDestinations(): Promise<ISiemDestination[]> {
const req = await api.post<ISiemDestination[]>("/siem/destinations");
return req.data;
}
export async function createSiemDestination(
data: ISiemDestinationInput,
): Promise<ISiemDestination> {
const req = await api.post<ISiemDestination>("/siem/destinations/create", data);
return req.data;
}
export async function updateSiemDestination(
data: IUpdateSiemDestinationInput,
): Promise<ISiemDestination> {
const req = await api.post<ISiemDestination>("/siem/destinations/update", data);
return req.data;
}
export async function deleteSiemDestination(data: {
destinationId: string;
}): Promise<void> {
await api.post("/siem/destinations/delete", data);
}
export async function testSiemDestination(
data: ITestSiemDestinationInput,
): Promise<ISiemTestResult> {
const req = await api.post<ISiemTestResult>("/siem/destinations/test", data);
return req.data;
}
export async function retrySiemDestination(data: {
destinationId: string;
}): Promise<void> {
await api.post("/siem/destinations/retry", data);
}
@@ -0,0 +1,91 @@
export const SIEM_MAX_DESTINATIONS_PER_WORKSPACE = 2;
export type SiemDestinationType = "http" | "splunk_hec" | "datadog";
export type SiemDestinationStatus = "healthy" | "failing";
export const DATADOG_SITES = [
"datadoghq.com",
"datadoghq.eu",
"us3.datadoghq.com",
"us5.datadoghq.com",
"ap1.datadoghq.com",
"ddog-gov.com",
] as const;
export interface ITlsOptions {
rejectUnauthorized: boolean;
}
export interface IHttpConfig {
url: string;
authHeaderName: string;
authHeaderPrefix: string;
format: "json" | "ndjson";
tls?: ITlsOptions;
}
export interface ISplunkHecConfig {
url: string;
index?: string;
source: string;
sourcetype: string;
host?: string;
channelId: string;
tls?: ITlsOptions;
}
export interface IDatadogConfig {
site: string;
service: string;
tags?: string;
}
export type ISiemConfig = IHttpConfig | ISplunkHecConfig | IDatadogConfig;
export interface ISiemDestination {
id: string;
name: string;
type: SiemDestinationType;
enabled: boolean;
status: SiemDestinationStatus;
config: ISiemConfig;
hasSecrets: Record<string, boolean>;
cursorCreatedAt: string;
lastDeliveredAt: string | null;
lastError: string | null;
lastErrorAt: string | null;
consecutiveFailures: number;
nextAttemptAt: string | null;
failingSince: string | null;
createdAt: string;
updatedAt: string;
}
export interface ISiemDestinationInput {
name: string;
type: SiemDestinationType;
config: Record<string, unknown>;
secrets?: Record<string, string>;
enabled?: boolean;
}
export interface IUpdateSiemDestinationInput {
destinationId: string;
name?: string;
config?: Record<string, unknown>;
secrets?: Record<string, string>;
enabled?: boolean;
}
export interface ITestSiemDestinationInput {
type: SiemDestinationType;
config: Record<string, unknown>;
secrets?: Record<string, string>;
destinationId?: string;
}
export interface ISiemTestResult {
delivered: boolean;
error?: string;
statusCode?: number;
}
@@ -85,6 +85,9 @@ export function AudioMenu({ editor }: EditorMenuProps) {
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
pluginKey={`audio-menu`} pluginKey={`audio-menu`}
ref={(element) => {
if (element) element.style.zIndex = "99";
}}
updateDelay={0} updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement} getReferencedVirtualElement={getReferencedVirtualElement}
options={{ options={{
@@ -121,12 +121,14 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
pluginKey={`callout-menu`} pluginKey={`callout-menu`}
ref={(element) => {
if (element) element.style.zIndex = "99";
}}
updateDelay={0} updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement} getReferencedVirtualElement={getReferencedVirtualElement}
options={{ options={{
placement: "bottom", placement: "bottom",
// offset: 233, // // offset: [0, 10], // offset: 233, // // offset: [0, 10],
// zIndex: 99,
flip: false, flip: false,
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
@@ -257,6 +257,9 @@ export function ColumnsMenu({ editor }: EditorMenuProps) {
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
pluginKey="columns-menu" pluginKey="columns-menu"
ref={(element) => {
if (element) element.style.zIndex = "99";
}}
updateDelay={0} updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement} getReferencedVirtualElement={getReferencedVirtualElement}
options={{ options={{
@@ -5,10 +5,6 @@ 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,
@@ -26,16 +22,6 @@ 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,
@@ -44,59 +30,6 @@ 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();
@@ -273,6 +273,9 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
pluginKey={`drawio-menu`} pluginKey={`drawio-menu`}
ref={(element) => {
if (element) element.style.zIndex = "99";
}}
updateDelay={0} updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement} getReferencedVirtualElement={getReferencedVirtualElement}
options={{ options={{
@@ -156,6 +156,9 @@ export function ImageMenu({ editor }: EditorMenuProps) {
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
pluginKey={`image-menu`} pluginKey={`image-menu`}
ref={(element) => {
if (element) element.style.zIndex = "99";
}}
updateDelay={0} updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement} getReferencedVirtualElement={getReferencedVirtualElement}
options={{ options={{
@@ -1,49 +0,0 @@
// 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;
}
@@ -1,65 +0,0 @@
.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;
}
@@ -1,525 +0,0 @@
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);
@@ -1,253 +0,0 @@
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);
@@ -1,205 +0,0 @@
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>
);
}
@@ -1,119 +0,0 @@
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"));
});
});
@@ -1,47 +0,0 @@
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 };
}
@@ -26,7 +26,12 @@ import { INTERNAL_LINK_REGEX } from "@/lib/constants";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx"; import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
import { usePageQuery } from "@/features/page/queries/page-query.ts"; import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { useSharePageQuery } from "@/features/share/queries/share-query.ts"; import { useSharePageQuery } from "@/features/share/queries/share-query.ts";
import { buildSharedPageUrl } from "@/features/page/page.utils.ts"; import { usePublicSpacePageQuery } from "@/features/public-space/queries/public-space-query.ts";
import {
buildPageUrl,
buildPublicSpaceUrl,
buildSharedPageUrl,
} from "@/features/page/page.utils.ts";
import { extractPageSlugId } from "@/lib"; import { extractPageSlugId } from "@/lib";
import { sanitizeUrl, copyToClipboard, isEditorReady } from "@docmost/editor-ext"; import { sanitizeUrl, copyToClipboard, isEditorReady } from "@docmost/editor-ext";
import { normalizeUrl } from "@/lib/utils"; import { normalizeUrl } from "@/lib/utils";
@@ -60,9 +65,10 @@ export default function LinkView(props: MarkViewProps) {
const href = mark.attrs.href as string; const href = mark.attrs.href as string;
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { shareId, pageSlug } = useParams(); const { shareId, spaceSlug, pageSlug } = useParams();
const { t } = useTranslation(); const { t } = useTranslation();
const isShareRoute = location.pathname.startsWith("/share"); const isShareRoute = location.pathname.startsWith("/share");
const isPublicSpaceRoute = location.pathname.startsWith("/docs/");
const [popoverState, setPopoverState] = useState< const [popoverState, setPopoverState] = useState<
"closed" | "preview" | "edit" "closed" | "preview" | "edit"
@@ -84,16 +90,33 @@ export default function LinkView(props: MarkViewProps) {
const activeView = isPopoverVisible ? popoverState : lastOpenState.current; const activeView = isPopoverVisible ? popoverState : lastOpenState.current;
const { data: linkedPage } = usePageQuery({ const { data: linkedPage } = usePageQuery({
pageId: isPopoverVisible && slugId && !isShareRoute ? slugId : null, pageId:
isPopoverVisible && slugId && !isShareRoute && !isPublicSpaceRoute
? slugId
: null,
}); });
const { data: sharedPageData } = useSharePageQuery({ const { data: sharedPageData } = useSharePageQuery({
pageId: isPopoverVisible && slugId && isShareRoute ? slugId : null, pageId: isPopoverVisible && slugId && isShareRoute ? slugId : null,
}); });
const pageTitle = isShareRoute // Resolved eagerly (not gated on the popover): an unresolvable target must
? sharedPageData?.page?.title // render as inert text rather than a link that dead-ends at /login.
: linkedPage?.title; const { data: publicSpacePageData } = usePublicSpacePageQuery({
spaceSlug: isPublicSpaceRoute && slugId ? spaceSlug : undefined,
pageSlugId: slugId,
contentless: true,
});
const isUnresolvedPublicLink =
isPublicSpaceRoute && isInternal && !publicSpacePageData?.page && !slugId;
let pageTitle = linkedPage?.title;
if (isShareRoute) {
pageTitle = sharedPageData?.page?.title;
} else if (isPublicSpaceRoute) {
pageTitle = publicSpacePageData?.page?.title;
}
const pendingTitleRef = useRef<string | null>(null); const pendingTitleRef = useRef<string | null>(null);
const titleInputRef = useRef<HTMLInputElement>(null); const titleInputRef = useRef<HTMLInputElement>(null);
@@ -260,6 +283,27 @@ export default function LinkView(props: MarkViewProps) {
anchorId: anchor || undefined, anchorId: anchor || undefined,
}); });
navigate(sharedUrl); navigate(sharedUrl);
} else if (isPublicSpaceRoute) {
if (slugId && publicSpacePageData?.page) {
navigate(
buildPublicSpaceUrl({
// cross-space targets resolve to their own space's public URL
spaceSlug: publicSpacePageData.space?.slug ?? spaceSlug,
pageSlugId: slugId,
pageTitle: pageTitle,
anchorId: anchor || undefined,
}),
);
} else if (slugId) {
// no public URL: the /p/ resolver redirects members straight to the
// page and funnels anonymous visitors through login first; a new tab
// keeps the docs tab's history intact through that redirect chain
window.open(
buildPageUrl(undefined, slugId, pageTitle, anchor || undefined),
"_blank",
"noopener,noreferrer",
);
}
} else { } else {
navigate(anchor ? `${targetPath}#${anchor}` : targetPath); navigate(anchor ? `${targetPath}#${anchor}` : targetPath);
} }
@@ -276,8 +320,11 @@ export default function LinkView(props: MarkViewProps) {
location.pathname, location.pathname,
isInternal, isInternal,
isShareRoute, isShareRoute,
isPublicSpaceRoute,
slugId, slugId,
shareId, shareId,
spaceSlug,
publicSpacePageData,
pageTitle, pageTitle,
pageSlug, pageSlug,
]); ]);
@@ -319,12 +366,18 @@ export default function LinkView(props: MarkViewProps) {
setPopoverState("closed"); setPopoverState("closed");
}, [editor]); }, [editor]);
const internalHref = () => {
if (isShareRoute && slugId) {
return buildSharedPageUrl({ shareId, pageSlugId: slugId, pageTitle });
}
if (isPublicSpaceRoute && slugId && publicSpacePageData?.page) {
return buildPublicSpaceUrl({ spaceSlug, pageSlugId: slugId, pageTitle });
}
return href;
};
const displayHref = sanitizeUrl( const displayHref = sanitizeUrl(
isInternal isInternal ? internalHref() : normalizeUrl(href),
? isShareRoute && slugId
? buildSharedPageUrl({ shareId, pageSlugId: slugId, pageTitle })
: href
: normalizeUrl(href),
); );
const linkTitleInput = ( const linkTitleInput = (
@@ -374,6 +427,16 @@ export default function LinkView(props: MarkViewProps) {
</> </>
); );
// Targets outside the published space have no public URL, so the label is
// rendered as inert text instead of a link that dead-ends at /login.
if (isUnresolvedPublicLink) {
return (
<span ref={wrapperRef} className={classes.linkWrapper}>
<MarkViewContent />
</span>
);
}
return ( return (
<Popover <Popover
opened={isPopoverVisible} opened={isPopoverVisible}
@@ -4,8 +4,10 @@ import { IconFileDescription } from "@tabler/icons-react";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom"; import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts"; import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { useSharePageQuery } from "@/features/share/queries/share-query.ts"; import { useSharePageQuery } from "@/features/share/queries/share-query.ts";
import { usePublicSpacePageQuery } from "@/features/public-space/queries/public-space-query.ts";
import { import {
buildPageUrl, buildPageUrl,
buildPublicSpaceUrl,
buildSharedPageUrl, buildSharedPageUrl,
} from "@/features/page/page.utils.ts"; } from "@/features/page/page.utils.ts";
import { extractPageSlugId } from "@/lib"; import { extractPageSlugId } from "@/lib";
@@ -21,17 +23,30 @@ export default function MentionView(props: NodeViewProps) {
const location = useLocation(); const location = useLocation();
const isShareRoute = location.pathname.startsWith("/share"); const isShareRoute = location.pathname.startsWith("/share");
const isPublicSpaceRoute = location.pathname.startsWith("/docs/");
const { const {
data: page, data: page,
isLoading, isLoading,
isError, isError,
} = usePageQuery({ pageId: isPageMention && !isShareRoute ? slugId : null }); } = usePageQuery({
pageId:
isPageMention && !isShareRoute && !isPublicSpaceRoute ? slugId : null,
});
const { data: sharedPage } = useSharePageQuery({ const { data: sharedPage } = useSharePageQuery({
pageId: isPageMention && isShareRoute ? slugId : undefined, pageId: isPageMention && isShareRoute ? slugId : undefined,
}); });
// Without the slugId guard the request resolves to the space home, which
// would render a mention pointing at the wrong page.
const { data: publicPageData } = usePublicSpacePageQuery({
spaceSlug:
isPageMention && isPublicSpaceRoute && slugId ? spaceSlug : undefined,
pageSlugId: slugId,
contentless: true,
});
const currentPageSlugId = extractPageSlugId(pageSlug); const currentPageSlugId = extractPageSlugId(pageSlug);
const isSamePage = currentPageSlugId === slugId; const isSamePage = currentPageSlugId === slugId;
@@ -87,7 +102,61 @@ export default function MentionView(props: NodeViewProps) {
</Anchor> </Anchor>
)} )}
{isPageMention && !isShareRoute && isError && ( {isPageMention && isPublicSpaceRoute && publicPageData?.page && (
<Anchor
component={Link}
fw={500}
to={buildPublicSpaceUrl({
spaceSlug: publicPageData.space?.slug ?? spaceSlug,
pageSlugId: slugId,
pageTitle: publicPageData.page.title || label,
anchorId,
})}
onClick={handleClick}
underline="never"
className={classes.pageMentionLink}
>
<ActionIcon
variant="transparent"
color="gray"
component="span"
size={18}
style={{ verticalAlign: "text-bottom" }}
>
<IconFileDescription size={18} />
</ActionIcon>
<span className={classes.pageMentionText}>
{publicPageData.page.title || label}
</span>
</Anchor>
)}
{/* No public URL: the /p/ resolver redirects members to the page and
funnels anonymous visitors through login first. New tab, so the
redirect chain never rewrites the docs tab's history. */}
{isPageMention && isPublicSpaceRoute && !publicPageData?.page && (
<Anchor
fw={500}
href={buildPageUrl(undefined, slugId, label, anchorId)}
target="_blank"
rel="noopener noreferrer"
underline="never"
className={classes.pageMentionLink}
>
<ActionIcon
variant="transparent"
color="gray"
component="span"
size={18}
style={{ verticalAlign: "text-bottom" }}
>
<IconFileDescription size={18} />
</ActionIcon>
<span className={classes.pageMentionText}>{label}</span>
</Anchor>
)}
{isPageMention && !isShareRoute && !isPublicSpaceRoute && isError && (
<Anchor <Anchor
component={Link} component={Link}
fw={500} fw={500}
@@ -111,7 +180,7 @@ export default function MentionView(props: NodeViewProps) {
</Anchor> </Anchor>
)} )}
{isPageMention && !isShareRoute && !isError && ( {isPageMention && !isShareRoute && !isPublicSpaceRoute && !isError && (
<Anchor <Anchor
component={Link} component={Link}
fw={500} fw={500}
@@ -106,6 +106,9 @@ export function PdfMenu({ editor }: EditorMenuProps) {
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
pluginKey={`pdf-menu`} pluginKey={`pdf-menu`}
ref={(element) => {
if (element) element.style.zIndex = "99";
}}
updateDelay={0} updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement} getReferencedVirtualElement={getReferencedVirtualElement}
options={{ options={{
@@ -61,6 +61,9 @@ export const SubpagesMenu = React.memo(
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
pluginKey={`subpages-menu`} pluginKey={`subpages-menu`}
ref={(element) => {
if (element) element.style.zIndex = "99";
}}
updateDelay={0} updateDelay={0}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
@@ -3,22 +3,30 @@ import { Stack, Text, Anchor, ActionIcon } from "@mantine/core";
import { IconFileDescription } from "@tabler/icons-react"; import { IconFileDescription } from "@tabler/icons-react";
import { useGetSidebarPagesQuery } from "@/features/page/queries/page-query"; import { useGetSidebarPagesQuery } from "@/features/page/queries/page-query";
import { useMemo } from "react"; import { useMemo } from "react";
import { Link, useParams } from "react-router-dom"; import { Link, useLocation, useParams } from "react-router-dom";
import classes from "./subpages.module.css"; import classes from "./subpages.module.css";
import styles from "../mention/mention.module.css"; import styles from "../mention/mention.module.css";
import { import {
buildPageUrl, buildPageUrl,
buildPublicSpaceUrl,
buildSharedPageUrl, buildSharedPageUrl,
} from "@/features/page/page.utils.ts"; } from "@/features/page/page.utils.ts";
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 { useAtomValue } from "jotai";
import { publicSpaceTreeDataAtom } from "@/features/public-space/atoms/public-space-atoms.ts";
import { findSubpagesInTree } from "@/features/share/utils";
import { extractPageSlugId } from "@/lib"; 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, pageSlug } = useParams();
const { t } = useTranslation(); const { t } = useTranslation();
const location = useLocation();
const isPublicSpaceRoute = location.pathname.startsWith("/docs/");
const publicSpaceTreeData = useAtomValue(publicSpaceTreeDataAtom);
// @ts-ignore // @ts-ignore
const storagePageId = editor.storage.pageId; const storagePageId = editor.storage.pageId;
@@ -29,11 +37,25 @@ export default function SubpagesView(props: NodeViewProps) {
currentPageId = routePageId; currentPageId = routePageId;
} }
// Public docs must resolve the page from the route, not editor storage:
// storage.pageId is set after this view's first render and is not reactive,
// which froze the list at "No subpages" until something re-rendered it. The
// space home renders at the bare URL, so it falls back to the first root.
if (isPublicSpaceRoute) {
currentPageId = routePageId ?? publicSpaceTreeData?.[0]?.slugId;
}
// 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);
const publicSpaceSubpages = useMemo(
() => findSubpagesInTree(publicSpaceTreeData, currentPageId),
[publicSpaceTreeData, currentPageId],
);
const isPublicView = Boolean(shareId) || isPublicSpaceRoute;
const { data, isLoading, error } = useGetSidebarPagesQuery( const { data, isLoading, error } = useGetSidebarPagesQuery(
shareId ? null : { pageId: currentPageId }, isPublicView ? null : { pageId: currentPageId },
); );
const subpages = useMemo(() => { const subpages = useMemo(() => {
@@ -48,17 +70,33 @@ export default function SubpagesView(props: NodeViewProps) {
})); }));
} }
if (isPublicSpaceRoute) {
return publicSpaceSubpages.map((node) => ({
id: node.value,
slugId: node.slugId,
title: node.name,
icon: node.icon,
position: node.position,
}));
}
// Otherwise use the API data // Otherwise use the API data
if (!data?.pages) return []; if (!data?.pages) return [];
const allPages = data.pages.flatMap((page) => page.items); const allPages = data.pages.flatMap((page) => page.items);
return sortPositionKeys(allPages); return sortPositionKeys(allPages);
}, [data, shareId, sharedSubpages]); }, [
data,
shareId,
sharedSubpages,
isPublicSpaceRoute,
publicSpaceSubpages,
]);
if (isLoading && !shareId) { if (isLoading && !isPublicView) {
return null; return null;
} }
if (error && !shareId) { if (error && !isPublicView) {
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper data-drag-handle>
<Text c="dimmed" size="md" py="md"> <Text c="dimmed" size="md" py="md">
@@ -96,7 +134,13 @@ export default function SubpagesView(props: NodeViewProps) {
pageSlugId: page.slugId, pageSlugId: page.slugId,
pageTitle: page.title, pageTitle: page.title,
}) })
: buildPageUrl(spaceSlug, page.slugId, page.title) : isPublicSpaceRoute
? buildPublicSpaceUrl({
spaceSlug,
pageSlugId: page.slugId,
pageTitle: page.title,
})
: buildPageUrl(spaceSlug, page.slugId, page.title)
} }
underline="never" underline="never"
className={styles.pageMentionLink} className={styles.pageMentionLink}
@@ -18,7 +18,7 @@ export type HeadingLink = {
position: number; position: number;
}; };
const recalculateLinks = (nodePos: NodePos[]) => { export const recalculateLinks = (nodePos: NodePos[]) => {
const nodes: HTMLElement[] = []; const nodes: HTMLElement[] = [];
const links: HeadingLink[] = Array.from(nodePos).reduce<HeadingLink[]>( const links: HeadingLink[] = Array.from(nodePos).reduce<HeadingLink[]>(
@@ -9,6 +9,7 @@ import React, {
} from "react"; } from "react";
import { import {
lookupTransclusion, lookupTransclusion,
lookupTransclusionForPublicSpace,
lookupTransclusionForShare, lookupTransclusionForShare,
} from "@/features/transclusion/services/transclusion-api"; } from "@/features/transclusion/services/transclusion-api";
import type { TransclusionLookup } from "@/features/transclusion/types/transclusion.types"; import type { TransclusionLookup } from "@/features/transclusion/types/transclusion.types";
@@ -38,6 +39,7 @@ const TransclusionLookupContext = createContext<ContextValue | null>(null);
export function TransclusionLookupProvider({ export function TransclusionLookupProvider({
children, children,
shareId, shareId,
spaceSlug,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
/** /**
@@ -47,6 +49,11 @@ export function TransclusionLookupProvider({
* app, where personal permissions gate access. * app, where personal permissions gate access.
*/ */
shareId?: string; shareId?: string;
/**
* When set, lookups go through the public-space endpoint and are gated by
* the published space. Used by the public docs viewer.
*/
spaceSlug?: string;
}) { }) {
const subscribersRef = useRef(new Map<LookupKey, Subscriber[]>()); const subscribersRef = useRef(new Map<LookupKey, Subscriber[]>());
const queueRef = useRef(new Set<LookupKey>()); const queueRef = useRef(new Set<LookupKey>());
@@ -55,6 +62,8 @@ export function TransclusionLookupProvider({
// memoized callbacks (and thus doesn't re-render every consumer). // memoized callbacks (and thus doesn't re-render every consumer).
const shareIdRef = useRef<string | undefined>(shareId); const shareIdRef = useRef<string | undefined>(shareId);
shareIdRef.current = shareId; shareIdRef.current = shareId;
const spaceSlugRef = useRef<string | undefined>(spaceSlug);
spaceSlugRef.current = spaceSlug;
// Last looked-up value for each key. Re-subscribers (e.g. when the editor // Last looked-up value for each key. Re-subscribers (e.g. when the editor
// remounts after switching from static to live) get this immediately // remounts after switching from static to live) get this immediately
// instead of triggering a duplicate fetch. // instead of triggering a duplicate fetch.
@@ -91,12 +100,18 @@ export function TransclusionLookupProvider({
try { try {
const activeShareId = shareIdRef.current; const activeShareId = shareIdRef.current;
const activeSpaceSlug = spaceSlugRef.current;
const { items } = activeShareId const { items } = activeShareId
? await lookupTransclusionForShare({ ? await lookupTransclusionForShare({
shareId: activeShareId, shareId: activeShareId,
references, references,
}) })
: await lookupTransclusion({ references }); : activeSpaceSlug
? await lookupTransclusionForPublicSpace({
spaceSlug: activeSpaceSlug,
references,
})
: await lookupTransclusion({ references });
for (const r of items) { for (const r of items) {
const key = `${r.sourcePageId}::${r.transclusionId}`; const key = `${r.sourcePageId}::${r.transclusionId}`;
resultCacheRef.current.set(key, r); resultCacheRef.current.set(key, r);
@@ -132,6 +132,9 @@ export function VideoMenu({ editor }: EditorMenuProps) {
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
pluginKey={`video-menu`} pluginKey={`video-menu`}
ref={(element) => {
if (element) element.style.zIndex = "99";
}}
updateDelay={0} updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement} getReferencedVirtualElement={getReferencedVirtualElement}
options={{ options={{
@@ -57,8 +57,6 @@ import {
Indent, Indent,
UniqueID, UniqueID,
SharedStorage, SharedStorage,
IntegrationLink,
IntegrationMention,
Columns, Columns,
Column, Column,
Status, Status,
@@ -97,9 +95,6 @@ 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";
@@ -392,13 +387,6 @@ 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,
}), }),
@@ -1,44 +0,0 @@
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;
},
},
}),
];
},
});
@@ -79,8 +79,6 @@ 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 {
@@ -360,18 +358,6 @@ 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
@@ -490,7 +476,6 @@ 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) && (
@@ -35,6 +35,16 @@ interface PageEditorProps {
* that isn't itself shared. * that isn't itself shared.
*/ */
shareId?: string; shareId?: string;
/**
* When rendering inside a public space, pass the space slug. Transclusion
* lookups then resolve against the published space instead of the viewer's
* personal permissions.
*/
spaceSlug?: string;
/** Rendered between the title and the content. */
byline?: React.ReactNode;
/** Set false when the consumer renders its own end matter (e.g. prev/next). */
trailingSpace?: boolean;
} }
export default function ReadonlyPageEditor({ export default function ReadonlyPageEditor({
@@ -43,12 +53,16 @@ export default function ReadonlyPageEditor({
pageId, pageId,
printMode = false, printMode = false,
shareId, shareId,
spaceSlug,
byline,
trailingSpace = true,
}: PageEditorProps) { }: PageEditorProps) {
const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom); const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom);
const [lightboxRequest, setLightboxRequest] = useAtom(lightboxRequestAtom); const [lightboxRequest, setLightboxRequest] = useAtom(lightboxRequestAtom);
const [contentEditor, setContentEditor] = useState<Editor | null>(null); const [contentEditor, setContentEditor] = useState<Editor | null>(null);
const isComponentMounted = useRef(false); const isComponentMounted = useRef(false);
const editorCreated = useRef(false); const editorCreated = useRef(false);
const isPublicView = Boolean(shareId || spaceSlug);
const canScroll = useCallback( const canScroll = useCallback(
() => isComponentMounted.current && editorCreated.current, () => isComponentMounted.current && editorCreated.current,
@@ -64,9 +78,9 @@ export default function ReadonlyPageEditor({
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!shareId) return; if (!isPublicView) return;
setLightboxRequest(null); setLightboxRequest(null);
}, [pageId, shareId]); }, [pageId, isPublicView]);
const extensions = useMemo(() => { const extensions = useMemo(() => {
const excludedExtensions = new Set([ const excludedExtensions = new Set([
@@ -99,7 +113,7 @@ export default function ReadonlyPageEditor({
]; ];
return ( return (
<TransclusionLookupProvider shareId={shareId}> <TransclusionLookupProvider shareId={shareId} spaceSlug={spaceSlug}>
<div className="page-title"> <div className="page-title">
<EditorProvider <EditorProvider
editable={false} editable={false}
@@ -110,6 +124,8 @@ export default function ReadonlyPageEditor({
></EditorProvider> ></EditorProvider>
</div> </div>
{byline}
<EditorProvider <EditorProvider
editable={false} editable={false}
immediatelyRender={true} immediatelyRender={true}
@@ -117,7 +133,7 @@ export default function ReadonlyPageEditor({
extensions={extensions} extensions={extensions}
content={content} content={content}
editorProps={ editorProps={
shareId isPublicView
? { ? {
handleClickOn: (_view, _pos, node) => { handleClickOn: (_view, _pos, node) => {
const request = getLightboxClickRequest(node); const request = getLightboxClickRequest(node);
@@ -144,7 +160,7 @@ export default function ReadonlyPageEditor({
} }
}} }}
></EditorProvider> ></EditorProvider>
{shareId && contentEditor && ( {isPublicView && contentEditor && (
<LightboxView <LightboxView
editor={contentEditor} editor={contentEditor}
open={!!lightboxRequest} open={!!lightboxRequest}
@@ -153,7 +169,7 @@ export default function ReadonlyPageEditor({
onClose={() => setLightboxRequest(null)} onClose={() => setLightboxRequest(null)}
/> />
)} )}
<div style={{ paddingBottom: "20vh" }}></div> {trailingSpace && <div style={{ paddingBottom: "20vh" }}></div>}
</TransclusionLookupProvider> </TransclusionLookupProvider>
); );
} }
@@ -12,13 +12,19 @@ import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { IUser } from "@/features/user/types/user.types.ts"; import { IUser } from "@/features/user/types/user.types.ts";
import Paginate from "@/components/common/paginate.tsx"; import Paginate from "@/components/common/paginate.tsx";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate"; import { SearchInput } from "@/components/common/search-input.tsx";
import NoTableResults from "@/components/common/no-table-results.tsx";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search.tsx";
export default function GroupMembersList() { export default function GroupMembersList() {
const { t } = useTranslation(); const { t } = useTranslation();
const { groupId } = useParams(); const { groupId } = useParams();
const { cursor, goNext, goPrev } = useCursorPaginate(); const { search, cursor, goNext, goPrev, handleSearch } =
const { data, isLoading } = useGroupMembersQuery(groupId, { cursor }); usePaginateAndSearch();
const { data, isLoading } = useGroupMembersQuery(groupId, {
cursor,
query: search,
});
const removeGroupMember = useRemoveGroupMemberMutation(); const removeGroupMember = useRemoveGroupMemberMutation();
const { isAdmin } = useUserRole(); const { isAdmin } = useUserRole();
@@ -48,6 +54,7 @@ export default function GroupMembersList() {
return ( return (
<> <>
<SearchInput onSearch={handleSearch} />
<Table.ScrollContainer minWidth={500}> <Table.ScrollContainer minWidth={500}>
<Table highlightOnHover verticalSpacing="sm"> <Table highlightOnHover verticalSpacing="sm">
<Table.Thead> <Table.Thead>
@@ -59,55 +66,62 @@ export default function GroupMembersList() {
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
{data?.items.map((user: IUser, index: number) => ( {data?.items.length > 0 ? (
<Table.Tr key={index}> data?.items.map((user: IUser, index: number) => (
<Table.Td> <Table.Tr key={index}>
<Group gap="sm" wrap="nowrap"> <Table.Td>
<CustomAvatar avatarUrl={user.avatarUrl} name={user.name} /> <Group gap="sm" wrap="nowrap">
<div> <CustomAvatar
<Text fz="sm" fw={500} lineClamp={1}> avatarUrl={user.avatarUrl}
{user.name} name={user.name}
</Text> />
<Text fz="xs" c="dimmed"> <div>
{user.email} <Text fz="sm" fw={500} lineClamp={1}>
</Text> {user.name}
</div> </Text>
</Group> <Text fz="xs" c="dimmed">
</Table.Td> {user.email}
<Table.Td> </Text>
<Badge variant="light">{t("Active")}</Badge> </div>
</Table.Td> </Group>
<Table.Td> </Table.Td>
{isAdmin && ( <Table.Td>
<Menu <Badge variant="light">{t("Active")}</Badge>
shadow="xl" </Table.Td>
position="bottom-end" <Table.Td>
offset={20} {isAdmin && (
width={200} <Menu
withArrow shadow="xl"
arrowPosition="center" position="bottom-end"
> offset={20}
<Menu.Target> width={200}
<ActionIcon withArrow
variant="subtle" arrowPosition="center"
c="gray" >
aria-label={t("Member actions for {{name}}", { <Menu.Target>
name: user.name, <ActionIcon
})} variant="subtle"
> c="gray"
<IconDots size={20} stroke={2} /> aria-label={t("Member actions for {{name}}", {
</ActionIcon> name: user.name,
</Menu.Target> })}
<Menu.Dropdown> >
<Menu.Item onClick={() => openRemoveModal(user.id)}> <IconDots size={20} stroke={2} />
{t("Remove group member")} </ActionIcon>
</Menu.Item> </Menu.Target>
</Menu.Dropdown> <Menu.Dropdown>
</Menu> <Menu.Item onClick={() => openRemoveModal(user.id)}>
)} {t("Remove group member")}
</Table.Td> </Menu.Item>
</Table.Tr> </Menu.Dropdown>
))} </Menu>
)}
</Table.Td>
</Table.Tr>
))
) : (
<NoTableResults colSpan={3} />
)}
</Table.Tbody> </Table.Tbody>
</Table> </Table>
</Table.ScrollContainer> </Table.ScrollContainer>
@@ -1,93 +0,0 @@
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>
);
}
@@ -1,30 +0,0 @@
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} />;
}
@@ -1,66 +0,0 @@
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>
);
}
@@ -1,93 +0,0 @@
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>
);
}
@@ -1,134 +0,0 @@
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>
)}
</>
);
}
@@ -1,126 +0,0 @@
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>
)}
</>
);
}
@@ -1,112 +0,0 @@
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>
);
}
@@ -1,100 +0,0 @@
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",
});
},
});
}
@@ -1,92 +0,0 @@
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;
}
@@ -1,23 +0,0 @@
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 });
}
@@ -1,69 +0,0 @@
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;
};
@@ -63,6 +63,12 @@ export function NotificationItem({
return "Page verification expires soon"; return "Page verification expires soon";
case "page.verification_expired": case "page.verification_expired":
return "Page verification has expired"; return "Page verification has expired";
case "siem_destination.failing":
return "SIEM destination <bold>{{name}}</bold> is failing";
case "siem_destination.disabled":
return "SIEM destination <bold>{{name}}</bold> was disabled after 24 hours of failures";
case "siem_destination.recovered":
return "SIEM destination <bold>{{name}}</bold> recovered";
default: default:
return ""; return "";
} }
@@ -77,6 +83,19 @@ export function NotificationItem({
) )
: undefined; : undefined;
const isSiemDestination = notification.type.startsWith("siem_destination.");
const destinationName =
typeof notification.data?.destinationName === "string"
? notification.data.destinationName
: "";
const lastError =
(notification.type === "siem_destination.failing" ||
notification.type === "siem_destination.disabled") &&
typeof notification.data?.lastError === "string"
? notification.data.lastError
: null;
const linkUrl = isSiemDestination ? "/settings/audit/siem" : pageUrl;
const markReadIfNeeded = () => { const markReadIfNeeded = () => {
if (isUnread) { if (isUnread) {
markRead.mutate([notification.id]); markRead.mutate([notification.id]);
@@ -97,7 +116,7 @@ export function NotificationItem({
return ( return (
<UnstyledButton <UnstyledButton
component={Link} component={Link}
to={pageUrl ?? ""} to={linkUrl ?? ""}
onClick={handleClick} onClick={handleClick}
// auxclick fires for all non-primary buttons; guard to middle-click only (button 1) // auxclick fires for all non-primary buttons; guard to middle-click only (button 1)
// so that right-click (button 2, context menu) does not mark as read // so that right-click (button 2, context menu) does not mark as read
@@ -124,11 +143,21 @@ export function NotificationItem({
<Text size="sm" lineClamp={2}> <Text size="sm" lineClamp={2}>
<Trans <Trans
i18nKey={getNotificationMessageKey()} i18nKey={getNotificationMessageKey()}
values={{ name: notification.actor?.name }} values={{
name: isSiemDestination
? destinationName
: notification.actor?.name,
}}
components={{ bold: <Text span fw={600} /> }} components={{ bold: <Text span fw={600} /> }}
/> />
</Text> </Text>
{lastError && (
<Text size="xs" c="dimmed" lineClamp={1} mt={2}>
{lastError}
</Text>
)}
{notification.page && ( {notification.page && (
<Group gap={4} mt={2} wrap="nowrap"> <Group gap={4} mt={2} wrap="nowrap">
{notification.page.icon ? ( {notification.page.icon ? (
@@ -9,7 +9,10 @@ export type NotificationType =
| "page.verification_expired" | "page.verification_expired"
| "page.verified" | "page.verified"
| "page.approval_requested" | "page.approval_requested"
| "page.approval_rejected"; | "page.approval_rejected"
| "siem_destination.failing"
| "siem_destination.disabled"
| "siem_destination.recovered";
export type INotification = { export type INotification = {
id: string; id: string;
@@ -1,16 +1,63 @@
import classes from "./page-header.module.css"; import classes from "./page-header.module.css";
import PageHeaderMenu from "@/features/page/components/header/page-header-menu.tsx"; import PageHeaderMenu from "@/features/page/components/header/page-header-menu.tsx";
import { Group } from "@mantine/core"; import { Badge, Group, Tooltip } from "@mantine/core";
import { IconExternalLink, IconWorld } from "@tabler/icons-react";
import Breadcrumb from "@/features/page/components/breadcrumbs/breadcrumb.tsx"; import Breadcrumb from "@/features/page/components/breadcrumbs/breadcrumb.tsx";
import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { extractPageSlugId } from "@/lib";
import { buildPublicSpaceUrl } from "@/features/page/page.utils.ts";
import { isBetaPublicSpaces } from "@/lib/config.ts";
interface Props { interface Props {
readOnly?: boolean; readOnly?: boolean;
} }
export default function PageHeader({ readOnly }: Props) { export default function PageHeader({ readOnly }: Props) {
const { t } = useTranslation();
const { spaceSlug, pageSlug } = useParams();
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
const { data: page } = usePageQuery({
pageId: extractPageSlugId(pageSlug),
});
// Restricted pages are never publicly reachable, so the chip only shows on
// pages the public site actually serves.
const showPublicBadge =
isBetaPublicSpaces() &&
space?.isPublished &&
page &&
page.permissions?.hasRestriction !== true;
return ( return (
<div className={classes.header} data-page-header="true"> <div className={classes.header} data-page-header="true">
<Group justify="space-between" h="100%" px="md" wrap="nowrap" className={classes.group}> <Group justify="space-between" h="100%" px="md" wrap="nowrap" className={classes.group}>
<Breadcrumb /> <Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
<Breadcrumb />
{showPublicBadge && (
<Tooltip label={t("Open public page")} openDelay={250} withArrow>
<Badge
component="a"
href={buildPublicSpaceUrl({
spaceSlug: space.slug,
pageSlugId: page.slugId,
pageTitle: page.title,
})}
target="_blank"
rel="noopener"
size="sm"
variant="light"
leftSection={<IconWorld size={12} />}
rightSection={<IconExternalLink size={11} />}
style={{ flexShrink: 0, cursor: "pointer" }}
>
{t("Public")}
</Badge>
</Tooltip>
)}
</Group>
<Group justify="flex-end" h="100%" px="md" wrap="nowrap" gap="var(--mantine-spacing-xs)"> <Group justify="flex-end" h="100%" px="md" wrap="nowrap" gap="var(--mantine-spacing-xs)">
<PageHeaderMenu readOnly={readOnly} /> <PageHeaderMenu readOnly={readOnly} />
@@ -55,3 +55,16 @@ export const buildSharedPageUrl = (opts: {
} }
return anchorId ? `${url}#${anchorId}` : url; return anchorId ? `${url}#${anchorId}` : url;
}; };
export const buildPublicSpaceUrl = (opts: {
spaceSlug: string;
pageSlugId?: string;
pageTitle?: string;
anchorId?: string;
}): string => {
const { spaceSlug, pageSlugId, pageTitle, anchorId } = opts;
const url = pageSlugId
? `/docs/${spaceSlug}/${buildPageSlug(pageSlugId, pageTitle)}`
: `/docs/${spaceSlug}`;
return anchorId ? `${url}#${anchorId}` : url;
};
@@ -41,6 +41,7 @@ type Props<T extends object> = {
activeId?: string; activeId?: string;
renderRow: (props: RenderRowProps<T>) => ReactNode; renderRow: (props: RenderRowProps<T>) => ReactNode;
indentPerLevel: number; indentPerLevel: number;
rowClassName?: string;
onMove: (sourceId: string, op: DropOp) => void | Promise<void>; onMove: (sourceId: string, op: DropOp) => void | Promise<void>;
onToggle: (id: string, isOpen: boolean) => void; onToggle: (id: string, isOpen: boolean) => void;
readOnly: boolean; readOnly: boolean;
@@ -68,6 +69,7 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
activeId, activeId,
renderRow, renderRow,
indentPerLevel, indentPerLevel,
rowClassName,
onMove, onMove,
onToggle, onToggle,
readOnly, readOnly,
@@ -323,7 +325,7 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
style={{ paddingLeft: level * indentPerLevel }} style={{ paddingLeft: level * indentPerLevel }}
> >
<div <div
className={styles.node} className={rowClassName ? `${styles.node} ${rowClassName}` : styles.node}
data-dragging={isDragging || undefined} data-dragging={isDragging || undefined}
data-selected={isSelected || undefined} data-selected={isSelected || undefined}
data-receiving-drop={ data-receiving-drop={
@@ -376,6 +378,7 @@ function arePropsEqual<T extends object>(
if (prev.readOnly !== next.readOnly) return false; if (prev.readOnly !== next.readOnly) return false;
if (prev.contextId !== next.contextId) return false; if (prev.contextId !== next.contextId) return false;
if (prev.indentPerLevel !== next.indentPerLevel) return false; if (prev.indentPerLevel !== next.indentPerLevel) return false;
if (prev.rowClassName !== next.rowClassName) return false;
if (prev.renderRow !== next.renderRow) return false; if (prev.renderRow !== next.renderRow) return false;
if (prev.onMove !== next.onMove) return false; if (prev.onMove !== next.onMove) return false;
if (prev.onToggle !== next.onToggle) return false; if (prev.onToggle !== next.onToggle) return false;
@@ -56,6 +56,18 @@ export type DocTreeProps<T extends object> = {
indentPerLevel?: number; indentPerLevel?: number;
rowHeight?: number; rowHeight?: number;
emptyState?: ReactNode; emptyState?: ReactNode;
// Extra class for the engine's row wrapper (the div carrying data-selected /
// data-dragging), letting consumers restyle hover/selected states.
rowClassName?: string;
// Extra vertical space above a row (e.g. to separate top-level groups).
// Added to the row's virtualized slot and applied as padding above it.
rowGap?: (node: TreeNode<T>, level: number, index: number) => number;
// Measure real row heights so rows may wrap to multiple lines; rowHeight
// then only seeds the estimate. Off by default (fixed-slot fast path).
dynamicRowHeight?: boolean;
// Rendered inside the scroll container after the last row, so it flows
// with the tree content instead of pinning to the container edge.
footer?: ReactNode;
onMove: (sourceId: string, op: DropOp) => void | Promise<void>; onMove: (sourceId: string, op: DropOp) => void | Promise<void>;
onToggle: (id: string, isOpen: boolean) => void; onToggle: (id: string, isOpen: boolean) => void;
@@ -123,6 +135,9 @@ function DocTreeInner<T extends object>(
renderRow, renderRow,
indentPerLevel = 16, indentPerLevel = 16,
rowHeight = 32, rowHeight = 32,
rowClassName,
rowGap,
dynamicRowHeight = false,
onMove, onMove,
onToggle, onToggle,
onSelect, onSelect,
@@ -132,6 +147,7 @@ function DocTreeInner<T extends object>(
getDragLabel, getDragLabel,
uniqueContextId, uniqueContextId,
emptyState, emptyState,
footer,
'aria-label': ariaLabel, 'aria-label': ariaLabel,
} = props; } = props;
@@ -197,10 +213,20 @@ function DocTreeInner<T extends object>(
return flat[0]?.node.id; return flat[0]?.node.id;
}, [activeId, selectedId, flatIds, flat]); }, [activeId, selectedId, flatIds, flat]);
// Keyed by node id so measured sizes follow their rows across
// expand/collapse instead of sticking to positions. Never reset the
// measurement cache wholesale: ResizeObserver only re-reports elements
// whose size changed, so unchanged mounted rows would be left positioned
// by the estimate and overlap their neighbors.
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
count: flat.length, count: flat.length,
getScrollElement: () => scrollRef.current, getScrollElement: () => scrollRef.current,
estimateSize: () => rowHeight, getItemKey: (index) => flat[index].node.id,
estimateSize: (index) => {
const row = flat[index];
const gap = row && rowGap ? rowGap(row.node, row.level, index) : 0;
return rowHeight + gap;
},
overscan: 10, overscan: 10,
}); });
@@ -471,7 +497,12 @@ function DocTreeInner<T extends object>(
); );
if (data.length === 0 && emptyState) { if (data.length === 0 && emptyState) {
return <div className={styles.treeContainer}>{emptyState}</div>; return (
<div className={styles.treeContainer}>
{emptyState}
{footer}
</div>
);
} }
const virtualItems = virtualizer.getVirtualItems(); const virtualItems = virtualizer.getVirtualItems();
@@ -494,6 +525,9 @@ function DocTreeInner<T extends object>(
> >
{virtualItems.map((virtualItem) => { {virtualItems.map((virtualItem) => {
const row = flat[virtualItem.index]; const row = flat[virtualItem.index];
const gap = rowGap
? rowGap(row.node, row.level, virtualItem.index)
: 0;
return ( return (
<li <li
key={row.node.id} key={row.node.id}
@@ -501,12 +535,15 @@ function DocTreeInner<T extends object>(
// (the row's <a>), so screen readers announce "treeitem" on // (the row's <a>), so screen readers announce "treeitem" on
// navigation. The <li> is just layout glue. // navigation. The <li> is just layout glue.
role="none" role="none"
ref={dynamicRowHeight ? virtualizer.measureElement : undefined}
data-index={dynamicRowHeight ? virtualItem.index : undefined}
style={{ style={{
position: 'absolute', position: 'absolute',
top: 0, top: 0,
left: 0, left: 0,
width: '100%', width: '100%',
transform: `translateY(${virtualItem.start}px)`, transform: `translateY(${virtualItem.start}px)`,
...(gap > 0 ? { paddingTop: gap } : {}),
}} }}
> >
<DocTreeRow <DocTreeRow
@@ -518,6 +555,7 @@ function DocTreeInner<T extends object>(
activeId={effectiveActiveId} activeId={effectiveActiveId}
renderRow={renderRow} renderRow={renderRow}
indentPerLevel={indentPerLevel} indentPerLevel={indentPerLevel}
rowClassName={rowClassName}
onMove={onMove} onMove={onMove}
onToggle={onToggle} onToggle={onToggle}
readOnly={readOnly} readOnly={readOnly}
@@ -532,6 +570,7 @@ function DocTreeInner<T extends object>(
); );
})} })}
</ul> </ul>
{footer}
</div> </div>
); );
} }
@@ -0,0 +1,17 @@
import { atom } from "jotai";
import { IPublicSpaceTree } from "@/features/public-space/types/public-space.types.ts";
import { SharedPageTreeNode } from "@/features/share/utils.ts";
export const publicSpaceTreeAtom = atom<IPublicSpaceTree | null>(
null as IPublicSpaceTree | null,
);
export const publicSpaceTreeDataAtom = atom<SharedPageTreeNode[] | null>(
null as SharedPageTreeNode[] | null,
);
export const openPublicSpaceTreeNodesAtom = atom<Record<string, boolean>>({});
export const docsMobileSidebarAtom = atom<boolean>(false);
export const docsMobileTocAtom = atom<boolean>(false);
@@ -0,0 +1,51 @@
.presetRow {
display: flex;
flex-wrap: wrap;
gap: rem(8px);
}
.presetCard {
display: flex;
flex-direction: column;
align-items: center;
gap: rem(6px);
min-width: rem(72px);
padding: rem(10px) rem(12px);
border: 1px solid var(--mantine-color-default-border);
border-radius: rem(8px);
@media (hover: hover) {
&:hover {
background-color: var(--mantine-color-default-hover);
}
}
&:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 1px;
}
}
.presetCard[data-selected="true"] {
border-color: var(--mantine-primary-color-filled);
background-color: var(--mantine-primary-color-light);
}
.presetSwatch {
width: rem(22px);
height: rem(22px);
border-radius: 50%;
border: 1px solid var(--mantine-color-default-border);
flex-shrink: 0;
}
.customSwatch {
display: inline-flex;
align-items: center;
justify-content: center;
width: rem(22px);
height: rem(22px);
border-radius: 50%;
border: 1px dashed var(--mantine-color-default-border);
color: var(--mantine-color-dimmed);
}
@@ -0,0 +1,165 @@
import {
ColorInput,
Group,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { IconColorPicker } from "@tabler/icons-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
DEFAULT_DOCS_PRESET,
DOCS_THEME_PRESETS,
isValidDocsColor,
matchDocsPreset,
} from "@/features/public-space/theme/docs-theme.ts";
import { IPublicSpaceAppearance } from "@/features/public-space/types/public-space.types.ts";
import { usePublishSpaceMutation } from "@/features/public-space/queries/public-space-query.ts";
import { useHasFeature } from "@/ee/hooks/use-feature.ts";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
import { Feature } from "@/ee/features.ts";
import classes from "./appearance-settings.module.css";
type AppearanceSettingsProps = {
spaceId: string;
appearance?: IPublicSpaceAppearance;
};
export default function AppearanceSettings({
spaceId,
appearance,
}: AppearanceSettingsProps) {
const { t } = useTranslation();
const publishMutation = usePublishSpaceMutation();
const hasAppearance = useHasFeature(Feature.PUBLIC_SPACE_APPEARANCE);
const upgradeLabel = useUpgradeLabel();
const matchedPreset = matchDocsPreset(appearance);
const [customOpen, setCustomOpen] = useState(matchedPreset === null);
const [customLight, setCustomLight] = useState(
appearance?.primaryColorLight ?? DEFAULT_DOCS_PRESET.light,
);
const [customDark, setCustomDark] = useState(
appearance?.primaryColorDark ?? DEFAULT_DOCS_PRESET.dark,
);
useEffect(() => {
setCustomOpen(matchDocsPreset(appearance) === null);
setCustomLight(appearance?.primaryColorLight ?? DEFAULT_DOCS_PRESET.light);
setCustomDark(appearance?.primaryColorDark ?? DEFAULT_DOCS_PRESET.dark);
}, [appearance?.primaryColorLight, appearance?.primaryColorDark]);
const saveAppearance = (payload: {
primaryColorLight: string | null;
primaryColorDark: string | null;
}) => {
if (!hasAppearance) return;
publishMutation.mutate({ spaceId, enabled: true, appearance: payload });
};
const selectPreset = (presetId: string) => {
setCustomOpen(false);
const preset = DOCS_THEME_PRESETS.find((item) => item.id === presetId);
if (!preset) return;
if (preset.id === DEFAULT_DOCS_PRESET.id) {
saveAppearance({ primaryColorLight: null, primaryColorDark: null });
return;
}
saveAppearance({
primaryColorLight: preset.light,
primaryColorDark: preset.dark,
});
};
const commitCustom = (light: string, dark: string) => {
if (!isValidDocsColor(light) || !isValidDocsColor(dark)) return;
saveAppearance({ primaryColorLight: light, primaryColorDark: dark });
};
const swatches = DOCS_THEME_PRESETS.flatMap((preset) => [
preset.light,
preset.dark,
]);
return (
<div>
<Text size="md" mt="md">
{t("Appearance")}
</Text>
<Text size="sm" c="dimmed">
{t("Choose the primary color of the public docs site.")}
</Text>
<Tooltip
label={upgradeLabel}
disabled={hasAppearance}
position="top-start"
>
<div className={classes.presetRow} style={{ marginTop: 10 }}>
{DOCS_THEME_PRESETS.map((preset) => {
const selected = !customOpen && matchedPreset?.id === preset.id;
return (
<UnstyledButton
key={preset.id}
className={classes.presetCard}
data-selected={selected || undefined}
aria-pressed={selected}
disabled={!hasAppearance}
onClick={() => selectPreset(preset.id)}
>
<span
className={classes.presetSwatch}
style={{
background: `linear-gradient(135deg, ${preset.light} 50%, ${preset.dark} 50%)`,
}}
/>
<Text size="xs">{t(preset.nameKey)}</Text>
</UnstyledButton>
);
})}
<UnstyledButton
className={classes.presetCard}
data-selected={customOpen || undefined}
aria-pressed={customOpen}
disabled={!hasAppearance}
onClick={() => setCustomOpen(true)}
>
<span className={classes.customSwatch}>
<IconColorPicker size={13} stroke={2} aria-hidden />
</span>
<Text size="xs">{t("Custom")}</Text>
</UnstyledButton>
</div>
</Tooltip>
{customOpen && hasAppearance && (
<Group grow mt="sm" align="flex-start">
<ColorInput
label={t("Light mode color")}
format="hex"
value={customLight}
swatches={swatches}
onChange={setCustomLight}
onChangeEnd={(value) => {
setCustomLight(value);
commitCustom(value, customDark);
}}
/>
<ColorInput
label={t("Dark mode color")}
format="hex"
value={customDark}
swatches={swatches}
onChange={setCustomDark}
onChangeEnd={(value) => {
setCustomDark(value);
commitCustom(customLight, value);
}}
/>
</Group>
)}
</div>
);
}
@@ -0,0 +1,114 @@
import { Menu } from "@mantine/core";
import { useMediaQuery } from "@mantine/hooks";
import { Link, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Fragment, useMemo, type ReactNode } from "react";
import { useDocsSurface } from "@/features/public-space/components/docs/docs-surface-context.tsx";
import { findAncestorTrail } from "@/features/public-space/utils/docs-tree.ts";
import { extractPageSlugId } from "@/lib";
import styles from "./docs.module.css";
type Crumb = {
key: string;
name: string;
url: string;
};
export default function DocsBreadcrumbs() {
const { t } = useTranslation();
const { pageSlug } = useParams();
const { treeData, siteName, homeUrl, getNodeUrl } = useDocsSurface();
const isMobile = useMediaQuery("(max-width: 48em)");
const crumbs = useMemo<Crumb[] | null>(() => {
if (!treeData?.length) return null;
const currentSlugId = pageSlug
? extractPageSlugId(pageSlug)
: treeData[0]?.slugId;
if (!currentSlugId) return null;
const trail = findAncestorTrail(treeData, currentSlugId);
if (trail === null) return null;
const siteCrumbs: Crumb[] =
siteName && homeUrl
? [{ key: "site", name: siteName, url: homeUrl }]
: [];
const list = [
...siteCrumbs,
...trail.map((node) => ({
key: node.slugId,
name: node.name || t("untitled"),
url: getNodeUrl(node),
})),
];
return list.length ? list : null;
}, [treeData, siteName, homeUrl, getNodeUrl, pageSlug, t]);
if (!crumbs) return null;
// Mobile keeps a single line (menu + last crumb, like the app header's
// breadcrumb); desktop collapses the middle beyond 4 crumbs.
const collapsed = crumbs.length > (isMobile ? 1 : 4);
const hidden = !collapsed
? []
: isMobile
? crumbs.slice(0, crumbs.length - 1)
: crumbs.slice(1, crumbs.length - 1);
const items: ReactNode[] = [];
if (collapsed && !isMobile) {
items.push(
<Link key={crumbs[0].key} to={crumbs[0].url} className={styles.crumbLink}>
{crumbs[0].name}
</Link>,
);
}
if (collapsed) {
items.push(
<Menu shadow="md" position="bottom-start" key="hidden">
<Menu.Target>
<button
type="button"
className={styles.crumbEllipsis}
aria-label={t("Show hidden pages")}
>
</button>
</Menu.Target>
<Menu.Dropdown>
{hidden.map((item) => (
<Menu.Item key={item.key} component={Link} to={item.url}>
{item.name}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>,
);
}
const trailing = collapsed ? [crumbs[crumbs.length - 1]] : crumbs;
for (const crumb of trailing) {
items.push(
<Link key={crumb.key} to={crumb.url} className={styles.crumbLink}>
{crumb.name}
</Link>,
);
}
return (
<nav className={styles.breadcrumbs} aria-label={t("Breadcrumb")}>
{items.map((item, index) => (
<Fragment key={index}>
{index > 0 && (
<span className={styles.crumbSeparator} aria-hidden>
/
</span>
)}
{item}
</Fragment>
))}
</nav>
);
}
@@ -0,0 +1,45 @@
import { Button } from "@mantine/core";
import { useAtomValue } from "jotai";
import { useTranslation } from "react-i18next";
import { IconCheck, IconCopy } from "@tabler/icons-react";
import { htmlToMarkdown } from "@docmost/editor-ext";
import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import { useDocsCurrentPage } from "@/features/public-space/hooks/use-docs-current-page.ts";
import { useClipboard } from "@/hooks/use-clipboard";
import styles from "./docs.module.css";
export default function DocsCopyPage() {
const { t } = useTranslation();
const editor = useAtomValue(readOnlyEditorAtom);
const page = useDocsCurrentPage();
const clipboard = useClipboard();
if (!editor) {
return null;
}
const handleCopy = () => {
if (editor.isDestroyed) return;
const markdown = htmlToMarkdown(editor.getHTML());
const title = page?.name ? `# ${page.name}\n\n` : "";
clipboard.copy(`${title}${markdown}`);
};
return (
<Button
variant="default"
size="compact-sm"
className={styles.copyPageButton}
onClick={handleCopy}
leftSection={
clipboard.copied ? (
<IconCheck size={14} stroke={1.8} />
) : (
<IconCopy size={14} stroke={1.8} />
)
}
>
{clipboard.copied ? t("Copied") : t("Copy page")}
</Button>
);
}
@@ -0,0 +1,31 @@
import { Link, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { IconPencil } from "@tabler/icons-react";
import { useAuthenticatedUser } from "@/features/public-space/hooks/use-authenticated-user.ts";
import { useDocsCurrentPage } from "@/features/public-space/hooks/use-docs-current-page.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import styles from "./docs.module.css";
export default function DocsEditPage() {
const { t } = useTranslation();
const { spaceSlug } = useParams();
const page = useDocsCurrentPage();
const { data: currentUser } = useAuthenticatedUser();
if (!currentUser?.user || !page) {
return null;
}
return (
<Link
className={styles.editPageLink}
to={buildPageUrl(spaceSlug, page.slugId, page.name)}
target="_blank"
rel="noopener"
>
<IconPencil size={14} stroke={1.8} aria-hidden />
{t("Edit page")}
</Link>
);
}
@@ -0,0 +1,23 @@
import clsx from "clsx";
import styles from "./docs.module.css";
export default function DocsFooterBranding({
className,
refSource = "public-space",
}: {
className?: string;
refSource?: string;
}) {
return (
<footer className={clsx(styles.footer, className)}>
<a
className={styles.footerBranding}
href={`https://docmost.com?ref=${refSource}`}
target="_blank"
rel="noreferrer"
>
Powered by Docmost
</a>
</footer>
);
}
@@ -0,0 +1,485 @@
/* Design tokens for the public docs hub, transcribed from the "1a solid brand
* band" direction of the Directory design spec (a fixed light look). Declared
* on :root like --docs-* so cover/brand customization can override them at
* runtime. */
:root {
--docs-hub-max: 80rem;
--docs-hub-bg: #ffffff;
--docs-hub-fg: #111827;
--docs-hub-muted: #5b6270;
--docs-hub-nav-fg: #4b5563;
--docs-hub-footer-fg: #6b7280;
--docs-hub-border: #eceef2;
--docs-hub-brand: #12275c;
--docs-hub-brand-fg: #ffffff;
--docs-hub-hero-bg: var(--docs-hub-brand);
--docs-hub-hero-fg: #ffffff;
--docs-hub-hero-muted: rgba(255, 255, 255, 0.78);
--docs-hub-search-bg: #ffffff;
--docs-hub-search-fg: #111827;
--docs-hub-search-placeholder: #8a919e;
--docs-hub-search-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
--docs-hub-card-bg: #ffffff;
--docs-hub-card-border: #e6e7ea;
--docs-hub-card-radius: 14px;
--docs-hub-card-shadow: 0 4px 16px rgba(15, 23, 42, 0.06);
--docs-hub-card-border-hover: #c4c8d0;
--docs-hub-card-shadow-hover: 0 12px 32px rgba(15, 23, 42, 0.14);
--docs-hub-tile-fg: #ffffff;
}
.root {
min-height: 100dvh;
background-color: var(--docs-hub-bg);
color: var(--docs-hub-fg);
}
.container {
max-width: var(--docs-hub-max);
margin-inline: auto;
}
/* ---------- Top bar ---------- */
.topBar {
border-bottom: 1px solid var(--docs-hub-border);
padding: 0 rem(48px);
@media (max-width: $mantine-breakpoint-sm) {
padding: 0 rem(20px);
}
}
.topBarInner {
height: rem(64px);
display: flex;
align-items: center;
justify-content: space-between;
@media (max-width: $mantine-breakpoint-sm) {
height: rem(56px);
}
}
.brand {
display: flex;
align-items: center;
gap: rem(10px);
min-width: 0;
color: var(--docs-hub-fg);
text-decoration: none;
font-size: rem(16px);
font-weight: 600;
border-radius: rem(8px);
&:focus-visible {
outline: 2px solid var(--docs-hub-brand);
outline-offset: 4px;
}
@media (max-width: $mantine-breakpoint-sm) {
gap: rem(8px);
font-size: rem(15px);
}
}
.brandTile {
width: rem(28px);
height: rem(28px);
border-radius: rem(8px);
background-color: var(--docs-hub-brand);
color: var(--docs-hub-brand-fg);
display: grid;
place-items: center;
flex-shrink: 0;
font-weight: 700;
font-size: rem(13px);
@media (max-width: $mantine-breakpoint-sm) {
width: rem(26px);
height: rem(26px);
border-radius: rem(7px);
font-size: rem(12px);
}
}
.topActions {
display: flex;
align-items: center;
gap: rem(24px);
font-size: rem(14px);
color: var(--docs-hub-nav-fg);
}
.signIn {
display: inline-block;
padding: rem(8px) rem(14px);
border-radius: rem(8px);
background-color: var(--docs-hub-brand);
color: var(--docs-hub-brand-fg);
text-decoration: none;
font-weight: 500;
white-space: nowrap;
@media (hover: hover) {
&:hover {
filter: brightness(1.15);
}
}
&:focus-visible {
outline: 2px solid var(--docs-hub-brand);
outline-offset: 2px;
}
@media (max-width: $mantine-breakpoint-sm) {
padding: rem(8px) rem(12px);
font-size: rem(13px);
}
}
/* ---------- Hero band ---------- */
.hero {
background-color: var(--docs-hub-hero-bg);
color: var(--docs-hub-hero-fg);
padding: rem(72px) rem(48px) rem(80px);
@media (max-width: $mantine-breakpoint-sm) {
padding: rem(40px) rem(20px) rem(44px);
}
}
.heroInner {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: rem(16px);
@media (max-width: $mantine-breakpoint-sm) {
gap: rem(10px);
}
}
.heading {
margin: 0;
color: var(--docs-hub-hero-fg);
font-size: rem(48px);
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.15;
@media (max-width: $mantine-breakpoint-sm) {
font-size: rem(30px);
}
}
.subtitle {
margin: 0;
color: var(--docs-hub-hero-muted);
font-size: rem(18px);
@media (max-width: $mantine-breakpoint-sm) {
font-size: rem(15px);
line-height: 1.45;
}
}
.search {
margin-top: rem(16px);
width: rem(640px);
max-width: 100%;
height: rem(56px);
display: flex;
align-items: center;
padding: 0 rem(6px) 0 rem(22px);
border-radius: 999px;
background-color: var(--docs-hub-search-bg);
box-shadow: var(--docs-hub-search-shadow);
&:focus-within {
outline: 2px solid var(--docs-hub-hero-fg);
outline-offset: 3px;
}
@media (max-width: $mantine-breakpoint-sm) {
margin-top: rem(10px);
width: 100%;
height: rem(48px);
padding: 0 rem(5px) 0 rem(18px);
box-shadow: none;
}
}
.searchInput {
flex: 1;
min-width: 0;
height: 100%;
border: 0;
background: transparent;
font: inherit;
font-size: rem(16px);
color: var(--docs-hub-search-fg);
text-align: left;
outline: none;
&::placeholder {
color: var(--docs-hub-search-placeholder);
}
@media (max-width: $mantine-breakpoint-sm) {
font-size: rem(15px);
}
}
.searchButton {
width: rem(44px);
height: rem(44px);
flex-shrink: 0;
border: 0;
border-radius: 999px;
background-color: var(--docs-hub-brand);
color: var(--docs-hub-brand-fg);
display: grid;
place-items: center;
cursor: pointer;
&:focus-visible {
outline: 2px solid var(--docs-hub-search-fg);
outline-offset: 2px;
}
@media (max-width: $mantine-breakpoint-sm) {
width: rem(38px);
height: rem(38px);
}
}
/* ---------- Spaces ---------- */
.main {
padding: rem(48px) rem(48px) rem(56px);
@media (max-width: $mantine-breakpoint-sm) {
padding: rem(24px) rem(20px) rem(28px);
}
}
.mainInner {
display: flex;
flex-direction: column;
gap: rem(24px);
@media (max-width: $mantine-breakpoint-sm) {
gap: rem(16px);
}
}
.sectionHeader {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: rem(16px);
border-bottom: 1px solid var(--docs-hub-border);
padding-bottom: rem(14px);
@media (max-width: $mantine-breakpoint-sm) {
padding-bottom: rem(10px);
}
}
.sectionTitle {
margin: 0;
font-size: rem(13px);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--docs-hub-fg);
@media (max-width: $mantine-breakpoint-sm) {
font-size: rem(12px);
}
}
.sectionCount {
font-size: rem(14px);
color: var(--docs-hub-footer-fg);
@media (max-width: $mantine-breakpoint-sm) {
font-size: rem(13px);
}
}
/* Phones flow one card per row; wider viewports fit two, then 3/4 columns. */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(rem(220px), 1fr));
gap: rem(12px);
@media (min-width: $mantine-breakpoint-sm) {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: rem(20px);
}
@media (min-width: 64em) {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.card {
display: flex;
flex-direction: column;
gap: rem(14px);
padding: rem(24px) rem(24px) rem(26px);
background-color: var(--docs-hub-card-bg);
border: 1px solid var(--docs-hub-card-border);
border-radius: var(--docs-hub-card-radius);
box-shadow: var(--docs-hub-card-shadow);
color: inherit;
text-decoration: none;
transition:
border-color 120ms ease,
box-shadow 160ms ease;
@media (hover: hover) {
&:hover {
border-color: var(--docs-hub-card-border-hover);
box-shadow: var(--docs-hub-card-shadow-hover);
}
}
&:focus-visible {
outline: 2px solid var(--docs-hub-brand);
outline-offset: 2px;
}
@media (max-width: $mantine-breakpoint-sm) {
gap: rem(12px);
padding: rem(18px) rem(18px) rem(20px);
}
}
/* Tile and title share a row so titles get the card's full width. */
.cardHeader {
display: flex;
align-items: center;
gap: rem(14px);
min-width: 0;
@media (max-width: $mantine-breakpoint-sm) {
gap: rem(12px);
}
}
.cardTile {
width: rem(40px);
height: rem(40px);
flex-shrink: 0;
border-radius: rem(10px);
display: grid;
place-items: center;
overflow: hidden;
color: var(--docs-hub-tile-fg);
font-weight: 700;
font-size: rem(15px);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
@media (max-width: $mantine-breakpoint-sm) {
width: rem(36px);
height: rem(36px);
border-radius: rem(9px);
font-size: rem(14px);
}
}
.cardName {
min-width: 0;
font-size: rem(18px);
font-weight: 600;
line-height: 1.3;
color: var(--docs-hub-fg);
text-wrap: balance;
@media (max-width: $mantine-breakpoint-sm) {
font-size: rem(16px);
}
}
.cardDescription {
font-size: rem(15px);
line-height: 1.5;
color: var(--docs-hub-muted);
text-wrap: pretty;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
@media (max-width: $mantine-breakpoint-sm) {
font-size: rem(14px);
}
}
.empty {
margin: 0;
padding: rem(48px) 0;
text-align: center;
color: var(--docs-hub-muted);
}
/* ---------- Footer ---------- */
.footer {
border-top: 1px solid var(--docs-hub-border);
padding: rem(24px) rem(48px);
font-size: rem(14px);
color: var(--docs-hub-footer-fg);
@media (max-width: $mantine-breakpoint-sm) {
padding: rem(20px);
font-size: rem(13px);
}
}
.footerInner {
display: flex;
align-items: center;
justify-content: flex-end;
@media (max-width: $mantine-breakpoint-sm) {
flex-direction: column;
align-items: center;
justify-content: center;
gap: rem(12px);
}
}
.footerBranding {
color: var(--docs-hub-fg);
font-weight: 600;
text-decoration: none;
@media (hover: hover) {
&:hover {
color: var(--docs-hub-brand);
}
}
&:focus-visible {
outline: 2px solid var(--docs-hub-brand);
outline-offset: 2px;
border-radius: rem(2px);
}
}
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
}
@@ -0,0 +1,70 @@
import { useMemo } from "react";
import { Link, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { IconArrowLeft, IconArrowRight } from "@tabler/icons-react";
import { useDocsSurface } from "@/features/public-space/components/docs/docs-surface-context.tsx";
import { flattenTreePreorder } from "@/features/public-space/utils/docs-tree.ts";
import { extractPageSlugId } from "@/lib";
import { SharedPageTreeNode } from "@/features/share/utils.ts";
import styles from "./docs.module.css";
export default function DocsPageNav() {
const { t } = useTranslation();
const { pageSlug } = useParams();
const { treeData, getNodeUrl } = useDocsSurface();
const { prev, next } = useMemo(() => {
if (!treeData?.length) {
return {
prev: null as SharedPageTreeNode | null,
next: null as SharedPageTreeNode | null,
};
}
const flat = flattenTreePreorder(treeData);
const currentSlugId = pageSlug
? extractPageSlugId(pageSlug)
: treeData[0]?.slugId;
const index = flat.findIndex((node) => node.slugId === currentSlugId);
return {
prev: index > 0 ? flat[index - 1] : null,
next: index >= 0 && index < flat.length - 1 ? flat[index + 1] : null,
};
}, [treeData, pageSlug]);
if (!prev && !next) return null;
return (
<nav className={styles.pageNav} aria-label={t("Page navigation")}>
{prev && (
<Link
to={getNodeUrl(prev)}
className={styles.pageNavCard}
data-direction="prev"
>
<span className={styles.pageNavLabel}>
<IconArrowLeft size={13} stroke={2} aria-hidden />
{t("Previous")}
</span>
<span className={styles.pageNavTitle}>
{prev.name || t("untitled")}
</span>
</Link>
)}
{next && (
<Link
to={getNodeUrl(next)}
className={styles.pageNavCard}
data-direction="next"
>
<span className={styles.pageNavLabel}>
{t("Next")}
<IconArrowRight size={13} stroke={2} aria-hidden />
</span>
<span className={styles.pageNavTitle}>
{next.name || t("untitled")}
</span>
</Link>
)}
</nav>
);
}
@@ -0,0 +1,22 @@
import { IconSearch } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { platformModifierLabel } from "@/lib";
import styles from "./docs.module.css";
type DocsSearchButtonProps = {
onClick: () => void;
};
export default function DocsSearchButton({ onClick }: DocsSearchButtonProps) {
const { t } = useTranslation();
return (
<button type="button" className={styles.searchButton} onClick={onClick}>
<IconSearch size={15} stroke={2} aria-hidden />
<span className={styles.searchLabel}>{t("Search")}</span>
<span className={styles.searchKbd} aria-hidden>
{platformModifierLabel} K
</span>
</button>
);
}
@@ -0,0 +1,182 @@
import React from "react";
import { ActionIcon, Drawer, Tooltip } from "@mantine/core";
import { Link } from "react-router-dom";
import { useAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { IconList, IconMenu2 } from "@tabler/icons-react";
import clsx from "clsx";
import {
docsMobileSidebarAtom,
docsMobileTocAtom,
} from "@/features/public-space/atoms/public-space-atoms.ts";
import {
DocsSurface,
DocsSurfaceProvider,
} from "@/features/public-space/components/docs/docs-surface-context.tsx";
import DocsSidebarTree from "@/features/public-space/components/docs/docs-sidebar-tree.tsx";
import DocsToc from "@/features/public-space/components/docs/docs-toc.tsx";
import DocsEditPage from "@/features/public-space/components/docs/docs-edit-page.tsx";
import DocsCopyPage from "@/features/public-space/components/docs/docs-copy-page.tsx";
import DocsSearchButton from "@/features/public-space/components/docs/docs-search-button.tsx";
import DocsThemeToggle from "@/features/public-space/components/docs/docs-theme-toggle.tsx";
import DocsFooterBranding from "@/features/public-space/components/docs/docs-footer-branding.tsx";
import { MAIN_CONTENT_ID, SkipToMain } from "@/components/ui/skip-to-main.tsx";
import { SearchMobileControl } from "@/features/search/components/search-control.tsx";
import styles from "./docs.module.css";
const MemoizedDocsSidebarTree = React.memo(DocsSidebarTree);
type DocsShellProps = {
surface: DocsSurface;
onSearchOpen?: () => void;
searchSpotlight?: React.ReactNode;
children: React.ReactNode;
};
export default function DocsShell({
surface,
onSearchOpen,
searchSpotlight,
children,
}: DocsShellProps) {
const { t } = useTranslation();
const { hasSidebar, siteName, homeUrl, showBranding, showEditPage } = surface;
const [mobileSidebarOpen, setMobileSidebarOpen] = useAtom(
docsMobileSidebarAtom,
);
const [mobileTocOpen, setMobileTocOpen] = useAtom(docsMobileTocAtom);
return (
<DocsSurfaceProvider value={surface}>
<div className={clsx(styles.root, "public-typography")}>
<SkipToMain />
<header className={styles.header}>
<div className={styles.headerInner}>
<div className={styles.headerLeft}>
{hasSidebar && (
<Tooltip label={t("Toggle sidebar")}>
<ActionIcon
variant="subtle"
className={clsx(styles.headerAction, styles.sidebarToggle)}
size="md"
onClick={() => setMobileSidebarOpen((value) => !value)}
aria-label={t("Toggle sidebar")}
aria-expanded={mobileSidebarOpen}
>
<IconMenu2 size={18} stroke={2} />
</ActionIcon>
</Tooltip>
)}
{!hasSidebar && siteName && homeUrl && (
<Link to={homeUrl} className={styles.headerSpaceName}>
{siteName}
</Link>
)}
</div>
<div className={styles.headerCenter}>
{onSearchOpen && (
<div className={styles.searchSlot}>
<DocsSearchButton onClick={onSearchOpen} />
</div>
)}
</div>
<div className={styles.headerRight}>
{onSearchOpen && (
<span className={styles.mobileOnly}>
<SearchMobileControl onSearch={onSearchOpen} />
</span>
)}
<DocsThemeToggle />
</div>
</div>
</header>
<div className={styles.body}>
<nav
className={styles.sidebar}
data-hidden={!hasSidebar || undefined}
aria-label={t("Pages")}
aria-hidden={!hasSidebar || undefined}
>
{hasSidebar && (
<>
{siteName && homeUrl && (
<>
<Link to={homeUrl} className={styles.sidebarTitle}>
{siteName}
</Link>
<div className={styles.sidebarDivider} aria-hidden />
</>
)}
<div className={styles.sidebarScroll}>
<MemoizedDocsSidebarTree />
</div>
</>
)}
</nav>
<main className={styles.main} id={MAIN_CONTENT_ID} tabIndex={-1}>
<div className={styles.article}>
<div className={styles.articleActions}>
<DocsCopyPage />
<span className={styles.tocOverlayControl}>
<Tooltip label={t("Table of contents")} withArrow>
<ActionIcon
variant="subtle"
className={styles.headerAction}
onClick={() => setMobileTocOpen(true)}
size="md"
aria-label={t("Table of contents")}
>
<IconList size={18} stroke={2} />
</ActionIcon>
</Tooltip>
</span>
</div>
{children}
{showBranding && (
<DocsFooterBranding refSource={surface.brandingRef} />
)}
</div>
</main>
<aside className={styles.toc} aria-label={t("On this page")}>
<DocsToc />
{showEditPage && <DocsEditPage />}
</aside>
</div>
<Drawer
opened={mobileSidebarOpen}
onClose={() => setMobileSidebarOpen(false)}
title={siteName}
size={300}
padding="sm"
>
<div className={styles.drawerTree}>
{hasSidebar && <MemoizedDocsSidebarTree />}
</div>
</Drawer>
<Drawer
opened={mobileTocOpen}
onClose={() => setMobileTocOpen(false)}
position="right"
size={300}
padding="md"
>
<DocsToc />
{showEditPage && <DocsEditPage />}
</Drawer>
{searchSpotlight}
</div>
</DocsSurfaceProvider>
);
}
@@ -0,0 +1,198 @@
import { SharedPageTreeNode } from "@/features/share/utils.ts";
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { Link, useParams } from "react-router-dom";
import { useAtom, useSetAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { IconChevronRight } from "@tabler/icons-react";
import { ActionIcon } from "@mantine/core";
import { extractPageSlugId } from "@/lib";
import {
DocTree,
type DocTreeApi,
type RenderRowProps,
} from "@/features/page/tree/components/doc-tree";
import {
docsMobileSidebarAtom,
openPublicSpaceTreeNodesAtom,
} from "@/features/public-space/atoms/public-space-atoms.ts";
import { useDocsSurface } from "@/features/public-space/components/docs/docs-surface-context.tsx";
import { findAncestorTrail } from "@/features/public-space/utils/docs-tree.ts";
import styles from "./docs.module.css";
export default function DocsSidebarTree() {
const { t } = useTranslation();
const treeRef = useRef<DocTreeApi | null>(null);
const { pageSlug } = useParams();
const { treeData, getNodeUrl } = useDocsSurface();
const [openTreeNodes, setOpenTreeNodes] = useAtom(
openPublicSpaceTreeNodesAtom,
);
// The first root page is the surface home, served at the bare URL.
const firstRootSlugId = treeData?.[0]?.slugId;
const currentNodeId = pageSlug ? extractPageSlugId(pageSlug) : firstRootSlugId;
const openIds = useMemo(
() => new Set(Object.keys(openTreeNodes).filter((k) => openTreeNodes[k])),
[openTreeNodes],
);
useEffect(() => {
// Auto-open the first level of the tree on initial load.
const root = treeData?.[0];
if (!root) return;
setOpenTreeNodes((prev) => {
if (prev[root.slugId]) return prev;
const next = { ...prev, [root.slugId]: true };
for (const child of root.children ?? []) {
next[child.slugId] = true;
}
return next;
});
}, [treeData, setOpenTreeNodes]);
// Reveal the current page: expand its ancestor trail (deep links land with
// everything collapsed otherwise) and the page itself when it has children.
useEffect(() => {
if (!currentNodeId || !treeData?.length) return;
const trail = findAncestorTrail(treeData, currentNodeId);
if (trail === null) return;
setOpenTreeNodes((prev) => {
const next = { ...prev };
let changed = false;
for (const node of [...trail.map((n) => n.slugId), currentNodeId]) {
if (!next[node]) {
next[node] = true;
changed = true;
}
}
return changed ? next : prev;
});
}, [currentNodeId, treeData, setOpenTreeNodes]);
useEffect(() => {
if (currentNodeId) {
treeRef.current?.select(currentNodeId, { scrollIntoView: true });
}
}, [currentNodeId, treeData]);
const handleToggle = useCallback(
(id: string, isOpen: boolean) =>
setOpenTreeNodes((prev) => ({ ...prev, [id]: isOpen })),
[setOpenTreeNodes],
);
const getDragLabel = useCallback(
(n: SharedPageTreeNode) => n.name || "untitled",
[],
);
const renderRow = useCallback(
(props: RenderRowProps<SharedPageTreeNode>) => (
<DocsTreeRow {...props} getNodeUrl={getNodeUrl} />
),
[getNodeUrl],
);
if (!treeData?.length) {
return null;
}
return (
<DocTree<SharedPageTreeNode>
readOnly
ref={treeRef}
data={treeData}
openIds={openIds}
selectedId={currentNodeId}
renderRow={renderRow}
indentPerLevel={INDENT_PER_LEVEL}
rowHeight={36}
dynamicRowHeight
rowClassName={styles.treeNodeChrome}
onMove={noopMove}
onToggle={handleToggle}
getDragLabel={getDragLabel}
aria-label={t("Pages")}
/>
);
}
// Module-scope noop so it's a stable reference across renders.
const noopMove = () => {};
const INDENT_PER_LEVEL = 16;
type DocsTreeRowProps = RenderRowProps<SharedPageTreeNode> & {
getNodeUrl: (node: Pick<SharedPageTreeNode, "slugId" | "name">) => string;
};
function DocsTreeRow({
node,
level,
isOpen,
hasChildren,
isSelected,
rowRef,
tabIndex,
treeItemProps,
toggleOpen,
getNodeUrl,
}: DocsTreeRowProps) {
const { t } = useTranslation();
const setMobileSidebarOpen = useSetAtom(docsMobileSidebarAtom);
return (
<Link
ref={rowRef as React.Ref<HTMLAnchorElement>}
tabIndex={tabIndex}
{...treeItemProps}
data-selected={isSelected || undefined}
data-open-parent={(level === 0 && isOpen && hasChildren) || undefined}
className={styles.treeRow}
to={getNodeUrl(node)}
onClick={() => {
setMobileSidebarOpen(false);
}}
>
{/* One segment per ancestor level; contiguous rows join into a rail. */}
{Array.from({ length: level }, (_, ancestor) => (
<span
key={ancestor}
className={styles.treeGuide}
style={{ left: -((level - ancestor) * INDENT_PER_LEVEL - 6) }}
aria-hidden
/>
))}
{node.icon && (
<span className={styles.treeIcon} aria-hidden>
{node.icon}
</span>
)}
<span className={styles.treeText}>{node.name || t("untitled")}</span>
{hasChildren && (
<ActionIcon
component="span"
variant="subtle"
color="gray"
size={20}
tabIndex={-1}
aria-hidden
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleOpen();
}}
>
<IconChevronRight
className={styles.treeChevron}
data-open={isOpen || undefined}
stroke={2}
size={14}
/>
</ActionIcon>
)}
</Link>
);
}
@@ -0,0 +1,27 @@
import { createContext, useContext } from "react";
import { SharedPageTreeNode } from "@/features/share/utils.ts";
// What varies between the public surfaces (/docs and /share) rendered by the
// docs shell; every shell component reads this contract instead of a feature.
export type DocsSurface = {
treeData: SharedPageTreeNode[] | null;
hasSidebar: boolean;
siteName?: string;
homeUrl?: string;
getNodeUrl: (node: Pick<SharedPageTreeNode, "slugId" | "name">) => string;
showBranding: boolean;
showEditPage: boolean;
brandingRef?: string;
};
const DocsSurfaceContext = createContext<DocsSurface | null>(null);
export const DocsSurfaceProvider = DocsSurfaceContext.Provider;
export function useDocsSurface(): DocsSurface {
const surface = useContext(DocsSurfaceContext);
if (!surface) {
throw new Error("useDocsSurface must be used within DocsShell");
}
return surface;
}
@@ -0,0 +1,35 @@
import {
ActionIcon,
Tooltip,
useComputedColorScheme,
useMantineColorScheme,
} from "@mantine/core";
import { IconMoon, IconSun } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import styles from "./docs.module.css";
export default function DocsThemeToggle() {
const { t } = useTranslation();
const { setColorScheme } = useMantineColorScheme();
const computedColorScheme = useComputedColorScheme("light");
return (
<Tooltip label={t("Toggle color scheme")} withArrow>
<ActionIcon
variant="subtle"
className={styles.headerAction}
size="md"
onClick={() =>
setColorScheme(computedColorScheme === "light" ? "dark" : "light")
}
aria-label={t("Toggle color scheme")}
>
{computedColorScheme === "light" ? (
<IconMoon size={18} stroke={1.75} />
) : (
<IconSun size={18} stroke={1.75} />
)}
</ActionIcon>
</Tooltip>
);
}
@@ -0,0 +1,116 @@
import { useCallback, useEffect, useState } from "react";
import { TextSelection } from "@tiptap/pm/state";
import { useAtomValue } from "jotai";
import { useTranslation } from "react-i18next";
import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import {
HeadingLink,
recalculateLinks,
} from "@/features/editor/components/table-of-contents/table-of-contents.tsx";
import styles from "./docs.module.css";
function getHeaderOffset(): number {
const raw = getComputedStyle(document.documentElement).getPropertyValue(
"--docs-header-h",
);
const parsed = parseInt(raw, 10);
return Number.isNaN(parsed) ? 56 : parsed;
}
export default function DocsToc() {
const { t } = useTranslation();
const editor = useAtomValue(readOnlyEditorAtom);
const [links, setLinks] = useState<HeadingLink[]>([]);
const [headingDOMNodes, setHeadingDOMNodes] = useState<HTMLElement[]>([]);
const [activeElement, setActiveElement] = useState<HTMLElement | null>(null);
const handleUpdate = useCallback(() => {
if (!editor || editor.isDestroyed) return;
const result = recalculateLinks(editor.$nodes("heading"));
setLinks(result.links);
setHeadingDOMNodes(result.nodes);
}, [editor]);
useEffect(() => {
// "create" repopulates once the editor view mounts after this component.
editor?.on("create", handleUpdate);
editor?.on("update", handleUpdate);
handleUpdate();
return () => {
editor?.off("create", handleUpdate);
editor?.off("update", handleUpdate);
};
}, [editor, handleUpdate]);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActiveElement(entry.target as HTMLElement);
}
});
},
{
rootMargin: `-${getHeaderOffset()}px 0px -85% 0px`,
threshold: 0,
root: null,
},
);
headingDOMNodes.forEach((heading) => observer.observe(heading));
return () => {
headingDOMNodes.forEach((heading) => observer.unobserve(heading));
};
}, [headingDOMNodes]);
const handleScrollToHeading = (position: number) => {
if (!editor || editor.isDestroyed) return;
const { view } = editor;
const { node } = view.domAtPos(position);
const element = node as HTMLElement;
const scrollPosition =
element.getBoundingClientRect().top +
window.scrollY -
getHeaderOffset() -
16;
window.scrollTo({ top: scrollPosition, behavior: "smooth" });
const tr = view.state.tr;
tr.setSelection(new TextSelection(tr.doc.resolve(position)));
view.dispatch(tr);
view.focus();
};
if (!links.length) {
return null;
}
const minLevel = Math.min(...links.map((link) => link.level));
const effectiveActive = activeElement ?? links[0]?.element;
return (
<div>
<span className={styles.tocLabel}>{t("On this page")}</span>
<div className={styles.tocList}>
{links.map((item, idx) => (
<button
type="button"
key={idx}
className={styles.tocLink}
data-active={item.element === effectiveActive || undefined}
style={{
paddingLeft: `${(item.level - minLevel) * 12 + 11}px`,
}}
onClick={() => handleScrollToHeading(item.position)}
>
{item.label}
</button>
))}
</div>
</div>
);
}
@@ -0,0 +1,902 @@
/* Design tokens for the public docs surface. Declared on :root (not .root)
* because mobile drawers and the spotlight render in portals outside the shell
* subtree. The accent pair is overridden at runtime by docs-theme.ts. */
/* light-dark() cannot live in a bare :root block (the transform emits a
* descendant selector that never matches :root), so dark values get their own
* attribute-qualified block; flipping semantics ride Mantine's own vars. */
:root {
--docs-header-h: 56px;
--docs-sidebar-w: 280px;
--docs-toc-w: 240px;
--docs-site-max: 96rem;
--docs-content-max: 54rem;
--docs-radius: 6px;
--docs-accent: #2b7af1;
--docs-accent-soft: color-mix(in srgb, var(--docs-accent) 10%, transparent);
/* Cloudflare-style single-ink model: one foreground for headings, bold, and
* body on a just-off-white page; neither end of the scale is pure. */
--docs-bg: oklch(99% 0 0);
--docs-fg: oklch(21% 0 0);
--docs-content-fg: var(--docs-fg);
--docs-nav-fg: oklch(47% 0 0);
--docs-muted: var(--mantine-color-dimmed);
--docs-hover: var(--mantine-color-default-hover);
--docs-faint: var(--mantine-color-gray-6);
--docs-border: var(--mantine-color-gray-2);
--docs-header-bg: color-mix(in srgb, var(--docs-bg) 78%, transparent);
}
:root[data-mantine-color-scheme="dark"] {
--docs-bg: var(--mantine-color-body);
--docs-fg: oklch(90% 0 0);
--docs-nav-fg: oklch(72% 0 0);
--docs-faint: var(--mantine-color-dark-2);
--docs-border: var(--mantine-color-dark-5);
}
.root {
min-height: 100dvh;
background-color: var(--docs-bg);
color: var(--docs-fg);
}
/* ---------- Header ---------- */
.header {
position: sticky;
top: 0;
z-index: 90;
height: var(--docs-header-h);
padding-inline: rem(20px);
background-color: var(--docs-header-bg);
backdrop-filter: saturate(180%) blur(10px);
-webkit-backdrop-filter: saturate(180%) blur(10px);
border-bottom: 1px solid var(--docs-border);
}
.headerInner {
max-width: var(--docs-site-max);
margin-inline: auto;
height: 100%;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: rem(16px);
}
.headerLeft {
display: flex;
align-items: center;
gap: rem(10px);
min-width: 0;
}
.headerCenter {
display: flex;
justify-content: center;
min-width: 0;
}
.headerRight {
display: flex;
align-items: center;
justify-content: flex-end;
gap: rem(4px);
}
.searchSlot {
@media (max-width: $mantine-breakpoint-sm) {
display: none;
}
}
.mobileOnly {
display: inline-flex;
@media (min-width: $mantine-breakpoint-sm) {
display: none;
}
}
/* Plain space name in the header, used only when there is no sidebar to
* carry it. The brand slot is reserved for future org logo/name support. */
.headerSpaceName {
font-size: rem(14.5px);
font-weight: 600;
letter-spacing: -0.011em;
color: var(--docs-fg);
text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border-radius: var(--docs-radius);
padding: rem(4px) rem(6px);
&:focus-visible {
outline: 2px solid var(--docs-accent);
outline-offset: 1px;
}
}
/* Burger appears only once the sidebar column is collapsed. */
@media (min-width: 64em) {
.sidebarToggle {
display: none !important;
}
}
.headerAction {
color: var(--docs-faint);
border-radius: var(--docs-radius);
@media (hover: hover) {
&:hover {
color: var(--docs-fg);
background-color: var(--docs-hover);
}
}
}
/* ---------- Search ---------- */
.searchButton {
display: flex;
align-items: center;
gap: rem(8px);
width: rem(320px);
height: rem(34px);
padding-inline: rem(10px);
border: 1px solid var(--docs-border);
border-radius: rem(8px);
background-color: var(--docs-bg);
color: var(--docs-muted);
font-size: rem(13px);
cursor: pointer;
transition: border-color 120ms ease;
@media (hover: hover) {
&:hover {
border-color: light-dark(
var(--mantine-color-gray-4),
var(--mantine-color-dark-3)
);
}
}
&:focus-visible {
outline: 2px solid var(--docs-accent);
outline-offset: 1px;
}
@media (max-width: 62em) {
width: rem(220px);
}
}
.searchLabel {
flex: 1;
text-align: left;
}
.searchKbd {
font-size: rem(11px);
font-weight: 500;
color: var(--docs-faint);
border: 1px solid var(--docs-border);
border-radius: rem(4px);
padding: rem(1px) rem(5px);
line-height: 1.4;
}
/* Quiet bordered chip in the docs palette instead of Mantine's full-ink default. */
.copyPageButton {
color: var(--docs-muted);
border-color: var(--docs-border);
background-color: transparent;
font-size: rem(13px);
font-weight: 500;
border-radius: var(--docs-radius);
@media (hover: hover) {
&:hover {
color: var(--docs-fg);
background-color: var(--docs-hover);
}
}
}
/* Page actions pinned to the article's top-right corner; on small viewports
* they drop into flow above the content so breadcrumbs never run under them. */
.articleActions {
position: absolute;
top: rem(30px);
right: rem(24px);
display: inline-flex;
align-items: center;
gap: rem(6px);
@media (max-width: $mantine-breakpoint-sm) {
position: static;
display: flex;
justify-content: flex-end;
margin-bottom: rem(4px);
}
}
/* Below the rail breakpoint the toc opens as a drawer overlay instead. */
.tocOverlayControl {
display: inline-flex;
@media (min-width: 75em) {
display: none;
}
}
/* ---------- Body grid ---------- */
/* Contained site layout: rails anchor the edges of a centered max-width
* container, the article centers itself in the fixed middle track. Hiding a
* rail keeps its track, so toggling never moves the content column. */
.body {
max-width: var(--docs-site-max);
margin-inline: auto;
display: grid;
grid-template-columns:
var(--docs-sidebar-w)
minmax(0, 1fr)
var(--docs-toc-w);
align-items: start;
}
.sidebar {
grid-column: 1;
width: var(--docs-sidebar-w);
position: sticky;
top: var(--docs-header-h);
height: calc(100dvh - var(--docs-header-h));
display: flex;
flex-direction: column;
padding: rem(20px) rem(10px) rem(16px) rem(20px);
opacity: 1;
transform: translateX(0);
transition:
opacity 160ms ease,
transform 160ms ease;
}
.sidebar[data-hidden="true"] {
visibility: hidden;
opacity: 0;
transform: translateX(rem(-8px));
}
.sidebarTitle {
display: block;
font-size: rem(14px);
font-weight: 600;
letter-spacing: -0.011em;
color: var(--docs-fg);
text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border-radius: var(--docs-radius);
padding: rem(5px) rem(8px);
@media (hover: hover) {
&:hover {
color: var(--docs-accent);
}
}
&:focus-visible {
outline: 2px solid var(--docs-accent);
outline-offset: -2px;
}
}
.sidebarDivider {
height: 1px;
background-color: var(--docs-border);
margin: rem(10px) 0 rem(14px);
flex-shrink: 0;
}
.sidebarScroll {
flex: 1;
min-height: 0;
}
.main {
grid-column: 2;
min-width: 0;
}
.article {
max-width: var(--docs-content-max);
margin-inline: auto;
padding: rem(36px) rem(32px) rem(72px);
position: relative;
}
.toc {
grid-column: 3;
width: var(--docs-toc-w);
position: sticky;
top: var(--docs-header-h);
max-height: calc(100dvh - var(--docs-header-h));
overflow-y: auto;
scrollbar-width: thin;
padding: rem(36px) rem(16px) rem(24px) rem(4px);
}
@media (max-width: 75em) {
.body {
grid-template-columns: var(--docs-sidebar-w) minmax(0, 1fr);
}
.toc {
display: none;
}
}
/* Below 64em the sidebar collapses into the burger drawer. */
@media (max-width: 64em) {
.body {
display: block;
}
.sidebar {
display: none;
}
}
@media (max-width: $mantine-breakpoint-sm) {
.article {
padding: rem(24px) rem(20px) rem(56px);
}
}
@media (prefers-reduced-motion: reduce) {
.body,
.sidebar,
.toc,
.searchButton {
transition: none;
}
}
/* ---------- Sidebar tree ---------- */
/* Applied through DocTree's rowClassName onto the engine wrapper that carries
* data-selected. Class doubled to outrank the engine's own module styles. */
.treeNodeChrome.treeNodeChrome {
border-radius: var(--docs-radius);
color: var(--docs-nav-fg);
@media (hover: hover) {
&:hover {
background-color: var(--docs-hover);
color: var(--docs-fg);
}
}
}
.treeNodeChrome.treeNodeChrome[data-selected="true"] {
background-color: var(--docs-accent-soft);
}
.treeRow {
position: relative;
display: flex;
align-items: flex-start;
gap: rem(8px);
width: 100%;
min-width: 0;
min-height: rem(32px);
padding: rem(6px) rem(4px) rem(6px) rem(8px);
text-decoration: none;
color: inherit;
font-size: rem(14px);
font-weight: 400;
line-height: 1.45;
border-radius: var(--docs-radius);
&:focus-visible {
outline: 2px solid var(--docs-accent);
outline-offset: -2px;
}
}
/* Expanded parents read as section headers, Cloudflare-style. */
.treeRow[data-open-parent="true"] {
color: var(--docs-fg);
font-weight: 500;
}
.treeRow[data-selected="true"] {
color: var(--docs-accent);
font-weight: 500;
}
.treeIcon {
display: inline-flex;
align-items: center;
justify-content: center;
width: rem(18px);
font-size: rem(14px);
line-height: 1;
flex-shrink: 0;
margin-top: rem(2px);
}
/* Names wrap instead of truncating: the sidebar has a fixed width, so an
* ellipsis would permanently hide the tail of long titles. */
.treeText {
flex: 1;
min-width: 0;
overflow-wrap: anywhere;
}
/* Nesting rail: each row draws its ancestors' segments in the indent gutter;
* the -2px bleed covers the engine's row padding so segments connect. */
.treeGuide {
position: absolute;
top: 0;
bottom: rem(-2px);
width: 1px;
background-color: var(--docs-border);
pointer-events: none;
}
.treeChevron {
flex-shrink: 0;
color: var(--docs-faint);
transition: transform 140ms ease;
}
.treeChevron[data-open="true"] {
transform: rotate(90deg);
}
@media (prefers-reduced-motion: reduce) {
.treeChevron {
transition: none;
}
}
/* ---------- Table of contents ---------- */
.tocLabel {
display: block;
font-size: rem(11px);
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--docs-muted);
margin-bottom: rem(10px);
}
.tocList {
border-left: 1px solid var(--docs-border);
}
.tocLink {
display: block;
width: 100%;
text-align: left;
background: none;
border: 0;
border-left: 2px solid transparent;
margin-left: -1px;
padding: rem(4px) rem(8px) rem(4px) rem(11px);
font-size: rem(13px);
line-height: 1.45;
color: var(--docs-muted);
cursor: pointer;
overflow-wrap: break-word;
@media (hover: hover) {
&:hover {
color: var(--docs-fg);
}
}
&:focus-visible {
outline: 2px solid var(--docs-accent);
outline-offset: -2px;
border-radius: rem(2px);
}
}
.tocLink[data-active="true"] {
color: var(--docs-accent);
border-left-color: var(--docs-accent);
font-weight: 500;
}
/* ---------- Edit page (signed-in visitors) ---------- */
.editPageLink {
display: flex;
align-items: center;
gap: rem(6px);
margin-top: rem(16px);
padding-top: rem(14px);
border-top: 1px solid var(--docs-border);
font-size: rem(13px);
color: var(--docs-muted);
text-decoration: none;
@media (hover: hover) {
&:hover {
color: var(--docs-fg);
}
}
&:focus-visible {
outline: 2px solid var(--docs-accent);
outline-offset: 2px;
border-radius: rem(2px);
}
}
/* ---------- Sidebar branding experiments (GitBook card / ReadMe line) ---------- */
/* ---------- Footer branding ---------- */
.footer {
margin-top: rem(40px);
padding-top: rem(16px);
border-top: 1px solid var(--docs-border);
}
.footerBranding {
font-size: rem(14px);
color: var(--docs-muted);
text-decoration: none;
@media (hover: hover) {
&:hover {
color: var(--docs-fg);
}
}
&:focus-visible {
outline: 2px solid var(--docs-accent);
outline-offset: 2px;
border-radius: rem(2px);
}
}
/* ---------- Breadcrumbs ---------- */
.breadcrumbs {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: rem(4px);
font-size: rem(13px);
color: var(--docs-muted);
margin-bottom: rem(6px);
/* keep long trails clear of the pinned page actions */
padding-right: rem(160px);
@media (max-width: $mantine-breakpoint-sm) {
padding-right: 0;
}
}
.crumbLink {
color: var(--docs-muted);
text-decoration: none;
border-radius: rem(4px);
padding: rem(1px) rem(3px);
max-width: rem(220px);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@media (hover: hover) {
&:hover {
color: var(--docs-accent);
}
}
&:focus-visible {
outline: 2px solid var(--docs-accent);
outline-offset: 0;
}
}
.crumbSeparator {
color: light-dark(
var(--mantine-color-gray-4),
var(--mantine-color-dark-3)
);
user-select: none;
}
.crumbEllipsis {
color: var(--docs-muted);
border: 0;
background: none;
cursor: pointer;
border-radius: rem(4px);
padding: rem(1px) rem(4px);
@media (hover: hover) {
&:hover {
color: var(--docs-accent);
background-color: var(--docs-hover);
}
}
&:focus-visible {
outline: 2px solid var(--docs-accent);
outline-offset: 0;
}
}
/* ---------- Byline ---------- */
/* Pulled up into the title's own bottom margin so it reads as one block. */
.byline {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: rem(8px);
margin-top: rem(-16px);
margin-bottom: rem(28px);
font-size: rem(13px);
color: var(--docs-muted);
}
.bylineAuthor {
display: inline-flex;
align-items: center;
gap: rem(6px);
}
.bylineDot {
color: var(--docs-faint);
user-select: none;
}
/* ---------- Prev / next ---------- */
.pageNav {
display: grid;
grid-template-columns: 1fr 1fr;
gap: rem(12px);
margin-top: rem(48px);
}
.pageNavCard {
display: flex;
flex-direction: column;
gap: rem(4px);
padding: rem(12px) rem(16px);
border: 1px solid var(--docs-border);
border-radius: rem(10px);
text-decoration: none;
min-width: 0;
transition: border-color 120ms ease;
@media (hover: hover) {
&:hover {
border-color: var(--docs-accent);
}
&:hover .pageNavTitle {
color: var(--docs-accent);
}
}
&:focus-visible {
outline: 2px solid var(--docs-accent);
outline-offset: 1px;
}
}
.pageNavCard[data-direction="next"] {
grid-column: 2;
align-items: flex-end;
text-align: right;
}
.pageNavLabel {
display: inline-flex;
align-items: center;
gap: rem(4px);
font-size: rem(12px);
color: var(--docs-muted);
}
.pageNavTitle {
font-size: rem(14px);
font-weight: 500;
color: var(--docs-fg);
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: color 120ms ease;
}
@media (max-width: $mantine-breakpoint-sm) {
.pageNav {
grid-template-columns: 1fr;
}
.pageNavCard[data-direction="next"] {
grid-column: auto;
}
}
/* ---------- Content ---------- */
/* Reading typography: body copy softens to a blue-gray with relaxed leading
* while headings and bold keep full contrast. Class doubled to outrank the
* shared .public-typography metrics regardless of stylesheet order. */
.root.root :global(.ProseMirror) {
color: var(--docs-content-fg);
line-height: 1.75;
letter-spacing: normal;
/* The article owns horizontal spacing here; the editor's 3rem gutter would
* misalign content with the breadcrumb, and its own background bands
* against the off-white page. */
padding-left: 0;
padding-right: 0;
background-color: transparent;
}
/* Wide columns bleed into the editor gutter that no longer exists here. */
.root.root :global(div[data-type="columns"][data-width-mode="wide"]) {
margin-left: 0;
margin-right: 0;
width: 100%;
}
/* One ink by inheritance: Mantine's baseline gives headings an explicit
* color, which forks them from the body. Neutralize instead of re-declaring,
* so changing the single content foreground repaints all text. */
.root.root :global(.ProseMirror) h1,
.root.root :global(.ProseMirror) h2,
.root.root :global(.ProseMirror) h3,
.root.root :global(.ProseMirror) h4,
.root.root :global(.ProseMirror) h5,
.root.root :global(.ProseMirror) h6,
.root.root :global(.ProseMirror) strong {
color: inherit;
}
/* Modest semibold heading scale (Cloudflare-style); class doubled to outrank
* the shared editor and .public-typography rules. */
.root.root :global(.ProseMirror) h1 {
font-size: 2.1875rem;
font-weight: 600;
letter-spacing: -0.025em;
line-height: 1.25;
}
.root.root :global(.ProseMirror) h2 {
font-size: 1.3rem;
font-weight: 600;
letter-spacing: -0.015em;
line-height: 1.4;
}
.root.root :global(.ProseMirror) h3 {
font-size: 1.1rem;
font-weight: 600;
letter-spacing: -0.01em;
line-height: 1.45;
}
.root.root :global(.ProseMirror) h4,
.root.root :global(.ProseMirror) h5,
.root.root :global(.ProseMirror) h6 {
font-size: 1rem;
font-weight: 600;
line-height: 1.5;
}
.root.root :global(.ProseMirror) strong {
font-weight: 600;
}
.root.root :global(.page-title .ProseMirror) h1 {
font-size: 2.1875rem;
font-weight: 600;
letter-spacing: -0.025em;
line-height: 1.25;
}
.root :global(.ProseMirror) a {
color: var(--docs-accent);
}
/* Internal page links (mentions, subpages lists) follow the accent like any
* other link; the app skin pins them to ink with !important, hence the
* counter-!important, and the underline tint softens to match. */
.root :global(.ProseMirror) a[class*="pageMentionLink"] {
color: var(--docs-accent) !important;
}
.root :global(.ProseMirror) [class*="pageMentionText"] {
border-bottom-color: color-mix(in srgb, var(--docs-accent) 40%, transparent);
}
.root :global(.ProseMirror) h1,
.root :global(.ProseMirror) h2,
.root :global(.ProseMirror) h3,
.root :global(.ProseMirror) h4 {
scroll-margin-top: calc(var(--docs-header-h) + rem(16px));
}
/* ---------- Content tables ---------- */
/* The wrapper carries the rounded outer border so border-collapse never
* fights border-radius; its overflow-x clips the corners. */
.root.root :global(.ProseMirror .tableWrapper) {
border: 1px solid var(--docs-border);
border-radius: rem(10px);
}
.root.root :global(.ProseMirror table td),
.root.root :global(.ProseMirror table th) {
border: 0;
border-bottom: 1px solid var(--docs-border);
border-right: 1px solid var(--docs-border);
padding: rem(10px) rem(14px);
}
.root.root :global(.ProseMirror table :is(td, th):last-child) {
border-right: 0;
}
.root.root :global(.ProseMirror table tr:last-child td),
.root.root :global(.ProseMirror table tr:last-child th) {
border-bottom: 0;
}
.root.root :global(.ProseMirror table th) {
background-color: var(--docs-hover);
color: var(--docs-fg);
font-weight: 600;
}
/* Round the corner cells too for the no-overflow (pinned header) variant,
* where the wrapper does not clip. */
.root.root :global(.ProseMirror table tr:first-child :is(th, td):first-child) {
border-top-left-radius: rem(9px);
}
.root.root :global(.ProseMirror table tr:first-child :is(th, td):last-child) {
border-top-right-radius: rem(9px);
}
.root.root :global(.ProseMirror table tr:last-child :is(th, td):first-child) {
border-bottom-left-radius: rem(9px);
}
.root.root :global(.ProseMirror table tr:last-child :is(th, td):last-child) {
border-bottom-right-radius: rem(9px);
}
.emptyState {
padding-top: rem(96px);
text-align: center;
color: var(--docs-muted);
}
/* ---------- Mobile drawers ---------- */
.drawerTree {
height: calc(100dvh - rem(60px));
display: flex;
flex-direction: column;
}
@@ -0,0 +1,69 @@
import "@fontsource-variable/inter";
import "@/styles/public-typography.css";
import { useEffect, useMemo } from "react";
import { Outlet, useParams } from "react-router-dom";
import { useSetAtom } from "jotai";
import { usePublicSpaceTreeQuery } from "@/features/public-space/queries/public-space-query.ts";
import { buildSharedPageTree } from "@/features/share/utils.ts";
import {
publicSpaceTreeAtom,
publicSpaceTreeDataAtom,
} from "@/features/public-space/atoms/public-space-atoms.ts";
import { useDocsAccent } from "@/features/public-space/theme/docs-theme.ts";
import DocsShell from "@/features/public-space/components/docs/docs-shell.tsx";
import { DocsSurface } from "@/features/public-space/components/docs/docs-surface-context.tsx";
import { buildPublicSpaceUrl } from "@/features/page/page.utils.ts";
import { PublicSpaceSearchSpotlight } from "@/features/search/components/public-space-search-spotlight.tsx";
import { publicSpaceSearchSpotlight } from "@/features/search/constants";
export default function PublicSpaceLayout() {
const { spaceSlug } = useParams();
const { data } = usePublicSpaceTreeQuery(spaceSlug);
useDocsAccent(data?.appearance);
const setPublicSpaceTree = useSetAtom(publicSpaceTreeAtom);
const setPublicSpaceTreeData = useSetAtom(publicSpaceTreeDataAtom);
const treeData = useMemo(() => {
if (!data?.pageTree) return null;
return buildSharedPageTree(data.pageTree);
}, [data?.pageTree]);
useEffect(() => {
setPublicSpaceTree(data || null);
setPublicSpaceTreeData(treeData);
}, [data, treeData, setPublicSpaceTree, setPublicSpaceTreeData]);
const surface = useMemo<DocsSurface>(() => {
const homeUrl = buildPublicSpaceUrl({ spaceSlug });
// The first root page is the space home, served at the bare space URL.
const firstRootSlugId = treeData?.[0]?.slugId;
return {
treeData,
hasSidebar: (data?.pageTree?.length ?? 0) > 1,
siteName: data?.space?.name,
homeUrl,
getNodeUrl: (node) =>
node.slugId === firstRootSlugId
? homeUrl
: buildPublicSpaceUrl({
spaceSlug,
pageSlugId: node.slugId,
pageTitle: node.name,
}),
showBranding: Boolean(data),
showEditPage: true,
};
}, [data, treeData, spaceSlug]);
return (
<DocsShell
surface={surface}
onSearchOpen={publicSpaceSearchSpotlight.open}
searchSpotlight={<PublicSpaceSearchSpotlight spaceSlug={spaceSlug} />}
>
<Outlet />
</DocsShell>
);
}
@@ -0,0 +1,283 @@
import { ActionIcon, Group, Text, Switch, TextInput } from "@mantine/core";
import { modals } from "@mantine/modals";
import { useAtom } from "jotai";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { IconExternalLink, IconWorld } from "@tabler/icons-react";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { ISpace } from "@/features/space/types/space.types.ts";
import { IPublicSpace } from "@/features/public-space/types/public-space.types.ts";
import {
usePublicSpaceForSpaceQuery,
usePublishSpaceMutation,
} from "@/features/public-space/queries/public-space-query.ts";
import { getAppUrl } from "@/lib/config.ts";
import CopyTextButton from "@/components/common/copy.tsx";
import AppearanceSettings from "@/features/public-space/components/appearance-settings.tsx";
import { isPublicSpacesAllowed } from "@/features/public-space/utils/public-space-access.ts";
type PublishSpaceSettingsProps = {
space: ISpace;
};
export default function PublishSpaceSettings({
space,
}: PublishSpaceSettingsProps) {
const { t } = useTranslation();
const [workspace] = useAtom(workspaceAtom);
const allowPublicSpaces = isPublicSpacesAllowed(workspace);
const { data: publicSpace } = usePublicSpaceForSpaceQuery(
allowPublicSpaces ? space?.id : undefined,
);
const publishMutation = usePublishSpaceMutation();
const [published, setPublished] = useState(false);
const [searchIndexing, setSearchIndexing] = useState(false);
const [bylineAuthor, setBylineAuthor] = useState(false);
const [bylineUpdatedAt, setBylineUpdatedAt] = useState(true);
const [directoryListed, setDirectoryListed] = useState(false);
const workspaceDirectoryEnabled =
workspace?.settings?.publicSpaces?.directory === true;
const syncFromPublicSpace = (state?: IPublicSpace | null) => {
const byline = state?.settings?.byline;
setPublished(state?.enabled === true);
setSearchIndexing(state?.searchIndexing === true);
setBylineAuthor(byline?.author === true);
setBylineUpdatedAt(byline?.updatedAt !== false);
setDirectoryListed(state?.settings?.directory === true);
};
useEffect(() => {
syncFromPublicSpace(publicSpace);
}, [publicSpace]);
if (!allowPublicSpaces || !space) {
return null;
}
const publicUrl = `${getAppUrl()}/docs/${space.slug}`;
const applyPublish = async (enabled: boolean) => {
try {
const result = await publishMutation.mutateAsync({
spaceId: space.id,
enabled,
});
syncFromPublicSpace(result);
} catch {
// error handled by mutation
}
};
const handlePublishChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
if (!value) {
applyPublish(false);
return;
}
modals.openConfirmModal({
title: t("Publish space to the web"),
children: (
<Text size="sm">
{t(
"Anyone on the internet will be able to read every page in this space, except restricted pages. Are you sure?",
)}
</Text>
),
centered: true,
labels: { confirm: t("Publish"), cancel: t("Cancel") },
onConfirm: () => applyPublish(true),
});
};
const handleIndexingChange = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const value = event.currentTarget.checked;
try {
await publishMutation.mutateAsync({
spaceId: space.id,
enabled: true,
searchIndexing: value,
});
setSearchIndexing(value);
} catch {
// error handled by mutation
}
};
const handleBylineAuthorChange = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const value = event.currentTarget.checked;
try {
await publishMutation.mutateAsync({
spaceId: space.id,
enabled: true,
bylineAuthor: value,
});
setBylineAuthor(value);
} catch {
// error handled by mutation
}
};
const handleBylineUpdatedAtChange = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const value = event.currentTarget.checked;
try {
await publishMutation.mutateAsync({
spaceId: space.id,
enabled: true,
bylineUpdatedAt: value,
});
setBylineUpdatedAt(value);
} catch {
// error handled by mutation
}
};
const handleDirectoryChange = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const value = event.currentTarget.checked;
try {
await publishMutation.mutateAsync({
spaceId: space.id,
enabled: true,
directory: value,
});
setDirectoryListed(value);
} catch {
// error handled by mutation
}
};
return (
<div>
<Group justify="space-between" wrap="nowrap" gap="xl" mt="md">
<div>
<Text size="md">{t("Publish space to the web")}</Text>
<Text size="sm" c="dimmed">
{t("Make this space publicly readable by anyone on the internet.")}
</Text>
</div>
<Switch
checked={published}
onChange={handlePublishChange}
size={"xs"}
aria-label={t("Toggle publish space to the web")}
/>
</Group>
{published && (
<>
<Group justify="space-between" wrap="nowrap" gap="xl" mt="md">
<div>
<Text size="md">{t("Allow search engines to index")}</Text>
<Text size="sm" c="dimmed">
{t(
"Let public pages in this space appear in search engine results.",
)}
</Text>
</div>
<Switch
checked={searchIndexing}
onChange={handleIndexingChange}
size={"xs"}
aria-label={t("Toggle search engine indexing")}
/>
</Group>
<Group justify="space-between" wrap="nowrap" gap="xl" mt="md">
<div>
<Text size="md">{t("Show page author")}</Text>
<Text size="sm" c="dimmed">
{t("Display the page creator's name on public pages.")}
</Text>
</div>
<Switch
checked={bylineAuthor}
onChange={handleBylineAuthorChange}
size={"xs"}
aria-label={t("Toggle show page author")}
/>
</Group>
<Group justify="space-between" wrap="nowrap" gap="xl" mt="md">
<div>
<Text size="md">{t("Show last updated")}</Text>
<Text size="sm" c="dimmed">
{t("Display when each page was last updated.")}
</Text>
</div>
<Switch
checked={bylineUpdatedAt}
onChange={handleBylineUpdatedAtChange}
size={"xs"}
aria-label={t("Toggle show last updated")}
/>
</Group>
{workspaceDirectoryEnabled && (
<Group justify="space-between" wrap="nowrap" gap="xl" mt="md">
<div>
<Text size="md">{t("Show in public directory")}</Text>
<Text size="sm" c="dimmed">
{t("List this space in the public directory at /docs.")}
</Text>
</div>
<Switch
checked={directoryListed}
onChange={handleDirectoryChange}
size={"xs"}
aria-label={t("Toggle show in public directory")}
/>
</Group>
)}
<Group mt="md" gap={4} wrap="nowrap">
<TextInput
value={publicUrl}
readOnly
style={{ width: "100%" }}
leftSection={<IconWorld size={16} />}
aria-label={t("Public space link")}
rightSection={
<CopyTextButton
text={publicUrl}
label={t("Copy public space link")}
/>
}
/>
<ActionIcon
component="a"
variant="default"
size="input-sm"
target="_blank"
href={publicUrl}
aria-label={t("Open public space link")}
>
<IconExternalLink size={16} />
</ActionIcon>
</Group>
<Text size="sm" c="dimmed" mt="xs">
{t("Renaming the space slug will break public links.")}
</Text>
<AppearanceSettings
spaceId={space.id}
appearance={publicSpace?.settings?.appearance}
/>
</>
)}
</div>
);
}

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