Compare commits

..
Author SHA1 Message Date
Philipinho bc3734da86 feat: search group members 2026-08-27 12:40:20 +01:00
192 changed files with 1689 additions and 13481 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
FROM node:26-slim AS base
LABEL org.opencontainers.image.source="https://github.com/docmost/docmost"
RUN npm install -g pnpm@11.25.0
RUN npm install -g pnpm@11.23.0
FROM base AS builder
-1
View File
@@ -21,7 +21,6 @@
"@docmost/base-formula": "workspace:*",
"@docmost/editor-ext": "workspace:*",
"@excalidraw/excalidraw": "0.18.0-3a5ef40",
"@fontsource-variable/inter": "5.3.0",
"@mantine/core": "9.3.2",
"@mantine/dates": "9.3.2",
"@mantine/form": "9.3.2",
@@ -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 Server URL": "MCP-Server-URL",
"Connect AI assistants with your Docmost account via OAuth.": "Verbinde AI-Assistenten über OAuth mit deinem Docmost-Konto.",
"Enforce OAuth": "OAuth erzwingen",
"Enforce OAuth": "Enforce OAuth",
"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": "OAuth-Erzwingung für MCP umschalten",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
"Supported tools": "Unterstützte Tools",
"MCP server URL:": "MCP-Server-URL:",
"Learn more": "Mehr erfahren",
@@ -793,10 +793,6 @@
"Removed page restriction": "Seitenbeschränkung entfernt",
"Added page permission": "Seitenberechtigung hinzugefügt",
"Removed page permission": "Seitenberechtigung entfernt",
"Changed page permission": "Seitenberechtigung geändert",
"Requested password reset": "Zurücksetzen des Passworts angefordert",
"Created template": "Vorlage erstellt",
"Deleted template": "Vorlage gelöscht",
"day": "Tag",
"days": "Tage",
"week": "Woche",
@@ -884,9 +880,6 @@
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold> hat eine Seite zur Überarbeitung zurückgegeben",
"Page verification expires soon": "Die Seitenverifizierung läuft bald ab",
"Page verification has expired": "Die Seitenverifizierung ist abgelaufen",
"SIEM destination <bold>{{name}}</bold> is failing": "SIEM-Ziel <bold>{{name}}</bold> schlägt fehl",
"SIEM destination <bold>{{name}}</bold> was disabled after 24 hours of failures": "SIEM-Ziel <bold>{{name}}</bold> wurde nach 24 Stunden mit Fehlern deaktiviert",
"SIEM destination <bold>{{name}}</bold> recovered": "SIEM-Ziel <bold>{{name}}</bold> wurde wiederhergestellt",
"Verifying your email": "Ihre E-Mail wird bestätigt",
"Please wait...": "Bitte warten...",
"Verification failed. The link may have expired.": "Überprüfung fehlgeschlagen. Der Link ist möglicherweise abgelaufen.",
@@ -1344,137 +1337,5 @@
"AI Chat can search and read workspace content, but cannot create or edit pages.": "AI-Chat kann Workspace-Inhalte durchsuchen und lesen, aber keine Seiten erstellen oder bearbeiten.",
"Toggle AI Chat read-only mode": "Schreibgeschützten Modus für AI-Chat umschalten",
"Title only": "Nur Titel",
"you": "du",
"Allow public spaces": "Öffentliche Bereiche zulassen",
"Space admins can publish their spaces to the web.": "Bereichsadministratoren können ihre Bereiche im Web veröffentlichen.",
"Toggle allow public spaces": "Öffentliche Bereiche umschalten",
"Publish space to the web": "Bereich im Web veröffentlichen",
"Anyone on the internet will be able to read every page in this space, except restricted pages. Are you sure?": "Jeder im Internet kann jede Seite in diesem Bereich lesen, außer eingeschränkten Seiten. Sind Sie sicher?",
"Make this space publicly readable by anyone on the internet.": "Diesen Bereich für jeden im Internet öffentlich lesbar machen.",
"Toggle publish space to the web": "Veröffentlichung des Bereichs im Web umschalten",
"Allow search engines to index": "Suchmaschinen das Indexieren erlauben",
"Let public pages in this space appear in search engine results.": "Öffentliche Seiten in diesem Bereich in Suchmaschinenergebnissen erscheinen lassen.",
"Toggle search engine indexing": "Suchmaschinenindexierung umschalten",
"Public space link": "Öffentlicher Bereichslink",
"Copy public space link": "Öffentlichen Bereichslink kopieren",
"Renaming the space slug will break public links.": "Das Umbenennen des Bereichs-Slugs führt dazu, dass öffentliche Links nicht mehr funktionieren.",
"Failed to update space": "Bereich konnte nicht aktualisiert werden",
"This space is public": "Dieser Bereich ist öffentlich",
"Anyone on the internet can read the pages in this space, except restricted pages.": "Jeder im Internet kann die Seiten in diesem Bereich lesen, außer eingeschränkten Seiten.",
"Open public site": "Öffentliche Website öffnen",
"Public": "Öffentlich",
"This space has no public pages yet.": "Dieser Bereich hat noch keine öffentlichen Seiten.",
"On this page": "Auf dieser Seite",
"Previous": "Zurück",
"Next": "Weiter",
"Show hidden pages": "Ausgeblendete Seiten anzeigen",
"Page navigation": "Seitennavigation",
"Toggle sidebar": "Seitenleiste umschalten",
"Toggle table of contents": "Inhaltsverzeichnis umschalten",
"Appearance": "Erscheinungsbild",
"Forest": "Wald",
"Violet": "Violett",
"Light mode color": "Farbe für den hellen Modus",
"Dark mode color": "Farbe für den dunklen Modus",
"Choose the primary color of the public docs site.": "Wählen Sie die Primärfarbe der öffentlichen Dokumentationswebsite aus.",
"Show page author": "Seitenautor anzeigen",
"Display the page creator's name on public pages.": "Den Namen des Seitenerstellers auf öffentlichen Seiten anzeigen.",
"Toggle show page author": "Anzeige des Seitenautors umschalten",
"Show last updated": "Zuletzt aktualisiert anzeigen",
"Display when each page was last updated.": "Anzeigen, wann jede Seite zuletzt aktualisiert wurde.",
"Toggle show last updated": "Anzeige der letzten Aktualisierung umschalten",
"Open public page": "Öffentliche Seite öffnen",
"Toggle color scheme": "Farbschema umschalten",
"Ember": "Glut",
"Rose": "Rosa",
"Documentation": "Dokumentation",
"All published spaces.": "Alle veröffentlichten Bereiche.",
"No public spaces yet.": "Noch keine öffentlichen Bereiche.",
"Show public directory": "Öffentliches Verzeichnis anzeigen",
"List published spaces at /docs for anyone to browse.": "Veröffentlichte Bereiche unter /docs auflisten, damit sie von allen durchsucht werden können.",
"Toggle show public directory": "Anzeige des öffentlichen Verzeichnisses umschalten",
"Show in public directory": "Im öffentlichen Verzeichnis anzeigen",
"List this space in the public directory at /docs.": "Diesen Bereich im öffentlichen Verzeichnis unter /docs auflisten.",
"Toggle show in public directory": "Anzeige im öffentlichen Verzeichnis umschalten",
"Open public space link": "Öffentlichen Bereichslink öffnen",
"Disable public spaces": "Öffentliche Bereiche deaktivieren",
"Space admins will be able to make their spaces publicly readable by anyone on the internet. Are you sure?": "Bereichsadministratoren können ihre Bereiche für jeden im Internet öffentlich lesbar machen. Sind Sie sicher?",
"This will immediately unpublish every published space. Re-enabling later will not republish them. Are you sure?": "Dadurch wird die Veröffentlichung aller veröffentlichten Bereiche sofort aufgehoben. Ein späteres erneutes Aktivieren veröffentlicht sie nicht erneut. Sind Sie sicher?",
"Allow": "Zulassen",
"Shared pages": "Geteilte Seiten",
"Published spaces": "Veröffentlichte Bereiche",
"Spaces published to the web will appear here": "Hier werden im Web veröffentlichte Bereiche angezeigt",
"No published spaces": "Keine veröffentlichten Bereiche",
"Published by": "Veröffentlicht von",
"Published at": "Veröffentlicht am",
"Open space": "Bereich öffnen",
"Unpublish": "Veröffentlichung aufheben",
"Unpublish space": "Veröffentlichung des Bereichs aufheben",
"This space will no longer be publicly accessible. Are you sure?": "Dieser Bereich wird nicht mehr öffentlich zugänglich sein. Sind Sie sicher?",
"More options for {{name}}": "Weitere Optionen für {{name}}",
"Edit page": "Seite bearbeiten",
"Sign in": "Anmelden",
"Open app": "App öffnen",
"No spaces match your search.": "Keine Bereiche entsprechen Ihrer Suche.",
"Welcome to our documentation": "Willkommen in unserer Dokumentation",
"Guides, references and answers across all our published spaces.": "Anleitungen, Referenzen und Antworten aus all unseren veröffentlichten Bereichen.",
"Guides, references and answers across all our spaces.": "Anleitungen, Referenzen und Antworten aus all unseren Bereichen.",
"Search documentation...": "Dokumentation durchsuchen...",
"1 published space": "1 veröffentlichter Bereich",
"{{count}} published spaces": "{{count}} veröffentlichte Bereiche",
"Actions": "Aktionen",
"Add destination": "Ziel hinzufügen",
"Are you sure you want to delete the destination": "Sind Sie sicher, dass Sie das Ziel löschen möchten",
"Audit logs": "Audit-Protokolle",
"Audit logs & SIEM": "Audit-Protokolle & SIEM",
"Auth header name": "Name des Auth-Headers",
"Auth header prefix": "Präfix des Auth-Headers",
"Body format": "Body-Format",
"Created SIEM destination": "SIEM-Ziel erstellt",
"Datadog site": "Datadog-Website",
"Defaults to this instance's hostname": "Standardmäßig wird der Hostname dieser Instanz verwendet",
"Delete destination": "Ziel löschen",
"Deleted SIEM destination": "SIEM-Ziel gelöscht",
"Destination created": "Ziel erstellt",
"Destination deleted": "Ziel gelöscht",
"Destination updated": "Ziel aktualisiert",
"Disabled": "Deaktiviert",
"Edit destination": "Ziel bearbeiten",
"Endpoint URL": "Endpunkt-URL",
"Failing": "Fehlerhaft",
"Failing since {{time}}": "Fehlerhaft seit {{time}}",
"HEC token": "HEC-Token",
"HEC URL": "HEC-URL",
"Healthy": "Fehlerfrei",
"Hide advanced options": "Erweiterte Optionen ausblenden",
"Host": "Host",
"Index": "Index",
"Insecure: connections can be intercepted.": "Unsicher: Verbindungen können abgefangen werden.",
"JSON array": "JSON-Array",
"Last delivered": "Zuletzt zugestellt",
"Last error": "Letzter Fehler",
"Leave empty to use the token's default index": "Leer lassen, um den Standardindex des Tokens zu verwenden",
"Maximum of {{limit}} destinations reached": "Maximale Anzahl von {{limit}} Zielen erreicht",
"Could not load SIEM destinations: {{message}}": "SIEM-Ziele konnten nicht geladen werden: {{message}}",
"No destinations yet": "Noch keine Ziele",
"Preset": "Voreinstellung",
"Retry now": "Jetzt erneut versuchen",
"Retry scheduled": "Wiederholung geplant",
"Send test event": "Testereignis senden",
"Sent in the auth header below. Leave empty if your receiver does not need one.": "Wird im unten stehenden Auth-Header gesendet. Leer lassen, wenn Ihr Empfänger keinen benötigt.",
"Service": "Dienst",
"Show advanced options": "Erweiterte Optionen anzeigen",
"SIEM": "SIEM",
"SIEM streaming": "SIEM-Streaming",
"SIEM streaming requires an Enterprise license.": "SIEM-Streaming erfordert eine Enterprise-Lizenz.",
"Source": "Quelle",
"Sourcetype": "Quelltyp",
"Tags": "Tags",
"Test connection": "Verbindung testen",
"Test event delivered successfully.": "Testereignis erfolgreich zugestellt.",
"Test the connection before saving.": "Testen Sie die Verbindung vor dem Speichern.",
"Test event delivered to {{name}}": "Testereignis an {{name}} zugestellt",
"Updated SIEM destination": "SIEM-Ziel aktualisiert",
"Verify TLS certificate": "TLS-Zertifikat verifizieren",
"e.g. Splunk prod": "z. B. Splunk Prod"
"you": "du"
}
@@ -793,10 +793,6 @@
"Removed page restriction": "Removed page restriction",
"Added page permission": "Added 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",
"days": "days",
"week": "week",
@@ -884,9 +880,6 @@
"<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 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",
"Please wait...": "Please wait...",
"Verification failed. The link may have expired.": "Verification failed. The link may have expired.",
@@ -1344,137 +1337,5 @@
"AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat can search and read workspace content, but cannot create or edit pages.",
"Toggle AI Chat read-only mode": "Toggle AI Chat read-only mode",
"Title only": "Title only",
"you": "you",
"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"
"you": "you"
}
@@ -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 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.",
"Enforce OAuth": "Exigir OAuth",
"Enforce OAuth": "Enforce 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.",
"Toggle enforce OAuth for MCP": "Activar o desactivar la exigencia de OAuth para MCP",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
"Supported tools": "Herramientas compatibles",
"MCP server URL:": "URL del servidor MCP:",
"Learn more": "Más información",
@@ -793,10 +793,6 @@
"Removed page restriction": "Restricción de página eliminada",
"Added page permission": "Permiso de página añadido",
"Removed page permission": "Permiso de página eliminado",
"Changed page permission": "Permiso de página cambiado",
"Requested password reset": "Se solicitó el restablecimiento de contraseña",
"Created template": "Plantilla creada",
"Deleted template": "Plantilla eliminada",
"day": "día",
"days": "días",
"week": "semana",
@@ -884,9 +880,6 @@
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold> devolvió una página para revisión",
"Page verification expires soon": "La verificación de la página caduca pronto",
"Page verification has expired": "La verificación de la página ha caducado",
"SIEM destination <bold>{{name}}</bold> is failing": "El destino SIEM <bold>{{name}}</bold> está fallando",
"SIEM destination <bold>{{name}}</bold> was disabled after 24 hours of failures": "El destino SIEM <bold>{{name}}</bold> se deshabilitó después de 24 horas de fallos",
"SIEM destination <bold>{{name}}</bold> recovered": "El destino SIEM <bold>{{name}}</bold> se recuperó",
"Verifying your email": "Verificando tu correo electrónico",
"Please wait...": "Por favor, espera...",
"Verification failed. The link may have expired.": "La verificación ha fallado. Es posible que el enlace haya expirado.",
@@ -1344,137 +1337,5 @@
"AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat puede buscar y leer el contenido del espacio de trabajo, pero no puede crear ni editar páginas.",
"Toggle AI Chat read-only mode": "Alternar modo de solo lectura de AI Chat",
"Title only": "Solo título",
"you": "tú",
"Allow public spaces": "Permitir espacios públicos",
"Space admins can publish their spaces to the web.": "Los administradores de espacios pueden publicar sus espacios en la web.",
"Toggle allow public spaces": "Alternar permitir espacios públicos",
"Publish space to the web": "Publicar espacio en la web",
"Anyone on the internet will be able to read every page in this space, except restricted pages. Are you sure?": "Cualquier persona en internet podrá leer todas las páginas de este espacio, excepto las páginas restringidas. ¿Está seguro?",
"Make this space publicly readable by anyone on the internet.": "Haga que este espacio sea legible públicamente por cualquier persona en internet.",
"Toggle publish space to the web": "Alternar publicar espacio en la web",
"Allow search engines to index": "Permitir que los motores de búsqueda indexen",
"Let public pages in this space appear in search engine results.": "Permita que las páginas públicas de este espacio aparezcan en los resultados de los motores de búsqueda.",
"Toggle search engine indexing": "Alternar indexación en motores de búsqueda",
"Public space link": "Enlace público del espacio",
"Copy public space link": "Copiar enlace público del espacio",
"Renaming the space slug will break public links.": "Cambiar el slug del espacio romperá los enlaces públicos.",
"Failed to update space": "No se pudo actualizar el espacio",
"This space is public": "Este espacio es público",
"Anyone on the internet can read the pages in this space, except restricted pages.": "Cualquier persona en internet puede leer las páginas de este espacio, excepto las páginas restringidas.",
"Open public site": "Abrir sitio público",
"Public": "Público",
"This space has no public pages yet.": "Este espacio aún no tiene páginas públicas.",
"On this page": "En esta página",
"Previous": "Anterior",
"Next": "Siguiente",
"Show hidden pages": "Mostrar páginas ocultas",
"Page navigation": "Navegación de páginas",
"Toggle sidebar": "Alternar barra lateral",
"Toggle table of contents": "Alternar tabla de contenido",
"Appearance": "Apariencia",
"Forest": "Bosque",
"Violet": "Violeta",
"Light mode color": "Color del modo claro",
"Dark mode color": "Color del modo oscuro",
"Choose the primary color of the public docs site.": "Elija el color principal del sitio de documentación pública.",
"Show page author": "Mostrar autor de la página",
"Display the page creator's name on public pages.": "Mostrar el nombre del creador de la página en las páginas públicas.",
"Toggle show page author": "Alternar mostrar autor de la página",
"Show last updated": "Mostrar última actualización",
"Display when each page was last updated.": "Mostrar cuándo se actualizó por última vez cada página.",
"Toggle show last updated": "Alternar mostrar última actualización",
"Open public page": "Abrir página pública",
"Toggle color scheme": "Alternar esquema de color",
"Ember": "Ascua",
"Rose": "Rosa",
"Documentation": "Documentación",
"All published spaces.": "Todos los espacios publicados.",
"No public spaces yet.": "Aún no hay espacios públicos.",
"Show public directory": "Mostrar directorio público",
"List published spaces at /docs for anyone to browse.": "Enumere los espacios publicados en /docs para que cualquiera pueda explorarlos.",
"Toggle show public directory": "Alternar mostrar directorio público",
"Show in public directory": "Mostrar en el directorio público",
"List this space in the public directory at /docs.": "Enumere este espacio en el directorio público en /docs.",
"Toggle show in public directory": "Alternar mostrar en el directorio público",
"Open public space link": "Abrir enlace público del espacio",
"Disable public spaces": "Desactivar espacios públicos",
"Space admins will be able to make their spaces publicly readable by anyone on the internet. Are you sure?": "Los administradores de espacios podrán hacer que sus espacios sean legibles públicamente por cualquier persona en internet. ¿Está seguro?",
"This will immediately unpublish every published space. Re-enabling later will not republish them. Are you sure?": "Esto despublicará inmediatamente todos los espacios publicados. Volver a habilitarlos más tarde no los volverá a publicar. ¿Está seguro?",
"Allow": "Permitir",
"Shared pages": "Páginas compartidas",
"Published spaces": "Espacios publicados",
"Spaces published to the web will appear here": "Los espacios publicados en la web aparecerán aquí",
"No published spaces": "No hay espacios publicados",
"Published by": "Publicado por",
"Published at": "Publicado el",
"Open space": "Abrir espacio",
"Unpublish": "Despublicar",
"Unpublish space": "Despublicar espacio",
"This space will no longer be publicly accessible. Are you sure?": "Este espacio dejará de ser accesible públicamente. ¿Está seguro?",
"More options for {{name}}": "Más opciones para {{name}}",
"Edit page": "Editar página",
"Sign in": "Iniciar sesión",
"Open app": "Abrir aplicación",
"No spaces match your search.": "Ningún espacio coincide con su búsqueda.",
"Welcome to our documentation": "Bienvenido a nuestra documentación",
"Guides, references and answers across all our published spaces.": "Guías, referencias y respuestas en todos nuestros espacios publicados.",
"Guides, references and answers across all our spaces.": "Guías, referencias y respuestas en todos nuestros espacios.",
"Search documentation...": "Buscar en la documentación...",
"1 published space": "1 espacio publicado",
"{{count}} published spaces": "{{count, plural, one {# espacio publicado} other {# espacios publicados}}}",
"Actions": "Acciones",
"Add destination": "Añadir destino",
"Are you sure you want to delete the destination": "¿Está seguro de que desea eliminar el destino",
"Audit logs": "Registros de auditoría",
"Audit logs & SIEM": "Registros de auditoría y SIEM",
"Auth header name": "Nombre del encabezado de autenticación",
"Auth header prefix": "Prefijo del encabezado de autenticación",
"Body format": "Formato del cuerpo",
"Created SIEM destination": "Destino SIEM creado",
"Datadog site": "Sitio de Datadog",
"Defaults to this instance's hostname": "Usa de forma predeterminada el nombre de host de esta instancia",
"Delete destination": "Eliminar destino",
"Deleted SIEM destination": "Destino SIEM eliminado",
"Destination created": "Destino creado",
"Destination deleted": "Destino eliminado",
"Destination updated": "Destino actualizado",
"Disabled": "Deshabilitado",
"Edit destination": "Editar destino",
"Endpoint URL": "URL del endpoint",
"Failing": "Con fallos",
"Failing since {{time}}": "Con fallos desde {{time}}",
"HEC token": "Token HEC",
"HEC URL": "URL de HEC",
"Healthy": "Correcto",
"Hide advanced options": "Ocultar opciones avanzadas",
"Host": "Host",
"Index": "Índice",
"Insecure: connections can be intercepted.": "Inseguro: las conexiones pueden ser interceptadas.",
"JSON array": "Matriz JSON",
"Last delivered": "Última entrega",
"Last error": "Último error",
"Leave empty to use the token's default index": "Déjelo vacío para usar el índice predeterminado del token",
"Maximum of {{limit}} destinations reached": "Se alcanzó el máximo de {{limit}} destinos",
"Could not load SIEM destinations: {{message}}": "No se pudieron cargar los destinos SIEM: {{message}}",
"No destinations yet": "Aún no hay destinos",
"Preset": "Preajuste",
"Retry now": "Reintentar ahora",
"Retry scheduled": "Reintento programado",
"Send test event": "Enviar evento de prueba",
"Sent in the auth header below. Leave empty if your receiver does not need one.": "Se envía en el encabezado de autenticación de abajo. Déjelo vacío si su receptor no necesita uno.",
"Service": "Servicio",
"Show advanced options": "Mostrar opciones avanzadas",
"SIEM": "SIEM",
"SIEM streaming": "Transmisión SIEM",
"SIEM streaming requires an Enterprise license.": "La transmisión SIEM requiere una licencia Enterprise.",
"Source": "Origen",
"Sourcetype": "Tipo de origen",
"Tags": "Etiquetas",
"Test connection": "Probar conexión",
"Test event delivered successfully.": "El evento de prueba se entregó correctamente.",
"Test the connection before saving.": "Pruebe la conexión antes de guardar.",
"Test event delivered to {{name}}": "Evento de prueba entregado a {{name}}",
"Updated SIEM destination": "Destino SIEM actualizado",
"Verify TLS certificate": "Verificar certificado TLS",
"e.g. Splunk prod": "p. ej., Splunk producción"
"you": "tú"
}
@@ -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 Server URL": "URL du serveur MCP",
"Connect AI assistants with your Docmost account via OAuth.": "Connectez des assistants IA à votre compte Docmost via OAuth.",
"Enforce OAuth": "Imposer OAuth",
"Enforce OAuth": "Enforce 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.",
"Toggle enforce OAuth for MCP": "Activer ou désactiver limposition dOAuth pour MCP",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
"Supported tools": "Outils pris en charge",
"MCP server URL:": "URL du serveur MCP :",
"Learn more": "En savoir plus",
@@ -793,10 +793,6 @@
"Removed page restriction": "Restriction de la page supprimée",
"Added page permission": "Autorisation de la page ajoutée",
"Removed page permission": "Autorisation de la page supprimée",
"Changed page permission": "Autorisation de page modifiée",
"Requested password reset": "Réinitialisation du mot de passe demandée",
"Created template": "Modèle créé",
"Deleted template": "Modèle supprimé",
"day": "jour",
"days": "jours",
"week": "semaine",
@@ -884,9 +880,6 @@
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold> a renvoyé une page pour révision",
"Page verification expires soon": "La vérification de la page expire bientôt",
"Page verification has expired": "La vérification de la page a expiré",
"SIEM destination <bold>{{name}}</bold> is failing": "La destination SIEM <bold>{{name}}</bold> est en échec",
"SIEM destination <bold>{{name}}</bold> was disabled after 24 hours of failures": "La destination SIEM <bold>{{name}}</bold> a été désactivée après 24 heures d’échecs",
"SIEM destination <bold>{{name}}</bold> recovered": "La destination SIEM <bold>{{name}}</bold> a récupéré",
"Verifying your email": "Vérification de votre e-mail",
"Please wait...": "Veuillez patienter...",
"Verification failed. The link may have expired.": "Échec de la vérification. Le lien a peut-être expiré.",
@@ -1344,137 +1337,5 @@
"AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat peut rechercher et lire le contenu de lespace de travail, mais ne peut pas créer ni modifier des pages.",
"Toggle AI Chat read-only mode": "Activer/désactiver le mode lecture seule dAI Chat",
"Title only": "Titre uniquement",
"you": "vous",
"Allow public spaces": "Autoriser les espaces publics",
"Space admins can publish their spaces to the web.": "Les administrateurs despace peuvent publier leurs espaces sur le Web.",
"Toggle allow public spaces": "Basculer lautorisation des espaces publics",
"Publish space to the web": "Publier lespace sur le Web",
"Anyone on the internet will be able to read every page in this space, except restricted pages. Are you sure?": "Toute personne sur Internet pourra lire chaque page de cet espace, sauf les pages restreintes. Êtes-vous sûr ?",
"Make this space publicly readable by anyone on the internet.": "Rendre cet espace lisible publiquement par toute personne sur Internet.",
"Toggle publish space to the web": "Basculer la publication de lespace sur le Web",
"Allow search engines to index": "Autoriser les moteurs de recherche à indexer",
"Let public pages in this space appear in search engine results.": "Permettre aux pages publiques de cet espace dapparaître dans les résultats des moteurs de recherche.",
"Toggle search engine indexing": "Basculer lindexation par les moteurs de recherche",
"Public space link": "Lien public de lespace",
"Copy public space link": "Copier le lien public de lespace",
"Renaming the space slug will break public links.": "Renommer lidentifiant despace cassera les liens publics.",
"Failed to update space": "Échec de la mise à jour de lespace",
"This space is public": "Cet espace est public",
"Anyone on the internet can read the pages in this space, except restricted pages.": "Toute personne sur Internet peut lire les pages de cet espace, sauf les pages restreintes.",
"Open public site": "Ouvrir le site public",
"Public": "Public",
"This space has no public pages yet.": "Cet espace na encore aucune page publique.",
"On this page": "Sur cette page",
"Previous": "Précédent",
"Next": "Suivant",
"Show hidden pages": "Afficher les pages masquées",
"Page navigation": "Navigation de page",
"Toggle sidebar": "Basculer la barre latérale",
"Toggle table of contents": "Basculer la table des matières",
"Appearance": "Apparence",
"Forest": "Forêt",
"Violet": "Violet",
"Light mode color": "Couleur du mode clair",
"Dark mode color": "Couleur du mode sombre",
"Choose the primary color of the public docs site.": "Choisissez la couleur principale du site de documentation public.",
"Show page author": "Afficher lauteur de la page",
"Display the page creator's name on public pages.": "Afficher le nom du créateur de la page sur les pages publiques.",
"Toggle show page author": "Basculer laffichage de lauteur de la page",
"Show last updated": "Afficher la dernière mise à jour",
"Display when each page was last updated.": "Afficher la date de dernière mise à jour de chaque page.",
"Toggle show last updated": "Basculer laffichage de la dernière mise à jour",
"Open public page": "Ouvrir la page publique",
"Toggle color scheme": "Basculer le schéma de couleurs",
"Ember": "Braise",
"Rose": "Rose",
"Documentation": "Documentation",
"All published spaces.": "Tous les espaces publiés.",
"No public spaces yet.": "Aucun espace public pour le moment.",
"Show public directory": "Afficher le répertoire public",
"List published spaces at /docs for anyone to browse.": "Lister les espaces publiés dans /docs pour que tout le monde puisse les parcourir.",
"Toggle show public directory": "Basculer laffichage du répertoire public",
"Show in public directory": "Afficher dans le répertoire public",
"List this space in the public directory at /docs.": "Lister cet espace dans le répertoire public à /docs.",
"Toggle show in public directory": "Basculer laffichage dans le répertoire public",
"Open public space link": "Ouvrir le lien public de lespace",
"Disable public spaces": "Désactiver les espaces publics",
"Space admins will be able to make their spaces publicly readable by anyone on the internet. Are you sure?": "Les administrateurs despace pourront rendre leurs espaces lisibles publiquement par toute personne sur Internet. Êtes-vous sûr ?",
"This will immediately unpublish every published space. Re-enabling later will not republish them. Are you sure?": "Cela dépubliera immédiatement chaque espace publié. Les réactiver plus tard ne les republiera pas. Êtes-vous sûr ?",
"Allow": "Autoriser",
"Shared pages": "Pages partagées",
"Published spaces": "Espaces publiés",
"Spaces published to the web will appear here": "Les espaces publiés sur le Web apparaîtront ici",
"No published spaces": "Aucun espace publié",
"Published by": "Publié par",
"Published at": "Publié le",
"Open space": "Ouvrir lespace",
"Unpublish": "Dépublier",
"Unpublish space": "Dépublier lespace",
"This space will no longer be publicly accessible. Are you sure?": "Cet espace ne sera plus accessible publiquement. Êtes-vous sûr ?",
"More options for {{name}}": "Plus doptions pour {{name}}",
"Edit page": "Modifier la page",
"Sign in": "Se connecter",
"Open app": "Ouvrir lapplication",
"No spaces match your search.": "Aucun espace ne correspond à votre recherche.",
"Welcome to our documentation": "Bienvenue dans notre documentation",
"Guides, references and answers across all our published spaces.": "Guides, références et réponses dans tous nos espaces publiés.",
"Guides, references and answers across all our spaces.": "Guides, références et réponses dans tous nos espaces.",
"Search documentation...": "Rechercher dans la documentation...",
"1 published space": "1 espace publié",
"{{count}} published spaces": "{{count}} published spaces",
"Actions": "Actions",
"Add destination": "Ajouter une destination",
"Are you sure you want to delete the destination": "Êtes-vous sûr de vouloir supprimer la destination",
"Audit logs": "Journaux daudit",
"Audit logs & SIEM": "Journaux daudit et SIEM",
"Auth header name": "Nom de len-tête dauthentification",
"Auth header prefix": "Préfixe de len-tête dauthentification",
"Body format": "Format du corps",
"Created SIEM destination": "Destination SIEM créée",
"Datadog site": "Site Datadog",
"Defaults to this instance's hostname": "Utilise par défaut le nom dhôte de cette instance",
"Delete destination": "Supprimer la destination",
"Deleted SIEM destination": "Destination SIEM supprimée",
"Destination created": "Destination créée",
"Destination deleted": "Destination supprimée",
"Destination updated": "Destination mise à jour",
"Disabled": "Désactivé",
"Edit destination": "Modifier la destination",
"Endpoint URL": "URL du point de terminaison",
"Failing": "En échec",
"Failing since {{time}}": "En échec depuis {{time}}",
"HEC token": "Jeton HEC",
"HEC URL": "URL HEC",
"Healthy": "Sain",
"Hide advanced options": "Masquer les options avancées",
"Host": "Hôte",
"Index": "Index",
"Insecure: connections can be intercepted.": "Non sécurisé : les connexions peuvent être interceptées.",
"JSON array": "Tableau JSON",
"Last delivered": "Dernière livraison",
"Last error": "Dernière erreur",
"Leave empty to use the token's default index": "Laissez vide pour utiliser lindex par défaut du jeton",
"Maximum of {{limit}} destinations reached": "Nombre maximal de destinations atteint : {{limit}}",
"Could not load SIEM destinations: {{message}}": "Impossible de charger les destinations SIEM : {{message}}",
"No destinations yet": "Aucune destination pour le moment",
"Preset": "Préréglage",
"Retry now": "Réessayer maintenant",
"Retry scheduled": "Nouvelle tentative planifiée",
"Send test event": "Envoyer un événement de test",
"Sent in the auth header below. Leave empty if your receiver does not need one.": "Envoyé dans len-tête dauthentification ci-dessous. Laissez vide si votre récepteur nen a pas besoin.",
"Service": "Service",
"Show advanced options": "Afficher les options avancées",
"SIEM": "SIEM",
"SIEM streaming": "Streaming SIEM",
"SIEM streaming requires an Enterprise license.": "Le streaming SIEM nécessite une licence Enterprise.",
"Source": "Source",
"Sourcetype": "Type de source",
"Tags": "Tags",
"Test connection": "Tester la connexion",
"Test event delivered successfully.": "Événement de test envoyé avec succès.",
"Test the connection before saving.": "Testez la connexion avant denregistrer.",
"Test event delivered to {{name}}": "Événement de test envoyé à {{name}}",
"Updated SIEM destination": "Destination SIEM mise à jour",
"Verify TLS certificate": "Vérifier le certificat TLS",
"e.g. Splunk prod": "p. ex. Splunk prod"
"you": "vous"
}
@@ -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 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.",
"Enforce OAuth": "Rendi obbligatorio OAuth",
"Enforce OAuth": "Enforce 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.",
"Toggle enforce OAuth for MCP": "Attiva/disattiva l'obbligo di OAuth per MCP",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
"Supported tools": "Strumenti supportati",
"MCP server URL:": "URL del server MCP:",
"Learn more": "Scopri di più",
@@ -793,10 +793,6 @@
"Removed page restriction": "Restrizione della pagina rimossa",
"Added page permission": "Permesso sulla pagina aggiunto",
"Removed page permission": "Permesso sulla pagina rimosso",
"Changed page permission": "Autorizzazione pagina modificata",
"Requested password reset": "Richiesta reimpostazione password",
"Created template": "Modello creato",
"Deleted template": "Modello eliminato",
"day": "giorno",
"days": "giorni",
"week": "settimana",
@@ -884,9 +880,6 @@
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold> ha restituito una pagina per la revisione",
"Page verification expires soon": "La verifica della pagina scadrà presto",
"Page verification has expired": "La verifica della pagina è scaduta",
"SIEM destination <bold>{{name}}</bold> is failing": "La destinazione SIEM <bold>{{name}}</bold> non funziona",
"SIEM destination <bold>{{name}}</bold> was disabled after 24 hours of failures": "La destinazione SIEM <bold>{{name}}</bold> è stata disattivata dopo 24 ore di errori",
"SIEM destination <bold>{{name}}</bold> recovered": "La destinazione SIEM <bold>{{name}}</bold> è stata ripristinata",
"Verifying your email": "Verifica della tua email in corso",
"Please wait...": "Attendere...",
"Verification failed. The link may have expired.": "Verifica non riuscita. Il link potrebbe essere scaduto.",
@@ -1344,137 +1337,5 @@
"AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat può cercare e leggere i contenuti del workspace, ma non può creare o modificare pagine.",
"Toggle AI Chat read-only mode": "Attiva/disattiva la modalità di sola lettura di AI Chat",
"Title only": "Solo titolo",
"you": "tu",
"Allow public spaces": "Consenti spazi pubblici",
"Space admins can publish their spaces to the web.": "Gli amministratori degli spazi possono pubblicare i loro spazi sul web.",
"Toggle allow public spaces": "Attiva/disattiva spazi pubblici",
"Publish space to the web": "Pubblica spazio sul web",
"Anyone on the internet will be able to read every page in this space, except restricted pages. Are you sure?": "Chiunque su internet potrà leggere ogni pagina in questo spazio, eccetto le pagine con restrizioni. Sei sicuro?",
"Make this space publicly readable by anyone on the internet.": "Rendi questo spazio leggibile pubblicamente da chiunque su internet.",
"Toggle publish space to the web": "Attiva/disattiva pubblicazione dello spazio sul web",
"Allow search engines to index": "Consenti ai motori di ricerca di indicizzare",
"Let public pages in this space appear in search engine results.": "Consenti alle pagine pubbliche in questo spazio di apparire nei risultati dei motori di ricerca.",
"Toggle search engine indexing": "Attiva/disattiva indicizzazione dei motori di ricerca",
"Public space link": "Link pubblico dello spazio",
"Copy public space link": "Copia link pubblico dello spazio",
"Renaming the space slug will break public links.": "La ridenominazione dello slug dello spazio interromperà i link pubblici.",
"Failed to update space": "Impossibile aggiornare lo spazio",
"This space is public": "Questo spazio è pubblico",
"Anyone on the internet can read the pages in this space, except restricted pages.": "Chiunque su internet può leggere le pagine in questo spazio, eccetto le pagine con restrizioni.",
"Open public site": "Apri sito pubblico",
"Public": "Pubblico",
"This space has no public pages yet.": "Questo spazio non ha ancora pagine pubbliche.",
"On this page": "In questa pagina",
"Previous": "Precedente",
"Next": "Successivo",
"Show hidden pages": "Mostra pagine nascoste",
"Page navigation": "Navigazione pagina",
"Toggle sidebar": "Attiva/disattiva barra laterale",
"Toggle table of contents": "Attiva/disattiva sommario",
"Appearance": "Aspetto",
"Forest": "Foresta",
"Violet": "Viola",
"Light mode color": "Colore modalità chiara",
"Dark mode color": "Colore modalità scura",
"Choose the primary color of the public docs site.": "Scegli il colore principale del sito pubblico della documentazione.",
"Show page author": "Mostra autore della pagina",
"Display the page creator's name on public pages.": "Mostra il nome del creatore della pagina nelle pagine pubbliche.",
"Toggle show page author": "Attiva/disattiva mostra autore della pagina",
"Show last updated": "Mostra ultimo aggiornamento",
"Display when each page was last updated.": "Mostra quando ogni pagina è stata aggiornata l'ultima volta.",
"Toggle show last updated": "Attiva/disattiva mostra ultimo aggiornamento",
"Open public page": "Apri pagina pubblica",
"Toggle color scheme": "Attiva/disattiva schema colori",
"Ember": "Brace",
"Rose": "Rosa",
"Documentation": "Documentazione",
"All published spaces.": "Tutti gli spazi pubblicati.",
"No public spaces yet.": "Nessuno spazio pubblico al momento.",
"Show public directory": "Mostra directory pubblica",
"List published spaces at /docs for anyone to browse.": "Elenca gli spazi pubblicati in /docs affinché chiunque possa consultarli.",
"Toggle show public directory": "Attiva/disattiva mostra directory pubblica",
"Show in public directory": "Mostra nella directory pubblica",
"List this space in the public directory at /docs.": "Elenca questo spazio nella directory pubblica in /docs.",
"Toggle show in public directory": "Attiva/disattiva mostra nella directory pubblica",
"Open public space link": "Apri link pubblico dello spazio",
"Disable public spaces": "Disabilita spazi pubblici",
"Space admins will be able to make their spaces publicly readable by anyone on the internet. Are you sure?": "Gli amministratori degli spazi potranno rendere i loro spazi leggibili pubblicamente da chiunque su internet. Sei sicuro?",
"This will immediately unpublish every published space. Re-enabling later will not republish them. Are you sure?": "Questo annullerà immediatamente la pubblicazione di ogni spazio pubblicato. Riattivarlo in seguito non li ripubblicherà. Sei sicuro?",
"Allow": "Consenti",
"Shared pages": "Pagine condivise",
"Published spaces": "Spazi pubblicati",
"Spaces published to the web will appear here": "Gli spazi pubblicati sul web appariranno qui",
"No published spaces": "Nessuno spazio pubblicato",
"Published by": "Pubblicato da",
"Published at": "Pubblicato il",
"Open space": "Apri spazio",
"Unpublish": "Annulla pubblicazione",
"Unpublish space": "Annulla pubblicazione dello spazio",
"This space will no longer be publicly accessible. Are you sure?": "Questo spazio non sarà più accessibile pubblicamente. Sei sicuro?",
"More options for {{name}}": "Altre opzioni per {{name}}",
"Edit page": "Modifica pagina",
"Sign in": "Accedi",
"Open app": "Apri app",
"No spaces match your search.": "Nessuno spazio corrisponde alla tua ricerca.",
"Welcome to our documentation": "Benvenuto nella nostra documentazione",
"Guides, references and answers across all our published spaces.": "Guide, riferimenti e risposte in tutti i nostri spazi pubblicati.",
"Guides, references and answers across all our spaces.": "Guide, riferimenti e risposte in tutti i nostri spazi.",
"Search documentation...": "Cerca nella documentazione...",
"1 published space": "1 published space",
"{{count}} published spaces": "{{count}} published spaces",
"Actions": "Azioni",
"Add destination": "Aggiungi destinazione",
"Are you sure you want to delete the destination": "Sei sicuro di voler eliminare la destinazione",
"Audit logs": "Log di controllo",
"Audit logs & SIEM": "Log di controllo e SIEM",
"Auth header name": "Nome intestazione di autenticazione",
"Auth header prefix": "Prefisso intestazione di autenticazione",
"Body format": "Formato del corpo",
"Created SIEM destination": "Destinazione SIEM creata",
"Datadog site": "Sito Datadog",
"Defaults to this instance's hostname": "Per impostazione predefinita usa il nome host di questa istanza",
"Delete destination": "Elimina destinazione",
"Deleted SIEM destination": "Destinazione SIEM eliminata",
"Destination created": "Destinazione creata",
"Destination deleted": "Destinazione eliminata",
"Destination updated": "Destinazione aggiornata",
"Disabled": "Disattivata",
"Edit destination": "Modifica destinazione",
"Endpoint URL": "URL endpoint",
"Failing": "Non funzionante",
"Failing since {{time}}": "Non funzionante da {{time}}",
"HEC token": "Token HEC",
"HEC URL": "URL HEC",
"Healthy": "Funzionante",
"Hide advanced options": "Nascondi opzioni avanzate",
"Host": "Host",
"Index": "Indice",
"Insecure: connections can be intercepted.": "Non sicuro: le connessioni possono essere intercettate.",
"JSON array": "Array JSON",
"Last delivered": "Ultima consegna",
"Last error": "Ultimo errore",
"Leave empty to use the token's default index": "Lascia vuoto per usare l'indice predefinito del token",
"Maximum of {{limit}} destinations reached": "Raggiunto il numero massimo di {{limit}} destinazioni",
"Could not load SIEM destinations: {{message}}": "Impossibile caricare le destinazioni SIEM: {{message}}",
"No destinations yet": "Nessuna destinazione al momento",
"Preset": "Predefinito",
"Retry now": "Riprova ora",
"Retry scheduled": "Riprova pianificata",
"Send test event": "Invia evento di test",
"Sent in the auth header below. Leave empty if your receiver does not need one.": "Inviato nell'intestazione di autenticazione qui sotto. Lascia vuoto se il ricevente non ne ha bisogno.",
"Service": "Servizio",
"Show advanced options": "Mostra opzioni avanzate",
"SIEM": "SIEM",
"SIEM streaming": "Streaming SIEM",
"SIEM streaming requires an Enterprise license.": "Lo streaming SIEM richiede una licenza Enterprise.",
"Source": "Origine",
"Sourcetype": "Tipo di origine",
"Tags": "Tag",
"Test connection": "Testa connessione",
"Test event delivered successfully.": "Evento di test inviato con successo.",
"Test the connection before saving.": "Testa la connessione prima di salvare.",
"Test event delivered to {{name}}": "Evento di test inviato a {{name}}",
"Updated SIEM destination": "Destinazione SIEM aggiornata",
"Verify TLS certificate": "Verifica certificato TLS",
"e.g. Splunk prod": "ad es. Splunk produzione"
"you": "tu"
}
@@ -708,9 +708,9 @@
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP は Docmost のエンタープライズ版でのみ利用可能です。sales@docmost.com までお問い合わせください。",
"MCP Server URL": "MCP サーバーの URL",
"Connect AI assistants with your Docmost account via OAuth.": "OAuth を使用して AI アシスタントを Docmost アカウントに接続します。",
"Enforce OAuth": "OAuth を必須化",
"Enforce OAuth": "Enforce 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 キーは使用できません。",
"Toggle enforce OAuth for MCP": "MCP の OAuth 必須化を切り替え",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
"Supported tools": "サポートされているツール",
"MCP server URL:": "MCP サーバーの URL:",
"Learn more": "詳細を見る",
@@ -793,10 +793,6 @@
"Removed page restriction": "ページの制限を解除しました",
"Added page permission": "ページの権限を追加しました",
"Removed page permission": "ページの権限を削除しました",
"Changed page permission": "ページの権限を変更しました",
"Requested password reset": "パスワードのリセットをリクエストしました",
"Created template": "テンプレートを作成しました",
"Deleted template": "テンプレートを削除しました",
"day": "日",
"days": "日",
"week": "週",
@@ -884,9 +880,6 @@
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold>がページを修正のため差し戻しました",
"Page verification expires soon": "ページ検証の期限が間もなく切れます",
"Page verification has expired": "ページ検証の期限が切れています",
"SIEM destination <bold>{{name}}</bold> is failing": "SIEM 宛先 <bold>{{name}}</bold> で障害が発生しています",
"SIEM destination <bold>{{name}}</bold> was disabled after 24 hours of failures": "SIEM 宛先 <bold>{{name}}</bold> は24時間障害が続いたため無効化されました",
"SIEM destination <bold>{{name}}</bold> recovered": "SIEM 宛先 <bold>{{name}}</bold> が復旧しました",
"Verifying your email": "メールアドレスを確認しています",
"Please wait...": "お待ちください…",
"Verification failed. The link may have expired.": "認証に失敗しました。リンクの有効期限が切れている可能性があります。",
@@ -1198,7 +1191,7 @@
"Default value": "デフォルト値",
"Delete property": "プロパティを削除",
"Delete view": "ビューを削除",
"Delete {{count}} rows?_one": "1行を削除しますか?",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "降順",
"Discard": "破棄",
@@ -1298,9 +1291,9 @@
"Value": "値",
"View updated for everyone": "ビューが全員向けに更新されました",
"You have unsaved changes. Do you want to discard them?": "未保存の変更があります。破棄しますか?",
"{{count}} rows deleted_one": "1行を削除しました",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1件を選択中",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected",
"Compare": "比較",
"Compare versions": "バージョンを比較",
@@ -1344,137 +1337,5 @@
"AI Chat can search and read workspace content, but cannot create or edit pages.": "AIチャットはワークスペースのコンテンツを検索および閲覧できますが、ページの作成や編集はできません。",
"Toggle AI Chat read-only mode": "AIチャットの読み取り専用モードを切り替え",
"Title only": "タイトルのみ",
"you": "あなた",
"Allow public spaces": "公開スペースを許可",
"Space admins can publish their spaces to the web.": "スペース管理者は、自分のスペースをウェブに公開できるようになります。",
"Toggle allow public spaces": "公開スペースの許可を切り替える",
"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?": "インターネット上の誰でも、このスペース内の制限付きページを除くすべてのページを読めるようになります。よろしいですか?",
"Make this space publicly readable by anyone on the internet.": "このスペースをインターネット上の誰でも読めるようにします。",
"Toggle publish space to the web": "スペースのウェブ公開を切り替える",
"Allow search engines to index": "検索エンジンによるインデックスを許可",
"Let public pages in this space appear in search engine results.": "このスペース内の公開ページを検索エンジンの検索結果に表示できるようにします。",
"Toggle search engine indexing": "検索エンジンのインデックス登録を切り替える",
"Public space link": "公開スペースリンク",
"Copy public space link": "公開スペースリンクをコピー",
"Renaming the space slug will break public links.": "スペースのスラッグ名を変更すると公開リンクは使えなくなります。",
"Failed to update space": "スペースの更新に失敗しました",
"This space is public": "このスペースは公開されています",
"Anyone on the internet can read the pages in this space, except restricted pages.": "インターネット上の誰でも、このスペース内の制限付きページを除くページを読めます。",
"Open public site": "公開サイトを開く",
"Public": "公開",
"This space has no public pages yet.": "このスペースにはまだ公開ページがありません。",
"On this page": "このページ内",
"Previous": "前へ",
"Next": "次へ",
"Show hidden pages": "非表示のページを表示",
"Page navigation": "ページナビゲーション",
"Toggle sidebar": "サイドバーを切り替える",
"Toggle table of contents": "目次を切り替える",
"Appearance": "外観",
"Forest": "フォレスト",
"Violet": "バイオレット",
"Light mode color": "ライトモードの色",
"Dark mode color": "ダークモードの色",
"Choose the primary color of the public docs site.": "公開ドキュメントサイトのメインカラーを選択します。",
"Show page author": "ページ作成者を表示",
"Display the page creator's name on public pages.": "公開ページにページ作成者の名前を表示します。",
"Toggle show page author": "ページ作成者の表示を切り替える",
"Show last updated": "最終更新を表示",
"Display when each page was last updated.": "各ページが最後に更新された日時を表示します。",
"Toggle show last updated": "最終更新の表示を切り替える",
"Open public page": "公開ページを開く",
"Toggle color scheme": "カラースキームを切り替える",
"Ember": "アンバー",
"Rose": "ローズ",
"Documentation": "ドキュメント",
"All published spaces.": "公開されているすべてのスペース。",
"No public spaces yet.": "まだ公開スペースはありません。",
"Show public directory": "公開ディレクトリを表示",
"List published spaces at /docs for anyone to browse.": "/docs に公開されたスペースを一覧表示し、誰でも閲覧できるようにします。",
"Toggle show public directory": "公開ディレクトリの表示を切り替える",
"Show in public directory": "公開ディレクトリに表示",
"List this space in the public directory at /docs.": "このスペースを /docs の公開ディレクトリに一覧表示します。",
"Toggle show in public directory": "公開ディレクトリへの表示を切り替える",
"Open public space link": "公開スペースリンクを開く",
"Disable public spaces": "公開スペースを無効にする",
"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?": "これにより、公開されているすべてのスペースが直ちに非公開になります。後で再度有効にしても、再公開はされません。よろしいですか?",
"Allow": "許可",
"Shared pages": "共有ページ",
"Published spaces": "公開スペース",
"Spaces published to the web will appear here": "ウェブに公開されたスペースがここに表示されます",
"No published spaces": "公開スペースはありません",
"Published by": "公開者",
"Published at": "公開日時",
"Open space": "スペースを開く",
"Unpublish": "公開を停止",
"Unpublish space": "スペースの公開を停止",
"This space will no longer be publicly accessible. Are you sure?": "このスペースには今後公開アクセスできなくなります。よろしいですか?",
"More options for {{name}}": "{{name}} のその他のオプション",
"Edit page": "ページを編集",
"Sign in": "サインイン",
"Open app": "アプリを開く",
"No spaces match your search.": "検索に一致するスペースはありません。",
"Welcome to our documentation": "ドキュメントへようこそ",
"Guides, references and answers across all our published spaces.": "公開されているすべてのスペースにわたるガイド、リファレンス、回答。",
"Guides, references and answers across all our spaces.": "すべてのスペースにわたるガイド、リファレンス、回答。",
"Search documentation...": "ドキュメントを検索…",
"1 published space": "1 件の公開スペース",
"{{count}} published spaces": "{{count}} published spaces",
"Actions": "操作",
"Add destination": "宛先を追加",
"Are you sure you want to delete the destination": "この宛先を削除してもよろしいですか",
"Audit logs": "監査ログ",
"Audit logs & SIEM": "監査ログと SIEM",
"Auth header name": "認証ヘッダー名",
"Auth header prefix": "認証ヘッダープレフィックス",
"Body format": "本文形式",
"Created SIEM destination": "SIEM 宛先を作成しました",
"Datadog site": "Datadog サイト",
"Defaults to this instance's hostname": "デフォルトではこのインスタンスのホスト名が使用されます",
"Delete destination": "宛先を削除",
"Deleted SIEM destination": "SIEM 宛先を削除しました",
"Destination created": "宛先を作成しました",
"Destination deleted": "宛先を削除しました",
"Destination updated": "宛先を更新しました",
"Disabled": "無効",
"Edit destination": "宛先を編集",
"Endpoint URL": "エンドポイント URL",
"Failing": "障害発生中",
"Failing since {{time}}": "{{time}} から障害発生中",
"HEC token": "HEC トークン",
"HEC URL": "HEC URL",
"Healthy": "正常",
"Hide advanced options": "詳細オプションを隠す",
"Host": "ホスト",
"Index": "インデックス",
"Insecure: connections can be intercepted.": "安全ではありません: 接続が傍受される可能性があります。",
"JSON array": "JSON 配列",
"Last delivered": "最終配信",
"Last error": "最後のエラー",
"Leave empty to use the token's default index": "トークンのデフォルトインデックスを使用するには空欄のままにしてください",
"Maximum of {{limit}} destinations reached": "宛先は最大 {{limit}} 件までです",
"Could not load SIEM destinations: {{message}}": "SIEM 宛先を読み込めませんでした: {{message}}",
"No destinations yet": "宛先はまだありません",
"Preset": "プリセット",
"Retry now": "今すぐ再試行",
"Retry scheduled": "再試行予定",
"Send test event": "テストイベントを送信",
"Sent in the auth header below. Leave empty if your receiver does not need one.": "以下の認証ヘッダーで送信されます。受信側で不要な場合は空欄のままにしてください。",
"Service": "サービス",
"Show advanced options": "詳細オプションを表示",
"SIEM": "SIEM",
"SIEM streaming": "SIEM ストリーミング",
"SIEM streaming requires an Enterprise license.": "SIEM ストリーミングにはエンタープライズライセンスが必要です。",
"Source": "ソース",
"Sourcetype": "ソースタイプ",
"Tags": "タグ",
"Test connection": "接続をテスト",
"Test event delivered successfully.": "テストイベントは正常に配信されました。",
"Test the connection before saving.": "保存する前に接続をテストしてください。",
"Test event delivered to {{name}}": "テストイベントを {{name}} に配信しました",
"Updated SIEM destination": "SIEM 宛先を更新しました",
"Verify TLS certificate": "TLS 証明書を検証",
"e.g. Splunk prod": "例: Splunk 本番"
"you": "あなた"
}
+30 -169
View File
@@ -6,21 +6,21 @@
"Add groups": "그룹 추가",
"Add members": "사용자 추가",
"Add to groups": "그룹에 추가",
"Add space members": "스페이스에 사용자 추가",
"Add space members": "Space에 사용자 추가",
"Add to favorites": "즐겨찾기에 추가",
"Admin": "관리자",
"Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "이 그룹을 삭제하시겠습니까? 그룹 멤버는 이 그룹이 가진 모든 권한을 잃게 됩니다.",
"Are you sure you want to delete this page?": "이 페이지를 삭제하시겠습니까?",
"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.": "이 사용자를 Space에서 제거하시겠습니까? 사용자는 이 Space에 대한 모든 접근 권한을 잃게 됩니다.",
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "이 버전으로 복원하시겠습니까? 저장되지 않은 모든 변경사항이 손실됩니다.",
"Can become members of groups and spaces in workspace": "워크스페이스의 그룹 및 스페이스의 멤버가 될 수 있음",
"Can create and edit pages in space.": "스페이스에서 페이지를 생성하고 편집할 수 있습니다.",
"Can create and edit pages in space.": "Space에 페이지를 생성하고 편집할 수 있습니다.",
"Can edit": "편집할 수 있음",
"Can manage workspace": "워크스페이스를 관리할 수 있음",
"Can manage workspace but cannot delete it": "워크스페이스를 관리할 수 있지만, 삭제할 수 없음",
"Can manage workspace but cannot delete it": "워크스페이스를 관리할 수 있지만, 삭제할 수 없음",
"Can view": "볼 수 있음",
"Can view pages in space but not edit.": "스페이스의 페이지를 볼 수 있지만, 편집은 할 수 없음.",
"Can view pages in space but not edit.": "Space의 페이지를 볼 수 있지만, 편집은 불가능.",
"Cancel": "취소",
"Change email": "이메일 변경",
"Change password": "비밀번호 변경",
@@ -35,7 +35,7 @@
"Create": "생성",
"Create group": "그룹 생성",
"Create page": "페이지 생성",
"Create space": "스페이스 생성",
"Create space": "Space 생성",
"Create workspace": "워크스페이스 생성",
"Current password": "현재 비밀번호",
"Dark": "어두운",
@@ -107,10 +107,10 @@
"Group description": "그룹 설명",
"Group name": "그룹 이름",
"Groups": "그룹",
"Has full access to space settings and pages.": "스페이스 설정과 페이지에 대한 전체 접근 권한이 있습니다.",
"Has full access to space settings and pages.": "Space 설정과 페이지에 대한 전체 접근 권한이 있습니다.",
"Home": "홈",
"Import pages": "페이지 가져오기",
"Import pages & space settings": "페이지 및 스페이스 설정 가져오기",
"Import pages & space settings": "페이지 및 Space 설정 가져오기",
"Importing pages": "페이지 가져오는 중",
"invalid invitation link": "유효하지 않은 초대 링크입니다",
"Invitation signup": "초대 가입",
@@ -167,7 +167,7 @@
"Remove": "제거",
"Remove from favorites": "즐겨찾기에서 제거",
"Remove group member": "그룹 멤버 제거",
"Remove space member": "스페이스에서 사용자 제거",
"Remove space member": "Space에서 사용자 제거",
"Restore": "복원",
"Role": "역할",
"Save": "저장",
@@ -276,22 +276,22 @@
"Duplicate": "복제",
"Select a user": "사용자 선택",
"Select a group": "그룹 선택",
"Export all pages and attachments in this space.": "이 스페이스의 모든 페이지와 첨부 파일을 내보냅니다.",
"Delete space": "스페이스 삭제",
"Are you sure you want to delete this space?": "이 스페이스를 삭제하시겠습니까?",
"Delete this space with all its pages and data.": "이 스페이스의 모든 페이지와 데이터를 삭제합니다.",
"All pages, comments, attachments and permissions in this space will be deleted irreversibly.": "이 스페이스의 모든 페이지, 댓글, 첨부 파일 및 권한이 영구적으로 삭제됩니다.",
"Export all pages and attachments in this space.": "이 Space의 모든 페이지와 첨부파일을 내보냅니다.",
"Delete space": "Space 삭제",
"Are you sure you want to delete this space?": "이 Space을 삭제하시겠습니까?",
"Delete this space with all its pages and data.": "이 Space의 모든 페이지와 데이터를 삭제합니다.",
"All pages, comments, attachments and permissions in this space will be deleted irreversibly.": "이 Space의 모든 페이지, 댓글, 첨부파일 및 권한이 영구적으로 삭제됩니다.",
"Confirm space name": "스페이스 이름 확인",
"Type the space name <b>{{spaceName}}</b> to confirm your action.": "작업을 진행하려면 스페이스 이름 <b>{{spaceName}}</b>을(를) 입력하세요.",
"Type the space name <b>{{spaceName}}</b> to confirm your action.": "작업을 진행하려면 Space 이름 <b>{{spaceName}}</b>을 입력하세요.",
"Format": "형식",
"Include subpages": "하위 페이지 포함",
"Include attachments": "첨부 파일 포함",
"Include attachments": "첨부파일 포함",
"Select export format": "내보내기 형식 선택",
"Export failed:": "내보내기 실패:",
"export error": "내보내기 오류",
"Export page": "페이지 내보내기",
"Export successful": "내보내기 완료",
"Export space": "스페이스 내보내기",
"Export space": "Space 내보내기",
"Export {{type}}": "{{type}} 내보내기",
"File exceeds the {{limit}} attachment limit": "첨부 파일 크기 제한 {{limit}}을 초과했습니다",
"Media": "미디어",
@@ -513,7 +513,7 @@
"Toggle viewer comments": "뷰어 댓글 전환",
"Public sharing is disabled at the workspace level": "공개 공유가 워크스페이스 수준에서 비활성화됨",
"Prevent pages in this space from being shared publicly.": "이 스페이스의 페이지가 공개 공유되지 않도록 합니다.",
"Page permissions": "페이지 권한",
"Page permissions": "페이지 권한},{",
"Control who can view and edit individual pages. Available with an enterprise license.": "개별 페이지의 조회 및 편집 권한을 제어합니다. 엔터프라이즈 라이선스에서 이용 가능합니다.",
"Enable public sharing": "공개 공유 활성화",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "공개 공유를 활성화하시겠습니까? 멤버가 페이지를 공개적으로 공유할 수 있게 됩니다.",
@@ -629,14 +629,14 @@
"No subpages": "하위 페이지가 없습니다",
"Subpages (Child pages)": "하위 페이지 (자식 페이지)",
"List all subpages of the current page": "현재 페이지의 모든 하위 페이지 나열",
"Attachments": "첨부 파일",
"Attachments": "첨부파일",
"All spaces": "모든 스페이스",
"Unknown": "알 수 없음",
"Find a space": "스페이스 찾기",
"Search in all your spaces": "모든 스페이스에서 검색",
"Type": "유형",
"Enterprise": "엔터프라이즈",
"Download attachment": "첨부 파일 다운로드",
"Download attachment": "첨부파일 다운로드",
"Allowed email domains": "허용된 이메일 도메인",
"Only users with email addresses from these domains can signup via SSO.": "이 도메인의 이메일 주소를 가진 사용자만 SSO를 통해 가입할 수 있습니다.",
"Enter valid domain names separated by comma or space": "쉼표 또는 공백으로 구분된 유효한 도메인 이름을 입력하세요",
@@ -708,9 +708,9 @@
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP는 Docmost 엔터프라이즈 에디션에서만 제공됩니다. sales@docmost.com으로 문의하세요.",
"MCP Server URL": "MCP 서버 URL",
"Connect AI assistants with your Docmost account via OAuth.": "OAuth를 통해 AI 도우미를 Docmost 계정에 연결합니다.",
"Enforce OAuth": "OAuth 강제",
"Enforce OAuth": "Enforce 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 키를 사용할 수 없습니다.",
"Toggle enforce OAuth for MCP": "MCP에 대해 OAuth 강제 전환",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
"Supported tools": "지원되는 도구",
"MCP server URL:": "MCP 서버 URL:",
"Learn more": "자세히 알아보기",
@@ -791,12 +791,8 @@
"Trash retention updated": "휴지통 보관 기간이 업데이트되었습니다.",
"Failed to update trash retention": "휴지통 보관 기간 업데이트에 실패했습니다.",
"Removed page restriction": "페이지 제한이 제거됨",
"Added page permission": "페이지 권한 추가됨",
"Removed page permission": "페이지 권한 제거됨",
"Changed page permission": "페이지 권한 변경됨",
"Requested password reset": "비밀번호 재설정 요청됨",
"Created template": "템플릿 생성됨",
"Deleted template": "템플릿 삭제됨",
"Added page permission": "페이지 권한 추가됨",
"Removed page permission": "페이지 권한 제거됨",
"day": "일",
"days": "일",
"week": "주",
@@ -884,9 +880,6 @@
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold>님이 수정을 위해 페이지를 반려했습니다",
"Page verification expires soon": "페이지 검토가 곧 만료됩니다",
"Page verification has expired": "페이지 검토가 만료되었습니다",
"SIEM destination <bold>{{name}}</bold> is failing": "SIEM 대상 <bold>{{name}}</bold> 장애 발생 중",
"SIEM destination <bold>{{name}}</bold> was disabled after 24 hours of failures": "24시간 동안 지속된 장애로 SIEM 대상 <bold>{{name}}</bold> 비활성화됨",
"SIEM destination <bold>{{name}}</bold> recovered": "SIEM 대상 <bold>{{name}}</bold> 복구됨",
"Verifying your email": "이메일 확인 중",
"Please wait...": "잠시만 기다려 주세요...",
"Verification failed. The link may have expired.": "인증에 실패했습니다. 링크가 만료되었을 수 있습니다.",
@@ -1129,7 +1122,7 @@
"Toggle allow personal spaces": "개인 공간 허용 전환",
"Create personal space": "개인 공간 만들기",
"Personal space": "개인 공간",
"{{name}}'s space": "{{name}}의 스페이스",
"{{name}}'s space": "{{name}}의 공간",
"Apply": "적용",
"Cells that aren't already a page reference will be cleared.": "이미 페이지 참조인 셀이 아닌 경우 지워집니다.",
"Cells that aren't a valid URL will be cleared.": "유효한 URL이 아닌 셀은 지워집니다.",
@@ -1308,9 +1301,9 @@
"Version actions for {{date}}": "{{date}} 버전 작업",
"Comparing {{newer}} and {{older}}": "{{newer}} 및 {{older}} 비교 중",
"Exit compare": "비교 종료",
"Search attachments...": "첨부 파일 검색...",
"Error loading attachments.": "첨부 파일을 불러오는 중 오류가 발생했습니다.",
"No attachments on this page yet.": "이 페이지에는 아직 첨부 파일이 없습니다.",
"Search attachments...": "첨부파일 검색...",
"Error loading attachments.": "첨부파일을 불러오는 중 오류가 발생했습니다.",
"No attachments on this page yet.": "이 페이지에는 아직 첨부파일이 없습니다.",
"Uploaded by {{name}}": "업로드한 사람: {{name}}",
"Download {{name}}": "{{name}} 다운로드",
"Access revoked": "액세스가 취소되었습니다",
@@ -1334,7 +1327,7 @@
"View content without making changes.": "변경하지 않고 콘텐츠를 봅니다.",
"Create and modify content.": "콘텐츠를 생성하고 수정합니다.",
"Applications and AI assistants you have authorized to access your account.": "회원님의 계정에 액세스하도록 승인한 애플리케이션 및 AI 도우미입니다.",
"Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.": "워크스페이스에서 MCP가 활성화되어 있습니다. OAuth를 통해 AI 어시스턴트를 Docmost 계정에 연결하세요.",
"Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.": "워크스페이스에서 MCP가 활성화되어 있습니다. OAuth를 통해 AI 도우미를 Docmost 계정에 연결하세요.",
"Authorized apps": "승인된 앱",
"No authorized apps yet.": "아직 승인된 앱이 없습니다.",
"Workspace knowledge only": "워크스페이스 지식만 사용",
@@ -1344,137 +1337,5 @@
"AI Chat can search and read workspace content, but cannot create or edit pages.": "AI 채팅은 워크스페이스 콘텐츠를 검색하고 읽을 수 있지만, 페이지를 생성하거나 편집할 수는 없습니다.",
"Toggle AI Chat read-only mode": "AI 채팅 읽기 전용 모드 전환",
"Title only": "제목만",
"you": "회원님",
"Allow public spaces": "공개 스페이스 허용",
"Space admins can publish their spaces to the web.": "스페이스 관리자는 자신의 스페이스를 웹에 게시할 수 있습니다.",
"Toggle allow public spaces": "공개 스페이스 허용 전환",
"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?": "제한된 페이지를 제외하고 이 스페이스의 모든 페이지를 인터넷상의 누구나 읽을 수 있게 됩니다. 계속하시겠습니까?",
"Make this space publicly readable by anyone on the internet.": "이 스페이스를 인터넷상의 누구나 읽을 수 있도록 공개합니다.",
"Toggle publish space to the web": "스페이스 웹 게시 전환",
"Allow search engines to index": "검색 엔진 색인 허용",
"Let public pages in this space appear in search engine results.": "이 스페이스의 공개 페이지가 검색 엔진 결과에 표시되도록 합니다.",
"Toggle search engine indexing": "검색 엔진 색인 전환",
"Public space link": "공개 스페이스 링크",
"Copy public space link": "공개 스페이스 링크 복사",
"Renaming the space slug will break public links.": "스페이스 슬러그 이름을 변경하면 공개 링크가 작동하지 않게 됩니다.",
"Failed to update space": "스페이스 업데이트 실패",
"This space is public": "이 스페이스는 공개 상태입니다",
"Anyone on the internet can read the pages in this space, except restricted pages.": "제한된 페이지를 제외하고 이 스페이스의 페이지를 인터넷상의 누구나 읽을 수 있습니다.",
"Open public site": "공개 사이트 열기",
"Public": "공개",
"This space has no public pages yet.": "이 스페이스에는 아직 공개 페이지가 없습니다.",
"On this page": "이 페이지에서",
"Previous": "이전",
"Next": "다음",
"Show hidden pages": "숨겨진 페이지 표시",
"Page navigation": "페이지 탐색",
"Toggle sidebar": "사이드바 전환",
"Toggle table of contents": "목차 전환",
"Appearance": "모양",
"Forest": "포레스트",
"Violet": "바이올렛",
"Light mode color": "라이트 모드 색상",
"Dark mode color": "다크 모드 색상",
"Choose the primary color of the public docs site.": "공개 문서 사이트의 기본 색상을 선택하세요.",
"Show page author": "페이지 작성자 표시",
"Display the page creator's name on public pages.": "공개 페이지에 페이지 작성자의 이름을 표시합니다.",
"Toggle show page author": "페이지 작성자 표시 전환",
"Show last updated": "마지막 업데이트 표시",
"Display when each page was last updated.": "각 페이지가 마지막으로 업데이트된 시점을 표시합니다.",
"Toggle show last updated": "마지막 업데이트 표시 전환",
"Open public page": "공개 페이지 열기",
"Toggle color scheme": "색상 구성표 전환",
"Ember": "엠버",
"Rose": "로즈",
"Documentation": "문서",
"All published spaces.": "게시된 모든 스페이스입니다.",
"No public spaces yet.": "아직 공개 스페이스가 없습니다.",
"Show public directory": "공개 디렉터리 표시",
"List published spaces at /docs for anyone to browse.": "누구나 탐색할 수 있도록 /docs에 게시된 스페이스를 나열합니다.",
"Toggle show public directory": "공개 디렉터리 표시 전환",
"Show in public directory": "공개 디렉터리에 표시",
"List this space in the public directory at /docs.": "이 스페이스를 /docs의 공개 디렉터리에 나열합니다.",
"Toggle show in public directory": "공개 디렉터리에 표시 전환",
"Open public space link": "공개 스페이스 링크 열기",
"Disable public spaces": "공개 스페이스 비활성화",
"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?": "이 작업을 수행하면 현재 게시된 모든 스페이스의 게시가 즉시 취소됩니다. 나중에 다시 활성화해도 자동으로 다시 게시되지는 않습니다. 계속하시겠습니까?",
"Allow": "허용",
"Shared pages": "공유된 페이지",
"Published spaces": "게시된 스페이스",
"Spaces published to the web will appear here": "웹에 게시된 스페이스가 여기에 표시됩니다",
"No published spaces": "게시된 스페이스 없음",
"Published by": "게시자",
"Published at": "게시일",
"Open space": "스페이스 열기",
"Unpublish": "게시 취소",
"Unpublish space": "스페이스 게시 취소",
"This space will no longer be publicly accessible. Are you sure?": "이 스페이스는 더 이상 공개적으로 접근할 수 없게 됩니다. 계속하시겠습니까?",
"More options for {{name}}": "{{name}}에 대한 추가 옵션",
"Edit page": "페이지 편집",
"Sign in": "로그인",
"Open app": "앱 열기",
"No spaces match your search.": "검색과 일치하는 스페이스가 없습니다.",
"Welcome to our documentation": "문서에 오신 것을 환영합니다",
"Guides, references and answers across all our published spaces.": "게시된 모든 스페이스의 가이드, 참고 자료, 답변을 확인하세요.",
"Guides, references and answers across all our spaces.": "모든 스페이스의 가이드, 참고 자료, 답변을 확인하세요.",
"Search documentation...": "문서 검색...",
"1 published space": "게시된 스페이스 1개",
"{{count}} published spaces": "게시된 스페이스 {{count}}개",
"Actions": "작업",
"Add destination": "대상 추가",
"Are you sure you want to delete the destination": "대상을 삭제하시겠습니까?",
"Audit logs": "감사 로그",
"Audit logs & SIEM": "감사 로그 & SIEM",
"Auth header name": "인증 헤더 이름",
"Auth header prefix": "인증 헤더 접두사",
"Body format": "본문 형식",
"Created SIEM destination": "SIEM 대상 생성됨",
"Datadog site": "Datadog 사이트",
"Defaults to this instance's hostname": "기본값은 인스턴스의 호스트명입니다",
"Delete destination": "대상 삭제",
"Deleted SIEM destination": "SIEM 대상 삭제됨",
"Destination created": "대상 생성됨",
"Destination deleted": "대상 삭제됨",
"Destination updated": "대상 업데이트됨",
"Disabled": "비활성화됨",
"Edit destination": "대상 편집",
"Endpoint URL": "엔드포인트 URL",
"Failing": "장애 발생 중",
"Failing since {{time}}": "{{time}}부터 장애 발생 중",
"HEC token": "HEC 토큰",
"HEC URL": "HEC URL",
"Healthy": "정상",
"Hide advanced options": "고급 옵션 숨기기",
"Host": "호스트",
"Index": "인덱스",
"Insecure: connections can be intercepted.": "안전하지 않음: 연결이 가로채질 수 있습니다.",
"JSON array": "JSON 배열",
"Last delivered": "마지막 전송",
"Last error": "마지막 오류",
"Leave empty to use the token's default index": "토큰의 기본 인덱스를 사용하려면 비워 두세요",
"Maximum of {{limit}} destinations reached": "대상은 최대 {{limit}}개까지 추가할 수 있습니다",
"Could not load SIEM destinations: {{message}}": "SIEM 대상을 불러올 수 없습니다: {{message}}",
"No destinations yet": "등록된 대상이 없습니다",
"Preset": "프리셋",
"Retry now": "지금 재시도",
"Retry scheduled": "재시도 예정",
"Send test event": "테스트 이벤트 전송",
"Sent in the auth header below. Leave empty if your receiver does not need one.": "아래 인증 헤더를 통해 전송하였습니다. 수신 측에서 요구하지 않는 경우 비워 두세요.",
"Service": "서비스",
"Show advanced options": "고급 옵션 표시",
"SIEM": "SIEM",
"SIEM streaming": "SIEM 스트리밍",
"SIEM streaming requires an Enterprise license.": "SIEM 스트리밍은 엔터프라이즈 라이선스가 필요합니다.",
"Source": "소스",
"Sourcetype": "소스 유형",
"Tags": "태그",
"Test connection": "연결 테스트",
"Test event delivered successfully.": "테스트 이벤트가 전송되었습니다.",
"Test the connection before saving.": "저장하기 전에 연결을 테스트하세요.",
"Test event delivered to {{name}}": "테스트 이벤트가 {{name}}에 전송되었습니다",
"Updated SIEM destination": "SIEM 대상 업데이트됨",
"Verify TLS certificate": "TLS 인증서 검증",
"e.g. Splunk prod": "예: Splunk 운영"
"you": "회원님"
}
@@ -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 Server URL": "MCP-server-URL",
"Connect AI assistants with your Docmost account via OAuth.": "Verbind AI-assistenten met je Docmost-account via OAuth.",
"Enforce OAuth": "OAuth afdwingen",
"Enforce OAuth": "Enforce OAuth",
"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": "OAuth afdwingen voor MCP in- of uitschakelen",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
"Supported tools": "Ondersteunde tools",
"MCP server URL:": "MCP-server-URL:",
"Learn more": "Meer informatie",
@@ -793,10 +793,6 @@
"Removed page restriction": "Pagina-restrictie verwijderd",
"Added page permission": "Paginatoestemming toegevoegd",
"Removed page permission": "Paginatoestemming verwijderd",
"Changed page permission": "Paginamachtiging gewijzigd",
"Requested password reset": "Wachtwoordreset aangevraagd",
"Created template": "Sjabloon aangemaakt",
"Deleted template": "Sjabloon verwijderd",
"day": "dag",
"days": "dagen",
"week": "week",
@@ -884,9 +880,6 @@
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold> heeft een pagina teruggestuurd voor revisie",
"Page verification expires soon": "Paginaverificatie verloopt binnenkort",
"Page verification has expired": "Paginaverificatie is verlopen",
"SIEM destination <bold>{{name}}</bold> is failing": "SIEM-bestemming <bold>{{name}}</bold> faalt",
"SIEM destination <bold>{{name}}</bold> was disabled after 24 hours of failures": "SIEM-bestemming <bold>{{name}}</bold> is uitgeschakeld na 24 uur met fouten",
"SIEM destination <bold>{{name}}</bold> recovered": "SIEM-bestemming <bold>{{name}}</bold> hersteld",
"Verifying your email": "Je e-mailadres wordt geverifieerd",
"Please wait...": "Even geduld...",
"Verification failed. The link may have expired.": "Verificatie mislukt. De link is mogelijk verlopen.",
@@ -1344,137 +1337,5 @@
"AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat kan werkruimte-inhoud doorzoeken en lezen, maar kan geen pagina's maken of bewerken.",
"Toggle AI Chat read-only mode": "Alleen-lezenmodus voor AI Chat in-/uitschakelen",
"Title only": "Alleen titel",
"you": "jij",
"Allow public spaces": "Openbare ruimtes toestaan",
"Space admins can publish their spaces to the web.": "Ruimtebeheerders kunnen hun ruimtes op het web publiceren.",
"Toggle allow public spaces": "Openbare ruimtes toestaan in-/uitschakelen",
"Publish space to the web": "Ruimte op het web publiceren",
"Anyone on the internet will be able to read every page in this space, except restricted pages. Are you sure?": "Iedereen op internet kan elke pagina in deze ruimte lezen, behalve beperkte pagina's. Weet u het zeker?",
"Make this space publicly readable by anyone on the internet.": "Maak deze ruimte openbaar leesbaar voor iedereen op internet.",
"Toggle publish space to the web": "Publiceren van ruimte op het web in-/uitschakelen",
"Allow search engines to index": "Zoekmachines toestaan te indexeren",
"Let public pages in this space appear in search engine results.": "Laat openbare pagina's in deze ruimte verschijnen in zoekresultaten.",
"Toggle search engine indexing": "Indexering door zoekmachines in-/uitschakelen",
"Public space link": "Openbare ruimtelink",
"Copy public space link": "Openbare ruimtelink kopiëren",
"Renaming the space slug will break public links.": "Als u de slug van de ruimte wijzigt, werken openbare links niet meer.",
"Failed to update space": "Bijwerken van ruimte mislukt",
"This space is public": "Deze ruimte is openbaar",
"Anyone on the internet can read the pages in this space, except restricted pages.": "Iedereen op internet kan de pagina's in deze ruimte lezen, behalve beperkte pagina's.",
"Open public site": "Open openbare site",
"Public": "Openbaar",
"This space has no public pages yet.": "Deze ruimte heeft nog geen openbare pagina's.",
"On this page": "Op deze pagina",
"Previous": "Vorige",
"Next": "Volgende",
"Show hidden pages": "Verborgen pagina's weergeven",
"Page navigation": "Paginanavigatie",
"Toggle sidebar": "Zijbalk in-/uitschakelen",
"Toggle table of contents": "Inhoudsopgave in-/uitschakelen",
"Appearance": "Weergave",
"Forest": "Bos",
"Violet": "Violet",
"Light mode color": "Kleur voor lichte modus",
"Dark mode color": "Kleur voor donkere modus",
"Choose the primary color of the public docs site.": "Kies de primaire kleur van de openbare documentatiesite.",
"Show page author": "Paginamaker weergeven",
"Display the page creator's name on public pages.": "Geef de naam van de maker van de pagina weer op openbare pagina's.",
"Toggle show page author": "Weergave van paginamaker in-/uitschakelen",
"Show last updated": "Laatste update weergeven",
"Display when each page was last updated.": "Geef weer wanneer elke pagina voor het laatst is bijgewerkt.",
"Toggle show last updated": "Weergave van laatste update in-/uitschakelen",
"Open public page": "Open openbare pagina",
"Toggle color scheme": "Kleurenschema in-/uitschakelen",
"Ember": "Gloeiend",
"Rose": "Roos",
"Documentation": "Documentatie",
"All published spaces.": "Alle gepubliceerde ruimtes.",
"No public spaces yet.": "Nog geen openbare ruimtes.",
"Show public directory": "Openbare directory weergeven",
"List published spaces at /docs for anyone to browse.": "Toon gepubliceerde ruimtes op /docs zodat iedereen erdoor kan bladeren.",
"Toggle show public directory": "Weergave van openbare directory in-/uitschakelen",
"Show in public directory": "Weergeven in openbare directory",
"List this space in the public directory at /docs.": "Toon deze ruimte in de openbare directory op /docs.",
"Toggle show in public directory": "Weergave in openbare directory in-/uitschakelen",
"Open public space link": "Open openbare ruimtelink",
"Disable public spaces": "Openbare ruimtes uitschakelen",
"Space admins will be able to make their spaces publicly readable by anyone on the internet. Are you sure?": "Ruimtebeheerders kunnen hun ruimtes openbaar leesbaar maken voor iedereen op internet. Weet u het zeker?",
"This will immediately unpublish every published space. Re-enabling later will not republish them. Are you sure?": "Hierdoor wordt de publicatie van elke gepubliceerde ruimte onmiddellijk ongedaan gemaakt. Als u dit later opnieuw inschakelt, worden ze niet opnieuw gepubliceerd. Weet u het zeker?",
"Allow": "Toestaan",
"Shared pages": "Gedeelde pagina's",
"Published spaces": "Gepubliceerde ruimtes",
"Spaces published to the web will appear here": "Hier verschijnen ruimtes die op het web zijn gepubliceerd",
"No published spaces": "Geen gepubliceerde ruimtes",
"Published by": "Gepubliceerd door",
"Published at": "Gepubliceerd op",
"Open space": "Ruimte openen",
"Unpublish": "Publicatie ongedaan maken",
"Unpublish space": "Publicatie van ruimte ongedaan maken",
"This space will no longer be publicly accessible. Are you sure?": "Deze ruimte zal niet langer openbaar toegankelijk zijn. Weet u het zeker?",
"More options for {{name}}": "Meer opties voor {{name}}",
"Edit page": "Pagina bewerken",
"Sign in": "Inloggen",
"Open app": "App openen",
"No spaces match your search.": "Geen ruimtes komen overeen met uw zoekopdracht.",
"Welcome to our documentation": "Welkom bij onze documentatie",
"Guides, references and answers across all our published spaces.": "Handleidingen, referenties en antwoorden in al onze gepubliceerde ruimtes.",
"Guides, references and answers across all our spaces.": "Handleidingen, referenties en antwoorden in al onze ruimtes.",
"Search documentation...": "Documentatie doorzoeken...",
"1 published space": "1 gepubliceerde ruimte",
"{{count}} published spaces": "{{count}} gepubliceerde ruimtes",
"Actions": "Acties",
"Add destination": "Bestemming toevoegen",
"Are you sure you want to delete the destination": "Weet u zeker dat u de bestemming wilt verwijderen",
"Audit logs": "Auditlogboeken",
"Audit logs & SIEM": "Auditlogboeken en SIEM",
"Auth header name": "Naam van auth-header",
"Auth header prefix": "Prefix van auth-header",
"Body format": "Body-indeling",
"Created SIEM destination": "SIEM-bestemming aangemaakt",
"Datadog site": "Datadog-site",
"Defaults to this instance's hostname": "Standaard ingesteld op de hostnaam van deze instantie",
"Delete destination": "Bestemming verwijderen",
"Deleted SIEM destination": "SIEM-bestemming verwijderd",
"Destination created": "Bestemming aangemaakt",
"Destination deleted": "Bestemming verwijderd",
"Destination updated": "Bestemming bijgewerkt",
"Disabled": "Uitgeschakeld",
"Edit destination": "Bestemming bewerken",
"Endpoint URL": "Endpoint-URL",
"Failing": "Foutstatus",
"Failing since {{time}}": "In foutstatus sinds {{time}}",
"HEC token": "HEC-token",
"HEC URL": "HEC-URL",
"Healthy": "Gezond",
"Hide advanced options": "Geavanceerde opties verbergen",
"Host": "Host",
"Index": "Index",
"Insecure: connections can be intercepted.": "Onveilig: verbindingen kunnen worden onderschept.",
"JSON array": "JSON-array",
"Last delivered": "Laatst afgeleverd",
"Last error": "Laatste fout",
"Leave empty to use the token's default index": "Laat leeg om de standaardindex van het token te gebruiken",
"Maximum of {{limit}} destinations reached": "Maximum van {{limit}} bestemmingen bereikt",
"Could not load SIEM destinations: {{message}}": "Kan SIEM-bestemmingen niet laden: {{message}}",
"No destinations yet": "Nog geen bestemmingen",
"Preset": "Voorinstelling",
"Retry now": "Nu opnieuw proberen",
"Retry scheduled": "Nieuwe poging gepland",
"Send test event": "Testgebeurtenis verzenden",
"Sent in the auth header below. Leave empty if your receiver does not need one.": "Wordt verzonden in de auth-header hieronder. Laat leeg als uw ontvanger er geen nodig heeft.",
"Service": "Service",
"Show advanced options": "Geavanceerde opties weergeven",
"SIEM": "SIEM",
"SIEM streaming": "SIEM-streaming",
"SIEM streaming requires an Enterprise license.": "SIEM-streaming vereist een Enterprise-licentie.",
"Source": "Bron",
"Sourcetype": "Brontype",
"Tags": "Tags",
"Test connection": "Verbinding testen",
"Test event delivered successfully.": "Testgebeurtenis succesvol afgeleverd.",
"Test the connection before saving.": "Test de verbinding voordat u opslaat.",
"Test event delivered to {{name}}": "Testgebeurtenis afgeleverd aan {{name}}",
"Updated SIEM destination": "SIEM-bestemming bijgewerkt",
"Verify TLS certificate": "TLS-certificaat verifiëren",
"e.g. Splunk prod": "bijv. Splunk prod"
"you": "jij"
}
@@ -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 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.",
"Enforce OAuth": "Exigir OAuth",
"Enforce OAuth": "Enforce 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.",
"Toggle enforce OAuth for MCP": "Ativar/desativar exigência de OAuth para MCP",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
"Supported tools": "Ferramentas compatíveis",
"MCP server URL:": "URL do servidor MCP:",
"Learn more": "Saiba mais",
@@ -793,10 +793,6 @@
"Removed page restriction": "Restrição de página removida",
"Added page permission": "Permissão de página adicionada",
"Removed page permission": "Permissão de página removida",
"Changed page permission": "Permissão da página alterada",
"Requested password reset": "Redefinição de senha solicitada",
"Created template": "Modelo criado",
"Deleted template": "Modelo excluído",
"day": "dia",
"days": "dias",
"week": "semana",
@@ -884,9 +880,6 @@
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold> devolveu uma página para revisão",
"Page verification expires soon": "A verificação da página expirará em breve",
"Page verification has expired": "A verificação da página expirou",
"SIEM destination <bold>{{name}}</bold> is failing": "O destino SIEM <bold>{{name}}</bold> está com falha",
"SIEM destination <bold>{{name}}</bold> was disabled after 24 hours of failures": "O destino SIEM <bold>{{name}}</bold> foi desativado após 24 horas de falhas",
"SIEM destination <bold>{{name}}</bold> recovered": "O destino SIEM <bold>{{name}}</bold> foi recuperado",
"Verifying your email": "Verificando seu e-mail",
"Please wait...": "Por favor, aguarde...",
"Verification failed. The link may have expired.": "Falha na verificação. O link pode ter expirado.",
@@ -1344,137 +1337,5 @@
"AI Chat can search and read workspace content, but cannot create or edit pages.": "O AI Chat pode pesquisar e ler o conteúdo do workspace, mas não pode criar nem editar páginas.",
"Toggle AI Chat read-only mode": "Alternar modo somente leitura do AI Chat",
"Title only": "Somente título",
"you": "você",
"Allow public spaces": "Permitir espaços públicos",
"Space admins can publish their spaces to the web.": "Os administradores de espaços podem publicar seus espaços na web.",
"Toggle allow public spaces": "Alternar permissão de espaços públicos",
"Publish space to the web": "Publicar espaço na web",
"Anyone on the internet will be able to read every page in this space, except restricted pages. Are you sure?": "Qualquer pessoa na internet poderá ler todas as páginas deste espaço, exceto as páginas restritas. Tem certeza?",
"Make this space publicly readable by anyone on the internet.": "Torne este espaço publicamente legível para qualquer pessoa na internet.",
"Toggle publish space to the web": "Alternar publicação do espaço na web",
"Allow search engines to index": "Permitir que mecanismos de busca indexem",
"Let public pages in this space appear in search engine results.": "Permita que as páginas públicas deste espaço apareçam nos resultados dos mecanismos de busca.",
"Toggle search engine indexing": "Alternar indexação por mecanismos de busca",
"Public space link": "Link público do espaço",
"Copy public space link": "Copiar link público do espaço",
"Renaming the space slug will break public links.": "Renomear o slug do espaço quebrará os links públicos.",
"Failed to update space": "Falha ao atualizar o espaço",
"This space is public": "Este espaço é público",
"Anyone on the internet can read the pages in this space, except restricted pages.": "Qualquer pessoa na internet pode ler as páginas deste espaço, exceto as páginas restritas.",
"Open public site": "Abrir site público",
"Public": "Público",
"This space has no public pages yet.": "Este espaço ainda não tem páginas públicas.",
"On this page": "Nesta página",
"Previous": "Anterior",
"Next": "Próximo",
"Show hidden pages": "Mostrar páginas ocultas",
"Page navigation": "Navegação da página",
"Toggle sidebar": "Alternar barra lateral",
"Toggle table of contents": "Alternar sumário",
"Appearance": "Aparência",
"Forest": "Floresta",
"Violet": "Violeta",
"Light mode color": "Cor do modo claro",
"Dark mode color": "Cor do modo escuro",
"Choose the primary color of the public docs site.": "Escolha a cor primária do site de documentação pública.",
"Show page author": "Mostrar autor da página",
"Display the page creator's name on public pages.": "Exiba o nome do criador da página nas páginas públicas.",
"Toggle show page author": "Alternar exibição do autor da página",
"Show last updated": "Mostrar última atualização",
"Display when each page was last updated.": "Exiba quando cada página foi atualizada pela última vez.",
"Toggle show last updated": "Alternar exibição da última atualização",
"Open public page": "Abrir página pública",
"Toggle color scheme": "Alternar esquema de cores",
"Ember": "Brasa",
"Rose": "Rosa",
"Documentation": "Documentação",
"All published spaces.": "Todos os espaços publicados.",
"No public spaces yet.": "Ainda não há espaços públicos.",
"Show public directory": "Mostrar diretório público",
"List published spaces at /docs for anyone to browse.": "Liste os espaços publicados em /docs para que qualquer pessoa possa navegar.",
"Toggle show public directory": "Alternar exibição do diretório público",
"Show in public directory": "Mostrar no diretório público",
"List this space in the public directory at /docs.": "Liste este espaço no diretório público em /docs.",
"Toggle show in public directory": "Alternar exibição no diretório público",
"Open public space link": "Abrir link público do espaço",
"Disable public spaces": "Desativar espaços públicos",
"Space admins will be able to make their spaces publicly readable by anyone on the internet. Are you sure?": "Os administradores de espaços poderão tornar seus espaços publicamente legíveis para qualquer pessoa na internet. Tem certeza?",
"This will immediately unpublish every published space. Re-enabling later will not republish them. Are you sure?": "Isso removerá imediatamente a publicação de todos os espaços publicados. Reativar mais tarde não os publicará novamente. Tem certeza?",
"Allow": "Permitir",
"Shared pages": "Páginas compartilhadas",
"Published spaces": "Espaços publicados",
"Spaces published to the web will appear here": "Os espaços publicados na web aparecerão aqui",
"No published spaces": "Nenhum espaço publicado",
"Published by": "Publicado por",
"Published at": "Publicado em",
"Open space": "Abrir espaço",
"Unpublish": "Despublicar",
"Unpublish space": "Despublicar espaço",
"This space will no longer be publicly accessible. Are you sure?": "Este espaço não ficará mais acessível publicamente. Tem certeza?",
"More options for {{name}}": "Mais opções para {{name}}",
"Edit page": "Editar página",
"Sign in": "Entrar",
"Open app": "Abrir aplicativo",
"No spaces match your search.": "Nenhum espaço corresponde à sua busca.",
"Welcome to our documentation": "Boas-vindas à nossa documentação",
"Guides, references and answers across all our published spaces.": "Guias, referências e respostas em todos os nossos espaços publicados.",
"Guides, references and answers across all our spaces.": "Guias, referências e respostas em todos os nossos espaços.",
"Search documentation...": "Pesquisar na documentação...",
"1 published space": "1 espaço publicado",
"{{count}} published spaces": "{{count}} espaços publicados",
"Actions": "Ações",
"Add destination": "Adicionar destino",
"Are you sure you want to delete the destination": "Tem certeza de que deseja excluir o destino",
"Audit logs": "Logs de auditoria",
"Audit logs & SIEM": "Logs de auditoria e SIEM",
"Auth header name": "Nome do cabeçalho de autenticação",
"Auth header prefix": "Prefixo do cabeçalho de autenticação",
"Body format": "Formato do corpo",
"Created SIEM destination": "Destino SIEM criado",
"Datadog site": "Site do Datadog",
"Defaults to this instance's hostname": "O padrão é o nome do host desta instância",
"Delete destination": "Excluir destino",
"Deleted SIEM destination": "Destino SIEM excluído",
"Destination created": "Destino criado",
"Destination deleted": "Destino excluído",
"Destination updated": "Destino atualizado",
"Disabled": "Desativado",
"Edit destination": "Editar destino",
"Endpoint URL": "URL do endpoint",
"Failing": "Com falha",
"Failing since {{time}}": "Com falha desde {{time}}",
"HEC token": "Token HEC",
"HEC URL": "URL do HEC",
"Healthy": "Saudável",
"Hide advanced options": "Ocultar opções avançadas",
"Host": "Host",
"Index": "Índice",
"Insecure: connections can be intercepted.": "Inseguro: as conexões podem ser interceptadas.",
"JSON array": "Matriz JSON",
"Last delivered": "Última entrega",
"Last error": "Último erro",
"Leave empty to use the token's default index": "Deixe em branco para usar o índice padrão do token",
"Maximum of {{limit}} destinations reached": "Número máximo de {{limit}} destinos atingido",
"Could not load SIEM destinations: {{message}}": "Não foi possível carregar os destinos SIEM: {{message}}",
"No destinations yet": "Ainda não há destinos",
"Preset": "Predefinição",
"Retry now": "Tentar novamente agora",
"Retry scheduled": "Nova tentativa agendada",
"Send test event": "Enviar evento de teste",
"Sent in the auth header below. Leave empty if your receiver does not need one.": "Enviado no cabeçalho de autenticação abaixo. Deixe em branco se o seu receptor não precisar de um.",
"Service": "Serviço",
"Show advanced options": "Mostrar opções avançadas",
"SIEM": "SIEM",
"SIEM streaming": "Streaming de SIEM",
"SIEM streaming requires an Enterprise license.": "O streaming de SIEM requer uma licença Enterprise.",
"Source": "Fonte",
"Sourcetype": "Tipo de fonte",
"Tags": "Tags",
"Test connection": "Testar conexão",
"Test event delivered successfully.": "Evento de teste entregue com sucesso.",
"Test the connection before saving.": "Teste a conexão antes de salvar.",
"Test event delivered to {{name}}": "Evento de teste entregue para {{name}}",
"Updated SIEM destination": "Destino SIEM atualizado",
"Verify TLS certificate": "Verificar certificado TLS",
"e.g. Splunk prod": "ex.: Splunk produção"
"you": "você"
}
@@ -708,9 +708,9 @@
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP доступен только в корпоративной версии Docmost. Свяжитесь по адресу sales@docmost.com.",
"MCP Server URL": "URL сервера MCP",
"Connect AI assistants with your Docmost account via OAuth.": "Подключайте AI-помощников к вашей учетной записи Docmost через OAuth.",
"Enforce OAuth": "Сделать OAuth обязательным",
"Enforce OAuth": "Enforce 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-сервером.",
"Toggle enforce OAuth for MCP": "Переключить обязательное использование OAuth для MCP",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
"Supported tools": "Поддерживаемые инструменты",
"MCP server URL:": "URL сервера MCP:",
"Learn more": "Подробнее",
@@ -793,10 +793,6 @@
"Removed page restriction": "Ограничение доступа к странице удалено",
"Added page permission": "Добавлено разрешение доступа к странице",
"Removed page permission": "Удалено разрешение доступа к странице",
"Changed page permission": "Изменено разрешение доступа к странице",
"Requested password reset": "Запрошен сброс пароля",
"Created template": "Создан шаблон",
"Deleted template": "Удалён шаблон",
"day": "день",
"days": "дни",
"week": "неделя",
@@ -884,9 +880,6 @@
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold> вернул(а) страницу на доработку",
"Page verification expires soon": "Срок проверки страницы скоро истекает",
"Page verification has expired": "Срок проверки страницы истёк",
"SIEM destination <bold>{{name}}</bold> is failing": "Сбой в месте назначения SIEM <bold>{{name}}</bold>",
"SIEM destination <bold>{{name}}</bold> was disabled after 24 hours of failures": "Место назначения SIEM <bold>{{name}}</bold> было отключено после 24 часов сбоев",
"SIEM destination <bold>{{name}}</bold> recovered": "Место назначения SIEM <bold>{{name}}</bold> восстановлено",
"Verifying your email": "Подтверждение вашего адреса электронной почты",
"Please wait...": "Пожалуйста, подождите...",
"Verification failed. The link may have expired.": "Ошибка проверки. Ссылка могла устареть.",
@@ -1344,137 +1337,5 @@
"AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat может искать и читать содержимое рабочего пространства, но не может создавать или редактировать страницы.",
"Toggle AI Chat read-only mode": "Переключить режим только для чтения для AI Chat",
"Title only": "Только заголовок",
"you": "вы",
"Allow public spaces": "Разрешить публичные пространства",
"Space admins can publish their spaces to the web.": "Администраторы пространств смогут публиковать свои пространства в интернете.",
"Toggle allow public spaces": "Переключить разрешение публичных пространств",
"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?": "Любой пользователь в интернете сможет читать каждую страницу в этом пространстве, кроме страниц с ограниченным доступом. Вы уверены?",
"Make this space publicly readable by anyone on the internet.": "Сделать это пространство общедоступным для чтения любым пользователем в интернете.",
"Toggle publish space to the web": "Переключить публикацию пространства в интернете",
"Allow search engines to index": "Разрешить поисковым системам индексировать",
"Let public pages in this space appear in search engine results.": "Разрешить публичным страницам в этом пространстве появляться в результатах поиска.",
"Toggle search engine indexing": "Переключить индексацию поисковыми системами",
"Public space link": "Ссылка на публичное пространство",
"Copy public space link": "Скопировать ссылку на публичное пространство",
"Renaming the space slug will break public links.": "Переименование slug пространства приведёт к поломке публичных ссылок.",
"Failed to update space": "Не удалось обновить пространство",
"This space is public": "Это пространство является публичным",
"Anyone on the internet can read the pages in this space, except restricted pages.": "Любой пользователь в интернете может читать страницы в этом пространстве, кроме страниц с ограниченным доступом.",
"Open public site": "Открыть публичный сайт",
"Public": "Публичное",
"This space has no public pages yet.": "В этом пространстве пока нет публичных страниц.",
"On this page": "На этой странице",
"Previous": "Назад",
"Next": "Далее",
"Show hidden pages": "Показать скрытые страницы",
"Page navigation": "Навигация по странице",
"Toggle sidebar": "Переключить боковую панель",
"Toggle table of contents": "Переключить оглавление",
"Appearance": "Внешний вид",
"Forest": "Лесной",
"Violet": "Фиолетовый",
"Light mode color": "Цвет светлой темы",
"Dark mode color": "Цвет тёмной темы",
"Choose the primary color of the public docs site.": "Выберите основной цвет публичного сайта документации.",
"Show page author": "Показывать автора страницы",
"Display the page creator's name on public pages.": "Отображать имя создателя страницы на публичных страницах.",
"Toggle show page author": "Переключить показ автора страницы",
"Show last updated": "Показывать последнее обновление",
"Display when each page was last updated.": "Отображать время последнего обновления каждой страницы.",
"Toggle show last updated": "Переключить показ последнего обновления",
"Open public page": "Открыть публичную страницу",
"Toggle color scheme": "Переключить цветовую схему",
"Ember": "Янтарный",
"Rose": "Розовый",
"Documentation": "Документация",
"All published spaces.": "Все опубликованные пространства.",
"No public spaces yet.": "Пока нет публичных пространств.",
"Show public directory": "Показывать публичный каталог",
"List published spaces at /docs for anyone to browse.": "Показывать опубликованные пространства по адресу /docs, чтобы их мог просматривать любой пользователь.",
"Toggle show public directory": "Переключить показ публичного каталога",
"Show in public directory": "Показывать в публичном каталоге",
"List this space in the public directory at /docs.": "Показывать это пространство в публичном каталоге по адресу /docs.",
"Toggle show in public directory": "Переключить показ в публичном каталоге",
"Open public space link": "Открыть ссылку на публичное пространство",
"Disable public spaces": "Отключить публичные пространства",
"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?": "Это приведёт к немедленной отмене публикации всех опубликованных пространств. Повторное включение позже не опубликует их заново. Вы уверены?",
"Allow": "Разрешить",
"Shared pages": "Общие страницы",
"Published spaces": "Опубликованные пространства",
"Spaces published to the web will appear here": "Здесь будут отображаться пространства, опубликованные в интернете",
"No published spaces": "Нет опубликованных пространств",
"Published by": "Опубликовано пользователем",
"Published at": "Опубликовано",
"Open space": "Открыть пространство",
"Unpublish": "Снять с публикации",
"Unpublish space": "Снять пространство с публикации",
"This space will no longer be publicly accessible. Are you sure?": "Это пространство больше не будет общедоступным. Вы уверены?",
"More options for {{name}}": "Дополнительные параметры для {{name}}",
"Edit page": "Редактировать страницу",
"Sign in": "Войти",
"Open app": "Открыть приложение",
"No spaces match your search.": "Нет пространств, соответствующих вашему запросу.",
"Welcome to our documentation": "Добро пожаловать в нашу документацию",
"Guides, references and answers across all our published spaces.": "Руководства, справочные материалы и ответы во всех наших опубликованных пространствах.",
"Guides, references and answers across all our spaces.": "Руководства, справочные материалы и ответы во всех наших пространствах.",
"Search documentation...": "Поиск по документации...",
"1 published space": "1 опубликованное пространство",
"{{count}} published spaces": "{count, plural, one {# опубликованное пространство} few {# опубликованных пространства} many {# опубликованных пространств} other {# опубликованного пространства}}",
"Actions": "Действия",
"Add destination": "Добавить место назначения",
"Are you sure you want to delete the destination": "Вы уверены, что хотите удалить место назначения",
"Audit logs": "Журналы аудита",
"Audit logs & SIEM": "Журналы аудита и SIEM",
"Auth header name": "Имя заголовка авторизации",
"Auth header prefix": "Префикс заголовка авторизации",
"Body format": "Формат тела",
"Created SIEM destination": "Создано место назначения SIEM",
"Datadog site": "Сайт Datadog",
"Defaults to this instance's hostname": "По умолчанию используется имя хоста этого экземпляра",
"Delete destination": "Удалить место назначения",
"Deleted SIEM destination": "Удалено место назначения SIEM",
"Destination created": "Место назначения создано",
"Destination deleted": "Место назначения удалено",
"Destination updated": "Место назначения обновлено",
"Disabled": "Отключено",
"Edit destination": "Изменить место назначения",
"Endpoint URL": "URL конечной точки",
"Failing": "Сбой",
"Failing since {{time}}": "Сбой с {{time}}",
"HEC token": "Токен HEC",
"HEC URL": "URL HEC",
"Healthy": "Исправно",
"Hide advanced options": "Скрыть дополнительные параметры",
"Host": "Хост",
"Index": "Индекс",
"Insecure: connections can be intercepted.": "Небезопасно: соединения могут быть перехвачены.",
"JSON array": "Массив JSON",
"Last delivered": "Последняя доставка",
"Last error": "Последняя ошибка",
"Leave empty to use the token's default index": "Оставьте пустым, чтобы использовать индекс токена по умолчанию",
"Maximum of {{limit}} destinations reached": "Достигнуто максимальное количество мест назначения: {{limit}}",
"Could not load SIEM destinations: {{message}}": "Не удалось загрузить места назначения SIEM: {{message}}",
"No destinations yet": "Пока нет мест назначения",
"Preset": "Предустановка",
"Retry now": "Повторить сейчас",
"Retry scheduled": "Повторная попытка запланирована",
"Send test event": "Отправить тестовое событие",
"Sent in the auth header below. Leave empty if your receiver does not need one.": "Отправляется в указанном ниже заголовке авторизации. Оставьте пустым, если вашему получателю он не нужен.",
"Service": "Сервис",
"Show advanced options": "Показать дополнительные параметры",
"SIEM": "SIEM",
"SIEM streaming": "Потоковая передача в SIEM",
"SIEM streaming requires an Enterprise license.": "Для потоковой передачи в SIEM требуется лицензия Enterprise.",
"Source": "Источник",
"Sourcetype": "Тип источника",
"Tags": "Теги",
"Test connection": "Проверить соединение",
"Test event delivered successfully.": "Тестовое событие успешно доставлено.",
"Test the connection before saving.": "Проверьте соединение перед сохранением.",
"Test event delivered to {{name}}": "Тестовое событие доставлено в {{name}}",
"Updated SIEM destination": "Обновлено место назначения SIEM",
"Verify TLS certificate": "Проверять сертификат TLS",
"e.g. Splunk prod": "например, Splunk prod"
"you": "вы"
}
@@ -708,9 +708,9 @@
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP доступний лише в корпоративній редакції Docmost. Зверніться до sales@docmost.com.",
"MCP Server URL": "URL сервера MCP",
"Connect AI assistants with your Docmost account via OAuth.": "Підключайте AI-асистентів до свого облікового запису Docmost через OAuth.",
"Enforce OAuth": "Зробити OAuth обов’язковим",
"Enforce OAuth": "Enforce 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-сервером.",
"Toggle enforce OAuth for MCP": "Увімкнути обов’язковий OAuth для MCP",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
"Supported tools": "Підтримувані інструменти",
"MCP server URL:": "URL сервера MCP:",
"Learn more": "Дізнатися більше",
@@ -793,10 +793,6 @@
"Removed page restriction": "Обмеження сторінки видалено",
"Added page permission": "Додано дозвіл на сторінку",
"Removed page permission": "Дозвіл на сторінку видалено",
"Changed page permission": "Змінено дозволи сторінки",
"Requested password reset": "Запитано скидання пароля",
"Created template": "Створено шаблон",
"Deleted template": "Видалено шаблон",
"day": "день",
"days": "дні",
"week": "тиждень",
@@ -884,9 +880,6 @@
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold> повернув(-ла) сторінку на доопрацювання",
"Page verification expires soon": "Термін перевірки сторінки скоро спливає",
"Page verification has expired": "Термін перевірки сторінки сплив",
"SIEM destination <bold>{{name}}</bold> is failing": "Збій у призначенні SIEM <bold>{{name}}</bold>",
"SIEM destination <bold>{{name}}</bold> was disabled after 24 hours of failures": "Призначення SIEM <bold>{{name}}</bold> було вимкнено після 24 годин збоїв",
"SIEM destination <bold>{{name}}</bold> recovered": "Роботу призначення SIEM <bold>{{name}}</bold> відновлено",
"Verifying your email": "Перевірка вашої електронної пошти",
"Please wait...": "Будь ласка, зачекайте...",
"Verification failed. The link may have expired.": "Підтвердження не вдалося. Посилання могло втратити чинність.",
@@ -1344,137 +1337,5 @@
"AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat може шукати та читати вміст робочого простору, але не може створювати або редагувати сторінки.",
"Toggle AI Chat read-only mode": "Перемкнути режим лише читання для AI Chat",
"Title only": "Лише заголовок",
"you": "ви",
"Allow public spaces": "Дозволити публічні простори",
"Space admins can publish their spaces to the web.": "Адміністратори просторів можуть публікувати свої простори в інтернеті.",
"Toggle allow public spaces": "Перемкнути дозвіл на публічні простори",
"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?": "Будь-хто в інтернеті зможе читати кожну сторінку в цьому просторі, крім обмежених сторінок. Ви впевнені?",
"Make this space publicly readable by anyone on the internet.": "Зробити цей простір публічно доступним для читання будь-ким в інтернеті.",
"Toggle publish space to the web": "Перемкнути публікацію простору в інтернеті",
"Allow search engines to index": "Дозволити пошуковим системам індексувати",
"Let public pages in this space appear in search engine results.": "Дозволити публічним сторінкам у цьому просторі з’являтися в результатах пошуку.",
"Toggle search engine indexing": "Перемкнути індексацію пошуковими системами",
"Public space link": "Публічне посилання на простір",
"Copy public space link": "Скопіювати публічне посилання на простір",
"Renaming the space slug will break public links.": "Перейменування slug простору зламає публічні посилання.",
"Failed to update space": "Не вдалося оновити простір",
"This space is public": "Цей простір є публічним",
"Anyone on the internet can read the pages in this space, except restricted pages.": "Будь-хто в інтернеті може читати сторінки в цьому просторі, крім обмежених сторінок.",
"Open public site": "Відкрити публічний сайт",
"Public": "Публічний",
"This space has no public pages yet.": "У цьому просторі ще немає публічних сторінок.",
"On this page": "На цій сторінці",
"Previous": "Попередня",
"Next": "Далі",
"Show hidden pages": "Показати приховані сторінки",
"Page navigation": "Навігація сторінками",
"Toggle sidebar": "Перемкнути бічну панель",
"Toggle table of contents": "Перемкнути зміст",
"Appearance": "Зовнішній вигляд",
"Forest": "Лісовий",
"Violet": "Фіолетовий",
"Light mode color": "Колір світлого режиму",
"Dark mode color": "Колір темного режиму",
"Choose the primary color of the public docs site.": "Виберіть основний колір публічного сайту документації.",
"Show page author": "Показувати автора сторінки",
"Display the page creator's name on public pages.": "Показувати ім’я автора сторінки на публічних сторінках.",
"Toggle show page author": "Перемкнути показ автора сторінки",
"Show last updated": "Показувати останнє оновлення",
"Display when each page was last updated.": "Показувати, коли кожну сторінку було востаннє оновлено.",
"Toggle show last updated": "Перемкнути показ останнього оновлення",
"Open public page": "Відкрити публічну сторінку",
"Toggle color scheme": "Перемкнути колірну схему",
"Ember": "Жар",
"Rose": "Трояндовий",
"Documentation": "Документація",
"All published spaces.": "Усі опубліковані простори.",
"No public spaces yet.": "Публічних просторів ще немає.",
"Show public directory": "Показувати публічний каталог",
"List published spaces at /docs for anyone to browse.": "Показувати список опублікованих просторів за адресою /docs, щоб кожен міг їх переглядати.",
"Toggle show public directory": "Перемкнути показ публічного каталогу",
"Show in public directory": "Показувати в публічному каталозі",
"List this space in the public directory at /docs.": "Показувати цей простір у публічному каталозі за адресою /docs.",
"Toggle show in public directory": "Перемкнути показ у публічному каталозі",
"Open public space link": "Відкрити публічне посилання на простір",
"Disable public spaces": "Вимкнути публічні простори",
"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?": "Це негайно скасує публікацію всіх опублікованих просторів. Повторне ввімкнення пізніше не опублікує їх знову. Ви впевнені?",
"Allow": "Дозволити",
"Shared pages": "Спільні сторінки",
"Published spaces": "Опубліковані простори",
"Spaces published to the web will appear here": "Тут з’являться простори, опубліковані в інтернеті",
"No published spaces": "Немає опублікованих просторів",
"Published by": "Опубліковано",
"Published at": "Опубліковано",
"Open space": "Відкрити простір",
"Unpublish": "Скасувати публікацію",
"Unpublish space": "Скасувати публікацію простору",
"This space will no longer be publicly accessible. Are you sure?": "Цей простір більше не буде публічно доступним. Ви впевнені?",
"More options for {{name}}": "Додаткові параметри для {{name}}",
"Edit page": "Редагувати сторінку",
"Sign in": "Увійти",
"Open app": "Відкрити застосунок",
"No spaces match your search.": "Жоден простір не відповідає вашому запиту.",
"Welcome to our documentation": "Ласкаво просимо до нашої документації",
"Guides, references and answers across all our published spaces.": "Посібники, довідкові матеріали та відповіді в усіх наших опублікованих просторах.",
"Guides, references and answers across all our spaces.": "Посібники, довідкові матеріали та відповіді в усіх наших просторах.",
"Search documentation...": "Шукати в документації...",
"1 published space": "1 опублікований простір",
"{{count}} published spaces": "{{count}} опублікованих просторів",
"Actions": "Дії",
"Add destination": "Додати призначення",
"Are you sure you want to delete the destination": "Ви впевнені, що хочете видалити призначення",
"Audit logs": "Журнали аудиту",
"Audit logs & SIEM": "Журнали аудиту й SIEM",
"Auth header name": "Назва заголовка авторизації",
"Auth header prefix": "Префікс заголовка авторизації",
"Body format": "Формат тіла",
"Created SIEM destination": "Створено призначення SIEM",
"Datadog site": "Сайт Datadog",
"Defaults to this instance's hostname": "За замовчуванням використовується ім’я хоста цього екземпляра",
"Delete destination": "Видалити призначення",
"Deleted SIEM destination": "Видалено призначення SIEM",
"Destination created": "Призначення створено",
"Destination deleted": "Призначення видалено",
"Destination updated": "Призначення оновлено",
"Disabled": "Вимкнено",
"Edit destination": "Редагувати призначення",
"Endpoint URL": "URL кінцевої точки",
"Failing": "Збій",
"Failing since {{time}}": "Збій із {{time}}",
"HEC token": "Токен HEC",
"HEC URL": "URL HEC",
"Healthy": "Справний",
"Hide advanced options": "Приховати додаткові параметри",
"Host": "Хост",
"Index": "Індекс",
"Insecure: connections can be intercepted.": "Небезпечно: з’єднання можуть бути перехоплені.",
"JSON array": "Масив JSON",
"Last delivered": "Остання доставка",
"Last error": "Остання помилка",
"Leave empty to use the token's default index": "Залиште порожнім, щоб використовувати індекс токена за замовчуванням",
"Maximum of {{limit}} destinations reached": "Досягнуто максимальної кількості призначень: {{limit}}",
"Could not load SIEM destinations: {{message}}": "Не вдалося завантажити призначення SIEM: {{message}}",
"No destinations yet": "Призначень ще немає",
"Preset": "Попереднє налаштування",
"Retry now": "Повторити зараз",
"Retry scheduled": "Повторну спробу заплановано",
"Send test event": "Надіслати тестову подію",
"Sent in the auth header below. Leave empty if your receiver does not need one.": "Надсилається в наведеному нижче заголовку авторизації. Залиште порожнім, якщо вашому отримувачу він не потрібен.",
"Service": "Сервіс",
"Show advanced options": "Показати додаткові параметри",
"SIEM": "SIEM",
"SIEM streaming": "Потокова передача SIEM",
"SIEM streaming requires an Enterprise license.": "Для потокової передачі SIEM потрібна ліцензія Enterprise.",
"Source": "Джерело",
"Sourcetype": "Тип джерела",
"Tags": "Теги",
"Test connection": "Перевірити з’єднання",
"Test event delivered successfully.": "Тестову подію успішно доставлено.",
"Test the connection before saving.": "Перевірте з’єднання перед збереженням.",
"Test event delivered to {{name}}": "Тестову подію доставлено до {{name}}",
"Updated SIEM destination": "Оновлено призначення SIEM",
"Verify TLS certificate": "Перевіряти сертифікат TLS",
"e.g. Splunk prod": "напр., Splunk prod"
"you": "ви"
}
@@ -708,9 +708,9 @@
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP 仅在 Docmost 企业版中提供。请联系 sales@docmost.com。",
"MCP Server URL": "MCP 服务器 URL",
"Connect AI assistants with your Docmost account via OAuth.": "通过 OAuth 将 AI 助手连接到你的 Docmost 账户。",
"Enforce OAuth": "强制使用 OAuth",
"Enforce OAuth": "Enforce 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 密钥。",
"Toggle enforce OAuth for MCP": "切换 MCP 的强制使用 OAuth 设置",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
"Supported tools": "支持的工具",
"MCP server URL:": "MCP 服务器 URL",
"Learn more": "了解更多",
@@ -793,10 +793,6 @@
"Removed page restriction": "已移除页面限制",
"Added page permission": "已添加页面权限",
"Removed page permission": "已移除页面权限",
"Changed page permission": "已更改页面权限",
"Requested password reset": "已请求重置密码",
"Created template": "已创建模板",
"Deleted template": "已删除模板",
"day": "天",
"days": "天",
"week": "周",
@@ -884,9 +880,6 @@
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold> 退回了一个页面以供修改",
"Page verification expires soon": "页面验证即将过期",
"Page verification has expired": "页面验证已过期",
"SIEM destination <bold>{{name}}</bold> is failing": "SIEM 目标 <bold>{{name}}</bold> 发生故障",
"SIEM destination <bold>{{name}}</bold> was disabled after 24 hours of failures": "SIEM 目标 <bold>{{name}}</bold> 在连续 24 小时故障后已被禁用",
"SIEM destination <bold>{{name}}</bold> recovered": "SIEM 目标 <bold>{{name}}</bold> 已恢复",
"Verifying your email": "正在验证您的邮箱",
"Please wait...": "请稍候……",
"Verification failed. The link may have expired.": "验证失败。该链接可能已过期。",
@@ -1344,137 +1337,5 @@
"AI Chat can search and read workspace content, but cannot create or edit pages.": "AI 聊天可以搜索和读取工作区内容,但不能创建或编辑页面。",
"Toggle AI Chat read-only mode": "切换 AI 聊天只读模式",
"Title only": "仅标题",
"you": "你",
"Allow public spaces": "允许公开空间",
"Space admins can publish their spaces to the web.": "空间管理员可以将其空间发布到 Web。",
"Toggle allow public spaces": "切换是否允许公开空间",
"Publish space to the web": "将空间发布到 Web",
"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.": "使此空间对互联网上的任何人公开可读。",
"Toggle publish space to the web": "切换将空间发布到 Web",
"Allow search engines to index": "允许搜索引擎编入索引",
"Let public pages in this space appear in search engine results.": "让此空间中的公开页面显示在搜索引擎结果中。",
"Toggle search engine indexing": "切换搜索引擎索引",
"Public space link": "公开空间链接",
"Copy public space link": "复制公开空间链接",
"Renaming the space slug will break public links.": "重命名空间 slug 会导致公开链接失效。",
"Failed to update space": "更新空间失败",
"This space is public": "此空间是公开的",
"Anyone on the internet can read the pages in this space, except restricted pages.": "互联网上的任何人都可以阅读此空间中的页面,但受限页面除外。",
"Open public site": "打开公开站点",
"Public": "公开",
"This space has no public pages yet.": "此空间中尚无公开页面。",
"On this page": "本页内容",
"Previous": "上一页",
"Next": "下一页",
"Show hidden pages": "显示隐藏页面",
"Page navigation": "页面导航",
"Toggle sidebar": "切换侧边栏",
"Toggle table of contents": "切换目录",
"Appearance": "外观",
"Forest": "森林绿",
"Violet": "紫罗兰",
"Light mode color": "浅色模式颜色",
"Dark mode color": "深色模式颜色",
"Choose the primary color of the public docs site.": "选择公开文档站点的主色。",
"Show page author": "显示页面作者",
"Display the page creator's name on public pages.": "在公开页面上显示页面创建者的姓名。",
"Toggle show page author": "切换显示页面作者",
"Show last updated": "显示最后更新时间",
"Display when each page was last updated.": "显示每个页面上次更新的时间。",
"Toggle show last updated": "切换显示最后更新时间",
"Open public page": "打开公开页面",
"Toggle color scheme": "切换配色方案",
"Ember": "余烬橙",
"Rose": "玫瑰红",
"Documentation": "文档",
"All published spaces.": "所有已发布的空间。",
"No public spaces yet.": "尚无公开空间。",
"Show public directory": "显示公开目录",
"List published spaces at /docs for anyone to browse.": "在 /docs 列出已发布的空间,供任何人浏览。",
"Toggle show public directory": "切换显示公开目录",
"Show in public directory": "在公开目录中显示",
"List this space in the public directory at /docs.": "在 /docs 的公开目录中列出此空间。",
"Toggle show in public directory": "切换在公开目录中显示",
"Open public space link": "打开公开空间链接",
"Disable public spaces": "禁用公开空间",
"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?": "这将立即取消发布所有已发布的空间。稍后重新启用也不会重新发布它们。您确定吗?",
"Allow": "允许",
"Shared pages": "共享页面",
"Published spaces": "已发布的空间",
"Spaces published to the web will appear here": "发布到 Web 的空间将显示在这里",
"No published spaces": "没有已发布的空间",
"Published by": "发布者",
"Published at": "发布时间",
"Open space": "打开空间",
"Unpublish": "取消发布",
"Unpublish space": "取消发布空间",
"This space will no longer be publicly accessible. Are you sure?": "此空间将不再可被公开访问。您确定吗?",
"More options for {{name}}": "{{name}} 的更多选项",
"Edit page": "编辑页面",
"Sign in": "登录",
"Open app": "打开应用",
"No spaces match your search.": "没有符合您搜索条件的空间。",
"Welcome to our documentation": "欢迎来到我们的文档",
"Guides, references and answers across all our published spaces.": "涵盖我们所有已发布空间的指南、参考资料和解答。",
"Guides, references and answers across all our spaces.": "涵盖我们所有空间的指南、参考资料和解答。",
"Search documentation...": "搜索文档……",
"1 published space": "1 个已发布空间",
"{{count}} published spaces": "{{count}} 个已发布空间",
"Actions": "操作",
"Add destination": "添加目标",
"Are you sure you want to delete the destination": "您确定要删除该目标吗",
"Audit logs": "审计日志",
"Audit logs & SIEM": "审计日志和 SIEM",
"Auth header name": "身份验证标头名称",
"Auth header prefix": "身份验证标头前缀",
"Body format": "正文格式",
"Created SIEM destination": "已创建 SIEM 目标",
"Datadog site": "Datadog 站点",
"Defaults to this instance's hostname": "默认为此实例的主机名",
"Delete destination": "删除目标",
"Deleted SIEM destination": "已删除 SIEM 目标",
"Destination created": "目标已创建",
"Destination deleted": "目标已删除",
"Destination updated": "目标已更新",
"Disabled": "已禁用",
"Edit destination": "编辑目标",
"Endpoint URL": "端点 URL",
"Failing": "故障中",
"Failing since {{time}}": "自 {{time}} 起发生故障",
"HEC token": "HEC 令牌",
"HEC URL": "HEC URL",
"Healthy": "正常",
"Hide advanced options": "隐藏高级选项",
"Host": "主机",
"Index": "索引",
"Insecure: connections can be intercepted.": "不安全:连接可能被拦截。",
"JSON array": "JSON 数组",
"Last delivered": "上次传送",
"Last error": "上次错误",
"Leave empty to use the token's default index": "留空以使用令牌的默认索引",
"Maximum of {{limit}} destinations reached": "最多只能添加 {{limit}} 个目标",
"Could not load SIEM destinations: {{message}}": "无法加载 SIEM 目标:{{message}}",
"No destinations yet": "暂无目标",
"Preset": "预设",
"Retry now": "立即重试",
"Retry scheduled": "已计划重试",
"Send test event": "发送测试事件",
"Sent in the auth header below. Leave empty if your receiver does not need one.": "将在下方的身份验证标头中发送。如果您的接收端不需要,请留空。",
"Service": "服务",
"Show advanced options": "显示高级选项",
"SIEM": "SIEM",
"SIEM streaming": "SIEM 流式传输",
"SIEM streaming requires an Enterprise license.": "SIEM 流式传输需要企业版许可证。",
"Source": "来源",
"Sourcetype": "源类型",
"Tags": "标签",
"Test connection": "测试连接",
"Test event delivered successfully.": "测试事件发送成功。",
"Test the connection before saving.": "请在保存前测试连接。",
"Test event delivered to {{name}}": "测试事件已发送到 {{name}}",
"Updated SIEM destination": "已更新 SIEM 目标",
"Verify TLS certificate": "验证 TLS 证书",
"e.g. Splunk prod": "例如:Splunk 生产环境"
"you": "你"
}
-23
View File
@@ -44,15 +44,6 @@ const ShareLayout = lazy(
() => import("@/features/share/components/share-layout.tsx"),
);
const ShareRedirect = lazy(() => import("@/pages/share/share-redirect.tsx"));
const PublicSpacePage = lazy(
() => import("@/pages/public-space/public-space-page.tsx"),
);
const PublicSpaceLayout = lazy(
() => import("@/features/public-space/components/public-space-layout.tsx"),
);
const PublicSpaceDirectoryPage = lazy(
() => import("@/pages/public-space/public-space-directory-page.tsx"),
);
const SpacesPage = lazy(() => import("@/pages/spaces/spaces.tsx"));
const MfaChallengePage = lazy(() =>
import("@/ee/mfa/pages/mfa-challenge-page").then((m) => ({
@@ -128,15 +119,6 @@ export default function App() {
<Route path={"/share/p/:pageSlug"} element={<SharedPage />} />
</Route>
<Route path={"/docs"} element={<PublicSpaceDirectoryPage />} />
<Route element={<PublicSpaceLayout />}>
<Route path={"/docs/:spaceSlug"} element={<PublicSpacePage />} />
<Route
path={"/docs/:spaceSlug/:pageSlug"}
element={<PublicSpacePage />}
/>
</Route>
<Route path={"/pdf-render/:pageId"} element={<PdfRenderPage />} />
<Route path={"/share/:shareId"} element={<ShareRedirect />} />
<Route path={"/p/:pageSlug"} element={<PageRedirect />} />
@@ -184,11 +166,6 @@ export default function App() {
<Route path={"ai"} element={<AiSettings />} />
<Route path={"ai/mcp"} element={<AiSettings />} />
<Route path={"audit"} element={<AuditLogs />} />
<Route path={"audit/siem"} element={<AuditLogs />} />
<Route
path={"siem"}
element={<Navigate to="/settings/audit/siem" replace />}
/>
<Route path={"verifications"} element={<VerifiedPages />} />
{!isCloud() && <Route path={"license"} element={<License />} />}
{isCloud() && <Route path={"billing"} element={<Billing />} />}
@@ -118,7 +118,7 @@ const groupedData: DataGroup[] = [
role: "admin",
},
{
label: "Audit logs & SIEM",
label: "Audit log",
icon: IconHistory,
path: "/settings/audit",
feature: Feature.AUDIT_LOGS,
@@ -219,7 +219,7 @@ export default function SettingsSidebar() {
case "API management":
prefetchHandler = prefetchApiKeyManagement;
break;
case "Audit logs & SIEM":
case "Audit log":
prefetchHandler = prefetchAuditLogs;
break;
case "Verified pages":
@@ -22,7 +22,6 @@ export const auditEventLabels: Record<string, string> = {
"user.role_changed": "Changed user role",
"user.password_changed": "Changed password",
"user.password_reset": "Reset password",
"user.password_reset_requested": "Requested password reset",
"user.updated": "Updated user",
"user.deactivated": "Deactivated user",
"user.activated": "Activated user",
@@ -63,7 +62,6 @@ export const auditEventLabels: Record<string, string> = {
"page.restriction_removed": "Removed page restriction",
"page.permission_added": "Added page permission",
"page.permission_removed": "Removed page permission",
"page.permission_role_changed": "Changed page permission",
"page.verification_created": "Created page verification",
"page.verification_updated": "Updated page verification",
"page.verification_removed": "Removed page verification",
@@ -81,13 +79,6 @@ export const auditEventLabels: Record<string, string> = {
"license.activated": "Activated 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 {
@@ -114,10 +105,6 @@ export const eventFilterOptions: EventGroup[] = [
{ value: "user.activated", label: "Activated user" },
{ value: "user.role_changed", label: "Changed user role" },
{ 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_disabled", label: "Disabled MFA" },
],
@@ -160,10 +147,6 @@ export const eventFilterOptions: EventGroup[] = [
{ value: "page.restriction_removed", label: "Removed page restriction" },
{ value: "page.permission_added", label: "Added 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_updated", label: "Updated page verification" },
{ value: "page.verification_removed", label: "Removed page verification" },
@@ -210,19 +193,4 @@ export const eventFilterOptions: EventGroup[] = [
{ 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" },
],
},
];
+111 -143
View File
@@ -7,12 +7,10 @@ import {
Popover,
Select,
Space,
Tabs,
Text,
Tooltip,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
import { IconSettings } from "@tabler/icons-react";
import SettingsTitle from "@/components/settings/settings-title";
import Paginate from "@/components/common/paginate";
@@ -25,7 +23,6 @@ import {
import { IAuditLogParams } from "@/ee/audit/types/audit.types";
import { eventFilterOptions } from "@/ee/audit/lib/audit-event-labels";
import AuditLogsTable from "@/ee/audit/components/audit-logs-table";
import SiemStreamingPanel from "@/ee/siem/components/siem-streaming-panel";
import useUserRole from "@/hooks/use-user-role";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
@@ -51,8 +48,6 @@ export default function AuditLogs() {
const { t } = useTranslation();
const { isOwner } = useUserRole();
const { cursor, goNext, goPrev, resetCursor } = useCursorPaginate();
const location = useLocation();
const navigate = useNavigate();
const [eventFilter, setEventFilter] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
@@ -90,8 +85,6 @@ export default function AuditLogs() {
const { data, isLoading } = useAuditLogsQuery(params);
const activeTab = location.pathname.endsWith("/siem") ? "siem" : "audit";
if (!isOwner) {
return null;
}
@@ -101,150 +94,125 @@ export default function AuditLogs() {
resetCursor();
};
const handleTabChange = (value: string | null) => {
if (value === "siem") {
navigate("/settings/audit/siem");
} else {
navigate("/settings/audit");
}
};
return (
<>
<DocumentTitle title={t("Audit logs & SIEM")} />
<DocumentTitle title={t("Audit log")} />
<SettingsTitle title={t("Audit logs & SIEM")} />
<SettingsTitle title={t("Audit log")} />
<Tabs color="dark" value={activeTab} onChange={handleTabChange}>
<Tabs.List>
<Tabs.Tab fw={500} value="audit">
{t("Audit logs")}
</Tabs.Tab>
<Tabs.Tab fw={500} value="siem">
{t("SIEM")}
</Tabs.Tab>
</Tabs.List>
<Group mb="md" gap="sm">
<Select
placeholder={t("Filter by event")}
data={eventFilterOptions.map((group) => ({
group: t(group.group),
items: group.items.map((item) => ({
value: item.value,
label: t(item.label),
})),
}))}
value={eventFilter}
onChange={handleEventChange}
clearable
searchable
w={220}
size="sm"
/>
<Tabs.Panel value="audit" pt="md">
<Group mb="md" gap="sm">
<Select
placeholder={t("Filter by event")}
data={eventFilterOptions.map((group) => ({
group: t(group.group),
items: group.items.map((item) => ({
value: item.value,
label: t(item.label),
})),
}))}
value={eventFilter}
onChange={handleEventChange}
clearable
searchable
w={220}
size="sm"
/>
<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>
<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>
<AuditLogsTable items={data?.items} isLoading={isLoading} />
<AuditLogsTable items={data?.items} isLoading={isLoading} />
<Space h="md" />
<Space h="md" />
{data?.items && data.items.length > 0 && (
<Paginate
hasPrevPage={data?.meta?.hasPrevPage}
hasNextPage={data?.meta?.hasNextPage}
onNext={() => goNext(data?.meta?.nextCursor)}
onPrev={goPrev}
/>
)}
</Tabs.Panel>
<Tabs.Panel value="siem" pt="md">
<SiemStreamingPanel />
</Tabs.Panel>
</Tabs>
{data?.items && data.items.length > 0 && (
<Paginate
hasPrevPage={data?.meta?.hasPrevPage}
hasNextPage={data?.meta?.hasNextPage}
onNext={() => goNext(data?.meta?.nextCursor)}
onPrev={goPrev}
/>
)}
</>
);
}
@@ -18,7 +18,6 @@ export type FieldProps = {
rowId: string;
readOnly: boolean;
onChange: (value: unknown) => void;
onEditingChange?: (editing: boolean) => void;
};
type FieldShellProps = {
@@ -100,10 +99,9 @@ type DetailFieldProps = {
row: IBaseRow;
readOnly: boolean;
onUpdate: (propertyId: string, value: unknown) => void;
onEditingChange: (editing: boolean) => void;
};
export function DetailField({ property, row, readOnly, onUpdate, onEditingChange }: DetailFieldProps) {
export function DetailField({ property, row, readOnly, onUpdate }: DetailFieldProps) {
const descriptor = getDescriptor(property.type);
const value = descriptor?.systemAccessor
? descriptor.systemAccessor(row)
@@ -114,7 +112,6 @@ export function DetailField({ property, row, readOnly, onUpdate, onEditingChange
rowId: row.id,
readOnly,
onChange: (next: unknown) => onUpdate(property.id, next),
onEditingChange
};
switch (property.type) {
@@ -9,13 +9,7 @@ const normalize = (s: string) => {
return trimmed.length ? trimmed : null;
};
export function FieldLongText({
property,
value,
readOnly,
onChange,
onEditingChange,
}: FieldProps) {
export function FieldLongText({ property, value, readOnly, onChange }: FieldProps) {
const text = toText(value);
const [draft, setDraft] = useState(text);
const [focused, setFocused] = useState(false);
@@ -29,7 +23,6 @@ export function FieldLongText({
const commit = () => {
setFocused(false);
onEditingChange?.(false);
if (cancelRef.current) {
cancelRef.current = false;
setDraft(text);
@@ -57,10 +50,7 @@ export function FieldLongText({
className={classes.fieldTextarea}
classNames={{ input: classes.fieldTextareaInput }}
value={draft}
onFocus={() => {
setFocused(true);
onEditingChange?.(true);
}}
onFocus={() => setFocused(true)}
onChange={(e) => setDraft(e.currentTarget.value)}
onBlur={commit}
onKeyDown={(e) => {
@@ -11,13 +11,7 @@ import classes from "@/ee/base/styles/row-detail-modal.module.css";
const toDraft = (value: unknown) =>
typeof value === "number" ? String(value) : "";
export function FieldNumber({
property,
value,
readOnly,
onChange,
onEditingChange,
}: FieldProps) {
export function FieldNumber({ property, value, readOnly, onChange }: FieldProps) {
const typeOptions = property.typeOptions as NumberTypeOptions | undefined;
const numValue = typeof value === "number" ? value : null;
const [draft, setDraft] = useState(toDraft(value));
@@ -42,7 +36,6 @@ export function FieldNumber({
const commit = () => {
setFocused(false);
onEditingChange?.(false);
if (cancelRef.current) {
cancelRef.current = false;
setDraft(toDraft(value));
@@ -61,7 +54,6 @@ export function FieldNumber({
onFocus={() => {
setDraft(toDraft(value));
setFocused(true);
onEditingChange?.(true);
}}
onChange={(e) => {
const v = e.target.value;
@@ -5,13 +5,7 @@ import classes from "@/ee/base/styles/row-detail-modal.module.css";
const toText = (value: unknown) => (typeof value === "string" ? value : "");
export function FieldText({
property,
value,
readOnly,
onChange,
onEditingChange,
}: FieldProps) {
export function FieldText({ property, value, readOnly, onChange }: FieldProps) {
const text = toText(value);
const [draft, setDraft] = useState(text);
const [focused, setFocused] = useState(false);
@@ -26,7 +20,6 @@ export function FieldText({
const commit = () => {
setFocused(false);
onEditingChange?.(false);
if (cancelRef.current) {
cancelRef.current = false;
setDraft(text);
@@ -61,10 +54,7 @@ export function FieldText({
className={classes.fieldInput}
value={draft}
maxLength={1000}
onFocus={() => {
setFocused(true);
onEditingChange?.(true);
}}
onFocus={() => setFocused(true)}
onChange={(e) => setDraft(e.currentTarget.value)}
onBlur={commit}
onKeyDown={(e) => {
@@ -17,7 +17,6 @@ type PropertyRowProps = {
onMenuOpenChange: (opened: boolean) => void;
onMenuDirtyChange: (dirty: boolean) => void;
onUpdate: (propertyId: string, value: unknown) => void;
onEditingChange?: (editing: boolean) => void;
autoFocusValue?: boolean;
onAutoFocused?: () => void;
};
@@ -30,7 +29,6 @@ export function PropertyRow({
onMenuOpenChange,
onMenuDirtyChange,
onUpdate,
onEditingChange,
autoFocusValue,
onAutoFocused,
}: PropertyRowProps) {
@@ -114,7 +112,6 @@ export function PropertyRow({
row={row}
readOnly={!canEdit}
onUpdate={onUpdate}
onEditingChange={onEditingChange}
/>
</div>
);
@@ -75,7 +75,6 @@ export function RowDetailModal({
const isSaving = updateRowMutation.isPending;
const opened = !!openRowId;
const [editingField, setEditingField] = useState(false);
// One field menu open at a time, mirroring the grid header's semantics.
// The shared closeRequest atom asks an open dirty PropertyMenuContent to
@@ -91,7 +90,6 @@ export function RowDetailModal({
useEffect(() => {
setOpenMenuId(null);
menuDirtyRef.current = false;
setEditingField(false);
}, [openRowId]);
const handleMenuDirtyChange = useCallback((dirty: boolean) => {
@@ -295,7 +293,7 @@ export function RowDetailModal({
row={row}
primaryProperty={primaryProperty}
canEdit={canEdit}
onEditingChange={setEditingField}
onClose={onClose}
onCommit={(value) => {
if (!primaryProperty) return;
updateRowMutation.mutate({
@@ -319,7 +317,6 @@ export function RowDetailModal({
autoFocusValue={property.id === newPropertyId}
onAutoFocused={clearNewProperty}
menuOpened={openMenuId === property.id}
onEditingChange={setEditingField}
onMenuOpenChange={(nextOpened) =>
handleMenuOpenChange(property.id, nextOpened)
}
@@ -370,38 +367,16 @@ export function RowDetailModal({
) : null}
</div>
<div className={classes.kbdHint}>
{editingField ? (
{rowIndex >= 0 && rows.length > 1 && (
<>
<span className={classes.kbdGroup}>
<kbd className={classes.kbd}>Ctrl/Cmd</kbd>
<span className={classes.kbdPlus} >+</span>
<kbd className={classes.kbd}>Enter</kbd>
<span>{t("to save")}</span>
</span>
<kbd className={classes.kbd}></kbd>
<kbd className={classes.kbd}></kbd>
<span>{t("to navigate")}</span>
<span className={classes.kbdSeparator} />
<span className={classes.kbdGroup}>
<kbd className={classes.kbd}>Esc</kbd>
<span>{t("to reset")}</span>
</span>
</>
) : (
<>
{rowIndex >= 0 && rows.length > 1 && (
<>
<kbd className={classes.kbd}></kbd>
<kbd className={classes.kbd}></kbd>
<span>{t("to navigate")}</span>
<span className={classes.kbdSeparator} />
</>
)}
<>
<kbd className={classes.kbd}>Esc</kbd>
<span>{t("to close")}</span>
</>
</>
)}
<kbd className={classes.kbd}>Esc</kbd>
<span>{t("to close")}</span>
</div>
</footer>
</>
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types";
import { timeAgo } from "@/lib/time.ts";
@@ -9,7 +9,7 @@ type RowDetailTitleProps = {
primaryProperty: IBaseProperty | undefined;
canEdit: boolean;
onCommit: (value: string) => void;
onEditingChange?: (editing: boolean) => void;
onClose: () => void;
};
export function RowDetailTitle({
@@ -17,24 +17,13 @@ export function RowDetailTitle({
primaryProperty,
canEdit,
onCommit,
onEditingChange,
onClose,
}: RowDetailTitleProps) {
const { t } = useTranslation();
const initial = primaryProperty
? (((row.cells ?? {})[primaryProperty.id] as string) ?? "")
: "";
const [value, setValue] = useState(initial);
const cancelRef = useRef(false);
const commit = () => {
onEditingChange?.(false);
if (cancelRef.current) {
cancelRef.current = false;
setValue(initial);
return;
}
if (value !== initial) onCommit(value);
};
// Re-sync when the row changes underneath us (navigation or remote edit).
useEffect(() => {
@@ -54,18 +43,18 @@ export function RowDetailTitle({
aria-label={primaryProperty?.name ?? t("Untitled")}
value={value}
maxLength={1000}
onFocus={() => {
onEditingChange?.(true);
}}
onChange={(e) => setValue(e.currentTarget.value)}
onBlur={commit}
onBlur={() => {
if (value !== initial) onCommit(value);
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
cancelRef.current = true;
e.currentTarget.blur();
} else if (e.key === "Enter") {
if (e.key === "Enter") {
e.preventDefault();
e.currentTarget.blur();
(e.currentTarget as HTMLInputElement).blur();
} else if (e.key === "Escape") {
e.preventDefault();
(e.currentTarget as HTMLInputElement).blur();
onClose();
}
}}
/>
@@ -416,25 +416,9 @@
}
.kbdHint {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
width: 100%;
}
.kbdGroup {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
height: fit-content;
}
.kbdPlus {
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
font-size: 11px;
}
.kbdSeparator {
-2
View File
@@ -25,6 +25,4 @@ export const Feature = {
OAUTH: 'oauth',
AI_CONTROLS: 'ai:controls',
MCP_CONTROLS: 'mcp:controls',
PUBLIC_SPACE_APPEARANCE: 'public-space:appearance',
SIEM: 'siem',
} as const;
@@ -82,7 +82,6 @@ export default function SpacePublicSharingToggle({
checked={checked}
onChange={handleChange}
disabled={isDisabled}
size={"xs"}
aria-label={t("Toggle space public sharing")}
/>
</Tooltip>
@@ -53,7 +53,6 @@ export default function SpaceViewerCommentsToggle({
checked={checked}
onChange={handleChange}
disabled={isDisabled}
size={"xs"}
aria-label={t("Toggle viewer comments")}
/>
</Tooltip>
@@ -1,44 +0,0 @@
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>
);
}
@@ -1,300 +0,0 @@
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>
);
}
@@ -1,15 +0,0 @@
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>;
}
@@ -1,155 +0,0 @@
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>
);
}
@@ -1,129 +0,0 @@
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}
/>
</>
);
}
@@ -1,179 +0,0 @@
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,
};
}
@@ -1,115 +0,0 @@
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,
});
}
@@ -1,46 +0,0 @@
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);
}
@@ -1,91 +0,0 @@
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,9 +85,6 @@ export function AudioMenu({ editor }: EditorMenuProps) {
<BaseBubbleMenu
editor={editor}
pluginKey={`audio-menu`}
ref={(element) => {
if (element) element.style.zIndex = "99";
}}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
@@ -121,14 +121,12 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
<BaseBubbleMenu
editor={editor}
pluginKey={`callout-menu`}
ref={(element) => {
if (element) element.style.zIndex = "99";
}}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
placement: "bottom",
// offset: 233, // // offset: [0, 10],
// zIndex: 99,
flip: false,
}}
shouldShow={shouldShow}
@@ -257,9 +257,6 @@ export function ColumnsMenu({ editor }: EditorMenuProps) {
<BaseBubbleMenu
editor={editor}
pluginKey="columns-menu"
ref={(element) => {
if (element) element.style.zIndex = "99";
}}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
@@ -273,9 +273,6 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
<BaseBubbleMenu
editor={editor}
pluginKey={`drawio-menu`}
ref={(element) => {
if (element) element.style.zIndex = "99";
}}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
@@ -156,9 +156,6 @@ export function ImageMenu({ editor }: EditorMenuProps) {
<BaseBubbleMenu
editor={editor}
pluginKey={`image-menu`}
ref={(element) => {
if (element) element.style.zIndex = "99";
}}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
@@ -26,12 +26,7 @@ import { INTERNAL_LINK_REGEX } from "@/lib/constants";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { useSharePageQuery } from "@/features/share/queries/share-query.ts";
import { usePublicSpacePageQuery } from "@/features/public-space/queries/public-space-query.ts";
import {
buildPageUrl,
buildPublicSpaceUrl,
buildSharedPageUrl,
} from "@/features/page/page.utils.ts";
import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
import { extractPageSlugId } from "@/lib";
import { sanitizeUrl, copyToClipboard, isEditorReady } from "@docmost/editor-ext";
import { normalizeUrl } from "@/lib/utils";
@@ -65,10 +60,9 @@ export default function LinkView(props: MarkViewProps) {
const href = mark.attrs.href as string;
const navigate = useNavigate();
const location = useLocation();
const { shareId, spaceSlug, pageSlug } = useParams();
const { shareId, pageSlug } = useParams();
const { t } = useTranslation();
const isShareRoute = location.pathname.startsWith("/share");
const isPublicSpaceRoute = location.pathname.startsWith("/docs/");
const [popoverState, setPopoverState] = useState<
"closed" | "preview" | "edit"
@@ -90,33 +84,16 @@ export default function LinkView(props: MarkViewProps) {
const activeView = isPopoverVisible ? popoverState : lastOpenState.current;
const { data: linkedPage } = usePageQuery({
pageId:
isPopoverVisible && slugId && !isShareRoute && !isPublicSpaceRoute
? slugId
: null,
pageId: isPopoverVisible && slugId && !isShareRoute ? slugId : null,
});
const { data: sharedPageData } = useSharePageQuery({
pageId: isPopoverVisible && slugId && isShareRoute ? slugId : null,
});
// Resolved eagerly (not gated on the popover): an unresolvable target must
// render as inert text rather than a link that dead-ends at /login.
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 pageTitle = isShareRoute
? sharedPageData?.page?.title
: linkedPage?.title;
const pendingTitleRef = useRef<string | null>(null);
const titleInputRef = useRef<HTMLInputElement>(null);
@@ -283,27 +260,6 @@ export default function LinkView(props: MarkViewProps) {
anchorId: anchor || undefined,
});
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 {
navigate(anchor ? `${targetPath}#${anchor}` : targetPath);
}
@@ -320,11 +276,8 @@ export default function LinkView(props: MarkViewProps) {
location.pathname,
isInternal,
isShareRoute,
isPublicSpaceRoute,
slugId,
shareId,
spaceSlug,
publicSpacePageData,
pageTitle,
pageSlug,
]);
@@ -366,18 +319,12 @@ export default function LinkView(props: MarkViewProps) {
setPopoverState("closed");
}, [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(
isInternal ? internalHref() : normalizeUrl(href),
isInternal
? isShareRoute && slugId
? buildSharedPageUrl({ shareId, pageSlugId: slugId, pageTitle })
: href
: normalizeUrl(href),
);
const linkTitleInput = (
@@ -427,16 +374,6 @@ 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 (
<Popover
opened={isPopoverVisible}
@@ -4,10 +4,8 @@ import { IconFileDescription } from "@tabler/icons-react";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { useSharePageQuery } from "@/features/share/queries/share-query.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";
@@ -23,30 +21,17 @@ export default function MentionView(props: NodeViewProps) {
const location = useLocation();
const isShareRoute = location.pathname.startsWith("/share");
const isPublicSpaceRoute = location.pathname.startsWith("/docs/");
const {
data: page,
isLoading,
isError,
} = usePageQuery({
pageId:
isPageMention && !isShareRoute && !isPublicSpaceRoute ? slugId : null,
});
} = usePageQuery({ pageId: isPageMention && !isShareRoute ? slugId : null });
const { data: sharedPage } = useSharePageQuery({
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 isSamePage = currentPageSlugId === slugId;
@@ -102,61 +87,7 @@ export default function MentionView(props: NodeViewProps) {
</Anchor>
)}
{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 && (
{isPageMention && !isShareRoute && isError && (
<Anchor
component={Link}
fw={500}
@@ -180,7 +111,7 @@ export default function MentionView(props: NodeViewProps) {
</Anchor>
)}
{isPageMention && !isShareRoute && !isPublicSpaceRoute && !isError && (
{isPageMention && !isShareRoute && !isError && (
<Anchor
component={Link}
fw={500}
@@ -106,9 +106,6 @@ export function PdfMenu({ editor }: EditorMenuProps) {
<BaseBubbleMenu
editor={editor}
pluginKey={`pdf-menu`}
ref={(element) => {
if (element) element.style.zIndex = "99";
}}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
@@ -66,7 +66,7 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
}
// Clear search term in editor
if (isEditorReady(editor)) {
editor.commands.setSearchTerms([""]);
editor.commands.setSearchTerm("");
}
};
@@ -117,7 +117,7 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
useEffect(() => {
if (!isEditorReady(editor)) return;
editor.commands.setSearchTerms([searchText]);
editor.commands.setSearchTerm(searchText);
editor.commands.resetIndex();
editor.commands.selectCurrentItem();
}, [searchText]);
@@ -181,10 +181,8 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
const location = useLocation();
useEffect(() => {
if (pageFindState.isOpen) {
closeDialog();
}
}, [location.pathname]);
closeDialog();
}, [location]);
return (
<Dialog
@@ -1,205 +0,0 @@
import { ActionIcon, Dialog, Flex, Text, Tooltip } from "@mantine/core";
import {
IconArrowNarrowDown,
IconArrowNarrowUp,
IconX,
} from "@tabler/icons-react";
import { useEditor } from "@tiptap/react";
import { isEditorReady } from "@docmost/editor-ext";
import React, { useCallback, useEffect, useRef, useState } from "react";
import classes from "./search-replace.module.css";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
interface SearchNavigationDialogProps {
editor: ReturnType<typeof useEditor>;
}
interface SearchNavigationEvent extends CustomEvent {
detail: {
searchTerms: string[];
wholeWord?: boolean;
};
}
function SearchNavigationDialog({ editor }: SearchNavigationDialogProps) {
const { t } = useTranslation();
const location = useLocation();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const openRef = useRef(false);
const [resultState, setResultState] = useState({
resultIndex: 0,
resultsLength: 0,
});
const goToSelection = () => {
if (!isEditorReady(editor)) return;
const { results, resultIndex } = editor.storage.searchAndReplace;
const position = results[resultIndex];
setResultState({
resultsLength: results.length,
resultIndex,
});
if (!position) return;
requestAnimationFrame(() => {
document
.querySelector(".search-result-current")
?.scrollIntoView({ behavior: "smooth", block: "center" });
});
};
const next = () => {
if (!isEditorReady(editor)) return;
editor.commands.nextSearchResult();
goToSelection();
};
const previous = () => {
if (!isEditorReady(editor)) return;
editor.commands.previousSearchResult();
goToSelection();
};
const close = useCallback(() => {
if (!openRef.current) return;
openRef.current = false;
setOpen(false);
if (isEditorReady(editor)) {
editor.commands.setSearchTerms([""]);
}
const nextParams = new URLSearchParams(location.search);
nextParams.delete("q");
nextParams.delete("m");
const nextSearch = nextParams.toString();
navigate(
{
pathname: location.pathname,
search: nextSearch ? `?${nextSearch}` : "",
hash: location.hash,
},
{ replace: true },
);
}, [editor, location.hash, location.pathname, location.search, navigate]);
useEffect(() => {
const handleOpen = (event: Event) => {
const { searchTerms: terms, wholeWord = true } = (
event as SearchNavigationEvent
).detail;
if (!terms?.length || !isEditorReady(editor)) return;
openRef.current = false;
editor.commands.setSearchTerms(terms);
editor.commands.setWholeWord(wholeWord);
editor.commands.resetIndex();
const { results, resultIndex } = editor.storage.searchAndReplace;
openRef.current = true;
if (results.length === 0) {
close();
return;
}
setOpen(true);
setResultState({
resultIndex,
resultsLength: results.length,
});
goToSelection();
};
const handleClose = () => {
if (openRef.current) {
close();
}
};
document.addEventListener("openSearchNavigationDialog", handleOpen);
document.addEventListener("openFindDialogFromEditor", handleClose);
document.addEventListener("closeFindDialogFromEditor", handleClose);
return () => {
document.removeEventListener("openSearchNavigationDialog", handleOpen);
document.removeEventListener("openFindDialogFromEditor", handleClose);
document.removeEventListener("closeFindDialogFromEditor", handleClose);
};
}, [close, editor]);
useEffect(() => {
const handleTransaction = () => {
if (!openRef.current || editor.isDestroyed) return;
const { results } = editor.storage.searchAndReplace;
if (results.length === 0) {
close();
}
};
editor.on("transaction", handleTransaction);
return () => {
editor.off("transaction", handleTransaction);
};
}, [close, editor]);
return (
<Dialog
className={classes.findDialog}
opened={open}
size="xs"
radius="md"
w="auto"
position={{ top: 90, right: 50 }}
withBorder
aria-label="Search navigation"
>
<Flex align="center" gap="xs">
<Text size="xs" style={{ flex: 1 }}>
{resultState.resultsLength > 0
? `${resultState.resultIndex + 1}/${resultState.resultsLength}`
: t("Not found")}
</Text>
<Tooltip label="Previous match">
<ActionIcon
variant="subtle"
color="gray"
onClick={previous}
aria-label="Previous match"
disabled={resultState.resultsLength === 0}
>
<IconArrowNarrowUp size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Next match">
<ActionIcon
variant="subtle"
color="gray"
onClick={next}
aria-label="Next match"
disabled={resultState.resultsLength === 0}
>
<IconArrowNarrowDown size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Close">
<ActionIcon
variant="subtle"
color="gray"
onClick={close}
aria-label="Close"
>
<IconX size={16} />
</ActionIcon>
</Tooltip>
</Flex>
</Dialog>
);
}
export default SearchNavigationDialog;
@@ -1,47 +0,0 @@
import { useEffect, useRef } from "react";
import type { useEditor } from "@tiptap/react";
interface UseSearchNavigationParamsProps {
editor: ReturnType<typeof useEditor>;
isSynced: boolean;
pageId: string;
searchParams: URLSearchParams;
showStatic: boolean;
}
export function useSearchNavigationParams({
editor,
isSynced,
pageId,
searchParams,
showStatic,
}: UseSearchNavigationParamsProps) {
const appliedSearchKeyRef = useRef<string | null>(null);
const searchKey = `${pageId}:${searchParams.toString()}`;
useEffect(() => {
const searchQueries = searchParams.getAll("q");
if (!searchQueries.length) {
appliedSearchKeyRef.current = null;
return;
}
if (
!editor ||
editor.isDestroyed ||
!editor.view.dom.isConnected ||
appliedSearchKeyRef.current === searchKey
) {
return;
}
const match = searchParams.get("m");
appliedSearchKeyRef.current = searchKey;
document.dispatchEvent(
new CustomEvent("openSearchNavigationDialog", {
detail: { searchTerms: searchQueries, wholeWord: match === "whole" },
}),
);
}, [editor, isSynced, searchKey, searchParams, showStatic]);
}
@@ -61,9 +61,6 @@ export const SubpagesMenu = React.memo(
<BaseBubbleMenu
editor={editor}
pluginKey={`subpages-menu`}
ref={(element) => {
if (element) element.style.zIndex = "99";
}}
updateDelay={0}
shouldShow={shouldShow}
>
@@ -3,30 +3,22 @@ import { Stack, Text, Anchor, ActionIcon } from "@mantine/core";
import { IconFileDescription } from "@tabler/icons-react";
import { useGetSidebarPagesQuery } from "@/features/page/queries/page-query";
import { useMemo } from "react";
import { Link, useLocation, useParams } from "react-router-dom";
import { Link, useParams } from "react-router-dom";
import classes from "./subpages.module.css";
import styles from "../mention/mention.module.css";
import {
buildPageUrl,
buildPublicSpaceUrl,
buildSharedPageUrl,
} from "@/features/page/page.utils.ts";
import { useTranslation } from "react-i18next";
import { sortPositionKeys } from "@/features/page/tree/utils/utils";
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";
export default function SubpagesView(props: NodeViewProps) {
const { editor } = props;
const { spaceSlug, shareId, pageSlug } = useParams();
const { t } = useTranslation();
const location = useLocation();
const isPublicSpaceRoute = location.pathname.startsWith("/docs/");
const publicSpaceTreeData = useAtomValue(publicSpaceTreeDataAtom);
// @ts-ignore
const storagePageId = editor.storage.pageId;
@@ -37,25 +29,11 @@ export default function SubpagesView(props: NodeViewProps) {
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
const sharedSubpages = useSharedPageSubpages(currentPageId);
const publicSpaceSubpages = useMemo(
() => findSubpagesInTree(publicSpaceTreeData, currentPageId),
[publicSpaceTreeData, currentPageId],
);
const isPublicView = Boolean(shareId) || isPublicSpaceRoute;
const { data, isLoading, error } = useGetSidebarPagesQuery(
isPublicView ? null : { pageId: currentPageId },
shareId ? null : { pageId: currentPageId },
);
const subpages = useMemo(() => {
@@ -70,33 +48,17 @@ 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
if (!data?.pages) return [];
const allPages = data.pages.flatMap((page) => page.items);
return sortPositionKeys(allPages);
}, [
data,
shareId,
sharedSubpages,
isPublicSpaceRoute,
publicSpaceSubpages,
]);
}, [data, shareId, sharedSubpages]);
if (isLoading && !isPublicView) {
if (isLoading && !shareId) {
return null;
}
if (error && !isPublicView) {
if (error && !shareId) {
return (
<NodeViewWrapper data-drag-handle>
<Text c="dimmed" size="md" py="md">
@@ -134,13 +96,7 @@ export default function SubpagesView(props: NodeViewProps) {
pageSlugId: page.slugId,
pageTitle: page.title,
})
: isPublicSpaceRoute
? buildPublicSpaceUrl({
spaceSlug,
pageSlugId: page.slugId,
pageTitle: page.title,
})
: buildPageUrl(spaceSlug, page.slugId, page.title)
: buildPageUrl(spaceSlug, page.slugId, page.title)
}
underline="never"
className={styles.pageMentionLink}
@@ -18,7 +18,7 @@ export type HeadingLink = {
position: number;
};
export const recalculateLinks = (nodePos: NodePos[]) => {
const recalculateLinks = (nodePos: NodePos[]) => {
const nodes: HTMLElement[] = [];
const links: HeadingLink[] = Array.from(nodePos).reduce<HeadingLink[]>(
@@ -9,7 +9,6 @@ import React, {
} from "react";
import {
lookupTransclusion,
lookupTransclusionForPublicSpace,
lookupTransclusionForShare,
} from "@/features/transclusion/services/transclusion-api";
import type { TransclusionLookup } from "@/features/transclusion/types/transclusion.types";
@@ -39,7 +38,6 @@ const TransclusionLookupContext = createContext<ContextValue | null>(null);
export function TransclusionLookupProvider({
children,
shareId,
spaceSlug,
}: {
children: React.ReactNode;
/**
@@ -49,11 +47,6 @@ export function TransclusionLookupProvider({
* app, where personal permissions gate access.
*/
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 queueRef = useRef(new Set<LookupKey>());
@@ -62,8 +55,6 @@ export function TransclusionLookupProvider({
// memoized callbacks (and thus doesn't re-render every consumer).
const shareIdRef = useRef<string | undefined>(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
// remounts after switching from static to live) get this immediately
// instead of triggering a duplicate fetch.
@@ -100,18 +91,12 @@ export function TransclusionLookupProvider({
try {
const activeShareId = shareIdRef.current;
const activeSpaceSlug = spaceSlugRef.current;
const { items } = activeShareId
? await lookupTransclusionForShare({
shareId: activeShareId,
references,
})
: activeSpaceSlug
? await lookupTransclusionForPublicSpace({
spaceSlug: activeSpaceSlug,
references,
})
: await lookupTransclusion({ references });
: await lookupTransclusion({ references });
for (const r of items) {
const key = `${r.sourcePageId}::${r.transclusionId}`;
resultCacheRef.current.set(key, r);
@@ -132,9 +132,6 @@ export function VideoMenu({ editor }: EditorMenuProps) {
<BaseBubbleMenu
editor={editor}
pluginKey={`video-menu`}
ref={(element) => {
if (element) element.style.zIndex = "99";
}}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
@@ -1,112 +0,0 @@
import { Editor, Node } from "@tiptap/core";
import { GapCursor } from "@tiptap/pm/gapcursor";
import { NodeSelection, TextSelection } from "@tiptap/pm/state";
import { StarterKit } from "@tiptap/starter-kit";
import { describe, expect, it } from "vitest";
import { TiptapDocument } from "./document";
const Footnotes = Node.create({
name: "footnotes",
group: "",
content: "paragraph*",
isolating: true,
renderHTML() {
return ["ol", { class: "footnotes" }, 0];
},
});
const AtomBlock = Node.create({
name: "atomBlock",
group: "block",
atom: true,
renderHTML() {
return ["div", { "data-atom-block": "" }];
},
});
const IsolatingBlock = Node.create({
name: "isolatingBlock",
group: "block",
content: "paragraph+",
isolating: true,
renderHTML() {
return ["div", { "data-isolating-block": "" }, 0];
},
});
function createEditor(content: object[]) {
const element = document.createElement("div");
document.body.appendChild(element);
return new Editor({
element,
extensions: [
TiptapDocument,
StarterKit.configure({ document: false }),
Footnotes,
AtomBlock,
IsolatingBlock,
],
content: { type: "doc", content },
});
}
function pressKey(editor: Editor, key: string, keyCode: number) {
editor.view.dom.dispatchEvent(
new KeyboardEvent("keydown", {
key,
keyCode,
bubbles: true,
cancelable: true,
}),
);
}
describe("TiptapDocument", () => {
it("stops on the gap when arrowing down from a selected block node", () => {
const editor = createEditor([
{ type: "atomBlock" },
{ type: "atomBlock" },
{ type: "paragraph" },
]);
const gapPos = editor.state.doc.child(0).nodeSize;
editor.view.dispatch(
editor.state.tr.setSelection(
NodeSelection.create(editor.state.doc, 0),
),
);
pressKey(editor, "ArrowDown", 40);
expect(editor.state.selection).toBeInstanceOf(GapCursor);
expect(editor.state.selection.head).toBe(gapPos);
editor.destroy();
});
it("stops on the gap when arrowing right out of an isolating block", () => {
const paragraph = (text: string) => ({
type: "paragraph",
content: [{ type: "text", text }],
});
const editor = createEditor([
{ type: "isolatingBlock", content: [paragraph("a")] },
{ type: "isolatingBlock", content: [paragraph("b")] },
{ type: "paragraph" },
]);
const gapPos = editor.state.doc.child(0).nodeSize;
const endOfFirstText = gapPos - 2;
editor.view.dispatch(
editor.state.tr.setSelection(
TextSelection.create(editor.state.doc, endOfFirstText),
),
);
pressKey(editor, "ArrowRight", 39);
expect(editor.state.selection).toBeInstanceOf(GapCursor);
expect(editor.state.selection.head).toBe(gapPos);
editor.destroy();
});
});
@@ -1,8 +0,0 @@
import { Document } from "@tiptap/extension-document";
// With `block+ footnotes?`, ProseMirror's defaultType after the first block is
// `footnotes` (not a textblock), so GapCursor.valid() rejects every top-level gap.
export const TiptapDocument = Document.extend({
content: "block+ footnotes?",
allowGapCursor: true,
});
@@ -1,6 +1,6 @@
import { markInputRule } from "@tiptap/core";
import { StarterKit } from "@tiptap/starter-kit";
import { TiptapDocument } from "@/features/editor/extensions/document";
import { Document } from "@tiptap/extension-document";
import { Code } from "@tiptap/extension-code";
import { TextAlign } from "@tiptap/extension-text-align";
import { TaskList, TaskItem } from "@tiptap/extension-list";
@@ -148,7 +148,9 @@ export const mainExtensions = [
codeBlock: false,
code: false,
}),
TiptapDocument,
Document.extend({
content: "block+ footnotes?",
}),
// Override TipTap's Code extension to fix the inline code input rule.
// The upstream regex /(^|[^`])`([^`]+)`(?!`)$/ captures the character
// before the opening backtick as part of the match, causing markInputRule
@@ -65,13 +65,11 @@ import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy";
import DrawioMenu from "./components/drawio/drawio-menu";
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
import SearchNavigationDialog from "@/features/editor/components/search-and-replace/search-navigation-dialog.tsx";
import { useSearchNavigationParams } from "@/features/editor/components/search-and-replace/use-search-navigation-params.ts";
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
import { useIdle } from "@/hooks/use-idle.ts";
import { queryClient } from "@/main.tsx";
import { IPage } from "@/features/page/types/page.types.ts";
import { useParams, useSearchParams } from "react-router-dom";
import { useParams } from "react-router-dom";
import { extractPageSlugId, platformModifierKey } from "@/lib";
import { FIVE_MINUTES } from "@/lib/constants.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts";
@@ -201,7 +199,6 @@ function CollabPageEditor({
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
const documentState = useDocumentVisibility();
const { pageSlug } = useParams();
const [searchParams] = useSearchParams();
const slugId = extractPageSlugId(pageSlug);
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
const canScroll = useCallback(
@@ -440,14 +437,6 @@ function CollabPageEditor({
const hasConnectedOnceRef = useRef(false);
const [showStatic, setShowStatic] = useState(true);
useSearchNavigationParams({
editor,
isSynced,
pageId,
searchParams,
showStatic,
});
useEffect(() => {
if (
!hasConnectedOnceRef.current &&
@@ -471,7 +460,6 @@ function CollabPageEditor({
{editor && (
<SearchAndReplaceDialog editor={editor} editable={editable} />
)}
{editor && <SearchNavigationDialog editor={editor} />}
{editor && editorIsEditable && (
<div>
@@ -35,16 +35,6 @@ interface PageEditorProps {
* that isn't itself shared.
*/
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({
@@ -53,16 +43,12 @@ export default function ReadonlyPageEditor({
pageId,
printMode = false,
shareId,
spaceSlug,
byline,
trailingSpace = true,
}: PageEditorProps) {
const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom);
const [lightboxRequest, setLightboxRequest] = useAtom(lightboxRequestAtom);
const [contentEditor, setContentEditor] = useState<Editor | null>(null);
const isComponentMounted = useRef(false);
const editorCreated = useRef(false);
const isPublicView = Boolean(shareId || spaceSlug);
const canScroll = useCallback(
() => isComponentMounted.current && editorCreated.current,
@@ -78,9 +64,9 @@ export default function ReadonlyPageEditor({
}, []);
useEffect(() => {
if (!isPublicView) return;
if (!shareId) return;
setLightboxRequest(null);
}, [pageId, isPublicView]);
}, [pageId, shareId]);
const extensions = useMemo(() => {
const excludedExtensions = new Set([
@@ -113,7 +99,7 @@ export default function ReadonlyPageEditor({
];
return (
<TransclusionLookupProvider shareId={shareId} spaceSlug={spaceSlug}>
<TransclusionLookupProvider shareId={shareId}>
<div className="page-title">
<EditorProvider
editable={false}
@@ -124,8 +110,6 @@ export default function ReadonlyPageEditor({
></EditorProvider>
</div>
{byline}
<EditorProvider
editable={false}
immediatelyRender={true}
@@ -133,7 +117,7 @@ export default function ReadonlyPageEditor({
extensions={extensions}
content={content}
editorProps={
isPublicView
shareId
? {
handleClickOn: (_view, _pos, node) => {
const request = getLightboxClickRequest(node);
@@ -160,7 +144,7 @@ export default function ReadonlyPageEditor({
}
}}
></EditorProvider>
{isPublicView && contentEditor && (
{shareId && contentEditor && (
<LightboxView
editor={contentEditor}
open={!!lightboxRequest}
@@ -169,7 +153,7 @@ export default function ReadonlyPageEditor({
onClose={() => setLightboxRequest(null)}
/>
)}
{trailingSpace && <div style={{ paddingBottom: "20vh" }}></div>}
<div style={{ paddingBottom: "20vh" }}></div>
</TransclusionLookupProvider>
);
}
@@ -63,12 +63,6 @@ export function NotificationItem({
return "Page verification expires soon";
case "page.verification_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:
return "";
}
@@ -83,19 +77,6 @@ export function NotificationItem({
)
: 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 = () => {
if (isUnread) {
markRead.mutate([notification.id]);
@@ -116,7 +97,7 @@ export function NotificationItem({
return (
<UnstyledButton
component={Link}
to={linkUrl ?? ""}
to={pageUrl ?? ""}
onClick={handleClick}
// 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
@@ -143,21 +124,11 @@ export function NotificationItem({
<Text size="sm" lineClamp={2}>
<Trans
i18nKey={getNotificationMessageKey()}
values={{
name: isSiemDestination
? destinationName
: notification.actor?.name,
}}
values={{ name: notification.actor?.name }}
components={{ bold: <Text span fw={600} /> }}
/>
</Text>
{lastError && (
<Text size="xs" c="dimmed" lineClamp={1} mt={2}>
{lastError}
</Text>
)}
{notification.page && (
<Group gap={4} mt={2} wrap="nowrap">
{notification.page.icon ? (
@@ -9,10 +9,7 @@ export type NotificationType =
| "page.verification_expired"
| "page.verified"
| "page.approval_requested"
| "page.approval_rejected"
| "siem_destination.failing"
| "siem_destination.disabled"
| "siem_destination.recovered";
| "page.approval_rejected";
export type INotification = {
id: string;
@@ -1,63 +1,16 @@
import classes from "./page-header.module.css";
import PageHeaderMenu from "@/features/page/components/header/page-header-menu.tsx";
import { Badge, Group, Tooltip } from "@mantine/core";
import { IconExternalLink, IconWorld } from "@tabler/icons-react";
import { Group } from "@mantine/core";
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 {
readOnly?: boolean;
}
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 (
<div className={classes.header} data-page-header="true">
<Group justify="space-between" h="100%" px="md" wrap="nowrap" className={classes.group}>
<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>
<Breadcrumb />
<Group justify="flex-end" h="100%" px="md" wrap="nowrap" gap="var(--mantine-spacing-xs)">
<PageHeaderMenu readOnly={readOnly} />
+1 -48
View File
@@ -25,37 +25,11 @@ const buildPageSlug = (pageSlugId: string, pageTitle?: string): string => {
return `${titleSlug}-${pageSlugId}`;
};
function appendSearchParams(
url: string,
search?: string[],
wholeWord?: boolean,
): string {
if(search?.length === 0){
return url;
}
const params = new URLSearchParams();
search
?.map((term) => term.trim())
.filter(Boolean)
.forEach((term) => params.append("q", term));
if (wholeWord) {
params.set("m", "whole");
}
const queryString = params.toString();
return queryString ? `${url}?${queryString}` : url;
}
export const buildPageUrl = (
spaceName: string,
pageSlugId: string,
pageTitle?: string,
anchorId?: string,
search?: string[],
wholeWord?: boolean,
): string => {
let url: string;
if (spaceName === undefined) {
@@ -63,9 +37,6 @@ export const buildPageUrl = (
} else {
url = `/s/${spaceName}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
}
url = appendSearchParams(url, search, wholeWord);
return anchorId ? `${url}#${anchorId}` : url;
};
@@ -74,31 +45,13 @@ export const buildSharedPageUrl = (opts: {
pageSlugId: string;
pageTitle?: string;
anchorId?: string;
search?: string[];
wholeWord?: boolean;
}): string => {
const { shareId, pageSlugId, pageTitle, anchorId, search, wholeWord } = opts;
const { shareId, pageSlugId, pageTitle, anchorId } = opts;
let url: string;
if (!shareId) {
url = `/share/p/${buildPageSlug(pageSlugId, pageTitle)}`;
} else {
url = `/share/${shareId}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
}
url = appendSearchParams(url, search, wholeWord);
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,7 +41,6 @@ type Props<T extends object> = {
activeId?: string;
renderRow: (props: RenderRowProps<T>) => ReactNode;
indentPerLevel: number;
rowClassName?: string;
onMove: (sourceId: string, op: DropOp) => void | Promise<void>;
onToggle: (id: string, isOpen: boolean) => void;
readOnly: boolean;
@@ -69,7 +68,6 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
activeId,
renderRow,
indentPerLevel,
rowClassName,
onMove,
onToggle,
readOnly,
@@ -325,7 +323,7 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
style={{ paddingLeft: level * indentPerLevel }}
>
<div
className={rowClassName ? `${styles.node} ${rowClassName}` : styles.node}
className={styles.node}
data-dragging={isDragging || undefined}
data-selected={isSelected || undefined}
data-receiving-drop={
@@ -378,7 +376,6 @@ function arePropsEqual<T extends object>(
if (prev.readOnly !== next.readOnly) return false;
if (prev.contextId !== next.contextId) 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.onMove !== next.onMove) return false;
if (prev.onToggle !== next.onToggle) return false;
@@ -56,18 +56,6 @@ export type DocTreeProps<T extends object> = {
indentPerLevel?: number;
rowHeight?: number;
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>;
onToggle: (id: string, isOpen: boolean) => void;
@@ -135,9 +123,6 @@ function DocTreeInner<T extends object>(
renderRow,
indentPerLevel = 16,
rowHeight = 32,
rowClassName,
rowGap,
dynamicRowHeight = false,
onMove,
onToggle,
onSelect,
@@ -147,7 +132,6 @@ function DocTreeInner<T extends object>(
getDragLabel,
uniqueContextId,
emptyState,
footer,
'aria-label': ariaLabel,
} = props;
@@ -213,20 +197,10 @@ function DocTreeInner<T extends object>(
return flat[0]?.node.id;
}, [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({
count: flat.length,
getScrollElement: () => scrollRef.current,
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;
},
estimateSize: () => rowHeight,
overscan: 10,
});
@@ -497,12 +471,7 @@ function DocTreeInner<T extends object>(
);
if (data.length === 0 && emptyState) {
return (
<div className={styles.treeContainer}>
{emptyState}
{footer}
</div>
);
return <div className={styles.treeContainer}>{emptyState}</div>;
}
const virtualItems = virtualizer.getVirtualItems();
@@ -525,9 +494,6 @@ function DocTreeInner<T extends object>(
>
{virtualItems.map((virtualItem) => {
const row = flat[virtualItem.index];
const gap = rowGap
? rowGap(row.node, row.level, virtualItem.index)
: 0;
return (
<li
key={row.node.id}
@@ -535,15 +501,12 @@ function DocTreeInner<T extends object>(
// (the row's <a>), so screen readers announce "treeitem" on
// navigation. The <li> is just layout glue.
role="none"
ref={dynamicRowHeight ? virtualizer.measureElement : undefined}
data-index={dynamicRowHeight ? virtualItem.index : undefined}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
...(gap > 0 ? { paddingTop: gap } : {}),
}}
>
<DocTreeRow
@@ -555,7 +518,6 @@ function DocTreeInner<T extends object>(
activeId={effectiveActiveId}
renderRow={renderRow}
indentPerLevel={indentPerLevel}
rowClassName={rowClassName}
onMove={onMove}
onToggle={onToggle}
readOnly={readOnly}
@@ -570,7 +532,6 @@ function DocTreeInner<T extends object>(
);
})}
</ul>
{footer}
</div>
);
}
@@ -1,17 +0,0 @@
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);
@@ -1,51 +0,0 @@
.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);
}
@@ -1,165 +0,0 @@
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>
);
}
@@ -1,114 +0,0 @@
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>
);
}
@@ -1,45 +0,0 @@
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>
);
}
@@ -1,31 +0,0 @@
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>
);
}
@@ -1,23 +0,0 @@
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>
);
}
@@ -1,485 +0,0 @@
/* 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;
}
}
@@ -1,70 +0,0 @@
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>
);
}
@@ -1,22 +0,0 @@
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>
);
}
@@ -1,223 +0,0 @@
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);
const MANTINE_COLOR_SCHEME_ATTRIBUTE = "data-mantine-color-scheme";
const DOCS_PRINT_COLOR_SCHEME_ATTRIBUTE = "data-docs-print-color-scheme";
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);
React.useEffect(() => {
const root = document.documentElement;
let previousColorScheme: string | null = null;
let isPrinting = false;
const restoreColorScheme = () => {
if (!isPrinting) return;
if (previousColorScheme === null) {
root.removeAttribute(MANTINE_COLOR_SCHEME_ATTRIBUTE);
} else {
root.setAttribute(MANTINE_COLOR_SCHEME_ATTRIBUTE, previousColorScheme);
}
root.removeAttribute(DOCS_PRINT_COLOR_SCHEME_ATTRIBUTE);
isPrinting = false;
};
const useLightPrintTheme = () => {
if (isPrinting) return;
previousColorScheme = root.getAttribute(MANTINE_COLOR_SCHEME_ATTRIBUTE);
root.setAttribute(
DOCS_PRINT_COLOR_SCHEME_ATTRIBUTE,
previousColorScheme ?? "light",
);
root.setAttribute(MANTINE_COLOR_SCHEME_ATTRIBUTE, "light");
isPrinting = true;
};
window.addEventListener("beforeprint", useLightPrintTheme);
window.addEventListener("afterprint", restoreColorScheme);
return () => {
window.removeEventListener("beforeprint", useLightPrintTheme);
window.removeEventListener("afterprint", restoreColorScheme);
restoreColorScheme();
};
}, []);
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>
);
}
@@ -1,198 +0,0 @@
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>
);
}
@@ -1,27 +0,0 @@
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;
}
@@ -1,35 +0,0 @@
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>
);
}
@@ -1,116 +0,0 @@
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>
);
}
@@ -1,952 +0,0 @@
/* 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);
--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. */
.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);
}
}
/* ---------- 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;
}
.root.root :global(.ProseMirror) h1 {
font-size: 1.75rem;
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1.3;
}
.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;
}
/* ---------- Print ---------- */
@page public-doc {
background-color: #fff;
}
/* Only the article prints; the body grid collapses so it takes the page width. */
@media print {
:global(html):has(.root),
:global(body):has(.root) {
page: public-doc;
background-color: #fff !important;
}
:global(html[data-docs-print-color-scheme="dark"])
.root.root
:global(.codeBlock svg) {
filter: invert(1) hue-rotate(180deg);
}
.header,
.sidebar,
.toc,
.articleActions,
.breadcrumbs,
.pageNav,
.footer {
display: none !important;
}
.root {
--docs-bg: #fff;
--docs-fg: #1f1f1f;
--docs-content-fg: var(--docs-fg);
--docs-nav-fg: #495057;
--docs-muted: #5f6368;
--docs-hover: #f1f3f5;
--docs-faint: #868e96;
--docs-border: #dee2e6;
--docs-header-bg: #fff;
min-height: 0;
background-color: #fff;
color: var(--docs-fg);
}
.body {
display: block;
}
.article {
max-width: none;
padding: 0;
}
}
@@ -1,69 +0,0 @@
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>
);
}
@@ -1,283 +0,0 @@
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>
);
}
@@ -1,210 +0,0 @@
import { Table, Group, Text, Anchor, Menu, ActionIcon } from "@mantine/core";
import React from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { modals } from "@mantine/modals";
import { notifications } from "@mantine/notifications";
import {
IconCopy,
IconDots,
IconExternalLink,
IconWorld,
IconWorldOff,
} from "@tabler/icons-react";
import Paginate from "@/components/common/paginate.tsx";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import {
usePublishedSpacesQuery,
usePublishSpaceMutation,
} from "@/features/public-space/queries/public-space-query.ts";
import { IPublishedSpaceItem } from "@/features/public-space/types/public-space.types.ts";
import { buildPublicSpaceUrl } from "@/features/page/page.utils.ts";
import { getAppUrl, getSpaceUrl } from "@/lib/config.ts";
import { useClipboard } from "@/hooks/use-clipboard";
import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import { EmptyState } from "@/components/ui/empty-state.tsx";
import rowClasses from "@/components/ui/clickable-table-row.module.css";
export default function PublishedSpacesList() {
const { t } = useTranslation();
const { cursor, goNext, goPrev } = useCursorPaginate();
const { data, isLoading } = usePublishedSpacesQuery({ cursor });
const locale = useDateFnsLocale();
if (!isLoading && data?.items.length === 0) {
return <EmptyState icon={IconWorld} title={t("No published spaces")} />;
}
return (
<>
<Table.ScrollContainer minWidth={500}>
<Table verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Space")}</Table.Th>
<Table.Th>{t("Published by")}</Table.Th>
<Table.Th>{t("Published at")}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data?.items.map((item: IPublishedSpaceItem) => (
<Table.Tr key={item.id} className={rowClasses.row}>
<Table.Td>
<Anchor
size="sm"
underline="never"
style={{
cursor: "pointer",
color: "var(--mantine-color-text)",
}}
className={rowClasses.link}
href={buildPublicSpaceUrl({ spaceSlug: item.space.slug })}
target="_blank"
>
<Group gap="8" wrap="nowrap">
<CustomAvatar
name={item.space.name}
avatarUrl={item.space.logo}
type={AvatarIconType.SPACE_ICON}
color="initials"
variant="filled"
size={20}
radius="sm"
/>
<Text fz="sm" fw={500} lineClamp={1}>
{item.space.name}
</Text>
</Group>
</Anchor>
</Table.Td>
<Table.Td>
<Group gap="4" wrap="nowrap">
<CustomAvatar
avatarUrl={item.creator?.avatarUrl}
name={item.creator?.name}
size="sm"
/>
<Text fz="sm" lineClamp={1}>
{item.creator?.name}
</Text>
</Group>
</Table.Td>
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatLocalized(
item.createdAt,
"MMM dd, yyyy",
"PP",
locale,
)}
</Text>
</Table.Td>
<Table.Td>
<PublishedSpaceActionMenu item={item} />
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
{data?.items.length > 0 && (
<Paginate
hasPrevPage={data?.meta?.hasPrevPage}
hasNextPage={data?.meta?.hasNextPage}
onNext={() => goNext(data?.meta?.nextCursor)}
onPrev={goPrev}
/>
)}
</>
);
}
function PublishedSpaceActionMenu({ item }: { item: IPublishedSpaceItem }) {
const { t } = useTranslation();
const navigate = useNavigate();
const clipboard = useClipboard();
const publishMutation = usePublishSpaceMutation();
const publicPath = buildPublicSpaceUrl({ spaceSlug: item.space.slug });
const copyLink = () => {
clipboard.copy(`${getAppUrl()}${publicPath}`);
notifications.show({ message: t("Link copied") });
};
const onUnpublish = async () => {
try {
await publishMutation.mutateAsync({
spaceId: item.spaceId,
enabled: false,
});
} catch {
// error handled by mutation
}
};
const openUnpublishModal = () =>
modals.openConfirmModal({
title: t("Unpublish space"),
children: (
<Text size="sm">
{t(
"This space will no longer be publicly accessible. Are you sure?",
)}
</Text>
),
centered: true,
labels: { confirm: t("Unpublish"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: onUnpublish,
});
return (
<Menu
shadow="xl"
position="bottom-end"
offset={20}
width={200}
withArrow
arrowPosition="center"
>
<Menu.Target>
<ActionIcon
variant="subtle"
c="gray"
aria-label={t("More options for {{name}}", {
name: item.space.name,
})}
>
<IconDots size={20} stroke={2} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={copyLink} leftSection={<IconCopy size={16} />}>
{t("Copy link")}
</Menu.Item>
<Menu.Item
onClick={() => navigate(getSpaceUrl(item.space.slug))}
leftSection={<IconExternalLink size={16} />}
>
{t("Open space")}
</Menu.Item>
<Menu.Item
c="red"
onClick={openUnpublishModal}
leftSection={<IconWorldOff size={16} />}
disabled={item.space?.userRole !== "admin"}
>
{t("Unpublish")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
}
@@ -1,47 +0,0 @@
import { Alert, Anchor, Group, Text } from "@mantine/core";
import { IconExternalLink, IconWorld } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { ISpace } from "@/features/space/types/space.types.ts";
import { buildPublicSpaceUrl } from "@/features/page/page.utils.ts";
import { isBetaPublicSpaces } from "@/lib/config.ts";
type SpacePublicNoticeProps = {
space: ISpace;
};
export default function SpacePublicNotice({ space }: SpacePublicNoticeProps) {
const { t } = useTranslation();
if (!isBetaPublicSpaces() || !space?.isPublished) {
return null;
}
return (
<Alert
variant="light"
color="blue"
icon={<IconWorld size={18} />}
title={t("This space is public")}
mb="lg"
>
<Group justify="space-between" gap="xs">
<Text size="sm">
{t(
"Anyone on the internet can read the pages in this space, except restricted pages.",
)}
</Text>
<Anchor
size="sm"
fw={500}
href={buildPublicSpaceUrl({ spaceSlug: space.slug })}
target="_blank"
rel="noopener"
style={{ display: "inline-flex", alignItems: "center", gap: 4 }}
>
{t("Open public site")}
<IconExternalLink size={14} />
</Anchor>
</Group>
</Alert>
);
}
@@ -1,14 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { getMyInfo } from "@/features/user/services/user-service";
import { ICurrentUser } from "@/features/user/types/user.types";
/** Probes login state from public surfaces; the /docs 401 exemption keeps anonymous visitors off the login redirect. */
export function useAuthenticatedUser(enabled = true) {
return useQuery<ICurrentUser>({
queryKey: ["currentUser"],
queryFn: getMyInfo,
retry: false,
staleTime: 5 * 60 * 1000,
enabled,
});
}
@@ -1,23 +0,0 @@
import { useMemo } from "react";
import { useParams } from "react-router-dom";
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";
export function useDocsCurrentPage(): SharedPageTreeNode | null {
const { pageSlug } = useParams();
const { treeData } = useDocsSurface();
return useMemo(() => {
if (!treeData?.length) return null;
const currentSlugId = pageSlug
? extractPageSlugId(pageSlug)
: treeData[0]?.slugId;
return (
flattenTreePreorder(treeData).find(
(node) => node.slugId === currentSlugId,
) ?? null
);
}, [treeData, pageSlug]);
}
@@ -1,109 +0,0 @@
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import {
getPublicSpaceDirectory,
getPublicSpaceForSpace,
getPublicSpacePage,
getPublicSpaceTree,
getPublishedSpaces,
publishSpace,
} from "@/features/public-space/services/public-space-service.ts";
import {
IPublicSpace,
IPublicSpaceDirectory,
IPublicSpacePage,
IPublicSpaceTree,
IPublishedSpaceItem,
IPublishSpace,
} from "@/features/public-space/types/public-space.types.ts";
import { IPagination, QueryParams } from "@/lib/types.ts";
export function usePublicSpaceTreeQuery(
spaceSlug: string,
): UseQueryResult<IPublicSpaceTree, Error> {
return useQuery({
queryKey: ["public-space-tree", spaceSlug],
queryFn: () => getPublicSpaceTree(spaceSlug),
enabled: !!spaceSlug,
placeholderData: keepPreviousData,
staleTime: 60 * 60 * 1000,
});
}
export function usePublicSpacePageQuery(params: {
spaceSlug: string;
pageSlugId?: string;
contentless?: boolean;
}): UseQueryResult<IPublicSpacePage, Error> {
return useQuery({
queryKey: ["public-space-page", params],
queryFn: () => getPublicSpacePage(params),
enabled: !!params.spaceSlug,
});
}
export function usePublicSpaceDirectoryQuery(): UseQueryResult<
IPublicSpaceDirectory,
Error
> {
return useQuery({
queryKey: ["public-space-directory"],
queryFn: () => getPublicSpaceDirectory(),
});
}
export function usePublicSpaceForSpaceQuery(
spaceId: string,
): UseQueryResult<IPublicSpace | null, Error> {
return useQuery({
queryKey: ["public-space-for-space", spaceId],
queryFn: () => getPublicSpaceForSpace(spaceId),
enabled: !!spaceId,
staleTime: 60 * 1000,
retry: false,
});
}
export function usePublishedSpacesQuery(
params?: QueryParams,
): UseQueryResult<IPagination<IPublishedSpaceItem>, Error> {
return useQuery({
queryKey: ["published-spaces", params],
queryFn: () => getPublishedSpaces(params),
placeholderData: keepPreviousData,
});
}
export function usePublishSpaceMutation() {
const { t } = useTranslation();
const queryClient = useQueryClient();
return useMutation<IPublicSpace, Error, IPublishSpace>({
mutationFn: (data) => publishSpace(data),
onSuccess: () => {
queryClient.invalidateQueries({
predicate: (item) =>
[
"public-space-for-space",
"published-spaces",
"space",
"spaces",
].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
notifications.show({
message:
error?.["response"]?.data?.message || t("Failed to update space"),
color: "red",
});
},
});
}
@@ -1,73 +0,0 @@
import api from "@/lib/api-client";
import { IPagination, QueryParams } from "@/lib/types.ts";
import {
IPublicSpace,
IPublicSpaceDirectory,
IPublicSpaceInfo,
IPublicSpacePage,
IPublicSpaceTree,
IPublishedSpaceItem,
IPublishSpace,
} from "@/features/public-space/types/public-space.types.ts";
export async function getPublishedSpaces(
params?: QueryParams,
): Promise<IPagination<IPublishedSpaceItem>> {
const req = await api.post<IPagination<IPublishedSpaceItem>>(
"/public-spaces",
params,
);
return req.data;
}
export async function getPublicSpaceInfo(
spaceSlug: string,
): Promise<IPublicSpaceInfo> {
const req = await api.post<IPublicSpaceInfo>("/public-spaces/info", {
spaceSlug,
});
return req.data;
}
export async function getPublicSpaceTree(
spaceSlug: string,
): Promise<IPublicSpaceTree> {
const req = await api.post<IPublicSpaceTree>("/public-spaces/tree", {
spaceSlug,
});
return req.data;
}
export async function getPublicSpacePage(params: {
spaceSlug: string;
pageSlugId?: string;
contentless?: boolean;
}): Promise<IPublicSpacePage> {
const req = await api.post<IPublicSpacePage>(
"/public-spaces/page-info",
params,
);
return req.data;
}
export async function getPublicSpaceDirectory(): Promise<IPublicSpaceDirectory> {
const req = await api.post<IPublicSpaceDirectory>(
"/public-spaces/directory",
{},
);
return req.data;
}
export async function getPublicSpaceForSpace(
spaceId: string,
): Promise<IPublicSpace | null> {
const req = await api.post<IPublicSpace | null>("/public-spaces/for-space", {
spaceId,
});
return req.data;
}
export async function publishSpace(data: IPublishSpace): Promise<IPublicSpace> {
const req = await api.post<IPublicSpace>("/public-spaces/publish", data);
return req.data;
}
@@ -1,91 +0,0 @@
import { useEffect } from "react";
import { useComputedColorScheme } from "@mantine/core";
import { IPublicSpaceAppearance } from "@/features/public-space/types/public-space.types.ts";
export type DocsThemePreset = {
id: string;
nameKey: string;
light: string;
dark: string;
};
export const DOCS_THEME_PRESETS: DocsThemePreset[] = [
{ id: "default", nameKey: "Default", light: "#2b7af1", dark: "#6ea6f6" },
{ id: "forest", nameKey: "Forest", light: "#0f766e", dark: "#2dd4bf" },
{ id: "violet", nameKey: "Violet", light: "#6d28d9", dark: "#a78bfa" },
{ id: "ember", nameKey: "Ember", light: "#c2410c", dark: "#fb923c" },
{ id: "rose", nameKey: "Rose", light: "#be123c", dark: "#fb7185" },
];
export const DEFAULT_DOCS_PRESET = DOCS_THEME_PRESETS[0];
const HEX_COLOR_REGEX = /^#[0-9a-fA-F]{6}$/;
export function isValidDocsColor(value: unknown): value is string {
return typeof value === "string" && HEX_COLOR_REGEX.test(value);
}
export function resolveDocsAccent(
appearance: IPublicSpaceAppearance | undefined,
scheme: "light" | "dark",
): string {
const custom =
scheme === "dark"
? appearance?.primaryColorDark
: appearance?.primaryColorLight;
if (isValidDocsColor(custom)) return custom;
return scheme === "dark"
? DEFAULT_DOCS_PRESET.dark
: DEFAULT_DOCS_PRESET.light;
}
export function matchDocsPreset(
appearance: IPublicSpaceAppearance | undefined,
): DocsThemePreset | null {
const light = appearance?.primaryColorLight;
const dark = appearance?.primaryColorDark;
if (!light && !dark) return DEFAULT_DOCS_PRESET;
return (
DOCS_THEME_PRESETS.find(
(preset) =>
preset.light.toLowerCase() === light?.toLowerCase() &&
preset.dark.toLowerCase() === dark?.toLowerCase(),
) ?? null
);
}
// Set on documentElement (not the shell root) so portaled Mantine surfaces on
// /docs routes (spotlight, drawers) follow the space accent too.
const ACCENT_VARIABLES = (accent: string): Record<string, string> => ({
"--docs-accent": accent,
"--docs-accent-soft": `color-mix(in srgb, ${accent} 10%, transparent)`,
"--mantine-primary-color-filled": accent,
"--mantine-primary-color-filled-hover": `color-mix(in srgb, ${accent} 85%, black)`,
"--mantine-primary-color-light": `color-mix(in srgb, ${accent} 10%, transparent)`,
"--mantine-primary-color-light-hover": `color-mix(in srgb, ${accent} 15%, transparent)`,
"--mantine-primary-color-light-color": accent,
"--mantine-color-anchor": accent,
});
export function useDocsAccent(appearance: IPublicSpaceAppearance | undefined) {
const scheme = useComputedColorScheme("light");
const light = appearance?.primaryColorLight;
const dark = appearance?.primaryColorDark;
useEffect(() => {
const accent = resolveDocsAccent(
{ primaryColorLight: light, primaryColorDark: dark },
scheme,
);
const root = document.documentElement;
const variables = ACCENT_VARIABLES(accent);
for (const [name, value] of Object.entries(variables)) {
root.style.setProperty(name, value);
}
return () => {
for (const name of Object.keys(variables)) {
root.style.removeProperty(name);
}
};
}, [light, dark, scheme]);
}
@@ -1,105 +0,0 @@
import { IPage } from "@/features/page/types/page.types.ts";
export interface IPublicSpaceSummary {
id: string;
name: string;
slug: string;
description?: string;
logo?: string;
}
export interface IPublicSpaceAppearance {
primaryColorLight?: string;
primaryColorDark?: string;
}
export interface IPublicSpaceByline {
author: boolean;
updatedAt: boolean;
}
export interface IPublicSpaceInfo {
space: IPublicSpaceSummary;
searchIndexing: boolean;
appearance?: IPublicSpaceAppearance;
features?: string[];
}
export interface IPublicSpaceTree {
space: IPublicSpaceSummary;
pageTree: Partial<IPage[]>;
appearance?: IPublicSpaceAppearance;
features?: string[];
}
export interface IPublicSpacePage {
page: IPage | null;
space: IPublicSpaceSummary;
searchIndexing: boolean;
appearance?: IPublicSpaceAppearance;
byline?: IPublicSpaceByline;
features?: string[];
}
export interface IPublicSpace {
id: string;
spaceId: string;
workspaceId: string;
enabled: boolean;
searchIndexing: boolean;
settings?: {
appearance?: IPublicSpaceAppearance;
byline?: Partial<IPublicSpaceByline>;
directory?: boolean;
} | null;
creatorId?: string;
createdAt: string;
updatedAt: string;
}
export interface IPublishedSpaceItem {
id: string;
spaceId: string;
workspaceId: string;
searchIndexing: boolean;
settings?: IPublicSpace["settings"];
createdAt: string;
updatedAt: string;
space: {
id: string;
name: string;
slug: string;
logo?: string;
userRole: string;
};
creator: {
id: string;
name: string;
avatarUrl: string | null;
};
}
export interface IPublishSpace {
spaceId: string;
enabled: boolean;
searchIndexing?: boolean;
appearance?: {
primaryColorLight?: string | null;
primaryColorDark?: string | null;
};
bylineAuthor?: boolean;
bylineUpdatedAt?: boolean;
directory?: boolean;
}
export interface IPublicSpaceDirectoryEntry {
name: string;
slug: string;
description?: string;
logo?: string;
}
export interface IPublicSpaceDirectory {
spaces: IPublicSpaceDirectoryEntry[];
features?: string[];
}
@@ -1,39 +0,0 @@
import { SharedPageTreeNode } from "@/features/share/utils.ts";
// Preorder walk of the whole tree, matching the sidebar's visual order. Drives
// prev/next navigation independently of which nodes are expanded.
export function flattenTreePreorder(
nodes: SharedPageTreeNode[],
): SharedPageTreeNode[] {
const out: SharedPageTreeNode[] = [];
const walk = (list: SharedPageTreeNode[]) => {
for (const node of list) {
out.push(node);
if (node.children?.length) walk(node.children);
}
};
walk(nodes);
return out;
}
// Ancestors of the node with the given slugId, root-first, excluding the node
// itself. Null when the slugId is not in the tree.
export function findAncestorTrail(
nodes: SharedPageTreeNode[],
slugId: string,
): SharedPageTreeNode[] | null {
const walk = (
list: SharedPageTreeNode[],
trail: SharedPageTreeNode[],
): SharedPageTreeNode[] | null => {
for (const node of list) {
if (node.slugId === slugId) return trail;
if (node.children?.length) {
const found = walk(node.children, [...trail, node]);
if (found) return found;
}
}
return null;
};
return walk(nodes, []);
}
@@ -1,8 +0,0 @@
import { isBetaPublicSpaces } from "@/lib/config.ts";
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
export function isPublicSpacesAllowed(workspace?: IWorkspace): boolean {
return (
isBetaPublicSpaces() && workspace?.settings?.publicSpaces?.enabled === true
);
}
@@ -1,112 +0,0 @@
import { Group, Center, Text, Button } from "@mantine/core";
import { Spotlight } from "@mantine/spotlight";
import { IconLetterCase, IconSearch } from "@tabler/icons-react";
import React, { useState } from "react";
import { Link } from "react-router-dom";
import { useDebouncedValue } from "@mantine/hooks";
import { usePublicSpaceSearchQuery } from "@/features/search/queries/search-query";
import { buildPublicSpaceUrl } from "@/features/page/page.utils.ts";
import { getPageIcon } from "@/lib";
import { useTranslation } from "react-i18next";
import { publicSpaceSearchSpotlightStore } from "@/features/search/constants.ts";
import DOMPurify from "dompurify";
interface PublicSpaceSearchSpotlightProps {
spaceSlug: string;
}
export function PublicSpaceSearchSpotlight({
spaceSlug,
}: PublicSpaceSearchSpotlightProps) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const [titleOnly, setTitleOnly] = useState(false);
const [debouncedSearchQuery] = useDebouncedValue(query, 300);
const { data: searchResults } = usePublicSpaceSearchQuery({
query: debouncedSearchQuery,
spaceSlug,
...(titleOnly && { titleOnly: true }),
});
const pages = (
searchResults && searchResults.length > 0 ? searchResults : []
).map((page) => (
<Spotlight.Action
key={page.id}
component={Link}
//@ts-ignore
to={buildPublicSpaceUrl({
spaceSlug: spaceSlug,
pageTitle: page.title,
pageSlugId: page.slugId,
})}
style={{ userSelect: "none" }}
>
<Group wrap="nowrap" w="100%">
<Center>{getPageIcon(page?.icon)}</Center>
<div style={{ flex: 1 }}>
<Text>{page.title}</Text>
{page?.highlight && (
<Text
opacity={0.6}
size="xs"
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(page.highlight, {
ALLOWED_TAGS: ["mark", "em", "strong", "b"],
ALLOWED_ATTR: [],
}),
}}
/>
)}
</div>
</Group>
</Spotlight.Action>
));
return (
<>
<Spotlight.Root
store={publicSpaceSearchSpotlightStore}
query={query}
onQueryChange={setQuery}
scrollable
overlayProps={{
backgroundOpacity: 0.55,
}}
>
<Spotlight.Search
placeholder={t("Search...")}
aria-label={t("Search")}
leftSection={<IconSearch size={20} stroke={1.5} />}
/>
<Group px="sm" py={6}>
<Button
variant={titleOnly ? "light" : "subtle"}
color={titleOnly ? undefined : "gray"}
size="compact-sm"
radius="xl"
fw={500}
leftSection={<IconLetterCase size={15} />}
aria-pressed={titleOnly}
onClick={() => setTitleOnly((value) => !value)}
>
{t("Title only")}
</Button>
</Group>
<Spotlight.ActionsList>
{query.length === 0 && pages.length === 0 && (
<Spotlight.Empty>{t("Start typing to search...")}</Spotlight.Empty>
)}
{query.length > 0 && pages.length === 0 && (
<Spotlight.Empty>{t("No results found...")}</Spotlight.Empty>
)}
{pages.length > 0 && pages}
</Spotlight.ActionsList>
</Spotlight.Root>
</>
);
}
@@ -117,9 +117,6 @@ export function SearchResultItem({
pageResult.space.slug,
pageResult.slugId,
pageResult.title,
undefined,
pageResult.matchedText,
pageResult.wholeWord
)}
style={{ userSelect: "none" }}
>
@@ -5,6 +5,3 @@ export const [searchSpotlightStore, searchSpotlight] = createSpotlight();
export const [shareSearchSpotlightStore, shareSearchSpotlight] =
createSpotlight();
export const [publicSpaceSearchSpotlightStore, publicSpaceSearchSpotlight] =
createSpotlight();
@@ -2,7 +2,6 @@ import { keepPreviousData, useQuery, UseQueryResult } from "@tanstack/react-quer
import {
searchAttachments,
searchPage,
searchPublicSpace,
searchShare,
searchSuggestions,
} from '@/features/search/services/search-service';
@@ -56,13 +55,3 @@ export function useAttachmentSearchQuery(
enabled: !!params.query,
});
}
export function usePublicSpaceSearchQuery(
params: IPageSearchParams & { spaceSlug: string },
): UseQueryResult<IPageSearch[], Error> {
return useQuery({
queryKey: ["public-space-search", params],
queryFn: () => searchPublicSpace(params),
enabled: !!params.query && !!params.spaceSlug,
});
}
@@ -28,16 +28,6 @@ export async function searchShare(
return req.data.items;
}
export async function searchPublicSpace(
params: IPageSearchParams & { spaceSlug: string },
): Promise<IPageSearch[]> {
const req = await api.post<{ items: IPageSearch[] }>(
"/search/public-space-search",
params,
);
return req.data.items;
}
export async function searchAttachments(
params: IPageSearchParams,
): Promise<IAttachmentSearch[]> {
@@ -14,8 +14,6 @@ export interface IPageSearch {
updatedAt: Date;
rank: string;
highlight: string;
matchedText: string[];
wholeWord: boolean;
space: Partial<ISpace>;
}
@@ -0,0 +1,3 @@
import { atom } from "jotai";
export const openSharedTreeNodesAtom = atom<Record<string, boolean>>({});

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