Compare commits

..
Author SHA1 Message Date
Philipinho 9b3e5dd3dc v0.96.0 2026-09-08 14:58:41 +01:00
Philip Okugbe dfc38c8718 fix: ws relay (#2482) 2026-09-08 14:00:49 +01:00
Philip Okugbe 7c368c86c6 fix: h1 heading weight in editor (#2481)
* fix: h1 heading weight in editor
* cleanup
2026-09-08 13:24:57 +01:00
Philip Okugbe 1397e109ed New Crowdin updates (#2472) 2026-09-08 13:19:09 +01:00
Philip Okugbe 5792fc7ca2 fix: db lock operations (#2479)
* fix: advisory lock for page move

* fix: lock role count check
2026-09-08 13:16:56 +01:00
Salihu 949072744d fix: align input shortcuts (#2467)
* align input shortcuts

* minor fix

* minor fix
2026-09-08 01:54:41 +01:00
Philip Okugbe 0a87db4f1f fix: meta title for shared subpages (#2478) 2026-09-07 16:43:02 +01:00
Philip Okugbe 89e27a1a07 fix(editor): allow gap cursors between top-level blocks (#2477) 2026-09-06 19:22:28 +01:00
Philip Okugbe 27fa9af959 fix: public page print (#2476)
* fix pdf-print for public pages

* fix print in dark mode
2026-09-05 17:28:45 +01:00
SalihuandPhilipinho 84e1e56cf6 feat: jump to search (#2453)
* jump to search init

* support multiple text matching

* search navigation dialog

* remove search text

* typesense support

* typesense support

* add search guard

* use search key check

* minor fix

* minor fix

* fix: clean up jump search state on close

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2026-09-05 16:06:52 +01:00
Philip Okugbe 6ff8c7cdfe refactor(favorites): move favorite filtering into the repository query (#2475)
* refactor(favorites): move favorite filtering into the repository query

* perf: resolve cleanup targets by primary key
2026-09-05 14:40:10 +01:00
Philip Okugbe ac7935eff9 chore: package updates (#2474) 2026-09-05 12:42:55 +01:00
Philip Okugbe 876f3da1b2 feat(beta): public spaces (#2473) 2026-09-05 11:52:10 +01:00
Philipinho f4796c982e sync 2026-09-04 14:56:08 +01:00
Philip Okugbe 0d69d48c52 feat(ee): SIEM (#2471) 2026-09-04 14:53:14 +01:00
Philipinho 5b85464561 sync 2026-08-28 00:07:10 +01:00
Philip Okugbe 622d085530 New Crowdin updates (#2435) 2026-08-27 12:43:19 +01:00
Philip Okugbe d92716d82f feat: search group members (#2445) 2026-08-27 12:43:08 +01:00
Philip Okugbe 579e22a45e fix: use ACL username and decode credentials in Redis URL parsing (#2434)
* fix: use ACL username and decode credentials in Redis URL parsing

* fix: pass family and TLS options to the collab app module
2026-08-26 14:16:18 +01:00
Philip Okugbe c3d9a19545 New Crowdin updates (#2332) 2026-08-26 14:08:10 +01:00
Philipinho 214a73b6a0 refactor: rename MCP oauth-only setting to enforceMcpOauth 2026-08-26 13:45:28 +01:00
Philipinho 278d318771 sync 2026-08-26 12:56:15 +01:00
Philip Okugbe e56de8eb6c feat(ee): MCP OAuth (#2432)
* feat: mcp oauth

* fix: small refactor

* fix: cleanup consent
2026-08-26 12:55:30 +01:00
Salihu bda802e5df convert only bare links to embeds (#2424) 2026-08-26 12:53:36 +01:00
Philip Okugbe b29e130f26 fix: enhance lightbox (#2429)
* fix: close on click outside; smaller icon size

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

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

* revert uninteded changes

* load page media more accurately

* restrict lock file changes to installed packages

* revert changes in lock file

* update lockfile

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

---------

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

* improve labelId filtering in query

* index label ids on typesense

* index label ids on typesense

* filters on AI search

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

* feat: show secondary search filters only when applied

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

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

* feat: add title-only search mode

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

* refactor: make title-only search a toggle chip

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

* feat: add trigram index on attachment file names

* feat: extend title-only search to attachments

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

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

* feat: index txt attachments for content search

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

* refactor: dim the you suffix in the creator filter

* feat: browse label-filtered pages without a query

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

* fix: match any selected label instead of requiring all

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

* feat: include search results in the spotlight tab order

* feat: show last updated time on search results

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

* feat: browse creator-filtered results without a query

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

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

* quick page sharing fix

* minor search filter fixes

---------

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

* feat: add comment listing tool to ai chat

* fix: scope comment endpoints to workspace and skip trashed pages
2026-08-24 20:29:21 +01:00
256 changed files with 17658 additions and 2256 deletions
+10 -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.15.1
RUN npm install -g pnpm@11.25.0
FROM base AS builder
@@ -18,6 +18,15 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends curl bash \
&& rm -rf /var/lib/apt/lists/*
# drop npm and corepack
RUN rm -rf /usr/local/lib/node_modules/npm \
&& rm -rf /usr/local/lib/node_modules/corepack \
&& rm -rf /usr/local/bin/npm \
&& rm -rf /usr/local/bin/npx \
&& rm -rf /usr/local/bin/corepack \
&& rm -rf /root/.npm \
&& rm -rf /root/.node-gyp
WORKDIR /app
# Copy apps
+3 -1
View File
@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.95.0",
"version": "0.96.0",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@@ -21,6 +21,7 @@
"@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",
@@ -64,6 +65,7 @@
"react-router-dom": "7.18.2",
"semver": "7.7.4",
"socket.io-client": "4.8.3",
"yet-another-react-lightbox": "^3.32.2",
"zod": "4.3.6"
},
"devDependencies": {
@@ -294,6 +294,7 @@
"Export space": "Bereich exportieren",
"Export {{type}}": "Exportiere {{type}}",
"File exceeds the {{limit}} attachment limit": "Datei überschreitet das Anhängelimit von {{limit}}",
"Media": "Medien",
"Align left": "Links ausrichten",
"Align right": "Rechts ausrichten",
"Align center": "Zentrieren",
@@ -387,6 +388,8 @@
"Insert horizontal rule divider": "Horizontale Trennlinie einfügen",
"Page break": "Seitenumbruch",
"Insert a page break for printing.": "Einen Seitenumbruch zum Drucken einfügen.",
"Footnote": "Fußnote",
"Insert a footnote reference.": "Einen Fußnotenverweis einfügen.",
"Upload any image from your device.": "Laden Sie ein beliebiges Bild von Ihrem Gerät hoch.",
"Upload any video from your device.": "Laden Sie ein beliebiges Video von Ihrem Gerät hoch.",
"Upload any audio from your device.": "Laden Sie beliebige Audiodateien von Ihrem Gerät hoch.",
@@ -696,7 +699,7 @@
"Upgrade your plan": "Upgrade Ihres Plans",
"Available with a paid license": "Verfügbar mit einer kostenpflichtigen Lizenz",
"Upgrade your license tier.": "Stufen Sie Ihre Lizenz hoch.",
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "KI ist nur in der Docmost Enterprise-Edition verfügbar. Kontaktieren Sie sales@docmost.com.",
"AI is available in the Docmost paid editions. Contact sales@docmost.com.": "KI ist in den kostenpflichtigen Docmost-Editionen verfügbar. Kontaktieren Sie sales@docmost.com.",
"AI & MCP": "KI & MCP",
"AI": "KI",
"MCP": "MCP",
@@ -704,9 +707,11 @@
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Aktivieren Sie den MCP-Server, damit KI-Assistenten und -Tools mit den Inhalten Ihres Arbeitsbereichs interagieren können.",
"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",
"Use your API key for authentication. You can manage API keys in your account settings.": "Verwenden Sie Ihren API-Schlüssel zur Authentifizierung. API-Schlüssel können in Ihren Kontoeinstellungen verwaltet werden.",
"Connect AI assistants with your Docmost account via OAuth.": "Verbinde AI-Assistenten über OAuth mit deinem Docmost-Konto.",
"Enforce OAuth": "OAuth erzwingen",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI-Assistenten müssen sich über OAuth mit einem Docmost-Konto verbinden. API-Schlüssel können nicht mit dem MCP-Server verwendet werden.",
"Toggle enforce OAuth for MCP": "OAuth-Erzwingung für MCP umschalten",
"Supported tools": "Unterstützte Tools",
"Your workspace has MCP enabled. Use your API key to connect AI assistants.": "In Ihrem Arbeitsbereich ist MCP aktiviert. Verwenden Sie Ihren API-Schlüssel, um KI-Assistenten anzubinden.",
"MCP server URL:": "MCP-Server-URL:",
"Learn more": "Mehr erfahren",
"Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "Verwalten Sie API-Schlüssel für alle Nutzer im Arbeitsbereich. Siehe die <anchor>API-Dokumentation</anchor> für Details zur Verwendung.",
@@ -788,6 +793,10 @@
"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",
@@ -875,6 +884,9 @@
"<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.",
@@ -1289,5 +1301,180 @@
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
"{{count}} selected_other": "{{count}} selected",
"Compare": "Vergleichen",
"Compare versions": "Versionen vergleichen",
"Select version from {{date}}": "Version vom {{date}} auswählen",
"Version actions for {{date}}": "Versionsaktionen für {{date}}",
"Comparing {{newer}} and {{older}}": "{{newer}} und {{older}} werden verglichen",
"Exit compare": "Vergleich beenden",
"Search attachments...": "Anhänge suchen...",
"Error loading attachments.": "Fehler beim Laden der Anhänge.",
"No attachments on this page yet.": "Auf dieser Seite gibt es noch keine Anhänge.",
"Uploaded by {{name}}": "Hochgeladen von {{name}}",
"Download {{name}}": "{{name}} herunterladen",
"Access revoked": "Zugriff widerrufen",
"Authorize application": "Anwendung autorisieren",
"{{name}} wants to access {{workspace}}": "{{name}} möchte auf {{workspace}} zugreifen",
"Not you? Switch account": "Nicht du? Konto wechseln",
"This application will be able to:": "Diese Anwendung kann Folgendes:",
"Write": "Schreiben",
"Invalid authorization request": "Ungültige Autorisierungsanfrage",
"Authorize": "Autorisieren",
"Application": "Anwendung",
"Permissions": "Berechtigungen",
"Authorized": "Autorisiert",
"Revoke access": "Zugriff widerrufen",
"Revoke access for {{name}}": "Zugriff für {{name}} widerrufen",
"Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.": "Möchtest du den Zugriff für {{name}} wirklich widerrufen? Die Anwendung kann dann nicht mehr auf dein Konto zugreifen.",
"Something went wrong. Please try again.": "Etwas ist schiefgelaufen. Bitte versuche es erneut.",
"Remove {{name}}": "{{name}} entfernen",
"Make sure you trust this application before authorizing it.": "Stelle sicher, dass du dieser Anwendung vertraust, bevor du sie autorisierst.",
"You will be redirected to": "Du wirst weitergeleitet zu",
"View content without making changes.": "Inhalte ansehen, ohne Änderungen vorzunehmen.",
"Create and modify content.": "Inhalte erstellen und bearbeiten.",
"Applications and AI assistants you have authorized to access your account.": "Anwendungen und AI-Assistenten, denen du den Zugriff auf dein Konto autorisiert hast.",
"Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.": "MCP ist für deinen Workspace aktiviert. Verbinde AI-Assistenten über OAuth mit deinem Docmost-Konto.",
"Authorized apps": "Autorisierte Apps",
"No authorized apps yet.": "Noch keine autorisierten Apps.",
"Workspace knowledge only": "Nur Workspace-Wissen",
"Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Beschränke den AI-Chat darauf, nur anhand deiner Workspace-Seiten und hochgeladenen Dateien zu antworten. Es wird kein externes Wissen verwendet.",
"Toggle workspace knowledge only": "Nur Workspace-Wissen umschalten",
"Read-only mode": "Schreibgeschützter Modus",
"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"
}
+197 -23
View File
@@ -47,24 +47,24 @@
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.",
"Description": "Description",
"Details": "Details",
"e.g ACME": "e.g ACME",
"e.g ACME Inc": "e.g ACME Inc",
"e.g Developers": "e.g Developers",
"e.g Group for developers": "e.g Group for developers",
"e.g product": "e.g product",
"e.g Product Team": "e.g Product Team",
"e.g Sales": "e.g Sales",
"e.g Space for product team": "e.g Space for product team",
"e.g Space for sales team to collaborate": "e.g Space for sales team to collaborate",
"e.g ACME": "e.g. ACME",
"e.g ACME Inc": "e.g. ACME Inc",
"e.g Developers": "e.g. Developers",
"e.g Group for developers": "e.g. Group for developers",
"e.g product": "e.g. product",
"e.g Product Team": "e.g. Product Team",
"e.g Sales": "e.g. Sales",
"e.g Space for product team": "e.g. Space for product team",
"e.g Space for sales team to collaborate": "e.g. Space for sales team to collaborate",
"Edit": "Edit",
"Read": "Read",
"Edit group": "Edit group",
"Email": "Email",
"Enter a strong password": "Enter a strong password",
"Enter valid email addresses separated by comma or space max_50": "Enter valid email addresses separated by comma or space [max: 50]",
"enter valid emails addresses": "enter valid emails addresses",
"enter valid emails addresses": "Enter valid email addresses",
"Enter your current password": "Enter your current password",
"enter your full name": "enter your full name",
"enter your full name": "Enter your full name",
"Enter your new password": "Enter your new password",
"Enter your new preferred email": "Enter your new preferred email",
"Enter your password": "Enter your password",
@@ -112,7 +112,7 @@
"Import pages": "Import pages",
"Import pages & space settings": "Import pages & space settings",
"Importing pages": "Importing pages",
"invalid invitation link": "invalid invitation link",
"invalid invitation link": "Invalid invitation link",
"Invitation signup": "Invitation signup",
"Invite by email": "Invite by email",
"Invite members": "Invite members",
@@ -223,7 +223,7 @@
"Your password must be a minimum of 8 characters.": "Your password must be a minimum of 8 characters.",
"Sidebar toggle": "Sidebar toggle",
"Comments": "Comments",
"404 page not found": "404 page not found",
"404 page not found": "404! Page not found",
"Sorry, we can't find the page you are looking for.": "Sorry, we can't find the page you are looking for.",
"Take me back to homepage": "Take me back to homepage",
"Forgot password": "Forgot password",
@@ -294,6 +294,7 @@
"Export space": "Export space",
"Export {{type}}": "Export {{type}}",
"File exceeds the {{limit}} attachment limit": "File exceeds the {{limit}} attachment limit",
"Media": "Media",
"Align left": "Align left",
"Align right": "Align right",
"Align center": "Align center",
@@ -422,7 +423,7 @@
"Insert current date": "Insert current date",
"Time": "Time",
"Insert current time": "Insert current time",
"Draw and sketch excalidraw diagrams": "Draw and sketch excalidraw diagrams",
"Draw and sketch excalidraw diagrams": "Draw and sketch Excalidraw diagrams",
"Multiple": "Multiple",
"Turn into": "Turn into",
"Text align": "Text align",
@@ -563,7 +564,7 @@
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "Make sure to save these codes in a secure place. Your old backup codes are no longer valid.",
"Your new backup codes": "Your new backup codes",
"I've saved my backup codes": "I've saved my backup codes",
"Failed to setup MFA": "Failed to setup MFA",
"Failed to setup MFA": "Failed to set up MFA",
"Setup & Verify": "Setup & Verify",
"Add to authenticator": "Add to authenticator",
"1. Scan this QR code with your authenticator app": "1. Scan this QR code with your authenticator app",
@@ -637,7 +638,7 @@
"Enterprise": "Enterprise",
"Download attachment": "Download attachment",
"Allowed email domains": "Allowed email domains",
"Only users with email addresses from these domains can signup via SSO.": "Only users with email addresses from these domains can signup via SSO.",
"Only users with email addresses from these domains can signup via SSO.": "Only users with email addresses from these domains can sign up via SSO.",
"Enter valid domain names separated by comma or space": "Enter valid domain names separated by comma or space",
"Enforce two-factor authentication": "Enforce two-factor authentication",
"Once enforced, all members must enable two-factor authentication to access the workspace.": "Once enforced, all members must enable two-factor authentication to access the workspace.",
@@ -698,7 +699,7 @@
"Upgrade your plan": "Upgrade your plan",
"Available with a paid license": "Available with a paid license",
"Upgrade your license tier.": "Upgrade your license tier.",
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
"AI is available in the Docmost paid editions. Contact sales@docmost.com.": "AI is available in the Docmost paid editions. Contact sales@docmost.com.",
"AI & MCP": "AI & MCP",
"AI": "AI",
"MCP": "MCP",
@@ -706,9 +707,11 @@
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.",
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
"MCP Server URL": "MCP Server URL",
"Use your API key for authentication. You can manage API keys in your account settings.": "Use your API key for authentication. You can manage API keys in your account settings.",
"Connect AI assistants with your Docmost account via OAuth.": "Connect AI assistants with your Docmost account via 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 assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
"Supported tools": "Supported tools",
"Your workspace has MCP enabled. Use your API key to connect AI assistants.": "Your workspace has MCP enabled. Use your API key to connect AI assistants.",
"MCP server URL:": "MCP server URL:",
"Learn more": "Learn more",
"Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.",
@@ -790,6 +793,10 @@
"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",
@@ -826,7 +833,7 @@
"Choose how this page should stay accurate.": "Choose how this page should stay accurate.",
"Recurring verification": "Recurring verification",
"Verifiers re-confirm this page on a schedule.": "Verifiers re-confirm this page on a schedule.",
"Re-verify on a schedule (e.g every 30 days )": "Re-verify on a schedule (e.g every 30 days )",
"Re-verify on a schedule (e.g every 30 days )": "Re-verify on a schedule (e.g. every 30 days)",
"Page stays editable at all times": "Page stays editable at all times",
"Best for runbooks, FAQs, living documentation": "Best for runbooks, FAQs, living documentation",
"Approval workflow": "Approval workflow",
@@ -877,6 +884,9 @@
"<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.",
@@ -902,7 +912,7 @@
"Publish": "Publish",
"Security": "Security",
"Enforce SSO": "Enforce SSO",
"Once enforced, members will not be able to login with email and password.": "Once enforced, members will not be able to login with email and password.",
"Once enforced, members will not be able to login with email and password.": "Once enforced, members will not be able to log in with email and password.",
"AI-generated content may not be accurate.": "AI-generated content may not be accurate.",
"AI Chat": "AI Chat",
"Analyze for insights": "Analyze for insights",
@@ -1248,7 +1258,7 @@
"Not started": "Not started",
"Number": "Number",
"One month ago": "One month ago",
"One month from now": "One month from now",
"One month from now": "In one month",
"One week ago": "One week ago",
"One week from now": "One week from now",
"Open as page": "Open as page",
@@ -1302,5 +1312,169 @@
"Error loading attachments.": "Error loading attachments.",
"No attachments on this page yet.": "No attachments on this page yet.",
"Uploaded by {{name}}": "Uploaded by {{name}}",
"Download {{name}}": "Download {{name}}"
"Download {{name}}": "Download {{name}}",
"Access revoked": "Access revoked",
"Authorize application": "Authorize application",
"{{name}} wants to access {{workspace}}": "{{name}} wants to access {{workspace}}",
"Not you? Switch account": "Not you? Switch account",
"This application will be able to:": "This application will be able to:",
"Write": "Write",
"Invalid authorization request": "Invalid authorization request",
"Authorize": "Authorize",
"Application": "Application",
"Permissions": "Permissions",
"Authorized": "Authorized",
"Revoke access": "Revoke access",
"Revoke access for {{name}}": "Revoke access for {{name}}",
"Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.": "Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.",
"Something went wrong. Please try again.": "Something went wrong. Please try again.",
"Remove {{name}}": "Remove {{name}}",
"Make sure you trust this application before authorizing it.": "Make sure you trust this application before authorizing it.",
"You will be redirected to": "You will be redirected to",
"View content without making changes.": "View content without making changes.",
"Create and modify content.": "Create and modify content.",
"Applications and AI assistants you have authorized to access your account.": "Applications and AI assistants you have authorized to access your account.",
"Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.": "Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.",
"Authorized apps": "Authorized apps",
"No authorized apps yet.": "No authorized apps yet.",
"Workspace knowledge only": "Workspace knowledge only",
"Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.",
"Toggle workspace knowledge only": "Toggle workspace knowledge only",
"Read-only mode": "Read-only mode",
"AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat can search and read workspace content, but cannot create or edit pages.",
"Toggle AI Chat read-only mode": "Toggle AI Chat read-only mode",
"Title only": "Title only",
"you": "you",
"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"
}
@@ -294,6 +294,7 @@
"Export space": "Exportar espacio",
"Export {{type}}": "Exportar {{type}}",
"File exceeds the {{limit}} attachment limit": "El archivo supera el límite de {{limit}} adjuntos",
"Media": "Multimedia",
"Align left": "Alinear a la izquierda",
"Align right": "Alinear a la derecha",
"Align center": "Alinear al centro",
@@ -387,6 +388,8 @@
"Insert horizontal rule divider": "Insertar regla horizontal",
"Page break": "Salto de página",
"Insert a page break for printing.": "Inserta un salto de página para imprimir.",
"Footnote": "Nota al pie",
"Insert a footnote reference.": "Insertar una referencia de nota al pie.",
"Upload any image from your device.": "Sube cualquier imagen desde tu dispositivo.",
"Upload any video from your device.": "Sube cualquier video desde tu dispositivo.",
"Upload any audio from your device.": "Sube cualquier audio desde tu dispositivo.",
@@ -696,7 +699,7 @@
"Upgrade your plan": "Mejora tu plan",
"Available with a paid license": "Disponible con una licencia de pago",
"Upgrade your license tier.": "Mejora el nivel de tu licencia.",
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "La IA solo está disponible en la edición empresarial de Docmost. Contacte con sales@docmost.com.",
"AI is available in the Docmost paid editions. Contact sales@docmost.com.": "La IA está disponible en las ediciones de pago de Docmost. Contacte con sales@docmost.com.",
"AI & MCP": "IA y MCP",
"AI": "IA",
"MCP": "MCP",
@@ -704,9 +707,11 @@
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Habilite el servidor MCP para permitir que asistentes de IA y herramientas interactúen con el contenido de su espacio de trabajo.",
"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",
"Use your API key for authentication. You can manage API keys in your account settings.": "Use su clave API para la autenticación. Puede gestionar las claves API en la configuración de su cuenta.",
"Connect AI assistants with your Docmost account via OAuth.": "Conecta asistentes de IA con tu cuenta de Docmost mediante OAuth.",
"Enforce OAuth": "Exigir OAuth",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "Los asistentes de IA deben conectarse con una cuenta de Docmost mediante OAuth. No se pueden usar claves API con el servidor MCP.",
"Toggle enforce OAuth for MCP": "Activar o desactivar la exigencia de OAuth para MCP",
"Supported tools": "Herramientas compatibles",
"Your workspace has MCP enabled. Use your API key to connect AI assistants.": "Su espacio de trabajo tiene MCP habilitado. Use su clave API para conectar asistentes de IA.",
"MCP server URL:": "URL del servidor MCP:",
"Learn more": "Más información",
"Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "Gestiona las claves de API para todos los usuarios en el espacio de trabajo. Consulta la <anchor>documentación de la API</anchor> para detalles de uso.",
@@ -788,6 +793,10 @@
"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",
@@ -875,6 +884,9 @@
"<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.",
@@ -1186,8 +1198,8 @@
"Default value": "Valor predeterminado",
"Delete property": "Eliminar propiedad",
"Delete view": "Eliminar vista",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Delete {{count}} rows?_one": "¿Eliminar 1 fila?",
"Delete {{count}} rows?_other": "¿Eliminar {{count}} filas?",
"Descending": "Descendente",
"Discard": "Descartar",
"Doesn't contain": "No contiene",
@@ -1286,8 +1298,183 @@
"Value": "Valor",
"View updated for everyone": "Vista actualizada para todos",
"You have unsaved changes. Do you want to discard them?": "Tiene cambios no guardados. ¿Quiere descartarlos?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
"{{count}} rows deleted_one": "1 fila eliminada",
"{{count}} rows deleted_other": "{{count}} filas eliminadas",
"{{count}} selected_one": "1 seleccionado",
"{{count}} selected_other": "{{count}} seleccionadas",
"Compare": "Comparar",
"Compare versions": "Comparar versiones",
"Select version from {{date}}": "Seleccionar la versión del {{date}}",
"Version actions for {{date}}": "Acciones de la versión del {{date}}",
"Comparing {{newer}} and {{older}}": "Comparando {{newer}} y {{older}}",
"Exit compare": "Salir de la comparación",
"Search attachments...": "Buscar archivos adjuntos...",
"Error loading attachments.": "Error al cargar los archivos adjuntos.",
"No attachments on this page yet.": "Todavía no hay archivos adjuntos en esta página.",
"Uploaded by {{name}}": "Subido por {{name}}",
"Download {{name}}": "Descargar {{name}}",
"Access revoked": "Acceso revocado",
"Authorize application": "Autorizar aplicación",
"{{name}} wants to access {{workspace}}": "{{name}} quiere acceder a {{workspace}}",
"Not you? Switch account": "¿No eres tú? Cambiar de cuenta",
"This application will be able to:": "Esta aplicación podrá:",
"Write": "Escribir",
"Invalid authorization request": "Solicitud de autorización no válida",
"Authorize": "Autorizar",
"Application": "Aplicación",
"Permissions": "Permisos",
"Authorized": "Autorizado",
"Revoke access": "Revocar acceso",
"Revoke access for {{name}}": "Revocar acceso para {{name}}",
"Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.": "¿Seguro que quieres revocar el acceso de {{name}}? La aplicación ya no podrá acceder a tu cuenta.",
"Something went wrong. Please try again.": "Algo salió mal. Inténtalo de nuevo.",
"Remove {{name}}": "Eliminar {{name}}",
"Make sure you trust this application before authorizing it.": "Asegúrate de confiar en esta aplicación antes de autorizarla.",
"You will be redirected to": "Serás redirigido a",
"View content without making changes.": "Ver contenido sin realizar cambios.",
"Create and modify content.": "Crear y modificar contenido.",
"Applications and AI assistants you have authorized to access your account.": "Aplicaciones y asistentes de IA que has autorizado para acceder a tu cuenta.",
"Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.": "Tu espacio de trabajo tiene MCP habilitado. Conecta asistentes de IA con tu cuenta de Docmost mediante OAuth.",
"Authorized apps": "Aplicaciones autorizadas",
"No authorized apps yet.": "Todavía no hay aplicaciones autorizadas.",
"Workspace knowledge only": "Solo conocimiento del espacio de trabajo",
"Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Restringe AI Chat para que responda solo con información de las páginas y archivos subidos de tu espacio de trabajo. No usará conocimiento externo.",
"Toggle workspace knowledge only": "Alternar solo conocimiento del espacio de trabajo",
"Read-only mode": "Modo de solo lectura",
"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"
}
@@ -294,6 +294,7 @@
"Export space": "Exporter l'espace",
"Export {{type}}": "Exporter {{type}}",
"File exceeds the {{limit}} attachment limit": "Le fichier dépasse la limite de {{limit}} pièces jointes",
"Media": "Médias",
"Align left": "Aligner à gauche",
"Align right": "Aligner à droite",
"Align center": "Aligner au centre",
@@ -387,6 +388,8 @@
"Insert horizontal rule divider": "Insérer un séparateur de règle horizontale",
"Page break": "Saut de page",
"Insert a page break for printing.": "Insérer un saut de page pour limpression.",
"Footnote": "Note de bas de page",
"Insert a footnote reference.": "Insérer une référence de note de bas de page.",
"Upload any image from your device.": "Téléchargez n'importe quelle image depuis votre appareil.",
"Upload any video from your device.": "Téléchargez n'importe quelle vidéo depuis votre appareil.",
"Upload any audio from your device.": "Téléchargez n'importe quel fichier audio depuis votre appareil.",
@@ -696,7 +699,7 @@
"Upgrade your plan": "Mettez à niveau votre forfait",
"Available with a paid license": "Disponible avec une licence payante",
"Upgrade your license tier.": "Mettez à niveau votre niveau de licence.",
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "L'IA n'est disponible que dans l'édition Entreprise de Docmost. Contactez sales@docmost.com.",
"AI is available in the Docmost paid editions. Contact sales@docmost.com.": "L'IA est disponible dans les éditions payantes de Docmost. Contactez sales@docmost.com.",
"AI & MCP": "IA & MCP",
"AI": "IA",
"MCP": "MCP",
@@ -704,9 +707,11 @@
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Activez le serveur MCP pour permettre aux assistants et outils IA d'interagir avec le contenu de votre espace de travail.",
"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",
"Use your API key for authentication. You can manage API keys in your account settings.": "Utilisez votre clé API pour l'authentification. Vous pouvez gérer les clés API dans les paramètres de votre compte.",
"Connect AI assistants with your Docmost account via OAuth.": "Connectez des assistants IA à votre compte Docmost via OAuth.",
"Enforce OAuth": "Imposer OAuth",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "Les assistants IA doivent se connecter avec un compte Docmost via OAuth. Les clés API ne peuvent pas être utilisées avec le serveur MCP.",
"Toggle enforce OAuth for MCP": "Activer ou désactiver limposition dOAuth pour MCP",
"Supported tools": "Outils pris en charge",
"Your workspace has MCP enabled. Use your API key to connect AI assistants.": "Votre espace de travail a MCP activé. Utilisez votre clé API pour connecter des assistants IA.",
"MCP server URL:": "URL du serveur MCP :",
"Learn more": "En savoir plus",
"Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "Gérez les clés API pour tous les utilisateurs de l'espace de travail. Consultez la <anchor>documentation API</anchor> pour plus de détails sur l'utilisation.",
@@ -788,6 +793,10 @@
"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",
@@ -875,6 +884,9 @@
"<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é.",
@@ -1186,8 +1198,8 @@
"Default value": "Valeur par défaut",
"Delete property": "Supprimer la propriété",
"Delete view": "Supprimer la vue",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Delete {{count}} rows?_one": "Supprimer 1 ligne ?",
"Delete {{count}} rows?_other": "Supprimer {{count}} lignes ?",
"Descending": "Décroissant",
"Discard": "Ignorer",
"Doesn't contain": "Ne contient pas",
@@ -1286,8 +1298,183 @@
"Value": "Valeur",
"View updated for everyone": "Vue mise à jour pour tout le monde",
"You have unsaved changes. Do you want to discard them?": "Vous avez des modifications non enregistrées. Voulez-vous les ignorer ?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
"{{count}} rows deleted_one": "1 ligne supprimée",
"{{count}} rows deleted_other": "{{count}} lignes supprimées",
"{{count}} selected_one": "1 sélectionné(e)",
"{{count}} selected_other": "{{count}} sélectionné(e)s",
"Compare": "Comparer",
"Compare versions": "Comparer les versions",
"Select version from {{date}}": "Sélectionner la version du {{date}}",
"Version actions for {{date}}": "Actions de version pour le {{date}}",
"Comparing {{newer}} and {{older}}": "Comparaison de {{newer}} et {{older}}",
"Exit compare": "Quitter la comparaison",
"Search attachments...": "Rechercher des pièces jointes...",
"Error loading attachments.": "Erreur lors du chargement des pièces jointes.",
"No attachments on this page yet.": "Aucune pièce jointe sur cette page pour le moment.",
"Uploaded by {{name}}": "Téléversé par {{name}}",
"Download {{name}}": "Télécharger {{name}}",
"Access revoked": "Accès révoqué",
"Authorize application": "Autoriser lapplication",
"{{name}} wants to access {{workspace}}": "{{name}} souhaite accéder à {{workspace}}",
"Not you? Switch account": "Ce nest pas vous ? Changer de compte",
"This application will be able to:": "Cette application pourra :",
"Write": "Écriture",
"Invalid authorization request": "Demande dautorisation invalide",
"Authorize": "Autoriser",
"Application": "Application",
"Permissions": "Autorisations",
"Authorized": "Autorisé",
"Revoke access": "Révoquer laccès",
"Revoke access for {{name}}": "Révoquer laccès pour {{name}}",
"Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.": "Êtes-vous sûr de vouloir révoquer laccès pour {{name}} ? Lapplication ne pourra plus accéder à votre compte.",
"Something went wrong. Please try again.": "Une erreur sest produite. Veuillez réessayer.",
"Remove {{name}}": "Supprimer {{name}}",
"Make sure you trust this application before authorizing it.": "Assurez-vous de faire confiance à cette application avant de lautoriser.",
"You will be redirected to": "Vous serez redirigé vers",
"View content without making changes.": "Afficher le contenu sans apporter de modifications.",
"Create and modify content.": "Créer et modifier du contenu.",
"Applications and AI assistants you have authorized to access your account.": "Applications et assistants IA que vous avez autorisés à accéder à votre compte.",
"Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.": "MCP est activé dans votre espace de travail. Connectez des assistants IA à votre compte Docmost via OAuth.",
"Authorized apps": "Applications autorisées",
"No authorized apps yet.": "Aucune application autorisée pour le moment.",
"Workspace knowledge only": "Connaissances de lespace de travail uniquement",
"Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Limiter AI Chat aux réponses provenant uniquement des pages de votre espace de travail et des fichiers téléversés. Il nutilisera pas de connaissances externes.",
"Toggle workspace knowledge only": "Activer/désactiver les connaissances de lespace de travail uniquement",
"Read-only mode": "Mode lecture seule",
"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"
}
+197 -10
View File
@@ -205,7 +205,7 @@
"Templates": "Modelli",
"Theme": "Tema",
"To change your email, you have to enter your password and new email.": "Per cambiare la tua email, devi inserire la tua password e la nuova email.",
"Toggle full page width": "Attiva/disattiva larghezza completa della pagina",
"Toggle full page width": "Attiva/disattiva larghezza intera della pagina",
"Unable to import pages. Please try again.": "Impossibile importare le pagine. Riprova.",
"untitled": "senza titolo",
"Untitled": "Senza titolo",
@@ -294,6 +294,7 @@
"Export space": "Esporta spazio",
"Export {{type}}": "Esporta {{type}}",
"File exceeds the {{limit}} attachment limit": "Il file supera il limite per gli allegati di {{limit}}",
"Media": "Contenuti multimediali",
"Align left": "Allinea a sinistra",
"Align right": "Allinea a destra",
"Align center": "Allinea al centro",
@@ -387,6 +388,8 @@
"Insert horizontal rule divider": "Inserisci divisore di regola orizzontale",
"Page break": "Interruzione di pagina",
"Insert a page break for printing.": "Inserisci un'interruzione di pagina per la stampa.",
"Footnote": "Nota a piè di pagina",
"Insert a footnote reference.": "Inserisci un riferimento a una nota a piè di pagina.",
"Upload any image from your device.": "Carica un'immagine dal tuo dispositivo.",
"Upload any video from your device.": "Carica qualsiasi video dal tuo dispositivo.",
"Upload any audio from your device.": "Carica qualsiasi audio dal tuo dispositivo.",
@@ -696,7 +699,7 @@
"Upgrade your plan": "Aggiorna il tuo piano",
"Available with a paid license": "Disponibile con una licenza a pagamento",
"Upgrade your license tier.": "Aggiorna il livello della tua licenza.",
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "L'IA è disponibile solo nell'edizione Enterprise di Docmost. Contatta sales@docmost.com.",
"AI is available in the Docmost paid editions. Contact sales@docmost.com.": "L'IA è disponibile nelle edizioni a pagamento di Docmost. Contatta sales@docmost.com.",
"AI & MCP": "IA e MCP",
"AI": "IA",
"MCP": "MCP",
@@ -704,9 +707,11 @@
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Abilita il server MCP per consentire ad assistenti e strumenti IA di interagire con i contenuti del tuo spazio di lavoro.",
"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",
"Use your API key for authentication. You can manage API keys in your account settings.": "Usa la tua chiave API per l'autenticazione. Puoi gestire le chiavi API nelle impostazioni del tuo account.",
"Connect AI assistants with your Docmost account via OAuth.": "Connetti gli assistenti AI al tuo account Docmost tramite OAuth.",
"Enforce OAuth": "Rendi obbligatorio OAuth",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "Gli assistenti AI devono connettersi con un account Docmost tramite OAuth. Le chiavi API non possono essere utilizzate con il server MCP.",
"Toggle enforce OAuth for MCP": "Attiva/disattiva l'obbligo di OAuth per MCP",
"Supported tools": "Strumenti supportati",
"Your workspace has MCP enabled. Use your API key to connect AI assistants.": "Il tuo spazio di lavoro ha MCP abilitato. Usa la tua chiave API per collegare gli assistenti IA.",
"MCP server URL:": "URL del server MCP:",
"Learn more": "Scopri di più",
"Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "Gestisci le API key per tutti gli utenti nello spazio di lavoro. Consulta la <anchor>documentazione API</anchor> per i dettagli sull'utilizzo.",
@@ -788,6 +793,10 @@
"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",
@@ -875,6 +884,9 @@
"<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.",
@@ -1186,8 +1198,8 @@
"Default value": "Valore predefinito",
"Delete property": "Elimina proprietà",
"Delete view": "Elimina vista",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Delete {{count}} rows?_one": "Eliminare 1 riga?",
"Delete {{count}} rows?_other": "Eliminare {{count}} righe?",
"Descending": "Decrescente",
"Discard": "Ignora",
"Doesn't contain": "Non contiene",
@@ -1286,8 +1298,183 @@
"Value": "Valore",
"View updated for everyone": "Vista aggiornata per tutti",
"You have unsaved changes. Do you want to discard them?": "Hai modifiche non salvate. Vuoi ignorarle?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
"{{count}} rows deleted_one": "1 riga eliminata",
"{{count}} rows deleted_other": "{{count}} righe eliminate",
"{{count}} selected_one": "1 selezionato",
"{{count}} selected_other": "{{count}} selezionati",
"Compare": "Confronta",
"Compare versions": "Confronta versioni",
"Select version from {{date}}": "Seleziona la versione del {{date}}",
"Version actions for {{date}}": "Azioni della versione del {{date}}",
"Comparing {{newer}} and {{older}}": "Confronto tra {{newer}} e {{older}}",
"Exit compare": "Esci dal confronto",
"Search attachments...": "Cerca allegati...",
"Error loading attachments.": "Errore durante il caricamento degli allegati.",
"No attachments on this page yet.": "Ancora nessun allegato in questa pagina.",
"Uploaded by {{name}}": "Caricato da {{name}}",
"Download {{name}}": "Scarica {{name}}",
"Access revoked": "Accesso revocato",
"Authorize application": "Autorizza applicazione",
"{{name}} wants to access {{workspace}}": "{{name}} vuole accedere a {{workspace}}",
"Not you? Switch account": "Non sei tu? Cambia account",
"This application will be able to:": "Questa applicazione potrà:",
"Write": "Scrivere",
"Invalid authorization request": "Richiesta di autorizzazione non valida",
"Authorize": "Autorizza",
"Application": "Applicazione",
"Permissions": "Autorizzazioni",
"Authorized": "Autorizzato",
"Revoke access": "Revoca accesso",
"Revoke access for {{name}}": "Revoca l'accesso per {{name}}",
"Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.": "Sei sicuro di voler revocare l'accesso per {{name}}? L'applicazione non potrà più accedere al tuo account.",
"Something went wrong. Please try again.": "Qualcosa è andato storto. Riprova.",
"Remove {{name}}": "Rimuovi {{name}}",
"Make sure you trust this application before authorizing it.": "Assicurati di fidarti di questa applicazione prima di autorizzarla.",
"You will be redirected to": "Verrai reindirizzato a",
"View content without making changes.": "Visualizza il contenuto senza apportare modifiche.",
"Create and modify content.": "Crea e modifica contenuti.",
"Applications and AI assistants you have authorized to access your account.": "Applicazioni e assistenti AI che hai autorizzato ad accedere al tuo account.",
"Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.": "Il tuo workspace ha MCP abilitato. Connetti gli assistenti AI al tuo account Docmost tramite OAuth.",
"Authorized apps": "App autorizzate",
"No authorized apps yet.": "Ancora nessuna app autorizzata.",
"Workspace knowledge only": "Solo conoscenze del workspace",
"Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Limita AI Chat a rispondere solo in base alle pagine del tuo workspace e ai file caricati. Non userà conoscenze esterne.",
"Toggle workspace knowledge only": "Attiva/disattiva solo conoscenze del workspace",
"Read-only mode": "Modalità di sola lettura",
"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"
}
@@ -294,6 +294,7 @@
"Export space": "エクスポートスペース",
"Export {{type}}": "{{type}}をエクスポート",
"File exceeds the {{limit}} attachment limit": "ファイルが{{limit}}の添付制限を超えています",
"Media": "メディア",
"Align left": "左揃え",
"Align right": "右揃え",
"Align center": "中央揃え",
@@ -387,6 +388,8 @@
"Insert horizontal rule divider": "区切り線を挿入します",
"Page break": "改ページ",
"Insert a page break for printing.": "印刷用に改ページを挿入します。",
"Footnote": "脚注",
"Insert a footnote reference.": "脚注参照を挿入します。",
"Upload any image from your device.": "デバイスから画像をアップロードします",
"Upload any video from your device.": "デバイスから動画をアップロードします",
"Upload any audio from your device.": "デバイスから音声ファイルをアップロードします。",
@@ -696,7 +699,7 @@
"Upgrade your plan": "プランをアップグレードする",
"Available with a paid license": "有料ライセンスで利用可能",
"Upgrade your license tier.": "ライセンスタイアをアップグレードしてください。",
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI は Docmost のエンタープライズ版でのみ利用可能です。sales@docmost.com までお問い合わせください。",
"AI is available in the Docmost paid editions. Contact sales@docmost.com.": "AI は Docmost の有料版で利用可能です。sales@docmost.com までお問い合わせください。",
"AI & MCP": "AI と MCP",
"AI": "AI",
"MCP": "MCP",
@@ -704,9 +707,11 @@
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "MCP サーバーを有効にして、AI アシスタントやツールがワークスペースのコンテンツとやり取りできるようにします。",
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP は Docmost のエンタープライズ版でのみ利用可能です。sales@docmost.com までお問い合わせください。",
"MCP Server URL": "MCP サーバーの URL",
"Use your API key for authentication. You can manage API keys in your account settings.": "認証には API キーを使用してください。API キーはアカウント設定で管理できます。",
"Connect AI assistants with your Docmost account via OAuth.": "OAuth を使用して AI アシスタントを Docmost アカウントに接続します。",
"Enforce OAuth": "OAuth を必須化",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI アシスタントは OAuth を使用して Docmost アカウントに接続する必要があります。MCP サーバーでは API キーは使用できません。",
"Toggle enforce OAuth for MCP": "MCP の OAuth 必須化を切り替え",
"Supported tools": "サポートされているツール",
"Your workspace has MCP enabled. Use your API key to connect AI assistants.": "このワークスペースでは MCP が有効になっています。AI アシスタントを接続するには API キーを使用してください。",
"MCP server URL:": "MCP サーバーの URL:",
"Learn more": "詳細を見る",
"Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "ワークスペース内のすべてのユーザーのAPIキーを管理します。利用方法の詳細は<anchor>APIドキュメント</anchor>をご覧ください。",
@@ -788,6 +793,10 @@
"Removed page restriction": "ページの制限を解除しました",
"Added page permission": "ページの権限を追加しました",
"Removed page permission": "ページの権限を削除しました",
"Changed page permission": "ページの権限を変更しました",
"Requested password reset": "パスワードのリセットをリクエストしました",
"Created template": "テンプレートを作成しました",
"Deleted template": "テンプレートを削除しました",
"day": "日",
"days": "日",
"week": "週",
@@ -875,6 +884,9 @@
"<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.": "認証に失敗しました。リンクの有効期限が切れている可能性があります。",
@@ -1186,7 +1198,7 @@
"Default value": "デフォルト値",
"Delete property": "プロパティを削除",
"Delete view": "ビューを削除",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_one": "1行を削除しますか?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "降順",
"Discard": "破棄",
@@ -1286,8 +1298,183 @@
"Value": "値",
"View updated for everyone": "ビューが全員向けに更新されました",
"You have unsaved changes. Do you want to discard them?": "未保存の変更があります。破棄しますか?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_one": "1行を削除しました",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
"{{count}} selected_one": "1件を選択中",
"{{count}} selected_other": "{{count}} selected",
"Compare": "比較",
"Compare versions": "バージョンを比較",
"Select version from {{date}}": "{{date}} のバージョンを選択",
"Version actions for {{date}}": "{{date}} のバージョンの操作",
"Comparing {{newer}} and {{older}}": "{{newer}} と {{older}} を比較中",
"Exit compare": "比較を終了",
"Search attachments...": "添付ファイルを検索…",
"Error loading attachments.": "添付ファイルの読み込み中にエラーが発生しました。",
"No attachments on this page yet.": "このページにはまだ添付ファイルがありません。",
"Uploaded by {{name}}": "アップロード者: {{name}}",
"Download {{name}}": "{{name}} をダウンロード",
"Access revoked": "アクセスが取り消されました",
"Authorize application": "アプリケーションを認可",
"{{name}} wants to access {{workspace}}": "{{name}} が {{workspace}} へのアクセスを求めています",
"Not you? Switch account": "あなたではありませんか? アカウントを切り替え",
"This application will be able to:": "このアプリケーションで可能なこと:",
"Write": "書き込み",
"Invalid authorization request": "無効な認可リクエストです",
"Authorize": "認可",
"Application": "アプリケーション",
"Permissions": "権限",
"Authorized": "認可済み",
"Revoke access": "アクセスを取り消す",
"Revoke access for {{name}}": "{{name}} のアクセスを取り消す",
"Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.": "{{name}} のアクセスを取り消してもよろしいですか? このアプリケーションは今後あなたのアカウントにアクセスできなくなります。",
"Something went wrong. Please try again.": "問題が発生しました。もう一度お試しください。",
"Remove {{name}}": "{{name}} を削除",
"Make sure you trust this application before authorizing it.": "認可する前に、このアプリケーションを信頼できることを確認してください。",
"You will be redirected to": "次へリダイレクトされます",
"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 アカウントに接続します。",
"Authorized apps": "認可済みアプリ",
"No authorized apps yet.": "認可済みアプリはまだありません。",
"Workspace knowledge only": "ワークスペースの知識のみ",
"Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "AIチャットの回答を、ワークスペース内のページとアップロードされたファイルのみに制限します。外部の知識は使用されません。",
"Toggle workspace knowledge only": "「ワークスペースの知識のみ」を切り替え",
"Read-only mode": "読み取り専用モード",
"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 本番"
}
+302 -115
View File
@@ -2,25 +2,25 @@
"Account": "계정",
"Active": "활성",
"Add": "추가",
"Add group members": "팀에 사용자 추가",
"Add groups": "팀 생성",
"Add group members": "그룹에 멤버 추가",
"Add groups": "그룹 추가",
"Add members": "사용자 추가",
"Add to groups": "에 추가",
"Add space members": "Space에 사용자 추가",
"Add to groups": "그룹에 추가",
"Add space members": "스페이스에 사용자 추가",
"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 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.": "이 사용자를 Space에서 제거하시겠습니까? 사용자는 이 Space에 대한 모든 접근 권한을 잃게 됩니다.",
"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 restore this version? Any changes not versioned will be lost.": "이 버전으로 복원하시겠습니까? 저장되지 않은 모든 변경사항이 손실됩니다.",
"Can become members of groups and spaces in workspace": "Workspace 내 팀 및 Space의 사용자가 될 수 있습니다.",
"Can create and edit pages in space.": "Space에 페이지를 생성하고 편집할 수 있습니다.",
"Can become members of groups and spaces in workspace": "워크스페이스의 그룹 및 스페이스의 멤버가 될 수 있",
"Can create and edit pages in space.": "스페이스에서 페이지를 생성하고 편집할 수 있습니다.",
"Can edit": "편집할 수 있음",
"Can manage workspace": "Workspace를 관리할 수 있음",
"Can manage workspace but cannot delete it": "Workspace를 관리할 수 있지만, 삭제는 불가능.",
"Can manage workspace": "워크스페이스를 관리할 수 있음",
"Can manage workspace but cannot delete it": "워크스페이스를 관리할 수 있지만, 삭제는 할 수 없음",
"Can view": "볼 수 있음",
"Can view pages in space but not edit.": "Space의 페이지를 볼 수 있지만, 편집은 불가능.",
"Can view pages in space but not edit.": "스페이스의 페이지를 볼 수 있지만, 편집은 할 수 없음.",
"Cancel": "취소",
"Change email": "이메일 변경",
"Change password": "비밀번호 변경",
@@ -33,17 +33,17 @@
"Copy as Markdown": "Markdown으로 복사",
"Copy link": "링크 복사",
"Create": "생성",
"Create group": " 생성",
"Create group": "그룹 생성",
"Create page": "페이지 생성",
"Create space": "Space 생성",
"Create workspace": "Workspace 생성",
"Create space": "스페이스 생성",
"Create workspace": "워크스페이스 생성",
"Current password": "현재 비밀번호",
"Dark": "어두운",
"Date": "날짜",
"Delete": "삭제",
"Remove from page": "페이지에서 제거",
"Base options": "베이스 옵션",
"Delete group": " 삭제",
"Delete group": "그룹 삭제",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "이 페이지를 삭제하시겠습니까? 하위 페이지와 페이지 기록이 모두 삭제됩니다. 이 작업은 되돌릴 수 없습니다.",
"Description": "설명",
"Details": "세부사항",
@@ -58,7 +58,7 @@
"e.g Space for sales team to collaborate": "예: 영업 팀이 협업하는 스페이스",
"Edit": "편집",
"Read": "읽기",
"Edit group": " 편집",
"Edit group": "그룹 편집",
"Email": "이메일",
"Enter a strong password": "강력한 비밀번호를 입력하세요",
"Enter valid email addresses separated by comma or space max_50": "유효한 이메일 주소를 쉼표나 공백으로 구분하여 입력하세요 [최대: 50]",
@@ -103,14 +103,14 @@
"Full page width": "전체 페이지 너비",
"Full width": "전체 너비",
"General": "일반",
"Group": "",
"Group description": " 설명",
"Group name": " 이름",
"Groups": "",
"Has full access to space settings and pages.": "Space 설정과 페이지에 대한 전체 접근 권한이 있습니다.",
"Group": "그룹",
"Group description": "그룹 설명",
"Group name": "그룹 이름",
"Groups": "그룹",
"Has full access to space settings and pages.": "스페이스 설정과 페이지에 대한 전체 접근 권한이 있습니다.",
"Home": "홈",
"Import pages": "페이지 가져오기",
"Import pages & space settings": "페이지 및 Space 설정 가져오기",
"Import pages & space settings": "페이지 및 스페이스 설정 가져오기",
"Importing pages": "페이지 가져오는 중",
"invalid invitation link": "유효하지 않은 초대 링크입니다",
"Invitation signup": "초대 가입",
@@ -119,14 +119,14 @@
"Invite new members": "새 사용자 초대",
"Invite People": "사용자 초대",
"Invited members who are yet to accept their invitation will appear here.": "초대를 아직 수락하지 않은 초대된 사용자가 여기에 표시됩니다.",
"Invited members will be granted access to spaces the groups can access": "초대된 사용자는 팀이 접근할 수 있는 Space에 대한 접근 권한을 받게 됩니다",
"Join the workspace": "Workspace 참여",
"Invited members will be granted access to spaces the groups can access": "초대된 멤버에게는 그룹이 접근할 수 있는 스페이스에 대한 액세스 권한이 부여됩니다",
"Join the workspace": "워크스페이스 참여",
"Language": "언어",
"Light": "밝은",
"Link copied": "링크 복사됨",
"Login": "로그인",
"Logout": "로그아웃",
"Manage Group": " 관리",
"Manage Group": "그룹 관리",
"Manage members": "사용자 관리",
"member": "사용자",
"Member": "사용자",
@@ -139,7 +139,7 @@
"New email": "새 이메일",
"New page": "새 페이지",
"New password": "새 비밀번호",
"No group found": "그룹을 찾을 수 없습니다",
"No group found": "그룹을 찾을 수 없",
"No page history saved yet.": "아직 저장된 페이지 기록이 없습니다.",
"No pages yet": "아직 페이지가 없습니다",
"No shared pages": "공유된 페이지가 없습니다.",
@@ -148,7 +148,7 @@
"Overview": "개요",
"Owner": "소유자",
"page": "페이지",
"Page deleted successfully": "페이지 삭제 완료",
"Page deleted successfully": "페이지 삭제",
"Page history": "페이지 기록",
"Select version": "버전 선택",
"Highlight changes": "변경 사항 강조",
@@ -156,7 +156,7 @@
"Pages": "페이지",
"pages": "페이지",
"Password": "비밀번호",
"Password changed successfully": "비밀번호 변경 완료",
"Password changed successfully": "비밀번호 변경",
"People": "사용자",
"Pending": "대기 중",
"Please confirm your action": "작업을 확인해 주세요",
@@ -166,15 +166,15 @@
"Recently updated": "최근 업데이트",
"Remove": "제거",
"Remove from favorites": "즐겨찾기에서 제거",
"Remove group member": "팀에서 사용자 제거",
"Remove space member": "Space에서 사용자 제거",
"Remove group member": "그룹 멤버 제거",
"Remove space member": "스페이스에서 사용자 제거",
"Restore": "복원",
"Role": "역할",
"Save": "저장",
"Search": "검색",
"Search for groups": " 검색",
"Search for groups": "그룹 검색",
"Search for users": "사용자 검색",
"Search for users and groups": "사용자 및 검색",
"Search for users and groups": "사용자 및 그룹 검색",
"Search...": "검색...",
"Select language": "언어 선택",
"Select role": "역할 선택",
@@ -199,8 +199,8 @@
"Search for spaces": "스페이스 검색",
"Start typing to search...": "검색하려면 입력을 시작하세요...",
"Status": "상태",
"Successfully imported": "가져오기에 성공했습니다",
"Successfully restored": "복원에 성공했습니다",
"Successfully imported": "가져오기 완료",
"Successfully restored": "복원 완료",
"System settings": "시스템 설정",
"Templates": "템플릿",
"Theme": "테마",
@@ -209,7 +209,7 @@
"Unable to import pages. Please try again.": "페이지를 가져올 수 없습니다. 다시 시도해주세요.",
"untitled": "제목 없음",
"Untitled": "제목 없음",
"Updated successfully": "성공적으로 업데이트되었습니다",
"Updated successfully": "업데이트 완료",
"User": "사용자",
"Workspace": "워크스페이스",
"Workspace Name": "워크스페이스 이름",
@@ -245,15 +245,15 @@
"Are you sure you want to delete this comment?": "이 댓글을 삭제하시겠습니까?",
"Delete chat": "채팅 삭제",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "'{{title}}'을(를) 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"Comment created successfully": "댓글 생성 완료",
"Comment created successfully": "댓글 생성",
"Error creating comment": "댓글 생성 오류",
"Comment updated successfully": "댓글 업데이트 완료",
"Comment updated successfully": "댓글 업데이트",
"Failed to update comment": "댓글 업데이트 실패",
"Comment deleted successfully": "댓글 삭제 완료",
"Comment deleted successfully": "댓글 삭제",
"Failed to delete comment": "댓글 삭제 실패",
"Comment resolved successfully": "댓글 처리 완료",
"Comment re-opened successfully": "댓글이 성공적으로 다시 열렸습니다",
"Comment unresolved successfully": "댓글 해결이 성공적으로 취소되었습니다",
"Comment resolved successfully": "댓글이 해결로 표시됨",
"Comment re-opened successfully": "댓글이 열린 상태로 표시됨",
"Comment unresolved successfully": "댓글이 미해결로 표시됨",
"Failed to resolve comment": "댓글 처리 실패",
"Resolve comment": "댓글 해결",
"Unresolve comment": "댓글 해결 취소",
@@ -263,37 +263,38 @@
"Are you sure you want to unresolve this comment thread?": "이 댓글 스레드를 미해결로 변경하시겠습니까?",
"Resolved": "해결됨",
"No active comments.": "활성 댓글이 없습니다.",
"Revoke invitation": "초대 취소",
"Revoke": "취소",
"Revoke invitation": "초대 폐기",
"Revoke": "폐기",
"Don't": "하지 않음",
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "이 초대를 취소하시겠습니까? 사용자가 Workspace에 참여할 수 없게 됩니다.",
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "이 초대를 폐기하시겠습니까? 사용자가 워크스페이스에 참여할 수 없게 됩니다.",
"Resend invitation": "초대 재전송",
"Anyone with this link can join this workspace.": "이 링크를 가진 모든 사용자가 이 Workspace에 참여할 수 있습니다.",
"Anyone with this link can join this workspace.": "이 링크를 가진 모든 사용자가 이 워크스페이스에 참여할 수 있습니다.",
"Invite link": "초대 링크",
"Copy": "복사",
"Copy to space": "스페이스로 복사",
"Copied": "복사됨",
"Duplicate": "복제",
"Select a user": "사용자 선택",
"Select a group": " 선택",
"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의 모든 페이지, 댓글, 첨부파일 및 권한이 영구적으로 삭제됩니다.",
"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.": "이 스페이스의 모든 페이지, 댓글, 첨부 파일 및 권한이 영구적으로 삭제됩니다.",
"Confirm space name": "스페이스 이름 확인",
"Type the space name <b>{{spaceName}}</b> to confirm your action.": "작업을 진행하려면 Space 이름 <b>{{spaceName}}</b>을 입력하세요.",
"Type the space name <b>{{spaceName}}</b> to confirm your action.": "작업을 진행하려면 스페이스 이름 <b>{{spaceName}}</b>을(를) 입력하세요.",
"Format": "형식",
"Include subpages": "하위 페이지 포함",
"Include attachments": "첨부파일 포함",
"Include attachments": "첨부 파일 포함",
"Select export format": "내보내기 형식 선택",
"Export failed:": "내보내기 실패:",
"export error": "내보내기 오류",
"Export page": "페이지 내보내기",
"Export successful": "내보내기 성공",
"Export space": "Space 내보내기",
"Export successful": "내보내기 완료",
"Export space": "스페이스 내보내기",
"Export {{type}}": "{{type}} 내보내기",
"File exceeds the {{limit}} attachment limit": "첨부 파일 크기 제한 {{limit}}을 초과했습니다",
"Media": "미디어",
"Align left": "왼쪽 정렬",
"Align right": "오른쪽 정렬",
"Align center": "가운데 정렬",
@@ -387,6 +388,8 @@
"Insert horizontal rule divider": "가로 구분선 삽입",
"Page break": "페이지 나누기",
"Insert a page break for printing.": "인쇄용 페이지 나누기를 삽입합니다.",
"Footnote": "각주",
"Insert a footnote reference.": "각주 참조를 삽입합니다.",
"Upload any image from your device.": "기기에서 이미지를 업로드하세요.",
"Upload any video from your device.": "기기에서 비디오를 업로드하세요.",
"Upload any audio from your device.": "기기에서 오디오를 업로드하세요.",
@@ -447,12 +450,12 @@
"Today, {{time}}": "오늘, {{time}}",
"Yesterday, {{time}}": "어제, {{time}}",
"now": "지금",
"Space created successfully": "스페이스가 성공적으로 생성되었습니다",
"Space updated successfully": "스페이스가 성공적으로 업데이트되었습니다",
"Space deleted successfully": "스페이스가 성공적으로 삭제되었습니다",
"Members added successfully": "멤버가 성공적으로 추가되었습니다",
"Member removed successfully": "멤버가 성공적으로 제거되었습니다",
"Member role updated successfully": "멤버 역할이 성공적으로 업데이트되었습니다",
"Space created successfully": "스페이스 생성됨",
"Space updated successfully": "스페이스 업데이트됨",
"Space deleted successfully": "스페이스 삭제됨",
"Members added successfully": "멤버 추가됨",
"Member removed successfully": "멤버 삭제됨",
"Member role updated successfully": "멤버 역할 업데이트됨",
"Created by: <b>{{creatorName}}</b>": "작성자: <b>{{creatorName}}</b>",
"Created at: {{time}}": "작성 시간: {{time}}",
"Edited by {{name}} {{time}}": "{{name}}님이 {{time}}에 편집함",
@@ -465,7 +468,7 @@
"Choose {{format}} file": "{{format}} 파일 선택",
"Reading": "읽기",
"Delete member": "멤버 삭제",
"Member deleted successfully": "멤버가 성공적으로 삭제되었습니다",
"Member deleted successfully": "멤버 삭제됨",
"Are you sure you want to delete this workspace member? This action is irreversible.": "이 워크스페이스 멤버를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"Deactivate member": "멤버 비활성화",
"Activate member": "멤버 활성화",
@@ -498,32 +501,32 @@
"Delete share": "공유 삭제",
"Are you sure you want to delete this shared link?": "이 공유 링크를 삭제하시겠습니까?",
"Publicly shared pages from spaces you are a member of will appear here": "회원으로 속한 스페이스의 공개 공유 페이지가 여기에 표시됩니다",
"Share deleted successfully": "공유가 성공적으로 삭제되었습니다",
"Share deleted successfully": "공유 삭제됨",
"Share not found": "공유를 찾을 수 없습니다",
"Failed to share page": "페이지 공유에 실패했습니다",
"Disable public sharing": "공유 비활성화",
"Prevent members from sharing pages publicly.": "멤버들이 페이지를 공개적으로 공유하지 못하도록 방지하십시오.",
"Toggle public sharing": "공유 전환",
"Toggle space public sharing": "공간 공유 전환",
"Disable public sharing": "공개 공유 비활성화",
"Prevent members from sharing pages publicly.": "멤버 페이지를 공개적으로 공유하지 못하게 합니다.",
"Toggle public sharing": "공개 공유 전환",
"Toggle space public sharing": "스페이스 공개 공유 전환",
"Allow viewers to comment": "뷰어가 댓글을 달 수 있도록 허용",
"Allow viewers to add comments on pages in this space.": "이 공간 내 페이지에 뷰어가 댓글을 추가할 수 있도록 허용합니다.",
"Toggle viewer comments": "뷰어 댓글 전환",
"Public sharing is disabled at the workspace level": "워크스페이스 수준에서 공유가 비활성화되었습니다.",
"Prevent pages in this space from being shared publicly.": "이 공간의 페이지가 공개적으로 공유되지 않도록 방지하십시오.",
"Page permissions": "페이지 권한},{",
"Public sharing is disabled at the workspace level": "공개 공유가 워크스페이스 수준에서 비활성화됨",
"Prevent pages in this space from being shared publicly.": "이 스페이스의 페이지가 공개 공유되지 않도록 합니다.",
"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.": "공유를 활성화하시겠습니까? 멤버들이 페이지를 공개적으로 공유할 수 있게 됩니다.",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "정말로 공유를 비활성화하시겠습니까? 이 워크스페이스의 모든 기존 공유 링크가 삭제됩니다.",
"Are you sure you want to enable public sharing for this space?": "이 공간의 공유를 활성화하시겠습니까?",
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "정말로 공유를 비활성화하시겠습니까? 이 공간의 모든 기존 공유 링크가 삭제됩니다.",
"Public sharing is disabled": "공유가 비활성화되었습니다.",
"Public sharing has been disabled at the workspace level.": "워크스페이스 수준에서 공유가 비활성화되었습니다.",
"Public sharing has been disabled for this space.": "이 공간의 공유가 비활성화되었습니다.",
"Enable public sharing": "공개 공유 활성화",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "공개 공유를 활성화하시겠습니까? 멤버 페이지를 공개적으로 공유할 수 있게 됩니다.",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "공개 공유를 비활성화하시겠습니까? 이 워크스페이스의 기존 공유 링크가 모두 삭제됩니다.",
"Are you sure you want to enable public sharing for this space?": "이 스페이스에 대해 공개 공유를 활성화하시겠습니까?",
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "공개 공유를 비활성화하시겠습니까? 이 스페이스의 기존 공유 링크가 모두 삭제됩니다.",
"Public sharing is disabled": "공개 공유가 비활성화",
"Public sharing has been disabled at the workspace level.": "공개 공유가 워크스페이스 수준에서 비활성화되었습니다.",
"Public sharing has been disabled for this space.": "이 스페이스의 공개 공유가 비활성화되었습니다.",
"Copy page": "페이지 복사",
"Copy page to a different space.": "다른 공간으로 페이지 복사하기.",
"Page copied successfully": "페이지가 성공적으로 복사되었습니다",
"Page duplicated successfully": "페이지가 성공적으로 복제되었습니다",
"Page copied successfully": "페이지 복사됨",
"Page duplicated successfully": "페이지 복제됨",
"Find": "찾기",
"Not found": "찾을 수 없음",
"Previous Match (Shift+Enter)": "이전 일치 항목 (Shift+Enter)",
@@ -539,13 +542,13 @@
"Error": "오류",
"Failed to disable MFA": "MFA 비활성화에 실패했습니다",
"Disable two-factor authentication": "2단계 인증 비활성화",
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "이중 인증을 비활성화하면 계정의 보안이 낮아집니다. 로그인 시 비밀번호만 필요하게 됩니다.",
"Please enter your password to disable two-factor authentication:": "이중 인증 비활성화를 위해 비밀번호를 입력하세요:",
"Two-factor authentication has been enabled": "2단계 인증이 활성화되었습니다",
"Two-factor authentication has been disabled": "2단계 인증이 비활성화되었습니다",
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "2단계 인증을 비활성화하면 계정의 보안이 낮아집니다. 로그인 시 비밀번호만 필요하게 됩니다.",
"Please enter your password to disable two-factor authentication:": "2단계 인증 비활성화를 위해 비밀번호를 입력하세요:",
"Two-factor authentication has been enabled": "2단계 인증이 활성화",
"Two-factor authentication has been disabled": "2단계 인증이 비활성화",
"2-step verification": "2단계 인증",
"Protect your account with an additional verification layer when signing in.": "로그인 시 추가 인증 단계를 통해 계정을 보호하세요.",
"Two-factor authentication is active on your account.": "이중 인증이 계정에 활성화되어 있습니다.",
"Two-factor authentication is active on your account.": "2단계 인증이 계정에 활성화되어 있습니다.",
"Add 2FA method": "2FA 방법 추가",
"Backup codes": "백업 코드",
"Disable": "비활성화",
@@ -575,13 +578,13 @@
"Save your backup codes": "백업 코드를 저장하세요",
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "인증 앱에 대한 접근 권한을 잃은 경우, 이 코드를 사용하여 귀하의 계정에 접근할 수 있습니다. 각 코드는 한 번만 사용할 수 있습니다.",
"Print": "인쇄",
"Two-factor authentication has been set up. Please log in again.": "이중 인증이 설정되었습니다. 다시 로그인해 주세요.",
"Two-Factor authentication required": "2단계 인증 필요합니다",
"Two-factor authentication has been set up. Please log in again.": "2단계 인증이 설정되었습니다. 다시 로그인해 주세요.",
"Two-Factor authentication required": "2단계 인증 필요",
"Your workspace requires two-factor authentication for all users": "이 워크스페이스는 모든 사용자에게 2단계 인증을 요구합니다",
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "워크스페이스 접근을 계속하려면 이중 인증을 설정해야 합니다. 이는 계정에 추가 보안 계층을 추가합니다.",
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "워크스페이스에 계속 액세스하려면 2단계 인증을 설정해야 합니다. 이는 계정의 보안을 한층 강화합니다.",
"Set up two-factor authentication": "2단계 인증 설정",
"Cancel and logout": "취소하고 로그아웃",
"Your workspace requires two-factor authentication. Please set it up to continue.": "워크스페이스에서는 이중 인증이 필요합니다. 계속하려면 설정해 주세요.",
"Your workspace requires two-factor authentication. Please set it up to continue.": "워크스페이스에서는 2단계 인증이 필요합니다. 계속하려면 설정해 주세요.",
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "인증앱에서 얻은 인증 코드를 요구하여 계정의 보안에 추가적인 계층을 추가합니다.",
"Password is required": "비밀번호는 필수입니다",
"Password must be at least 8 characters": "비밀번호는 8자 이상이어야 합니다",
@@ -590,10 +593,10 @@
"Enter the 6-digit code found in your authenticator app": "인증 앱에 표시된 6자리 코드를 입력하세요",
"Need help authenticating?": "인증에 도움이 필요하십니까?",
"MFA QR Code": "MFA QR 코드",
"Account created successfully. Please log in to set up two-factor authentication.": "계정이 성공적으로 생성되었습니다. 이중 인증을 설정하려면 로그인해 주세요.",
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "비밀번호 재설정 성공. 새 비밀번호로 로그인하여 이중 인증을 완료하세요.",
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "비밀번호 재설정 성공. 새 비밀번호로 로그인하여 이중 인증을 설정하세요.",
"Password reset was successful. Please log in with your new password.": "비밀번호 재설정이 성공적으로 완료되었습니다. 새 비밀번호로 로그인하세요.",
"Account created successfully. Please log in to set up two-factor authentication.": "계정 생성됨. 2단계 인증을 설정하려면 로그인해 주세요.",
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "비밀번호 재설정. 새 비밀번호로 로그인하여 2단계 인증을 완료하세요.",
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "비밀번호 재설정. 새 비밀번호로 로그인하여 2단계 인증을 설정하세요.",
"Password reset was successful. Please log in with your new password.": "비밀번호 재설정. 새 비밀번호로 로그인하세요.",
"Two-factor authentication": "2단계 인증",
"Use authenticator app instead": "대신 인증 앱 사용",
"Verify backup code": "백업 코드 확인",
@@ -615,7 +618,7 @@
"Permanently delete": "영구 삭제",
"<b>{{name}}</b> moved this page to Trash {{time}}.": "<b>{{name}}</b>님이 {{time}}에 이 페이지를 휴지통으로 이동했습니다.",
"Page moved to trash": "페이지가 휴지통으로 이동되었습니다",
"Page restored successfully": "페이지가 성공적으로 복원되었습니다",
"Page restored successfully": "페이지 복원됨",
"Deleted by": "삭제한 사람",
"Deleted at": "삭제 시간",
"Preview": "미리보기",
@@ -626,19 +629,19 @@
"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": "쉼표 또는 공백으로 구분된 유효한 도메인 이름을 입력하세요",
"Enforce two-factor authentication": "2단계 인증 강제",
"Once enforced, all members must enable two-factor authentication to access the workspace.": "시행되면 모든 멤버가 작업 공간에 액세스하기 위해 이중 인증을 활성화해야 합니다.",
"Once enforced, all members must enable two-factor authentication to access the workspace.": "시행되면 모든 멤버가 워크스페이스에 액세스하기 위해 2단계 인증을 활성화해야 합니다.",
"Toggle MFA enforcement": "MFA 강제 설정 전환",
"Display name": "표시 이름",
"Allow signup": "가입 허용",
@@ -657,7 +660,7 @@
"Remove image": "이미지 제거",
"Failed to remove image": "이미지 제거 실패",
"Image exceeds 10MB limit.": "이미지가 10MB 용량 제한을 초과합니다.",
"Image removed successfully": "이미지가 성공적으로 제거되었습니다",
"Image removed successfully": "이미지 삭제됨",
"API key": "API 키",
"API keys": "API 키",
"API management": "API 관리",
@@ -669,7 +672,7 @@
"Last use": "최근 사용",
"No API keys found": "API 키를 찾을 수 없습니다",
"No expiration": "유효기간 없음",
"Revoked successfully": "성공적으로 취소되었습니다",
"Revoked successfully": "폐기 완료",
"Select expiration date": "만료일 선택",
"This action cannot be undone. Any applications using this API key will stop working.": "이 작업은 되돌릴 수 없습니다. 이 API 키를 사용하는 모든 응용 프로그램이 작동을 멈출 것입니다.",
"Update": "업데이트",
@@ -696,7 +699,7 @@
"Upgrade your plan": "요금제를 업그레이드하세요",
"Available with a paid license": "유료 라이선스에서만 사용 가능합니다",
"Upgrade your license tier.": "라이선스 등급을 업그레이드하세요.",
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI는 Docmost 엔터프라이즈 에디션에서 제공됩니다. sales@docmost.com으로 문의하세요.",
"AI is available in the Docmost paid editions. Contact sales@docmost.com.": "AI는 Docmost 유료 에디션에서 제공됩니다. sales@docmost.com으로 문의하세요.",
"AI & MCP": "AI 및 MCP",
"AI": "AI",
"MCP": "MCP",
@@ -704,9 +707,11 @@
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "AI 어시스턴트와 도구가 워크스페이스 콘텐츠와 상호작용할 수 있도록 MCP 서버를 활성화하세요.",
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP는 Docmost 엔터프라이즈 에디션에서만 제공됩니다. sales@docmost.com으로 문의하세요.",
"MCP Server URL": "MCP 서버 URL",
"Use your API key for authentication. You can manage API keys in your account settings.": "인증을 위해 API 키를 사용하세요. API 키는 계정 설정에서 관리할 수 있습니다.",
"Connect AI assistants with your Docmost account via OAuth.": "OAuth를 통해 AI 도우미를 Docmost 계정에 연결합니다.",
"Enforce OAuth": "OAuth 강제",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI 도우미는 OAuth를 통해 Docmost 계정에 연결해야 합니다. MCP 서버에서는 API 키를 사용할 수 없습니다.",
"Toggle enforce OAuth for MCP": "MCP에 대해 OAuth 강제 전환",
"Supported tools": "지원되는 도구",
"Your workspace has MCP enabled. Use your API key to connect AI assistants.": "워크스페이스에 MCP가 활성화되어 있습니다. AI 어시스턴트를 연결하려면 API 키를 사용하세요.",
"MCP server URL:": "MCP 서버 URL:",
"Learn more": "자세히 알아보기",
"Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "워크스페이스의 모든 사용자를 위한 API 키를 관리하세요. 사용 방법은 <anchor>API 문서</anchor>를 참고하세요.",
@@ -786,8 +791,12 @@
"Trash retention updated": "휴지통 보관 기간이 업데이트되었습니다.",
"Failed to update trash retention": "휴지통 보관 기간 업데이트에 실패했습니다.",
"Removed page restriction": "페이지 제한이 제거됨",
"Added page permission": "페이지 권한 추가됨",
"Removed page permission": "페이지 권한 제거됨",
"Added page permission": "페이지 권한 추가됨",
"Removed page permission": "페이지 권한 제거됨",
"Changed page permission": "페이지 권한 변경됨",
"Requested password reset": "비밀번호 재설정 요청됨",
"Created template": "템플릿 생성됨",
"Deleted template": "템플릿 삭제됨",
"day": "일",
"days": "일",
"week": "주",
@@ -875,6 +884,9 @@
"<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.": "인증에 실패했습니다. 링크가 만료되었을 수 있습니다.",
@@ -939,8 +951,8 @@
"Try again": "다시 시도",
"Untitled chat": "제목 없는 채팅",
"What can I help you with?": "무엇을 도와드릴까요?",
"Are you sure you want to revoke this {{credential}}": "이 {{credential}}을 취소하시겠습니까?",
"Automatically provision users and groups from your identity provider via SCIM.": "SCIM을 통해 ID 공급자에서 사용자와 그룹을 자동으로 프로비저닝합니다.",
"Are you sure you want to revoke this {{credential}}": "이 {{credential}}을(를) 폐기하시겠습니까?",
"Automatically provision users and groups from your identity provider via SCIM.": "SCIM을 통해 ID 공급자로부터 사용자와 그룹을 자동으로 프로비저닝합니다.",
"Configure your identity provider with this URL to provision users and groups.": "사용자와 그룹을 프로비저닝할 수 있도록 이 URL로 ID 공급자를 구성하세요.",
"Create {{credential}}": "{{credential}} 만들기",
"{{credential}} created": "{{credential}} 생성됨",
@@ -953,7 +965,7 @@
"Important": "중요",
"Make sure to copy your {{credential}} now. You won't be able to see it again!": "지금 {{credential}}를 복사해 두세요. 다시는 볼 수 없습니다!",
"Never": "안 함",
"Revoke {{credential}}": "{{credential}} 취소",
"Revoke {{credential}}": "{{credential}} 폐기",
"SCIM endpoint URL": "SCIM 엔드포인트 URL",
"SCIM provisioning": "SCIM 프로비저닝",
"SCIM takes precedence over SSO group sync while enabled.": "SCIM이 활성화되어 있는 동안에는 SSO 그룹 동기화보다 SCIM이 우선 적용됩니다.",
@@ -1117,7 +1129,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이 아닌 셀은 지워집니다.",
@@ -1186,8 +1198,8 @@
"Default value": "기본값",
"Delete property": "속성 삭제",
"Delete view": "보기 삭제",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Delete {{count}} rows?_one": "1개 행을 삭제하시겠습니까?",
"Delete {{count}} rows?_other": "{{count}}개 행을 삭제하시겠습니까?",
"Descending": "내림차순",
"Discard": "삭제",
"Doesn't contain": "포함하지 않음",
@@ -1286,8 +1298,183 @@
"Value": "값",
"View updated for everyone": "보기가 모두에게 업데이트되었습니다",
"You have unsaved changes. Do you want to discard them?": "저장되지 않은 변경 사항이 있습니다. 버리시겠습니까?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
"{{count}} rows deleted_one": "1개 행 삭제 완료",
"{{count}} rows deleted_other": "{{count}}개 행 삭제 완료",
"{{count}} selected_one": "1개 선택됨",
"{{count}} selected_other": "{{count}}개 선택됨",
"Compare": "비교",
"Compare versions": "버전 비교",
"Select version from {{date}}": "{{date}}의 버전 선택",
"Version actions for {{date}}": "{{date}} 버전 작업",
"Comparing {{newer}} and {{older}}": "{{newer}} 및 {{older}} 비교 중",
"Exit compare": "비교 종료",
"Search attachments...": "첨부 파일 검색...",
"Error loading attachments.": "첨부 파일을 불러오는 중 오류가 발생했습니다.",
"No attachments on this page yet.": "이 페이지에는 아직 첨부 파일이 없습니다.",
"Uploaded by {{name}}": "업로드한 사람: {{name}}",
"Download {{name}}": "{{name}} 다운로드",
"Access revoked": "액세스가 취소되었습니다",
"Authorize application": "애플리케이션 승인",
"{{name}} wants to access {{workspace}}": "{{name}}에서 {{workspace}}에 액세스하려고 합니다",
"Not you? Switch account": "본인이 아니신가요? 계정을 전환하세요",
"This application will be able to:": "이 애플리케이션이 수행할 수 있는 작업:",
"Write": "쓰기",
"Invalid authorization request": "잘못된 승인 요청",
"Authorize": "승인",
"Application": "애플리케이션",
"Permissions": "권한",
"Authorized": "승인됨",
"Revoke access": "액세스 취소",
"Revoke access for {{name}}": "{{name}}의 액세스 취소",
"Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.": "정말로 {{name}}의 액세스를 취소하시겠습니까? 이 애플리케이션은 더 이상 회원님의 계정에 액세스할 수 없습니다.",
"Something went wrong. Please try again.": "문제가 발생했습니다. 다시 시도해 주세요.",
"Remove {{name}}": "{{name}} 제거",
"Make sure you trust this application before authorizing it.": "이 애플리케이션을 승인하기 전에 신뢰할 수 있는지 확인하세요.",
"You will be redirected to": "다음으로 리디렉션됩니다",
"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 계정에 연결하세요.",
"Authorized apps": "승인된 앱",
"No authorized apps yet.": "아직 승인된 앱이 없습니다.",
"Workspace knowledge only": "워크스페이스 지식만 사용",
"Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "AI 채팅이 워크스페이스 페이지와 업로드된 파일의 내용만 바탕으로 답변하도록 제한합니다. 외부 지식은 사용하지 않습니다.",
"Toggle workspace knowledge only": "워크스페이스 지식만 사용 전환",
"Read-only mode": "읽기 전용 모드",
"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 운영"
}
@@ -294,6 +294,7 @@
"Export space": "Exporteer ruimte",
"Export {{type}}": "Exporteer {{type}}",
"File exceeds the {{limit}} attachment limit": "Bestand overschrijdt de bijlagelimiet van {{limit}}",
"Media": "Media",
"Align left": "Links uitlijnen",
"Align right": "Rechts uitlijnen",
"Align center": "Centreren",
@@ -387,6 +388,8 @@
"Insert horizontal rule divider": "Horizontale lijn invoegen",
"Page break": "Pagina-einde",
"Insert a page break for printing.": "Voeg een pagina-einde in voor het afdrukken.",
"Footnote": "Voetnoot",
"Insert a footnote reference.": "Voeg een voetnootverwijzing in.",
"Upload any image from your device.": "Upload een afbeelding vanaf uw apparaat.",
"Upload any video from your device.": "Upload een video vanaf uw apparaat.",
"Upload any audio from your device.": "Upload een audio vanaf uw apparaat.",
@@ -696,7 +699,7 @@
"Upgrade your plan": "Upgrade je abonnement",
"Available with a paid license": "Beschikbaar met een betaalde licentie",
"Upgrade your license tier.": "Upgrade je licentieniveau.",
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI is alleen beschikbaar in de Docmost Enterprise-editie. Neem contact op met sales@docmost.com.",
"AI is available in the Docmost paid editions. Contact sales@docmost.com.": "AI is beschikbaar in de betaalde edities van Docmost. Neem contact op met sales@docmost.com.",
"AI & MCP": "AI & MCP",
"AI": "AI",
"MCP": "MCP",
@@ -704,9 +707,11 @@
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Schakel de MCP-server in zodat AI-assistenten en tools kunnen interageren met de inhoud van uw werkruimte.",
"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",
"Use your API key for authentication. You can manage API keys in your account settings.": "Gebruik uw API-sleutel voor authenticatie. U kunt API-sleutels beheren in uw accountinstellingen.",
"Connect AI assistants with your Docmost account via OAuth.": "Verbind AI-assistenten met je Docmost-account via OAuth.",
"Enforce OAuth": "OAuth afdwingen",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI-assistenten moeten verbinding maken met een Docmost-account via OAuth. API-sleutels kunnen niet worden gebruikt met de MCP-server.",
"Toggle enforce OAuth for MCP": "OAuth afdwingen voor MCP in- of uitschakelen",
"Supported tools": "Ondersteunde tools",
"Your workspace has MCP enabled. Use your API key to connect AI assistants.": "In uw werkruimte is MCP ingeschakeld. Gebruik uw API-sleutel om AI-assistenten te koppelen.",
"MCP server URL:": "MCP-server-URL:",
"Learn more": "Meer informatie",
"Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "Beheer API-sleutels voor alle gebruikers in de werkruimte. Bekijk de <anchor>API-documentatie</anchor> voor gebruiksdetails.",
@@ -788,6 +793,10 @@
"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",
@@ -875,6 +884,9 @@
"<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.",
@@ -1289,5 +1301,180 @@
"{{count}} rows deleted_one": "1 rij verwijderd",
"{{count}} rows deleted_other": "{{count}} rijen verwijderd",
"{{count}} selected_one": "1 geselecteerd",
"{{count}} selected_other": "{{count}} geselecteerd"
"{{count}} selected_other": "{{count}} geselecteerd",
"Compare": "Vergelijken",
"Compare versions": "Versies vergelijken",
"Select version from {{date}}": "Selecteer versie van {{date}}",
"Version actions for {{date}}": "Versieacties voor {{date}}",
"Comparing {{newer}} and {{older}}": "{{newer}} en {{older}} vergelijken",
"Exit compare": "Vergelijken afsluiten",
"Search attachments...": "Bijlagen zoeken...",
"Error loading attachments.": "Fout bij het laden van bijlagen.",
"No attachments on this page yet.": "Er zijn nog geen bijlagen op deze pagina.",
"Uploaded by {{name}}": "Geüpload door {{name}}",
"Download {{name}}": "{{name}} downloaden",
"Access revoked": "Toegang ingetrokken",
"Authorize application": "Applicatie autoriseren",
"{{name}} wants to access {{workspace}}": "{{name}} wil toegang tot {{workspace}}",
"Not you? Switch account": "Ben jij dit niet? Wissel van account",
"This application will be able to:": "Deze applicatie kan het volgende doen:",
"Write": "Schrijven",
"Invalid authorization request": "Ongeldig autorisatieverzoek",
"Authorize": "Autoriseren",
"Application": "Applicatie",
"Permissions": "Machtigingen",
"Authorized": "Geautoriseerd",
"Revoke access": "Toegang intrekken",
"Revoke access for {{name}}": "Toegang voor {{name}} intrekken",
"Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.": "Weet je zeker dat je de toegang voor {{name}} wilt intrekken? De applicatie heeft dan geen toegang meer tot je account.",
"Something went wrong. Please try again.": "Er is iets misgegaan. Probeer het opnieuw.",
"Remove {{name}}": "{{name}} verwijderen",
"Make sure you trust this application before authorizing it.": "Zorg ervoor dat je deze applicatie vertrouwt voordat je haar autoriseert.",
"You will be redirected to": "Je wordt doorgestuurd naar",
"View content without making changes.": "Inhoud bekijken zonder wijzigingen aan te brengen.",
"Create and modify content.": "Inhoud maken en wijzigen.",
"Applications and AI assistants you have authorized to access your account.": "Applicaties en AI-assistenten die je hebt geautoriseerd om toegang te krijgen tot je account.",
"Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.": "MCP is ingeschakeld voor je werkruimte. Verbind AI-assistenten met je Docmost-account via OAuth.",
"Authorized apps": "Geautoriseerde apps",
"No authorized apps yet.": "Nog geen geautoriseerde apps.",
"Workspace knowledge only": "Alleen werkruimtek kennis",
"Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Beperk AI Chat tot het beantwoorden op basis van alleen je werkruimtepagina's en geüploade bestanden. Er wordt geen externe kennis gebruikt.",
"Toggle workspace knowledge only": "Alleen werkruimtekennis in-/uitschakelen",
"Read-only mode": "Alleen-lezenmodus",
"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"
}
@@ -294,6 +294,7 @@
"Export space": "Exportar espaço",
"Export {{type}}": "Exportar para {{type}}",
"File exceeds the {{limit}} attachment limit": "O arquivo excede o limite de anexos {{limit}}",
"Media": "Mídia",
"Align left": "Alinhar à esquerda",
"Align right": "Alinhar à direita",
"Align center": "Alinhar ao centro",
@@ -387,6 +388,8 @@
"Insert horizontal rule divider": "Insira um divisor horizontal",
"Page break": "Quebra de página",
"Insert a page break for printing.": "Insira uma quebra de página para impressão.",
"Footnote": "Nota de rodapé",
"Insert a footnote reference.": "Inserir uma referência de nota de rodapé.",
"Upload any image from your device.": "Envie qualquer imagem do seu dispositivo.",
"Upload any video from your device.": "Envie qualquer vídeo do seu dispositivo.",
"Upload any audio from your device.": "Envie qualquer áudio do seu dispositivo.",
@@ -696,7 +699,7 @@
"Upgrade your plan": "Faça upgrade do seu plano",
"Available with a paid license": "Disponível com uma licença paga",
"Upgrade your license tier.": "Faça upgrade do seu nível de licença.",
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "A IA está disponível apenas na edição empresarial do Docmost. Contate sales@docmost.com.",
"AI is available in the Docmost paid editions. Contact sales@docmost.com.": "A IA está disponível nas edições pagas do Docmost. Contate sales@docmost.com.",
"AI & MCP": "IA e MCP",
"AI": "IA",
"MCP": "MCP",
@@ -704,9 +707,11 @@
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Ative o servidor MCP para permitir que assistentes de IA e ferramentas interajam com o conteúdo do seu espaço de trabalho.",
"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",
"Use your API key for authentication. You can manage API keys in your account settings.": "Use sua chave de API para autenticação. Você pode gerenciar chaves de API nas configurações da sua conta.",
"Connect AI assistants with your Docmost account via OAuth.": "Conecte assistentes de IA à sua conta do Docmost via OAuth.",
"Enforce OAuth": "Exigir OAuth",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "Os assistentes de IA devem se conectar com uma conta do Docmost via OAuth. Chaves de API não podem ser usadas com o servidor MCP.",
"Toggle enforce OAuth for MCP": "Ativar/desativar exigência de OAuth para MCP",
"Supported tools": "Ferramentas compatíveis",
"Your workspace has MCP enabled. Use your API key to connect AI assistants.": "Seu espaço de trabalho tem MCP habilitado. Use sua chave de API para conectar assistentes de IA.",
"MCP server URL:": "URL do servidor MCP:",
"Learn more": "Saiba mais",
"Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "Gerencie as chaves de API de todos os usuários do workspace. Veja a <anchor>documentação da API</anchor> para detalhes de uso.",
@@ -788,6 +793,10 @@
"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",
@@ -875,6 +884,9 @@
"<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.",
@@ -1289,5 +1301,180 @@
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
"{{count}} selected_other": "{{count}} selected",
"Compare": "Comparar",
"Compare versions": "Comparar versões",
"Select version from {{date}}": "Selecionar versão de {{date}}",
"Version actions for {{date}}": "Ações da versão de {{date}}",
"Comparing {{newer}} and {{older}}": "Comparando {{newer}} e {{older}}",
"Exit compare": "Sair da comparação",
"Search attachments...": "Pesquisar anexos...",
"Error loading attachments.": "Erro ao carregar anexos.",
"No attachments on this page yet.": "Ainda não há anexos nesta página.",
"Uploaded by {{name}}": "Enviado por {{name}}",
"Download {{name}}": "Baixar {{name}}",
"Access revoked": "Acesso revogado",
"Authorize application": "Autorizar aplicativo",
"{{name}} wants to access {{workspace}}": "{{name}} quer acessar {{workspace}}",
"Not you? Switch account": "Não é você? Trocar conta",
"This application will be able to:": "Este aplicativo poderá:",
"Write": "Escrever",
"Invalid authorization request": "Solicitação de autorização inválida",
"Authorize": "Autorizar",
"Application": "Aplicativo",
"Permissions": "Permissões",
"Authorized": "Autorizado",
"Revoke access": "Revogar acesso",
"Revoke access for {{name}}": "Revogar acesso de {{name}}",
"Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.": "Tem certeza de que deseja revogar o acesso de {{name}}? O aplicativo não poderá mais acessar sua conta.",
"Something went wrong. Please try again.": "Algo deu errado. Tente novamente.",
"Remove {{name}}": "Remover {{name}}",
"Make sure you trust this application before authorizing it.": "Certifique-se de confiar neste aplicativo antes de autorizá-lo.",
"You will be redirected to": "Você será redirecionado para",
"View content without making changes.": "Visualizar conteúdo sem fazer alterações.",
"Create and modify content.": "Criar e modificar conteúdo.",
"Applications and AI assistants you have authorized to access your account.": "Aplicativos e assistentes de IA que você autorizou a acessar sua conta.",
"Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.": "Seu workspace tem o MCP ativado. Conecte assistentes de IA à sua conta do Docmost via OAuth.",
"Authorized apps": "Aplicativos autorizados",
"No authorized apps yet.": "Ainda não há aplicativos autorizados.",
"Workspace knowledge only": "Somente conhecimento do workspace",
"Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Restrinja o AI Chat para responder apenas com base nas páginas do seu workspace e nos arquivos enviados. Ele não usará conhecimento externo.",
"Toggle workspace knowledge only": "Alternar somente conhecimento do workspace",
"Read-only mode": "Modo somente leitura",
"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"
}
@@ -294,6 +294,7 @@
"Export space": "Экспорт пространства",
"Export {{type}}": "Экспорт {{type}}",
"File exceeds the {{limit}} attachment limit": "Файл превышает лимит вложений {{limit}}",
"Media": "Медиа",
"Align left": "По левому краю",
"Align right": "По правому краю",
"Align center": "По центру",
@@ -387,6 +388,8 @@
"Insert horizontal rule divider": "Вставить горизонтальный разделитель",
"Page break": "Разрыв страницы",
"Insert a page break for printing.": "Вставить разрыв страницы для печати.",
"Footnote": "Сноска",
"Insert a footnote reference.": "Вставить ссылку на сноску.",
"Upload any image from your device.": "Загрузить любое изображение с вашего устройства.",
"Upload any video from your device.": "Загрузить любое видео с вашего устройства.",
"Upload any audio from your device.": "Загрузите любой аудиофайл с вашего устройства.",
@@ -696,7 +699,7 @@
"Upgrade your plan": "Обновите свой тарифный план",
"Available with a paid license": "Доступно с платной лицензией",
"Upgrade your license tier.": "Обновите уровень вашей лицензии.",
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "ИИ доступен только в корпоративной версии Docmost. Свяжитесь по адресу sales@docmost.com.",
"AI is available in the Docmost paid editions. Contact sales@docmost.com.": "ИИ доступен в платных версиях Docmost. Свяжитесь по адресу sales@docmost.com.",
"AI & MCP": "ИИ и MCP",
"AI": "ИИ",
"MCP": "MCP",
@@ -704,9 +707,11 @@
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Включите сервер MCP, чтобы ИИ-ассистенты и инструменты могли взаимодействовать с содержимым вашего рабочего пространства.",
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP доступен только в корпоративной версии Docmost. Свяжитесь по адресу sales@docmost.com.",
"MCP Server URL": "URL сервера MCP",
"Use your API key for authentication. You can manage API keys in your account settings.": "Используйте ваш API-ключ для аутентификации. Управлять API-ключами можно в настройках аккаунта.",
"Connect AI assistants with your Docmost account via OAuth.": "Подключайте AI-помощников к вашей учетной записи Docmost через OAuth.",
"Enforce OAuth": "Сделать OAuth обязательным",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI-помощники должны подключаться к учетной записи Docmost через OAuth. Ключи API нельзя использовать с MCP-сервером.",
"Toggle enforce OAuth for MCP": "Переключить обязательное использование OAuth для MCP",
"Supported tools": "Поддерживаемые инструменты",
"Your workspace has MCP enabled. Use your API key to connect AI assistants.": "В вашем рабочем пространстве включён MCP. Используйте свой API-ключ для подключения ИИ-ассистентов.",
"MCP server URL:": "URL сервера MCP:",
"Learn more": "Подробнее",
"Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "Управляйте API-ключами для всех пользователей в рабочем пространстве. Смотрите <anchor>документацию по API</anchor> для получения информации об использовании.",
@@ -788,6 +793,10 @@
"Removed page restriction": "Ограничение доступа к странице удалено",
"Added page permission": "Добавлено разрешение доступа к странице",
"Removed page permission": "Удалено разрешение доступа к странице",
"Changed page permission": "Изменено разрешение доступа к странице",
"Requested password reset": "Запрошен сброс пароля",
"Created template": "Создан шаблон",
"Deleted template": "Удалён шаблон",
"day": "день",
"days": "дни",
"week": "неделя",
@@ -875,6 +884,9 @@
"<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.": "Ошибка проверки. Ссылка могла устареть.",
@@ -1186,8 +1198,8 @@
"Default value": "Значение по умолчанию",
"Delete property": "Удалить свойство",
"Delete view": "Удалить представление",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Delete {{count}} rows?_one": "Удалить 1 строку?",
"Delete {{count}} rows?_other": "Удалить {{count}} строк?",
"Descending": "По убыванию",
"Discard": "Отменить",
"Doesn't contain": "Не содержит",
@@ -1286,8 +1298,183 @@
"Value": "Значение",
"View updated for everyone": "Представление обновлено для всех",
"You have unsaved changes. Do you want to discard them?": "У вас есть несохранённые изменения. Хотите их отменить?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
"{{count}} rows deleted_one": "1 строка удалена",
"{{count}} rows deleted_other": "{{count}} строк удалено",
"{{count}} selected_one": "1 выбрано",
"{{count}} selected_other": "{{count}} выбрано",
"Compare": "Сравнить",
"Compare versions": "Сравнить версии",
"Select version from {{date}}": "Выбрать версию от {{date}}",
"Version actions for {{date}}": "Действия с версией от {{date}}",
"Comparing {{newer}} and {{older}}": "Сравнение {{newer}} и {{older}}",
"Exit compare": "Выйти из режима сравнения",
"Search attachments...": "Поиск вложений...",
"Error loading attachments.": "Ошибка при загрузке вложений.",
"No attachments on this page yet.": "На этой странице пока нет вложений.",
"Uploaded by {{name}}": "Загружено пользователем {{name}}",
"Download {{name}}": "Скачать {{name}}",
"Access revoked": "Доступ отозван",
"Authorize application": "Авторизовать приложение",
"{{name}} wants to access {{workspace}}": "{{name}} запрашивает доступ к {{workspace}}",
"Not you? Switch account": "Не вы? Сменить аккаунт",
"This application will be able to:": "Это приложение сможет:",
"Write": "Запись",
"Invalid authorization request": "Недопустимый запрос на авторизацию",
"Authorize": "Авторизовать",
"Application": "Приложение",
"Permissions": "Разрешения",
"Authorized": "Авторизовано",
"Revoke access": "Отозвать доступ",
"Revoke access for {{name}}": "Отозвать доступ для {{name}}",
"Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.": "Вы уверены, что хотите отозвать доступ для {{name}}? Приложение больше не сможет получить доступ к вашей учетной записи.",
"Something went wrong. Please try again.": "Что-то пошло не так. Пожалуйста, попробуйте еще раз.",
"Remove {{name}}": "Удалить {{name}}",
"Make sure you trust this application before authorizing it.": "Прежде чем авторизовать это приложение, убедитесь, что вы ему доверяете.",
"You will be redirected to": "Вы будете перенаправлены на",
"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. Подключайте AI-помощников к вашей учетной записи Docmost через OAuth.",
"Authorized apps": "Авторизованные приложения",
"No authorized apps yet.": "Пока нет авторизованных приложений.",
"Workspace knowledge only": "Только знания рабочего пространства",
"Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Ограничить AI Chat ответами только на основе страниц вашего рабочего пространства и загруженных файлов. Внешние знания использоваться не будут.",
"Toggle workspace knowledge only": "Переключить режим \"Только знания рабочего пространства\"",
"Read-only mode": "Режим только для чтения",
"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"
}
@@ -294,6 +294,7 @@
"Export space": "Експорт простору",
"Export {{type}}": "Експорт {{type}}",
"File exceeds the {{limit}} attachment limit": "Файл перевищує ліміт вкладень {{limit}}",
"Media": "Медіа",
"Align left": "По лівому краю",
"Align right": "По правому краю",
"Align center": "По центру",
@@ -387,6 +388,8 @@
"Insert horizontal rule divider": "Вставити горизонтальний роздільник",
"Page break": "Розрив сторінки",
"Insert a page break for printing.": "Вставте розрив сторінки для друку.",
"Footnote": "Виноска",
"Insert a footnote reference.": "Вставити посилання на виноску.",
"Upload any image from your device.": "Завантажити будь-яке зображення з вашого пристрою.",
"Upload any video from your device.": "Завантажити будь-яке відео з вашого пристрою.",
"Upload any audio from your device.": "Завантажте будь-який аудіофайл зі свого пристрою.",
@@ -696,7 +699,7 @@
"Upgrade your plan": "Оновіть свій тарифний план",
"Available with a paid license": "Доступно за платною ліцензією",
"Upgrade your license tier.": "Оновіть рівень своєї ліцензії.",
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "ШІ доступний лише в корпоративній редакції Docmost. Зверніться до sales@docmost.com.",
"AI is available in the Docmost paid editions. Contact sales@docmost.com.": "ШІ доступний у платних редакціях Docmost. Зверніться до sales@docmost.com.",
"AI & MCP": "ШІ та MCP",
"AI": "ШІ",
"MCP": "MCP",
@@ -704,9 +707,11 @@
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Увімкніть MCP‑сервер, щоб дозволити ШІ‑помічникам та інструментам взаємодіяти з вмістом вашого робочого простору.",
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP доступний лише в корпоративній редакції Docmost. Зверніться до sales@docmost.com.",
"MCP Server URL": "URL сервера MCP",
"Use your API key for authentication. You can manage API keys in your account settings.": "Використовуйте свій API‑ключ для аутентифікації. Ви можете керувати API‑ключами в налаштуваннях облікового запису.",
"Connect AI assistants with your Docmost account via OAuth.": "Підключайте AI-асистентів до свого облікового запису Docmost через OAuth.",
"Enforce OAuth": "Зробити OAuth обов’язковим",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI-асистенти повинні підключатися до облікового запису Docmost через OAuth. Ключі API не можна використовувати з MCP-сервером.",
"Toggle enforce OAuth for MCP": "Увімкнути обов’язковий OAuth для MCP",
"Supported tools": "Підтримувані інструменти",
"Your workspace has MCP enabled. Use your API key to connect AI assistants.": "У вашому робочому просторі MCP увімкнено. Використайте свій API‑ключ, щоб підключити ШІ‑помічників.",
"MCP server URL:": "URL сервера MCP:",
"Learn more": "Дізнатися більше",
"Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "Керуйте ключами API для всіх користувачів у робочому просторі. Перегляньте <anchor>документацію API</anchor> для деталей використання.",
@@ -788,6 +793,10 @@
"Removed page restriction": "Обмеження сторінки видалено",
"Added page permission": "Додано дозвіл на сторінку",
"Removed page permission": "Дозвіл на сторінку видалено",
"Changed page permission": "Змінено дозволи сторінки",
"Requested password reset": "Запитано скидання пароля",
"Created template": "Створено шаблон",
"Deleted template": "Видалено шаблон",
"day": "день",
"days": "дні",
"week": "тиждень",
@@ -875,6 +884,9 @@
"<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.": "Підтвердження не вдалося. Посилання могло втратити чинність.",
@@ -1289,5 +1301,180 @@
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
"{{count}} selected_other": "{{count}} selected",
"Compare": "Порівняти",
"Compare versions": "Порівняти версії",
"Select version from {{date}}": "Виберіть версію від {{date}}",
"Version actions for {{date}}": "Дії з версією від {{date}}",
"Comparing {{newer}} and {{older}}": "Порівняння {{newer}} і {{older}}",
"Exit compare": "Вийти з режиму порівняння",
"Search attachments...": "Пошук вкладень...",
"Error loading attachments.": "Помилка завантаження вкладень.",
"No attachments on this page yet.": "На цій сторінці ще немає вкладень.",
"Uploaded by {{name}}": "Завантажено користувачем {{name}}",
"Download {{name}}": "Завантажити {{name}}",
"Access revoked": "Доступ відкликано",
"Authorize application": "Авторизувати застосунок",
"{{name}} wants to access {{workspace}}": "{{name}} хоче отримати доступ до {{workspace}}",
"Not you? Switch account": "Не ви? Змінити обліковий запис",
"This application will be able to:": "Цей застосунок зможе:",
"Write": "Запис",
"Invalid authorization request": "Недійсний запит на авторизацію",
"Authorize": "Авторизувати",
"Application": "Застосунок",
"Permissions": "Дозволи",
"Authorized": "Авторизовано",
"Revoke access": "Відкликати доступ",
"Revoke access for {{name}}": "Відкликати доступ для {{name}}",
"Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.": "Ви впевнені, що хочете відкликати доступ для {{name}}? Застосунок більше не зможе отримувати доступ до вашого облікового запису.",
"Something went wrong. Please try again.": "Щось пішло не так. Спробуйте ще раз.",
"Remove {{name}}": "Видалити {{name}}",
"Make sure you trust this application before authorizing it.": "Перш ніж авторизувати цей застосунок, переконайтеся, що ви йому довіряєте.",
"You will be redirected to": "Вас буде перенаправлено на",
"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. Підключайте AI-асистентів до свого облікового запису Docmost через OAuth.",
"Authorized apps": "Авторизовані застосунки",
"No authorized apps yet.": "Авторизованих застосунків ще немає.",
"Workspace knowledge only": "Лише знання робочого простору",
"Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Обмежити AI Chat відповідями лише на основі сторінок вашого робочого простору та завантажених файлів. Зовнішні знання не використовуватимуться.",
"Toggle workspace knowledge only": "Перемкнути режим лише знань робочого простору",
"Read-only mode": "Режим лише читання",
"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"
}
@@ -294,6 +294,7 @@
"Export space": "导出空间",
"Export {{type}}": "导出为 {{type}}",
"File exceeds the {{limit}} attachment limit": "文件超出了 {{limit}} 类型附件限制",
"Media": "媒体",
"Align left": "靠左对齐",
"Align right": "靠右对齐",
"Align center": "居中对齐",
@@ -387,6 +388,8 @@
"Insert horizontal rule divider": "插入水平分割线",
"Page break": "分页符",
"Insert a page break for printing.": "插入一个用于打印的分页符。",
"Footnote": "脚注",
"Insert a footnote reference.": "插入脚注引用。",
"Upload any image from your device.": "从设备上传任何图像",
"Upload any video from your device.": "从设备上传任何视频",
"Upload any audio from your device.": "从您的设备上传任意音频文件。",
@@ -696,7 +699,7 @@
"Upgrade your plan": "升级您的方案",
"Available with a paid license": "需付费许可才可用",
"Upgrade your license tier.": "升级您的许可等级。",
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI 在 Docmost 企业版中提供。请联系 sales@docmost.com。",
"AI is available in the Docmost paid editions. Contact sales@docmost.com.": "AI 在 Docmost 付费版中提供。请联系 sales@docmost.com。",
"AI & MCP": "AI 与 MCP",
"AI": "AI",
"MCP": "MCP",
@@ -704,9 +707,11 @@
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "启用 MCP 服务器以允许 AI 助手和工具与您的工作区内容交互。",
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP 仅在 Docmost 企业版中提供。请联系 sales@docmost.com。",
"MCP Server URL": "MCP 服务器 URL",
"Use your API key for authentication. You can manage API keys in your account settings.": "使用您的 API 密钥进行身份验证。您可以在账户设置中管理 API 密钥。",
"Connect AI assistants with your Docmost account via OAuth.": "通过 OAuth 将 AI 助手连接到你的 Docmost 账户。",
"Enforce OAuth": "强制使用 OAuth",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI 助手必须通过 OAuth 使用 Docmost 账户连接。MCP 服务器不能使用 API 密钥。",
"Toggle enforce OAuth for MCP": "切换 MCP 的强制使用 OAuth 设置",
"Supported tools": "支持的工具",
"Your workspace has MCP enabled. Use your API key to connect AI assistants.": "您的工作区已启用 MCP。使用您的 API 密钥连接 AI 助手。",
"MCP server URL:": "MCP 服务器 URL",
"Learn more": "了解更多",
"Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "为工作区内所有用户管理 API 密钥。有关使用详情,请查阅<anchor>API 文档</anchor>。",
@@ -788,6 +793,10 @@
"Removed page restriction": "已移除页面限制",
"Added page permission": "已添加页面权限",
"Removed page permission": "已移除页面权限",
"Changed page permission": "已更改页面权限",
"Requested password reset": "已请求重置密码",
"Created template": "已创建模板",
"Deleted template": "已删除模板",
"day": "天",
"days": "天",
"week": "周",
@@ -875,6 +884,9 @@
"<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.": "验证失败。该链接可能已过期。",
@@ -1289,5 +1301,180 @@
"{{count}} rows deleted_one": "已删除 1 行",
"{{count}} rows deleted_other": "已删除 {{count}} 行",
"{{count}} selected_one": "已选择 1 项",
"{{count}} selected_other": "已选择 {{count}} 项"
"{{count}} selected_other": "已选择 {{count}} 项",
"Compare": "比较",
"Compare versions": "比较版本",
"Select version from {{date}}": "选择 {{date}} 的版本",
"Version actions for {{date}}": "{{date}} 的版本操作",
"Comparing {{newer}} and {{older}}": "正在比较 {{newer}} 和 {{older}}",
"Exit compare": "退出比较",
"Search attachments...": "搜索附件……",
"Error loading attachments.": "加载附件时出错。",
"No attachments on this page yet.": "此页面上还没有附件。",
"Uploaded by {{name}}": "由 {{name}} 上传",
"Download {{name}}": "下载 {{name}}",
"Access revoked": "访问权限已撤销",
"Authorize application": "授权应用程序",
"{{name}} wants to access {{workspace}}": "{{name}} 想要访问 {{workspace}}",
"Not you? Switch account": "不是你?切换账户",
"This application will be able to:": "此应用程序将能够:",
"Write": "写入",
"Invalid authorization request": "无效的授权请求",
"Authorize": "授权",
"Application": "应用程序",
"Permissions": "权限",
"Authorized": "已授权",
"Revoke access": "撤销访问权限",
"Revoke access for {{name}}": "撤销 {{name}} 的访问权限",
"Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.": "你确定要撤销 {{name}} 的访问权限吗?该应用程序将无法再访问你的账户。",
"Something went wrong. Please try again.": "出了点问题。请重试。",
"Remove {{name}}": "移除 {{name}}",
"Make sure you trust this application before authorizing it.": "在授权之前,请确保你信任此应用程序。",
"You will be redirected to": "你将被重定向到",
"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 账户。",
"Authorized apps": "已授权的应用",
"No authorized apps yet.": "尚无已授权的应用。",
"Workspace knowledge only": "仅限工作区知识",
"Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "将 AI 聊天限制为仅根据你的工作区页面和已上传文件进行回答。它不会使用外部知识。",
"Toggle workspace knowledge only": "切换“仅限工作区知识”",
"Read-only mode": "只读模式",
"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 生产环境"
}
+107 -45
View File
@@ -1,60 +1,103 @@
import { lazy, Suspense, useEffect } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import SetupWorkspace from "@/pages/auth/setup-workspace.tsx";
import LoginPage from "@/pages/auth/login";
import Home from "@/pages/dashboard/home";
import Page from "@/pages/page/page";
import AccountSettings from "@/pages/settings/account/account-settings";
import WorkspaceMembers from "@/pages/settings/workspace/workspace-members";
import WorkspaceSettings from "@/pages/settings/workspace/workspace-settings";
import Groups from "@/pages/settings/group/groups";
import GroupInfo from "./pages/settings/group/group-info";
import Spaces from "@/pages/settings/space/spaces.tsx";
import { Error404 } from "@/components/ui/error-404.tsx";
import AccountPreferences from "@/pages/settings/account/account-preferences.tsx";
import SpaceHome from "@/pages/space/space-home.tsx";
import PageRedirect from "@/pages/page/page-redirect.tsx";
import Layout from "@/components/layouts/global/layout.tsx";
import InviteSignup from "@/pages/auth/invite-signup.tsx";
import ForgotPassword from "@/pages/auth/forgot-password.tsx";
import PasswordReset from "./pages/auth/password-reset";
import Billing from "@/ee/billing/pages/billing.tsx";
import CloudLogin from "@/ee/pages/cloud-login.tsx";
import CreateWorkspace from "@/ee/pages/create-workspace.tsx";
import { Error404 } from "@/components/ui/error-404.tsx";
import { isCloud } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
import Security from "@/ee/security/pages/security.tsx";
import License from "@/ee/licence/pages/license.tsx";
import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx";
import SharedPage from "@/pages/share/shared-page.tsx";
import PdfRenderPage from "@/ee/pdf-export/pdf-render-page.tsx";
import Shares from "@/pages/settings/shares/shares.tsx";
import ShareLayout from "@/features/share/components/share-layout.tsx";
import ShareRedirect from "@/pages/share/share-redirect.tsx";
import { useTrackOrigin } from "@/hooks/use-track-origin";
import SpacesPage from "@/pages/spaces/spaces.tsx";
import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page";
import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page";
import SpaceTrash from "@/pages/space/space-trash.tsx";
import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
import BasePage from "@/ee/base/pages/base-page.tsx";
import AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
import VerifiedPages from "@/ee/page-verification/pages/verified-pages.tsx";
import TemplateList from "@/ee/template/pages/template-list";
import TemplateEditor from "@/ee/template/pages/template-editor";
import FavoritesPage from "@/pages/favorites/favorites-page";
import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx";
import VerifyEmail from "@/ee/pages/verify-email.tsx";
import LabelPage from "@/pages/label/label-page";
const SetupWorkspace = lazy(() => import("@/pages/auth/setup-workspace.tsx"));
const LoginPage = lazy(() => import("@/pages/auth/login"));
const Home = lazy(() => import("@/pages/dashboard/home"));
const Page = lazy(() => import("@/pages/page/page"));
const AccountSettings = lazy(
() => import("@/pages/settings/account/account-settings"),
);
const WorkspaceMembers = lazy(
() => import("@/pages/settings/workspace/workspace-members"),
);
const WorkspaceSettings = lazy(
() => import("@/pages/settings/workspace/workspace-settings"),
);
const Groups = lazy(() => import("@/pages/settings/group/groups"));
const GroupInfo = lazy(() => import("./pages/settings/group/group-info"));
const Spaces = lazy(() => import("@/pages/settings/space/spaces.tsx"));
const AccountPreferences = lazy(
() => import("@/pages/settings/account/account-preferences.tsx"),
);
const SpaceHome = lazy(() => import("@/pages/space/space-home.tsx"));
const PageRedirect = lazy(() => import("@/pages/page/page-redirect.tsx"));
const InviteSignup = lazy(() => import("@/pages/auth/invite-signup.tsx"));
const ForgotPassword = lazy(() => import("@/pages/auth/forgot-password.tsx"));
const PasswordReset = lazy(() => import("./pages/auth/password-reset"));
const Billing = lazy(() => import("@/ee/billing/pages/billing.tsx"));
const CloudLogin = lazy(() => import("@/ee/pages/cloud-login.tsx"));
const CreateWorkspace = lazy(() => import("@/ee/pages/create-workspace.tsx"));
const Security = lazy(() => import("@/ee/security/pages/security.tsx"));
const License = lazy(() => import("@/ee/licence/pages/license.tsx"));
const SharedPage = lazy(() => import("@/pages/share/shared-page.tsx"));
const PdfRenderPage = lazy(() => import("@/ee/pdf-export/pdf-render-page.tsx"));
const Shares = lazy(() => import("@/pages/settings/shares/shares.tsx"));
const ShareLayout = lazy(
() => import("@/features/share/components/share-layout.tsx"),
);
const ShareRedirect = lazy(() => import("@/pages/share/share-redirect.tsx"));
const PublicSpacePage = lazy(
() => import("@/pages/public-space/public-space-page.tsx"),
);
const PublicSpaceLayout = lazy(
() => import("@/features/public-space/components/public-space-layout.tsx"),
);
const PublicSpaceDirectoryPage = lazy(
() => import("@/pages/public-space/public-space-directory-page.tsx"),
);
const SpacesPage = lazy(() => import("@/pages/spaces/spaces.tsx"));
const MfaChallengePage = lazy(() =>
import("@/ee/mfa/pages/mfa-challenge-page").then((m) => ({
default: m.MfaChallengePage,
})),
);
const MfaSetupRequiredPage = lazy(() =>
import("@/ee/mfa/pages/mfa-setup-required-page").then((m) => ({
default: m.MfaSetupRequiredPage,
})),
);
const SpaceTrash = lazy(() => import("@/pages/space/space-trash.tsx"));
const UserApiKeys = lazy(() => import("@/ee/api-key/pages/user-api-keys"));
const WorkspaceApiKeys = lazy(
() => import("@/ee/api-key/pages/workspace-api-keys"),
);
const AiSettings = lazy(() => import("@/ee/ai/pages/ai-settings.tsx"));
const BasePage = lazy(() => import("@/ee/base/pages/base-page.tsx"));
const AuditLogs = lazy(() => import("@/ee/audit/pages/audit-logs.tsx"));
const VerifiedPages = lazy(
() => import("@/ee/page-verification/pages/verified-pages.tsx"),
);
const TemplateList = lazy(() => import("@/ee/template/pages/template-list"));
const TemplateEditor = lazy(
() => import("@/ee/template/pages/template-editor"),
);
const FavoritesPage = lazy(() => import("@/pages/favorites/favorites-page"));
const AiChat = lazy(() => import("@/ee/ai-chat/pages/ai-chat.tsx"));
const VerifyEmail = lazy(() => import("@/ee/pages/verify-email.tsx"));
const LabelPage = lazy(() => import("@/pages/label/label-page"));
const OAuthConsent = lazy(() => import("@/ee/oauth/pages/oauth-consent.tsx"));
export default function App() {
const { t } = useTranslation();
useRedirectToCloudSelect();
useTrackOrigin();
useEffect(() => {
// warm the editor chunk so opening a page doesn't wait on the network
const timer = setTimeout(() => import("@/pages/page/page"), 3000);
return () => clearTimeout(timer);
}, []);
return (
<>
<Suspense fallback={null}>
<Routes>
<Route index element={<Navigate to="/home" />} />
<Route path={"/login"} element={<LoginPage />} />
@@ -63,6 +106,7 @@ export default function App() {
<Route path={"/password-reset"} element={<PasswordReset />} />
<Route path={"/login/mfa"} element={<MfaChallengePage />} />
<Route path={"/login/mfa/setup"} element={<MfaSetupRequiredPage />} />
<Route path={"/oauth/consent"} element={<OAuthConsent />} />
{!isCloud() && (
<Route path={"/setup/register"} element={<SetupWorkspace />} />
@@ -84,6 +128,15 @@ 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 />} />
@@ -116,6 +169,10 @@ export default function App() {
element={<AccountPreferences />}
/>
<Route path={"account/api-keys"} element={<UserApiKeys />} />
<Route
path={"account/api-keys/authorized-apps"}
element={<UserApiKeys />}
/>
<Route path={"workspace"} element={<WorkspaceSettings />} />
<Route path={"members"} element={<WorkspaceMembers />} />
<Route path={"api-keys"} element={<WorkspaceApiKeys />} />
@@ -127,6 +184,11 @@ 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 />} />}
@@ -135,6 +197,6 @@ export default function App() {
<Route path="*" element={<Error404 />} />
</Routes>
</>
</Suspense>
);
}
@@ -1,17 +1,30 @@
import { ActionIcon, Box, Group, ScrollArea, Title, Tooltip } from "@mantine/core";
import { IconX } from "@tabler/icons-react";
import CommentListWithTabs from "@/features/comment/components/comment-list-with-tabs.tsx";
import { useAtom } from "jotai";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import React, { ReactNode, useEffect } from "react";
import React, { lazy, ReactNode, Suspense, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx";
import { useAtomValue } from "jotai";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import AsideChatPanel from "@/ee/ai-chat/components/aside-chat-panel";
import { PageDetailsAside } from "@/features/page-details/components/page-details-aside.tsx";
import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx";
const CommentListWithTabs = lazy(
() => import("@/features/comment/components/comment-list-with-tabs.tsx"),
);
const TableOfContents = lazy(() =>
import(
"@/features/editor/components/table-of-contents/table-of-contents.tsx"
).then((m) => ({ default: m.TableOfContents })),
);
const AsideChatPanel = lazy(
() => import("@/ee/ai-chat/components/aside-chat-panel"),
);
const PageDetailsAside = lazy(() =>
import("@/features/page-details/components/page-details-aside.tsx").then(
(m) => ({ default: m.PageDetailsAside }),
),
);
export default function Aside() {
const [{ tab, isAsideOpen }, setAsideState] = useAtom(asideStateAtom);
const { t } = useTranslation();
@@ -68,17 +81,19 @@ export default function Aside() {
</Group>
)}
{tab === "comments" || tab === "chat" ? (
component
) : (
<ScrollArea
style={{ height: "85vh" }}
scrollbarSize={5}
type="scroll"
>
<div style={{ paddingBottom: "200px" }}>{component}</div>
</ScrollArea>
)}
<Suspense fallback={null}>
{tab === "comments" || tab === "chat" ? (
component
) : (
<ScrollArea
style={{ height: "85vh" }}
scrollbarSize={5}
type="scroll"
>
<div style={{ paddingBottom: "200px" }}>{component}</div>
</ScrollArea>
)}
</Suspense>
</>
)}
</Box>
@@ -11,7 +11,10 @@ import {
sidebarWidthAtom,
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
import AiChatSidebar from "@/ee/ai-chat/components/ai-chat-sidebar.tsx";
const AiChatSidebar = React.lazy(
() => import("@/ee/ai-chat/components/ai-chat-sidebar.tsx"),
);
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
import Aside from "@/components/layouts/global/aside.tsx";
import classes from "./app-shell.module.css";
@@ -126,7 +129,11 @@ export default function GlobalAppShell({
)}
{isSpaceRoute && <SpaceSidebar />}
{isSettingsRoute && <SettingsSidebar />}
{isAiRoute && <AiChatSidebar />}
{isAiRoute && (
<React.Suspense fallback={null}>
<AiChatSidebar />
</React.Suspense>
)}
{showGlobalSidebar && <GlobalSidebar />}
</AppShell.Navbar>
<AppShell.Main id={MAIN_CONTENT_ID} tabIndex={-1}>
@@ -118,7 +118,7 @@ const groupedData: DataGroup[] = [
role: "admin",
},
{
label: "Audit log",
label: "Audit logs & SIEM",
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 log":
case "Audit logs & SIEM":
prefetchHandler = prefetchAuditLogs;
break;
case "Verified pages":
@@ -0,0 +1,12 @@
import { UnstyledButton } from "@mantine/core";
import { type ComponentPropsWithoutRef, forwardRef } from "react";
// Menu.Item hard-codes role="menuitem"; use as its `component` to restore role="menuitemcheckbox" so aria-checked works.
export const CheckboxMenuItem = forwardRef<
HTMLButtonElement,
ComponentPropsWithoutRef<"button">
>((props, ref) => (
<UnstyledButton ref={ref} {...props} role="menuitemcheckbox" />
));
CheckboxMenuItem.displayName = "CheckboxMenuItem";
@@ -0,0 +1,74 @@
import { Badge, Group, Text, Switch, Tooltip } from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
export default function AiChatReadOnly() {
const { t } = useTranslation();
const hasAccess = useHasFeature(Feature.AI_CONTROLS);
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Group gap="xs" align="center">
<Text size="md">{t("Read-only mode")}</Text>
{!hasAccess && (
<Badge variant="light" size="sm" radius="sm">
{t("Enterprise")}
</Badge>
)}
</Group>
<Text size="sm" c="dimmed">
{t(
"AI Chat can search and read workspace content, but cannot create or edit pages.",
)}
</Text>
</div>
<AiChatReadOnlyToggle />
</Group>
);
}
function AiChatReadOnlyToggle() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(
workspace?.settings?.ai?.chatReadOnly,
);
const hasAccess = useHasFeature(Feature.AI_CONTROLS);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
const updatedWorkspace = await updateWorkspace({
aiChatReadOnly: value,
});
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err: any) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
<Switch
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle AI Chat read-only mode")}
/>
</Tooltip>
);
}
@@ -0,0 +1,74 @@
import { Badge, Group, Text, Switch, Tooltip } from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
export default function AiChatWorkspaceKnowledgeOnly() {
const { t } = useTranslation();
const hasAccess = useHasFeature(Feature.AI_CONTROLS);
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Group gap="xs" align="center">
<Text size="md">{t("Workspace knowledge only")}</Text>
{!hasAccess && (
<Badge variant="light" size="sm" radius="sm">
{t("Enterprise")}
</Badge>
)}
</Group>
<Text size="sm" c="dimmed">
{t(
"Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.",
)}
</Text>
</div>
<AiChatWorkspaceKnowledgeOnlyToggle />
</Group>
);
}
function AiChatWorkspaceKnowledgeOnlyToggle() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(
workspace?.settings?.ai?.chatWorkspaceKnowledgeOnly,
);
const hasAccess = useHasFeature(Feature.AI_CONTROLS);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
const updatedWorkspace = await updateWorkspace({
aiChatWorkspaceKnowledgeOnly: value,
});
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err: any) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
<Switch
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle workspace knowledge only")}
/>
</Tooltip>
);
}
@@ -6,7 +6,10 @@ import {
IconFileText,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import ChatInput from "./chat-input";
import { useAtomValue } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useRef } from "react";
import ChatInput, { ChatInputHandle } from "./chat-input";
import type { ChatAttachment, PageMention } from "../types/ai-chat.types";
import classes from "../styles/ai-chat.module.css";
@@ -14,6 +17,7 @@ type Suggestion = {
icon: React.ReactNode;
text: string;
prompt: string;
write?: boolean;
};
const SUGGESTIONS: Suggestion[] = [
@@ -26,6 +30,7 @@ const SUGGESTIONS: Suggestion[] = [
icon: <IconFilePlus size={16} />,
text: "Create a new page",
prompt: "Create a new page titled ",
write: true,
},
{
icon: <IconFileText size={16} />,
@@ -36,6 +41,7 @@ const SUGGESTIONS: Suggestion[] = [
icon: <IconEdit size={16} />,
text: "Update page content",
prompt: "Update the page @",
write: true,
},
];
@@ -47,9 +53,13 @@ type Props = {
export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) {
const { t } = useTranslation();
const workspace = useAtomValue(workspaceAtom);
const writesDisabled = workspace?.settings?.ai?.chatReadOnly === true;
const inputRef = useRef<ChatInputHandle>(null);
const handleSuggestionClick = (prompt: string) => {
onSend(prompt, [], []);
inputRef.current?.prefill(prompt);
};
return (
@@ -62,6 +72,7 @@ export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) {
<div className={classes.emptyStateInput}>
<ChatInput
ref={inputRef}
isStreaming={isStreaming}
onSend={onSend}
onStop={onStop}
@@ -73,7 +84,7 @@ export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) {
<div className={classes.suggestionsSection}>
<h2 className={classes.suggestionsLabel}>{t("Get started")}</h2>
<div className={classes.suggestionsGrid}>
{SUGGESTIONS.map((s) => (
{SUGGESTIONS.filter((s) => !writesDisabled || !s.write).map((s) => (
<button
key={s.text}
type="button"
@@ -1,4 +1,12 @@
import { useCallback, useId, useRef, useEffect, useState } from "react";
import {
forwardRef,
useCallback,
useId,
useImperativeHandle,
useRef,
useEffect,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { IconArrowUp, IconPaperclip, IconPlayerStopFilled, IconX, IconFile, IconPhoto, IconPlus, IconAt, IconFileText } from "@tabler/icons-react";
import { Popover } from "@mantine/core";
@@ -35,6 +43,10 @@ type Props = {
chatId?: string;
};
export type ChatInputHandle = {
prefill: (text: string) => void;
};
function extractMentions(json: any): PageMention[] {
const mentions: PageMention[] = [];
const seen = new Set<string>();
@@ -89,7 +101,7 @@ function editorJsonToText(json: any): string {
return text;
}
export default function ChatInput({
const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({
isStreaming,
onSend,
onStop,
@@ -100,7 +112,7 @@ export default function ChatInput({
variant = "card",
showDisclaimer = true,
chatId,
}: Props) {
}: Props, ref) {
const chatIdRef = useRef(chatId);
chatIdRef.current = chatId;
const { t } = useTranslation();
@@ -270,6 +282,19 @@ export default function ChatInput({
}
}, [editor]);
useImperativeHandle(
ref,
() => ({
prefill: (text: string) => {
if (!editor || editor.isDestroyed) return;
editor.commands.clearContent();
editor.commands.insertContent(text);
editor.commands.focus();
},
}),
[editor],
);
const hasContent = !isEmpty || pendingAttachments.some((a) => !a.uploading) || (contextPages?.length ?? 0) > 0;
const wrapperClass = variant === "flat" ? classes.inputWrapperFlat : classes.inputWrapper;
@@ -429,4 +454,6 @@ export default function ChatInput({
)}
</>
);
}
});
export default ChatInput;
@@ -1,5 +1,6 @@
import {
Anchor,
Badge,
Group,
List,
Text,
@@ -107,11 +108,11 @@ export default function McpSettings() {
</CopyButton>
</Group>
<Text size="sm" c="dimmed" mt="xs">
{t(
"Use your API key for authentication. You can manage API keys in your account settings.",
)}
{t("Connect AI assistants with your Docmost account via OAuth.")}
</Text>
<McpEnforceOauthSetting />
<div>
<Text size="sm" fw={500} mt="md" mb={4}>
{t("Supported tools")}
@@ -154,3 +155,56 @@ export default function McpSettings() {
</Stack>
);
}
function McpEnforceOauthSetting() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.settings?.ai?.enforceMcpOauth);
const hasAccess = useHasFeature(Feature.MCP_CONTROLS);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
const updatedWorkspace = await updateWorkspace({ enforceMcpOauth: value });
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<Group justify="space-between" wrap="nowrap" gap="xl" mt="md">
<div>
<Group gap="xs" align="center">
<Text size="sm" fw={500}>
{t("Enforce OAuth")}
</Text>
{!hasAccess && (
<Badge variant="light" size="sm" radius="sm">
{t("Enterprise")}
</Badge>
)}
</Group>
<Text size="sm" c="dimmed">
{t(
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.",
)}
</Text>
</div>
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
<Switch
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle enforce OAuth for MCP")}
/>
</Tooltip>
</Group>
);
}
+22 -2
View File
@@ -5,8 +5,10 @@ import { useTranslation } from "react-i18next";
import EnableAiSearch from "@/ee/ai/components/enable-ai-search.tsx";
import EnableGenerativeAi from "@/ee/ai/components/enable-generative-ai.tsx";
import EnableAiChat from "@/ee/ai-chat/components/enable-ai-chat.tsx";
import AiChatReadOnly from "@/ee/ai-chat/components/ai-chat-read-only.tsx";
import AiChatWorkspaceKnowledgeOnly from "@/ee/ai-chat/components/ai-chat-workspace-knowledge-only.tsx";
import McpSettings from "@/ee/ai/components/mcp-settings.tsx";
import { Alert, Stack, Tabs } from "@mantine/core";
import { Alert, Collapse, Stack, Tabs } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
@@ -14,12 +16,16 @@ import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { isCloud } from "@/lib/config.ts";
import { useLocation, useNavigate } from "react-router-dom";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
import { useAtomValue } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
export default function AiSettings() {
const { t } = useTranslation();
const { isAdmin } = useUserRole();
const hasAccess = useHasFeature(Feature.AI);
const upgradeLabel = useUpgradeLabel();
const workspace = useAtomValue(workspaceAtom);
const aiChatEnabled = workspace?.settings?.ai?.chat === true;
const location = useLocation();
const navigate = useNavigate();
@@ -61,7 +67,7 @@ export default function AiSettings() {
mb="lg"
>
{t(
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
"AI is available in the Docmost paid editions. Contact sales@docmost.com.",
)}
</Alert>
)}
@@ -70,6 +76,20 @@ export default function AiSettings() {
{!isCloud() && <EnableAiSearch />}
<EnableGenerativeAi />
<EnableAiChat />
<Collapse expanded={aiChatEnabled}>
<Stack
gap="md"
pl="md"
ml="xs"
style={{
borderLeft:
"2px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))",
}}
>
<AiChatReadOnly />
<AiChatWorkspaceKnowledgeOnly />
</Stack>
</Collapse>
</Stack>
</Tabs.Panel>
@@ -1,5 +1,5 @@
import React, { useState } from "react";
import { Anchor, Alert, Button, Group, Space, Text } from "@mantine/core";
import { Anchor, Alert, Button, Group, Space, Tabs, Text } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { Trans, useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title";
@@ -17,6 +17,8 @@ import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
import { useLocation, useNavigate } from "react-router-dom";
import { AuthorizedAppsPanel } from "@/ee/oauth/components/authorized-apps-panel.tsx";
export default function UserApiKeys() {
const { t } = useTranslation();
@@ -29,6 +31,11 @@ export default function UserApiKeys() {
const { data, isLoading } = useGetApiKeysQuery({ cursor });
const [workspace] = useAtom(workspaceAtom);
const { isAdmin } = useUserRole();
const location = useLocation();
const navigate = useNavigate();
const activeTab = location.pathname.endsWith("/authorized-apps")
? "authorized-apps"
: "api-keys";
const mcpEnabled = workspace?.settings?.ai?.mcp === true;
const restrictToAdmins = workspace?.settings?.api?.restrictToAdmins === true;
const canCreate = !restrictToAdmins || isAdmin;
@@ -47,26 +54,27 @@ export default function UserApiKeys() {
setRevokeModalOpened(true);
};
const handleTabChange = (value: string | null) => {
navigate(
value === "authorized-apps"
? "/settings/account/api-keys/authorized-apps"
: "/settings/account/api-keys",
);
};
return (
<>
<DocumentTitle title={t("API keys")} />
<DocumentTitle
title={activeTab === "authorized-apps" ? t("Authorized apps") : t("API keys")}
/>
<SettingsTitle title={t("API keys")} />
<Text size="sm" c="dimmed" mb="md">
<Trans
i18nKey="View the <anchor>API documentation</anchor> for usage details."
components={{
anchor: <Anchor href="https://docmost.com/api-docs" target="_blank" size="sm" />,
}}
/>
</Text>
{mcpEnabled && canCreate && (
{mcpEnabled && (
<Alert variant="light" color="blue" mb="md" p="sm" icon={<IconInfoCircle />}>
<Text size="sm">
{t(
"Your workspace has MCP enabled. Use your API key to connect AI assistants.",
"Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.",
)}{" "}
<Anchor
href="https://docmost.com/docs/user-guide/mcp"
@@ -85,37 +93,70 @@ export default function UserApiKeys() {
</Alert>
)}
{canCreate ? (
<Group justify="flex-end" mb="md">
<Button onClick={() => setCreateModalOpened(true)}>
{t("Create API Key")}
</Button>
</Group>
) : restrictToAdmins ? (
<Alert variant="light" color="yellow" mb="md" p="sm" icon={<IconInfoCircle />}>
<Text size="sm">
{t("API key creation is restricted to admins by your workspace administrator.")}
<Tabs color="dark" value={activeTab} onChange={handleTabChange}>
<Tabs.List>
<Tabs.Tab fw={500} value="api-keys">
{t("API keys")}
</Tabs.Tab>
<Tabs.Tab fw={500} value="authorized-apps">
{t("Authorized apps")}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="api-keys" pt="md">
<Group justify="space-between" align="center" mb="md">
<Text size="sm" c="dimmed">
<Trans
i18nKey="View the <anchor>API documentation</anchor> for usage details."
components={{
anchor: <Anchor href="https://docmost.com/api-docs" target="_blank" size="sm" />,
}}
/>
</Text>
</Alert>
) : null}
<ApiKeyTable
apiKeys={data?.items || []}
isLoading={isLoading}
onUpdate={handleUpdate}
onRevoke={handleRevoke}
/>
{canCreate && (
<Button
onClick={() => setCreateModalOpened(true)}
style={{ flexShrink: 0 }}
>
{t("Create API Key")}
</Button>
)}
</Group>
<Space h="md" />
{!canCreate && restrictToAdmins && (
<Alert variant="light" color="yellow" mb="md" p="sm" icon={<IconInfoCircle />}>
<Text size="sm">
{t("API key creation is restricted to admins by your workspace administrator.")}
</Text>
</Alert>
)}
{data?.items.length > 0 && (
<Paginate
hasPrevPage={data?.meta?.hasPrevPage}
hasNextPage={data?.meta?.hasNextPage}
onNext={() => goNext(data?.meta?.nextCursor)}
onPrev={goPrev}
<ApiKeyTable
apiKeys={data?.items || []}
isLoading={isLoading}
onUpdate={handleUpdate}
onRevoke={handleRevoke}
/>
)}
<Space h="md" />
{data?.items.length > 0 && (
<Paginate
hasPrevPage={data?.meta?.hasPrevPage}
hasNextPage={data?.meta?.hasNextPage}
onNext={() => goNext(data?.meta?.nextCursor)}
onPrev={goPrev}
/>
)}
</Tabs.Panel>
<Tabs.Panel value="authorized-apps" pt="md">
<AuthorizedAppsPanel />
</Tabs.Panel>
</Tabs>
<CreateApiKeyModal
opened={createModalOpened}
@@ -22,6 +22,7 @@ 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",
@@ -62,6 +63,7 @@ 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",
@@ -79,6 +81,13 @@ 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 {
@@ -105,6 +114,10 @@ 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" },
],
@@ -147,6 +160,10 @@ 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" },
@@ -193,4 +210,19 @@ 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" },
],
},
];
+143 -111
View File
@@ -7,10 +7,12 @@ 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";
@@ -23,6 +25,7 @@ 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";
@@ -48,6 +51,8 @@ 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);
@@ -85,6 +90,8 @@ export default function AuditLogs() {
const { data, isLoading } = useAuditLogsQuery(params);
const activeTab = location.pathname.endsWith("/siem") ? "siem" : "audit";
if (!isOwner) {
return null;
}
@@ -94,125 +101,150 @@ 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 log")} />
<DocumentTitle title={t("Audit logs & SIEM")} />
<SettingsTitle title={t("Audit log")} />
<SettingsTitle title={t("Audit logs & SIEM")} />
<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 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>
<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>
<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"
/>
<AuditLogsTable items={data?.items} isLoading={isLoading} />
<Popover
position="bottom-end"
shadow="md"
width={260}
withArrow
opened={settingsOpen}
onChange={(opened) => {
if (!opened) resetRetentionForm();
setSettingsOpen(opened);
}}
>
<Popover.Target>
<Tooltip label={t("Audit settings")}>
<ActionIcon variant="default" size="input-sm" ml="auto" onClick={() => setSettingsOpen((o) => !o)}>
<IconSettings size={16} />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
<Text fz="sm" fw={500} mb={4}>
{t("Retention")}
</Text>
<Text fz="xs" c="dimmed" mb="sm">
{t("Logs older than this period are automatically deleted.")}
</Text>
<Group gap="xs" wrap="nowrap" mb="sm">
<NumberInput
value={retentionAmount}
onChange={(val) => setRetentionAmount(val)}
min={1}
hideControls
size="sm"
w={60}
/>
<Select
data={[
{ value: "days", label: t("days") },
{ value: "months", label: t("months") },
{ value: "years", label: t("years") },
]}
value={retentionUnit}
onChange={(value) => {
if (value === "days" || value === "months" || value === "years") {
setRetentionUnit(value);
}
}}
size="sm"
style={{ flex: 1 }}
comboboxProps={{ withinPortal: false }}
/>
</Group>
<Group gap="xs" grow>
<Button
size="xs"
variant="default"
onClick={() => {
resetRetentionForm();
setSettingsOpen(false);
}}
>
{t("Cancel")}
</Button>
<Button
size="xs"
onClick={() => {
const num = typeof retentionAmount === "number" ? retentionAmount : 1;
const clamped = Math.max(1, num);
setRetentionAmount(clamped);
const days = retentionToDays(clamped, retentionUnit);
if (days !== currentDays) {
updateRetention.mutate({ auditRetentionDays: days });
}
setSettingsOpen(false);
}}
loading={updateRetention.isPending}
>
{t("Save")}
</Button>
</Group>
</Popover.Dropdown>
</Popover>
</Group>
<Space h="md" />
<AuditLogsTable items={data?.items} isLoading={isLoading} />
{data?.items && data.items.length > 0 && (
<Paginate
hasPrevPage={data?.meta?.hasPrevPage}
hasNextPage={data?.meta?.hasNextPage}
onNext={() => goNext(data?.meta?.nextCursor)}
onPrev={goPrev}
/>
)}
<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>
</>
);
}
@@ -18,6 +18,7 @@ export type FieldProps = {
rowId: string;
readOnly: boolean;
onChange: (value: unknown) => void;
onEditingChange?: (editing: boolean) => void;
};
type FieldShellProps = {
@@ -99,9 +100,10 @@ type DetailFieldProps = {
row: IBaseRow;
readOnly: boolean;
onUpdate: (propertyId: string, value: unknown) => void;
onEditingChange: (editing: boolean) => void;
};
export function DetailField({ property, row, readOnly, onUpdate }: DetailFieldProps) {
export function DetailField({ property, row, readOnly, onUpdate, onEditingChange }: DetailFieldProps) {
const descriptor = getDescriptor(property.type);
const value = descriptor?.systemAccessor
? descriptor.systemAccessor(row)
@@ -112,6 +114,7 @@ export function DetailField({ property, row, readOnly, onUpdate }: DetailFieldPr
rowId: row.id,
readOnly,
onChange: (next: unknown) => onUpdate(property.id, next),
onEditingChange
};
switch (property.type) {
@@ -9,7 +9,13 @@ const normalize = (s: string) => {
return trimmed.length ? trimmed : null;
};
export function FieldLongText({ property, value, readOnly, onChange }: FieldProps) {
export function FieldLongText({
property,
value,
readOnly,
onChange,
onEditingChange,
}: FieldProps) {
const text = toText(value);
const [draft, setDraft] = useState(text);
const [focused, setFocused] = useState(false);
@@ -23,6 +29,7 @@ export function FieldLongText({ property, value, readOnly, onChange }: FieldProp
const commit = () => {
setFocused(false);
onEditingChange?.(false);
if (cancelRef.current) {
cancelRef.current = false;
setDraft(text);
@@ -50,7 +57,10 @@ export function FieldLongText({ property, value, readOnly, onChange }: FieldProp
className={classes.fieldTextarea}
classNames={{ input: classes.fieldTextareaInput }}
value={draft}
onFocus={() => setFocused(true)}
onFocus={() => {
setFocused(true);
onEditingChange?.(true);
}}
onChange={(e) => setDraft(e.currentTarget.value)}
onBlur={commit}
onKeyDown={(e) => {
@@ -11,7 +11,13 @@ 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 }: FieldProps) {
export function FieldNumber({
property,
value,
readOnly,
onChange,
onEditingChange,
}: FieldProps) {
const typeOptions = property.typeOptions as NumberTypeOptions | undefined;
const numValue = typeof value === "number" ? value : null;
const [draft, setDraft] = useState(toDraft(value));
@@ -36,6 +42,7 @@ export function FieldNumber({ property, value, readOnly, onChange }: FieldProps)
const commit = () => {
setFocused(false);
onEditingChange?.(false);
if (cancelRef.current) {
cancelRef.current = false;
setDraft(toDraft(value));
@@ -54,6 +61,7 @@ export function FieldNumber({ property, value, readOnly, onChange }: FieldProps)
onFocus={() => {
setDraft(toDraft(value));
setFocused(true);
onEditingChange?.(true);
}}
onChange={(e) => {
const v = e.target.value;
@@ -5,7 +5,13 @@ 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 }: FieldProps) {
export function FieldText({
property,
value,
readOnly,
onChange,
onEditingChange,
}: FieldProps) {
const text = toText(value);
const [draft, setDraft] = useState(text);
const [focused, setFocused] = useState(false);
@@ -20,6 +26,7 @@ export function FieldText({ property, value, readOnly, onChange }: FieldProps) {
const commit = () => {
setFocused(false);
onEditingChange?.(false);
if (cancelRef.current) {
cancelRef.current = false;
setDraft(text);
@@ -54,7 +61,10 @@ export function FieldText({ property, value, readOnly, onChange }: FieldProps) {
className={classes.fieldInput}
value={draft}
maxLength={1000}
onFocus={() => setFocused(true)}
onFocus={() => {
setFocused(true);
onEditingChange?.(true);
}}
onChange={(e) => setDraft(e.currentTarget.value)}
onBlur={commit}
onKeyDown={(e) => {
@@ -17,6 +17,7 @@ type PropertyRowProps = {
onMenuOpenChange: (opened: boolean) => void;
onMenuDirtyChange: (dirty: boolean) => void;
onUpdate: (propertyId: string, value: unknown) => void;
onEditingChange?: (editing: boolean) => void;
autoFocusValue?: boolean;
onAutoFocused?: () => void;
};
@@ -29,6 +30,7 @@ export function PropertyRow({
onMenuOpenChange,
onMenuDirtyChange,
onUpdate,
onEditingChange,
autoFocusValue,
onAutoFocused,
}: PropertyRowProps) {
@@ -112,6 +114,7 @@ export function PropertyRow({
row={row}
readOnly={!canEdit}
onUpdate={onUpdate}
onEditingChange={onEditingChange}
/>
</div>
);
@@ -75,6 +75,7 @@ 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
@@ -90,6 +91,7 @@ export function RowDetailModal({
useEffect(() => {
setOpenMenuId(null);
menuDirtyRef.current = false;
setEditingField(false);
}, [openRowId]);
const handleMenuDirtyChange = useCallback((dirty: boolean) => {
@@ -293,7 +295,7 @@ export function RowDetailModal({
row={row}
primaryProperty={primaryProperty}
canEdit={canEdit}
onClose={onClose}
onEditingChange={setEditingField}
onCommit={(value) => {
if (!primaryProperty) return;
updateRowMutation.mutate({
@@ -317,6 +319,7 @@ export function RowDetailModal({
autoFocusValue={property.id === newPropertyId}
onAutoFocused={clearNewProperty}
menuOpened={openMenuId === property.id}
onEditingChange={setEditingField}
onMenuOpenChange={(nextOpened) =>
handleMenuOpenChange(property.id, nextOpened)
}
@@ -367,16 +370,38 @@ export function RowDetailModal({
) : null}
</div>
<div className={classes.kbdHint}>
{rowIndex >= 0 && rows.length > 1 && (
{editingField ? (
<>
<kbd className={classes.kbd}></kbd>
<kbd className={classes.kbd}></kbd>
<span>{t("to navigate")}</span>
<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>
<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, useState } from "react";
import { useEffect, useRef, 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;
onClose: () => void;
onEditingChange?: (editing: boolean) => void;
};
export function RowDetailTitle({
@@ -17,13 +17,24 @@ export function RowDetailTitle({
primaryProperty,
canEdit,
onCommit,
onClose,
onEditingChange,
}: 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(() => {
@@ -43,18 +54,18 @@ export function RowDetailTitle({
aria-label={primaryProperty?.name ?? t("Untitled")}
value={value}
maxLength={1000}
onChange={(e) => setValue(e.currentTarget.value)}
onBlur={() => {
if (value !== initial) onCommit(value);
onFocus={() => {
onEditingChange?.(true);
}}
onChange={(e) => setValue(e.currentTarget.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (e.key === "Escape") {
cancelRef.current = true;
e.currentTarget.blur();
} else if (e.key === "Enter") {
e.preventDefault();
(e.currentTarget as HTMLInputElement).blur();
} else if (e.key === "Escape") {
e.preventDefault();
(e.currentTarget as HTMLInputElement).blur();
onClose();
e.currentTarget.blur();
}
}}
/>
@@ -9,6 +9,7 @@ import {
Text,
UnstyledButton,
Button,
MultiSelect,
} from "@mantine/core";
import { IconPlus, IconTrash } from "@tabler/icons-react";
import {
@@ -52,6 +53,9 @@ const NO_VALUE_OPERATORS: FilterOperator[] = ["isEmpty", "isNotEmpty"];
// stored value so a stale shape isn't sent to the engine.
function valueClass(op: FilterOperator, inputKind: string): string {
if (NO_VALUE_OPERATORS.includes(op)) return "none";
if (inputKind === "choices") {
return op === "any" || op === "none" ? "choicesMulti" : "choicesSingle";
}
if (inputKind === "person") {
return op === "any" || op === "none" ? "personMulti" : "personSingle";
}
@@ -70,6 +74,10 @@ function getOperatorsForType(type: string): FilterOperator[] {
DEFAULT_FILTER_OPERATORS) as FilterOperator[];
}
function isMultiChoice(op: FilterCondition["op"]): boolean {
return op === "any" || op === "none";
}
function FilterValueInput({
condition,
property,
@@ -121,6 +129,32 @@ function FilterValueInput({
const typeOptions = property.typeOptions as SelectTypeOptions | undefined;
const choices = typeOptions?.choices ?? [];
const choiceOptions = choices.map((c) => ({ value: c.id, label: c.name }));
if (isMultiChoice(condition.op)) {
const { value } = condition;
const selected = (
Array.isArray(value) ? value : value ? [value] : []
).filter((id) => choices.some((c) => c.id === id));
return (
<MultiSelect
size="xs"
data={choiceOptions}
comboboxProps={{ withinPortal: false }}
value={selected}
onChange={(values) => onChange(values)}
w={160}
styles={{
pillsList: {
maxHeight: 70,
overflowY: "auto",
},
}}
maxDropdownHeight={220}
/>
);
}
return (
<Select
size="xs"
@@ -199,11 +233,18 @@ export function ViewFilterConfigPopover({
label: p.name,
}));
const [unSaved, setUnSaved] = useState(false)
const [draft, setDraft] = useState<FilterCondition | null>(null);
const [draftConditions, setDraftConditions] =
useState<FilterCondition[]>(conditions);
useEffect(() => {
if (!opened) setDraft(null);
}, [opened]);
if (opened) {
setDraftConditions(conditions);
setDraft(null);
setUnSaved(false)
}
}, [opened, conditions]);
const handleStartDraft = useCallback(() => {
const firstProperty = properties[0];
@@ -216,14 +257,21 @@ export function ViewFilterConfigPopover({
}, [properties]);
const handleSaveDraft = useCallback(() => {
if (!draft) return;
onChange([...conditions, draft]);
const nextConditions = draft
? [...draftConditions, draft]
: draftConditions;
onChange(nextConditions);
setDraft(null);
}, [draft, conditions, onChange]);
setUnSaved(false)
}, [draft, draftConditions, onChange]);
const handleCancelDraft = useCallback(() => {
setDraftConditions(conditions)
setDraft(null);
}, []);
setUnSaved(false)
}, [conditions]);
const handleDraftPropertyChange = useCallback(
(propertyId: string | null) => {
@@ -272,17 +320,19 @@ export function ViewFilterConfigPopover({
const handleRemove = useCallback(
(index: number) => {
onChange(conditions.filter((_, i) => i !== index));
setUnSaved(true);
setDraftConditions((current) => current.filter((_, i) => i !== index));
},
[conditions, onChange],
[],
);
const handlePropertyChange = useCallback(
(index: number, propertyId: string | null) => {
if (!propertyId) return;
const newProperty = properties.find((p) => p.id === propertyId);
onChange(
conditions.map((f, i) => {
setUnSaved(true)
setDraftConditions((current) =>
current.map((f, i) => {
if (i !== index) return f;
if (newProperty) {
const validOperators = getOperatorsForType(newProperty.type);
@@ -302,15 +352,16 @@ export function ViewFilterConfigPopover({
}),
);
},
[conditions, properties, onChange],
[properties],
);
const handleOperatorChange = useCallback(
(index: number, operator: string | null) => {
if (!operator) return;
const op = operator as FilterOperator;
onChange(
conditions.map((f, i) => {
setUnSaved(true)
setDraftConditions((current) =>
current.map((f, i) => {
if (i !== index) return f;
const kind = inputKindForProperty(
properties.find((p) => p.id === f.propertyId),
@@ -320,16 +371,17 @@ export function ViewFilterConfigPopover({
}),
);
},
[conditions, properties, onChange],
[properties],
);
const handleValueChange = useCallback(
(index: number, value: unknown) => {
onChange(
conditions.map((f, i) => (i === index ? { ...f, value } : f)),
setUnSaved(true)
setDraftConditions((current) =>
current.map((f, i) => (i === index ? { ...f, value } : f)),
);
},
[conditions, onChange],
[],
);
return (
@@ -362,13 +414,13 @@ export function ViewFilterConfigPopover({
{t("Filter by")}
</Text>
{conditions.length === 0 && !draft && (
{draftConditions.length === 0 && !draft && (
<Text size="xs" c="dimmed">
{t("No filters applied")}
</Text>
)}
{conditions.map((condition, index) => {
{draftConditions.map((condition, index) => {
const needsValue = !NO_VALUE_OPERATORS.includes(condition.op);
const property = properties.find(
(p) => p.id === condition.propertyId,
@@ -471,14 +523,6 @@ export function ViewFilterConfigPopover({
/>
)}
</Group>
<Group justify="flex-end" gap="xs">
<Button variant="default" size="xs" onClick={handleCancelDraft}>
{t("Cancel")}
</Button>
<Button size="xs" onClick={handleSaveDraft}>
{t("Save")}
</Button>
</Group>
</Stack>
);
})()}
@@ -492,6 +536,20 @@ export function ViewFilterConfigPopover({
{t("Add filter")}
</UnstyledButton>
)}
<Group justify="flex-end" gap="xs">
<Button
variant="default"
size="xs"
onClick={handleCancelDraft}
disabled={!draft && !unSaved}
>
{t("Cancel")}
</Button>
<Button size="xs" onClick={handleSaveDraft} disabled={!draft && !unSaved}>
{t("Save")}
</Button>
</Group>
</Stack>
</Popover.Dropdown>
</Popover>
@@ -416,9 +416,25 @@
}
.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 {
+5
View File
@@ -22,4 +22,9 @@ export const Feature = {
PERSONAL_SPACES: 'spaces:personal',
DOCX_EXPORT: 'export:docx',
BASES: 'bases',
OAUTH: 'oauth',
AI_CONTROLS: 'ai:controls',
MCP_CONTROLS: 'mcp:controls',
PUBLIC_SPACE_APPEARANCE: 'public-space:appearance',
SIEM: 'siem',
} as const;
@@ -0,0 +1,19 @@
import { Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { OAuthGrantsTable } from "@/ee/oauth/components/oauth-grants-table";
import { useOAuthGrantsQuery } from "@/ee/oauth/queries/oauth-query";
export function AuthorizedAppsPanel() {
const { t } = useTranslation();
const { data, isLoading } = useOAuthGrantsQuery();
return (
<>
<Text size="sm" c="dimmed" mb="md">
{t("Applications and AI assistants you have authorized to access your account.")}
</Text>
<OAuthGrantsTable grants={data || []} isLoading={isLoading} />
</>
);
}
@@ -0,0 +1,178 @@
import { ActionIcon, Badge, Group, Skeleton, Table, Text, Tooltip } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import { modals } from "@mantine/modals";
import { useTranslation } from "react-i18next";
import NoTableResults from "@/components/common/no-table-results";
import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts";
import { IOAuthGrant } from "@/ee/oauth/types/oauth.types";
import { useRevokeOAuthGrantMutation } from "@/ee/oauth/queries/oauth-query";
// Callback hosts identify the app; the full URL belongs on the consent screen.
function callbackHosts(redirectUris: string[] = []): string[] {
const hosts = redirectUris.map((uri) => {
try {
const url = new URL(uri);
return url.protocol === "http:" || url.protocol === "https:"
? url.host
: url.protocol;
} catch {
return uri;
}
});
return Array.from(new Set(hosts));
}
type OAuthGrantsTableProps = {
grants: IOAuthGrant[];
isLoading?: boolean;
};
function TableSkeleton() {
return (
<>
{Array.from({ length: 3 }).map((_, i) => (
<Table.Tr key={i}>
<Table.Td>
<Skeleton height={14} width={140} />
</Table.Td>
<Table.Td>
<Group gap={4}>
<Skeleton height={20} width={50} />
<Skeleton height={20} width={55} />
</Group>
</Table.Td>
<Table.Td>
<Skeleton height={14} width={90} />
</Table.Td>
<Table.Td>
<Skeleton height={14} width={90} />
</Table.Td>
<Table.Td>
<Skeleton height={28} width={28} circle />
</Table.Td>
</Table.Tr>
))}
</>
);
}
export function OAuthGrantsTable({ grants, isLoading }: OAuthGrantsTableProps) {
const { t } = useTranslation();
const locale = useDateFnsLocale();
const revokeMutation = useRevokeOAuthGrantMutation();
const scopeMeta: Record<string, { color: string; label: string }> = {
read: { color: "blue", label: t("Read") },
write: { color: "orange", label: t("Write") },
};
const formatDate = (date: string | null) => {
if (!date) return t("Never");
return formatLocalized(date, "MMM dd, yyyy", "PP", locale);
};
const openRevokeModal = (grant: IOAuthGrant) =>
modals.openConfirmModal({
title: t("Revoke access"),
centered: true,
children: (
<Text size="sm">
{t(
"Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.",
{ name: grant.clientName },
)}
</Text>
),
labels: { confirm: t("Revoke access"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => revokeMutation.mutate(grant.id),
});
return (
<Table.ScrollContainer minWidth={600}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Application")}</Table.Th>
<Table.Th>{t("Permissions")}</Table.Th>
<Table.Th>{t("Authorized")}</Table.Th>
<Table.Th>{t("Last used")}</Table.Th>
<Table.Th aria-label={t("Action")} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{isLoading ? (
<TableSkeleton />
) : grants.length > 0 ? (
grants.map((grant) => (
<Table.Tr key={grant.id}>
<Table.Td>
<Text fz="sm" fw={500}>
{grant.clientName}
</Text>
<Text
fz="xs"
c="dimmed"
title={grant.redirectUris?.join("\n")}
style={{ overflowWrap: "anywhere" }}
>
{callbackHosts(grant.redirectUris).join(", ")}
</Text>
</Table.Td>
<Table.Td>
<Group gap={4}>
{grant.scopes.map((scope) => {
const meta = scopeMeta[scope];
if (!meta) return null;
return (
<Badge
key={scope}
variant="light"
color={meta.color}
size="sm"
>
{meta.label}
</Badge>
);
})}
</Group>
</Table.Td>
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatDate(grant.createdAt)}
</Text>
</Table.Td>
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatDate(grant.lastUsedAt)}
</Text>
</Table.Td>
<Table.Td>
<Tooltip label={t("Revoke access")}>
<ActionIcon
variant="subtle"
color="red"
aria-label={t("Revoke access for {{name}}", {
name: grant.clientName,
})}
onClick={() => openRevokeModal(grant)}
>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
</Table.Td>
</Table.Tr>
))
) : (
<NoTableResults colSpan={5} text={t("No authorized apps yet.")} />
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}
@@ -0,0 +1,333 @@
import React, { useEffect, useMemo, useState } from "react";
import {
Alert,
Anchor,
Box,
Button,
Center,
Checkbox,
Container,
Divider,
Group,
Loader,
Paper,
Stack,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
import { notifications } from "@mantine/notifications";
import {
IconAlertTriangle,
IconEye,
IconPencil,
} from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { RESET } from "jotai/utils";
import { useTranslation } from "react-i18next";
import { useLocation, useSearchParams } from "react-router-dom";
import { AuthLayout } from "@/features/auth/components/auth-layout.tsx";
import classes from "@/features/auth/components/auth.module.css";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
import { UserInfo } from "@/components/common/user-info.tsx";
import useCurrentUser from "@/features/user/hooks/use-current-user";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { logout } from "@/features/auth/services/auth-service";
import { ICurrentUser } from "@/features/user/types/user.types";
import APP_ROUTE from "@/lib/app-route.ts";
import {
approveOAuthAuthorization,
getOAuthAuthorizeInfo,
} from "@/ee/oauth/services/oauth-service";
import {
IApproveAuthorizationPayload,
IAuthorizeParams,
IOAuthAuthorizeInfo,
} from "@/ee/oauth/types/oauth.types";
function loginRedirectUrl(pathname: string, search: string): string {
return `${APP_ROUTE.AUTH.LOGIN}?redirect=${encodeURIComponent(pathname + search)}`;
}
function errorStatus(error: any): number | undefined {
return error?.response?.status;
}
function errorText(error: any): string | undefined {
const data = error?.response?.data;
if (typeof data?.error_description === "string") return data.error_description;
if (Array.isArray(data?.message)) return data.message.join(", ");
return data?.message;
}
const OAUTH_PARAM_NAMES = [
"response_type",
"client_id",
"redirect_uri",
"state",
"code_challenge",
"code_challenge_method",
"scope",
"resource",
];
function pickOAuthParams(params: IAuthorizeParams): IAuthorizeParams {
const picked: IAuthorizeParams = {};
for (const name of OAUTH_PARAM_NAMES) {
if (params[name] !== undefined) {
picked[name] = params[name];
}
}
return picked;
}
export default function OAuthConsent() {
const { t } = useTranslation();
const location = useLocation();
const [searchParams] = useSearchParams();
const params = useMemo<IAuthorizeParams>(
() => Object.fromEntries(searchParams.entries()),
[searchParams],
);
const currentUserQuery = useCurrentUser();
const infoQuery = useQuery({
queryKey: ["oauth-authorize-info", params],
queryFn: () => getOAuthAuthorizeInfo(params),
});
const isUnauthenticated =
errorStatus(currentUserQuery.error) === 401 ||
errorStatus(infoQuery.error) === 401;
useEffect(() => {
if (isUnauthenticated) {
window.location.replace(
loginRedirectUrl(location.pathname, location.search),
);
}
}, [isUnauthenticated, location.pathname, location.search]);
const isLoading =
isUnauthenticated || currentUserQuery.isLoading || infoQuery.isLoading;
return (
<AuthLayout>
<DocumentTitle title={t("Authorize application")} />
<Container size={460} className={classes.container}>
<Box p="xl">
{isLoading ? (
<Center mih={200}>
<Loader />
</Center>
) : infoQuery.data && currentUserQuery.data ? (
<ConsentCard
info={infoQuery.data}
currentUser={currentUserQuery.data}
params={params}
/>
) : (
<InvalidRequestCard
description={errorText(infoQuery.error ?? currentUserQuery.error)}
/>
)}
</Box>
</Container>
</AuthLayout>
);
}
function InvalidRequestCard({ description }: { description?: string }) {
const { t } = useTranslation();
return (
<Stack align="center" gap="sm">
<ThemeIcon size={48} radius="xl" variant="light" color="red">
<IconAlertTriangle size={26} stroke={1.5} />
</ThemeIcon>
<Title order={3} ta="center" fw={600}>
{t("Invalid authorization request")}
</Title>
{description && (
<Text size="sm" c="dimmed" ta="center">
{description}
</Text>
)}
</Stack>
);
}
type ConsentCardProps = {
info: IOAuthAuthorizeInfo;
currentUser: ICurrentUser;
params: IAuthorizeParams;
};
function ConsentCard({ info, currentUser, params }: ConsentCardProps) {
const { t } = useTranslation();
const location = useLocation();
const [, setCurrentUser] = useAtom(currentUserAtom);
const [approvedScopes, setApprovedScopes] = useState<string[]>(
info.scopes.filter((scope) => scope === "read" || scope === "write"),
);
const [submitting, setSubmitting] = useState<"approve" | "deny" | null>(null);
const scopeRows = [
{
scope: "read",
icon: <IconEye size={16} stroke={1.5} />,
label: t("Read"),
description: t("View data in your workspace without making changes."),
},
{
scope: "write",
icon: <IconPencil size={16} stroke={1.5} />,
label: t("Write"),
description: t("Create and modify data in your workspace."),
},
];
function toggleScope(scope: string, checked: boolean) {
setApprovedScopes((prev) =>
checked ? [...prev, scope] : prev.filter((item) => item !== scope),
);
}
async function submitDecision(approved: boolean) {
setSubmitting(approved ? "approve" : "deny");
const oauthParams = pickOAuthParams(params);
const payload: IApproveAuthorizationPayload = approved
? { ...oauthParams, approved: true, approvedScopes }
: { ...oauthParams, approved: false };
try {
const res = await approveOAuthAuthorization(payload);
window.location.replace(res.redirectUrl);
} catch (err) {
if (errorStatus(err) === 401) {
window.location.replace(
loginRedirectUrl(location.pathname, location.search),
);
return;
}
setSubmitting(null);
notifications.show({
message: errorText(err) || t("Something went wrong. Please try again."),
color: "red",
});
}
}
async function switchAccount() {
if (submitting !== null) {
return;
}
setCurrentUser(RESET);
try {
await logout();
} finally {
window.location.replace(
loginRedirectUrl(location.pathname, location.search),
);
}
}
return (
<Stack gap="lg">
<Title order={3} ta="center" fw={600}>
{t("{{name}} wants to access {{workspace}}", {
name: info.clientName,
workspace: currentUser.workspace.name,
})}
</Title>
<Paper withBorder radius="md" p="sm">
<Group justify="space-between" wrap="nowrap">
<UserInfo user={currentUser.user} />
<Anchor
component="button"
type="button"
size="xs"
c="dimmed"
disabled={submitting !== null}
onClick={switchAccount}
style={{ whiteSpace: "nowrap" }}
>
{t("Not you? Switch account")}
</Anchor>
</Group>
</Paper>
<Divider />
<Stack gap="sm">
<Text size="sm" fw={500}>
{t("This application will be able to:")}
</Text>
{scopeRows.map((row) => {
const requested = info.scopes.includes(row.scope);
return (
<Checkbox
key={row.scope}
size="sm"
checked={approvedScopes.includes(row.scope)}
disabled={!requested || submitting !== null}
onChange={(event) =>
toggleScope(row.scope, event.currentTarget.checked)
}
label={
<Group gap={6} wrap="nowrap">
{row.icon}
<Text size="sm" fw={500}>
{row.label}
</Text>
</Group>
}
description={row.description}
/>
);
})}
</Stack>
<div>
<Text size="xs" c="dimmed">
{t("You will be redirected to")}
</Text>
<Text size="xs" ff="monospace" style={{ overflowWrap: "anywhere" }}>
{info.redirectUri}
</Text>
</div>
{!info.verified && (
<Alert
variant="light"
color="yellow"
py="xs"
icon={<IconAlertTriangle size={16} />}
>
{t("Make sure you trust this application before authorizing it.")}
</Alert>
)}
<Group grow>
<Button
variant="default"
onClick={() => submitDecision(false)}
loading={submitting === "deny"}
disabled={submitting === "approve"}
>
{t("Cancel")}
</Button>
<Button
onClick={() => submitDecision(true)}
loading={submitting === "approve"}
disabled={approvedScopes.length === 0 || submitting === "deny"}
>
{t("Authorize")}
</Button>
</Group>
</Stack>
);
}
@@ -0,0 +1,45 @@
import {
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import {
getOAuthGrants,
revokeOAuthGrant,
} from "@/ee/oauth/services/oauth-service";
import { IOAuthGrant } from "@/ee/oauth/types/oauth.types";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
export function useOAuthGrantsQuery(): UseQueryResult<IOAuthGrant[], Error> {
return useQuery({
queryKey: ["oauth-grants"],
queryFn: () => getOAuthGrants(),
staleTime: 0,
gcTime: 0,
});
}
export function useRevokeOAuthGrantMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, string>({
mutationFn: (grantId) => revokeOAuthGrant(grantId),
onSuccess: () => {
notifications.show({ message: t("Access revoked") });
queryClient.invalidateQueries({
predicate: (item) =>
["oauth-grants"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Something went wrong. Please try again."),
color: "red",
});
},
});
}
@@ -0,0 +1,36 @@
import api from "@/lib/api-client";
import {
IApproveAuthorizationPayload,
IAuthorizeParams,
IOAuthAuthorizeInfo,
IOAuthGrant,
} from "@/ee/oauth/types/oauth.types";
export async function getOAuthAuthorizeInfo(
params: IAuthorizeParams,
): Promise<IOAuthAuthorizeInfo> {
const req = await api.post<IOAuthAuthorizeInfo>(
"/oauth/authorize-info",
params,
);
return req.data;
}
export async function approveOAuthAuthorization(
payload: IApproveAuthorizationPayload,
): Promise<{ redirectUrl: string }> {
const req = await api.post<{ redirectUrl: string }>(
"/oauth/authorize",
payload,
);
return req.data;
}
export async function getOAuthGrants(): Promise<IOAuthGrant[]> {
const req = await api.post<IOAuthGrant[]>("/oauth/grants", {});
return req.data;
}
export async function revokeOAuthGrant(grantId: string): Promise<void> {
await api.post("/oauth/grants/revoke", { grantId });
}
@@ -0,0 +1,24 @@
export interface IOAuthAuthorizeInfo {
clientName: string;
redirectUri: string;
scopes: string[];
clientCreatedAt: string;
verified: boolean;
}
export interface IOAuthGrant {
id: string;
clientName: string;
redirectUris: string[];
scopes: string[];
createdAt: string;
lastUsedAt: string | null;
}
export type IAuthorizeParams = Record<string, string>;
export type IApproveAuthorizationPayload = {
[param: string]: unknown;
approved: boolean;
approvedScopes?: string[];
};
@@ -82,6 +82,7 @@ export default function SpacePublicSharingToggle({
checked={checked}
onChange={handleChange}
disabled={isDisabled}
size={"xs"}
aria-label={t("Toggle space public sharing")}
/>
</Tooltip>
@@ -53,6 +53,7 @@ export default function SpaceViewerCommentsToggle({
checked={checked}
onChange={handleChange}
disabled={isDisabled}
size={"xs"}
aria-label={t("Toggle viewer comments")}
/>
</Tooltip>
@@ -0,0 +1,44 @@
import { Button, Group, Modal, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ISiemDestination } from "@/ee/siem/types/siem.types";
import { useDeleteSiemDestinationMutation } from "@/ee/siem/queries/siem-query";
interface DeleteDestinationModalProps {
opened: boolean;
onClose: () => void;
destination: ISiemDestination | null;
}
export function DeleteDestinationModal({ opened, onClose, destination }: DeleteDestinationModalProps) {
const { t } = useTranslation();
const deleteMutation = useDeleteSiemDestinationMutation();
const handleDelete = async () => {
if (!destination) return;
await deleteMutation.mutateAsync({ destinationId: destination.id });
onClose();
};
return (
<Modal
opened={opened}
onClose={onClose}
title={t("Delete destination")}
size="md"
closeButtonProps={{ "aria-label": t("Close") }}
>
<Stack gap="md">
<Text>
{t("Are you sure you want to delete the destination")}{" "}
<strong>{destination?.name}</strong>?
</Text>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>{t("Cancel")}</Button>
<Button color="red" onClick={handleDelete} loading={deleteMutation.isPending}>
{t("Delete")}
</Button>
</Group>
</Stack>
</Modal>
);
}
@@ -0,0 +1,300 @@
import { useEffect, useState } from "react";
import {
Alert,
Button,
Collapse,
Group,
Modal,
PasswordInput,
Select,
Stack,
Switch,
Text,
TextInput,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import { IconAlertCircle, IconCheck } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { isCloud } from "@/lib/config.ts";
import { DATADOG_SITES, ISiemDestination, ISiemTestResult } from "@/ee/siem/types/siem.types";
import {
useCreateSiemDestinationMutation,
useTestSiemDestinationMutation,
useUpdateSiemDestinationMutation,
} from "@/ee/siem/queries/siem-query";
import {
DestinationFormValues,
initialValues,
toPayload,
validateForm,
} from "@/ee/siem/lib/destination-form";
import { DESTINATION_TYPE_LABELS } from "./destination-table";
interface DestinationFormModalProps {
opened: boolean;
onClose: () => void;
destination?: ISiemDestination | null;
}
function connectionKey(values: DestinationFormValues): string {
const { type, config, secrets } = toPayload(values);
return JSON.stringify({ type, config, secrets });
}
export function DestinationFormModal({ opened, onClose, destination }: DestinationFormModalProps) {
const { t } = useTranslation();
const isEdit = Boolean(destination);
const hasSecrets = destination?.hasSecrets ?? {};
const [advancedOpen, setAdvancedOpen] = useState(false);
const [testState, setTestState] = useState<{ result: ISiemTestResult | null; testedPayloadKey: string | null }>({
result: null,
testedPayloadKey: null,
});
const createMutation = useCreateSiemDestinationMutation();
const updateMutation = useUpdateSiemDestinationMutation();
const testMutation = useTestSiemDestinationMutation();
const form = useForm<DestinationFormValues>({
initialValues: initialValues(destination),
validate: (values) => validateForm(values, hasSecrets),
});
useEffect(() => {
if (opened) {
form.setValues(initialValues(destination));
form.resetDirty();
// eslint-disable-next-line react-hooks/set-state-in-effect
setTestState({ result: null, testedPayloadKey: null });
setAdvancedOpen(false);
}
}, [opened, destination?.id]);
const handleSubmit = async (values: DestinationFormValues) => {
const payload = toPayload(values);
try {
if (destination) {
await updateMutation.mutateAsync({
destinationId: destination.id,
name: payload.name,
config: payload.config,
secrets: payload.secrets,
enabled: payload.enabled,
});
} else {
await createMutation.mutateAsync(payload);
}
onClose();
} catch {}
};
const handleTest = async () => {
if (form.validate().hasErrors) return;
const payload = toPayload(form.values);
const testedPayloadKey = connectionKey(form.values);
setTestState((prev) => ({ ...prev, testedPayloadKey }));
try {
const result = await testMutation.mutateAsync({
type: payload.type,
config: payload.config,
secrets: payload.secrets,
destinationId: destination?.id,
});
setTestState({ result, testedPayloadKey });
} catch {
setTestState({ result: null, testedPayloadKey });
}
};
const type = form.values.type;
const showTls = type !== "datadog";
const currentPayloadKey = connectionKey(form.values);
const showTestResult = testState.result !== null && testState.testedPayloadKey === currentPayloadKey;
const connectionChanged = currentPayloadKey !== connectionKey(initialValues(destination));
const testPassed = showTestResult && testState.result.delivered;
const requiresTest = (!isEdit || connectionChanged) && !testPassed;
return (
<Modal
opened={opened}
onClose={onClose}
title={isEdit ? t("Edit destination") : t("Add destination")}
size="lg"
closeButtonProps={{ "aria-label": t("Close") }}
>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Select
label={t("Preset")}
data={[
{ value: "splunk_hec", label: DESTINATION_TYPE_LABELS.splunk_hec },
{ value: "datadog", label: DESTINATION_TYPE_LABELS.datadog },
{ value: "http", label: DESTINATION_TYPE_LABELS.http },
]}
allowDeselect={false}
disabled={isEdit}
{...form.getInputProps("type")}
/>
<TextInput
label={t("Name")}
placeholder={t("e.g. Splunk prod")}
required
data-autofocus
{...form.getInputProps("name")}
/>
{type === "splunk_hec" && (
<>
<TextInput
label={t("HEC URL")}
placeholder="https://splunk.example.com:8088"
required
{...form.getInputProps("url")}
/>
<PasswordInput
label={t("HEC token")}
required={!hasSecrets.token}
{...form.getInputProps("token")}
/>
</>
)}
{type === "datadog" && (
<>
<Select
label={t("Datadog site")}
data={DATADOG_SITES.map((site) => ({ value: site, label: site }))}
allowDeselect={false}
{...form.getInputProps("site")}
/>
<PasswordInput
label={t("API key")}
required={!hasSecrets.apiKey}
{...form.getInputProps("apiKey")}
/>
</>
)}
{type === "http" && (
<>
<TextInput
label={t("Endpoint URL")}
placeholder="https://collector.example.com/docmost"
required
{...form.getInputProps("url")}
/>
<PasswordInput
label={t("Token")}
description={t("Sent in the auth header below. Leave empty if your receiver does not need one.")}
{...form.getInputProps("token")}
/>
</>
)}
<Button
variant="subtle"
size="compact-sm"
onClick={() => setAdvancedOpen((open) => !open)}
style={{ alignSelf: "flex-start" }}
>
{advancedOpen ? t("Hide advanced options") : t("Show advanced options")}
</Button>
<Collapse expanded={advancedOpen}>
<Stack gap="md">
{type === "splunk_hec" && (
<>
<TextInput
label={t("Index")}
description={t("Leave empty to use the token's default index")}
{...form.getInputProps("index")}
/>
<Group grow>
<TextInput label={t("Source")} {...form.getInputProps("source")} />
<TextInput label={t("Sourcetype")} {...form.getInputProps("sourcetype")} />
</Group>
<TextInput
label={t("Host")}
description={t("Defaults to this instance's hostname")}
{...form.getInputProps("host")}
/>
</>
)}
{type === "datadog" && (
<>
<TextInput label={t("Service")} {...form.getInputProps("service")} />
<TextInput label={t("Tags")} placeholder="env:prod,team:security" {...form.getInputProps("tags")} />
</>
)}
{type === "http" && (
<>
<Group grow>
<TextInput label={t("Auth header name")} {...form.getInputProps("authHeaderName")} />
<TextInput label={t("Auth header prefix")} {...form.getInputProps("authHeaderPrefix")} />
</Group>
<Select
label={t("Body format")}
data={[
{ value: "json", label: t("JSON array") },
{ value: "ndjson", label: "NDJSON" },
]}
allowDeselect={false}
{...form.getInputProps("format")}
/>
</>
)}
{showTls && (
<>
{!isCloud() && (
<Switch
label={t("Verify TLS certificate")}
description={
form.values.rejectUnauthorized
? undefined
: t("Insecure: connections can be intercepted.")
}
{...form.getInputProps("rejectUnauthorized", { type: "checkbox" })}
/>
)}
</>
)}
<Switch label={t("Enabled")} {...form.getInputProps("enabled", { type: "checkbox" })} />
</Stack>
</Collapse>
{showTestResult && (
<Alert
color={testState.result.delivered ? "green" : "red"}
icon={testState.result.delivered ? <IconCheck size={16} /> : <IconAlertCircle size={16} />}
>
{testState.result.delivered ? t("Test event delivered successfully.") : testState.result.error}
</Alert>
)}
<Group justify="space-between" mt="md">
<Group gap="sm">
<Button variant="default" onClick={handleTest} loading={testMutation.isPending}>
{t("Test connection")}
</Button>
{requiresTest && (
<Text size="xs" c="dimmed">
{t("Test the connection before saving.")}
</Text>
)}
</Group>
<Group>
<Button variant="default" onClick={onClose}>{t("Cancel")}</Button>
<Button type="submit" disabled={requiresTest} loading={createMutation.isPending || updateMutation.isPending}>
{isEdit ? t("Save") : t("Create")}
</Button>
</Group>
</Group>
</Stack>
</form>
</Modal>
);
}
@@ -0,0 +1,15 @@
import { Badge } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ISiemDestination } from "@/ee/siem/types/siem.types";
export function DestinationStatusBadge({ destination }: { destination: ISiemDestination }) {
const { t } = useTranslation();
if (!destination.enabled) {
return <Badge color="gray" variant="light">{t("Disabled")}</Badge>;
}
if (destination.status === "failing") {
return <Badge color="red" variant="light">{t("Failing")}</Badge>;
}
return <Badge color="green" variant="light">{t("Healthy")}</Badge>;
}
@@ -0,0 +1,155 @@
import { ActionIcon, Menu, Switch, Table, Text, Tooltip } from "@mantine/core";
import {
IconDots,
IconEdit,
IconPlugConnected,
IconRefresh,
IconTrash,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { formattedDate, timeAgo } from "@/lib/time.ts";
import { ISiemDestination, SiemDestinationType } from "@/ee/siem/types/siem.types";
import { DestinationStatusBadge } from "./destination-status-badge";
export const DESTINATION_TYPE_LABELS: Record<SiemDestinationType, string> = {
splunk_hec: "Splunk HEC",
datadog: "Datadog",
http: "Generic HTTP",
};
interface DestinationTableProps {
destinations?: ISiemDestination[];
isLoading?: boolean;
onEdit: (destination: ISiemDestination) => void;
onTest: (destination: ISiemDestination) => void;
onRetry: (destination: ISiemDestination) => void;
onDelete: (destination: ISiemDestination) => void;
onToggle: (destination: ISiemDestination, enabled: boolean) => void;
}
export function DestinationTable({
destinations,
isLoading,
onEdit,
onTest,
onRetry,
onDelete,
onToggle,
}: DestinationTableProps) {
const { t } = useTranslation();
return (
<Table.ScrollContainer minWidth={760}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Name")}</Table.Th>
<Table.Th>{t("Type")}</Table.Th>
<Table.Th>{t("Enabled")}</Table.Th>
<Table.Th>{t("Status")}</Table.Th>
<Table.Th>{t("Last delivered")}</Table.Th>
<Table.Th>{t("Last error")}</Table.Th>
<Table.Th aria-label={t("Actions")} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{destinations && destinations.length > 0 ? (
destinations.map((destination) => (
<Table.Tr key={destination.id}>
<Table.Td>
<Text fz="sm" fw={500}>{destination.name}</Text>
</Table.Td>
<Table.Td>
<Text fz="sm">{DESTINATION_TYPE_LABELS[destination.type]}</Text>
</Table.Td>
<Table.Td>
<Switch
size="sm"
checked={destination.enabled}
onChange={(event) => onToggle(destination, event.currentTarget.checked)}
aria-label={t("Enabled")}
/>
</Table.Td>
<Table.Td>
<DestinationStatusBadge destination={destination} />
{destination.failingSince &&
(destination.status === "failing" ||
!destination.enabled) && (
<Text
fz="xs"
c="dimmed"
mt={4}
style={{ whiteSpace: "nowrap" }}
>
{t("Failing since {{time}}", {
time: formattedDate(
new Date(destination.failingSince),
),
})}
</Text>
)}
</Table.Td>
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{destination.lastDeliveredAt
? timeAgo(new Date(destination.lastDeliveredAt))
: t("Never")}
</Text>
</Table.Td>
<Table.Td>
{destination.lastError ? (
<Tooltip label={destination.lastError} multiline w={320}>
<Text fz="xs" c="red" lineClamp={2} style={{ maxWidth: 260 }}>
{destination.lastError}
</Text>
</Tooltip>
) : (
<Text fz="xs" c="dimmed"></Text>
)}
</Table.Td>
<Table.Td>
<Menu shadow="md" width={200}>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" aria-label={t("Actions")}>
<IconDots size={18} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<IconEdit size={16} />} onClick={() => onEdit(destination)}>
{t("Edit")}
</Menu.Item>
<Menu.Item leftSection={<IconPlugConnected size={16} />} onClick={() => onTest(destination)}>
{t("Send test event")}
</Menu.Item>
<Menu.Item
leftSection={<IconRefresh size={16} />}
onClick={() => onRetry(destination)}
disabled={!destination.nextAttemptAt}
>
{t("Retry now")}
</Menu.Item>
<Menu.Divider />
<Menu.Item color="red" leftSection={<IconTrash size={16} />} onClick={() => onDelete(destination)}>
{t("Delete")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
))
) : (
!isLoading && (
<Table.Tr>
<Table.Td colSpan={7}>
<Text fz="sm" c="dimmed" ta="center" py="md">
{t("No destinations yet")}
</Text>
</Table.Td>
</Table.Tr>
)
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}
@@ -0,0 +1,129 @@
import { useState } from "react";
import { Alert, Button, Group, Tooltip } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { IconAlertCircle, IconInfoCircle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import useUserRole from "@/hooks/use-user-role";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import {
ISiemDestination,
SIEM_MAX_DESTINATIONS_PER_WORKSPACE,
} from "@/ee/siem/types/siem.types";
import {
useRetrySiemDestinationMutation,
useSiemDestinationsQuery,
extractErrorMessage,
useTestSiemDestinationMutation,
useUpdateSiemDestinationMutation,
} from "@/ee/siem/queries/siem-query";
import { DestinationTable } from "@/ee/siem/components/destination-table";
import { DestinationFormModal } from "@/ee/siem/components/destination-form-modal";
import { DeleteDestinationModal } from "@/ee/siem/components/delete-destination-modal";
export default function SiemStreamingPanel() {
const { t } = useTranslation();
const { isOwner } = useUserRole();
const hasFeature = useHasFeature(Feature.SIEM);
const { data, isLoading, isError, error } = useSiemDestinationsQuery(hasFeature);
const updateMutation = useUpdateSiemDestinationMutation();
const retryMutation = useRetrySiemDestinationMutation();
const testMutation = useTestSiemDestinationMutation();
const [formOpened, setFormOpened] = useState(false);
const [deleteOpened, setDeleteOpened] = useState(false);
const [selected, setSelected] = useState<ISiemDestination | null>(null);
if (!isOwner) {
return null;
}
const atDestinationLimit =
(data?.length ?? 0) >= SIEM_MAX_DESTINATIONS_PER_WORKSPACE;
const handleTest = async (destination: ISiemDestination) => {
const result = await testMutation
.mutateAsync({
type: destination.type,
config: destination.config as unknown as Record<string, unknown>,
destinationId: destination.id,
})
.catch(() => null);
if (!result) return;
notifications.show({
message: result.delivered
? t("Test event delivered to {{name}}", { name: destination.name })
: result.error,
color: result.delivered ? "green" : "red",
});
};
return (
<>
{!hasFeature && (
<Alert icon={<IconInfoCircle size={16} />} color="yellow" mb="md">
{t("SIEM streaming requires an Enterprise license.")}
</Alert>
)}
<Group justify="flex-end" mb="md">
<Tooltip
label={t("Maximum of {{limit}} destinations reached", {
limit: SIEM_MAX_DESTINATIONS_PER_WORKSPACE,
})}
disabled={!atDestinationLimit}
>
<span>
<Button
onClick={() => {
setSelected(null);
setFormOpened(true);
}}
disabled={!hasFeature || atDestinationLimit}
>
{t("Add destination")}
</Button>
</span>
</Tooltip>
</Group>
{isError && (
<Alert icon={<IconAlertCircle size={16} />} color="red" mb="md">
{t("Could not load SIEM destinations: {{message}}", {
message: extractErrorMessage(error),
})}
</Alert>
)}
{hasFeature && !isError && (
<DestinationTable
destinations={data}
isLoading={isLoading}
onEdit={(destination) => {
setSelected(destination);
setFormOpened(true);
}}
onTest={handleTest}
onRetry={(destination) => retryMutation.mutate({ destinationId: destination.id })}
onDelete={(destination) => {
setSelected(destination);
setDeleteOpened(true);
}}
onToggle={(destination, enabled) =>
updateMutation.mutate({ destinationId: destination.id, enabled })
}
/>
)}
<DestinationFormModal
opened={formOpened}
onClose={() => setFormOpened(false)}
destination={selected}
/>
<DeleteDestinationModal
opened={deleteOpened}
onClose={() => setDeleteOpened(false)}
destination={selected}
/>
</>
);
}
@@ -0,0 +1,179 @@
import {
DATADOG_SITES,
ISiemDestination,
ISiemDestinationInput,
SiemDestinationType,
} from "@/ee/siem/types/siem.types";
export type DestinationFormValues = {
name: string;
type: SiemDestinationType;
url: string;
token: string;
apiKey: string;
authHeaderName: string;
authHeaderPrefix: string;
format: "json" | "ndjson";
index: string;
source: string;
sourcetype: string;
host: string;
site: string;
service: string;
tags: string;
rejectUnauthorized: boolean;
enabled: boolean;
};
export const DEFAULT_FORM_VALUES: DestinationFormValues = {
name: "",
type: "splunk_hec",
url: "",
token: "",
apiKey: "",
authHeaderName: "Authorization",
authHeaderPrefix: "Bearer ",
format: "json",
index: "",
source: "docmost",
sourcetype: "docmost:audit",
host: "",
site: DATADOG_SITES[0],
service: "docmost",
tags: "",
rejectUnauthorized: true,
enabled: true,
};
const HEADER_NAME_RE = /^[A-Za-z0-9-]+$/;
export const SECRET_MASK = "********";
export function initialValues(
destination?: ISiemDestination | null,
): DestinationFormValues {
if (!destination) return { ...DEFAULT_FORM_VALUES };
const config = destination.config as Record<string, any>;
const tls = config.tls ?? {};
return {
...DEFAULT_FORM_VALUES,
name: destination.name,
type: destination.type,
enabled: destination.enabled,
token: destination.hasSecrets?.token ? SECRET_MASK : "",
apiKey: destination.hasSecrets?.apiKey ? SECRET_MASK : "",
url: config.url ?? "",
authHeaderName: config.authHeaderName ?? DEFAULT_FORM_VALUES.authHeaderName,
authHeaderPrefix: config.authHeaderPrefix ?? DEFAULT_FORM_VALUES.authHeaderPrefix,
format: config.format ?? "json",
index: config.index ?? "",
source: config.source ?? DEFAULT_FORM_VALUES.source,
sourcetype: config.sourcetype ?? DEFAULT_FORM_VALUES.sourcetype,
host: config.host ?? "",
site: config.site ?? DEFAULT_FORM_VALUES.site,
service: config.service ?? DEFAULT_FORM_VALUES.service,
tags: config.tags ?? "",
rejectUnauthorized: tls.rejectUnauthorized ?? true,
};
}
function isValidUrl(value: string): boolean {
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
}
export function validateForm(
values: DestinationFormValues,
hasSecrets: Record<string, boolean> = {},
): Partial<Record<keyof DestinationFormValues, string>> {
const errors: Partial<Record<keyof DestinationFormValues, string>> = {};
if (!values.name.trim()) errors.name = "Name is required";
if (values.type !== "datadog" && !isValidUrl(values.url.trim())) {
errors.url = "Enter a valid http(s) URL";
}
if (values.type === "splunk_hec") {
if (!values.token && !hasSecrets.token) {
errors.token = "HEC token is required";
}
try {
const url = new URL(values.url.trim());
const path = url.pathname.replace(/\/+$/, "");
if (path !== "" && path !== "/services/collector" && path !== "/services/collector/event") {
errors.url = "Enter the HEC base URL or the /services/collector/event endpoint";
}
} catch {}
}
if (values.type === "datadog") {
if (!(DATADOG_SITES as readonly string[]).includes(values.site)) {
errors.site = "Select a Datadog site";
}
if (!values.apiKey && !hasSecrets.apiKey) errors.apiKey = "API key is required";
}
if (values.type === "http") {
if (!HEADER_NAME_RE.test(values.authHeaderName.trim())) {
errors.authHeaderName = "Use letters, digits and hyphens only";
}
}
return errors;
}
function enteredSecret(key: string, value: string): Record<string, string> {
const trimmed = value.trim();
return trimmed && trimmed !== SECRET_MASK ? { [key]: trimmed } : {};
}
export function toPayload(values: DestinationFormValues): ISiemDestinationInput {
const tls = { rejectUnauthorized: values.rejectUnauthorized };
let config: Record<string, unknown>;
let secrets: Record<string, string>;
switch (values.type) {
case "splunk_hec":
config = {
url: values.url.trim(),
index: values.index.trim(),
source: values.source.trim() || "docmost",
sourcetype: values.sourcetype.trim() || "docmost:audit",
host: values.host.trim(),
tls,
};
secrets = enteredSecret("token", values.token);
break;
case "datadog":
config = {
site: values.site,
service: values.service.trim() || "docmost",
tags: values.tags.trim(),
};
secrets = enteredSecret("apiKey", values.apiKey);
break;
default:
config = {
url: values.url.trim(),
authHeaderName: values.authHeaderName.trim(),
authHeaderPrefix: values.authHeaderPrefix,
format: values.format,
tls,
};
secrets = enteredSecret("token", values.token);
}
return {
name: values.name.trim(),
type: values.type,
config,
secrets: Object.fromEntries(Object.entries(secrets).filter(([, v]) => v !== "")),
enabled: values.enabled,
};
}
@@ -0,0 +1,115 @@
import {
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import {
createSiemDestination,
deleteSiemDestination,
getSiemDestinations,
retrySiemDestination,
testSiemDestination,
updateSiemDestination,
} from "@/ee/siem/services/siem-service";
import {
ISiemDestination,
ISiemDestinationInput,
ISiemTestResult,
ITestSiemDestinationInput,
IUpdateSiemDestinationInput,
} from "@/ee/siem/types/siem.types";
export const SIEM_DESTINATIONS_KEY = ["siem-destinations"];
export function extractErrorMessage(error: Error): string {
const data = (error as any)?.response?.data;
const message = data?.message ?? error.message;
return Array.isArray(message) ? message.join(", ") : String(message);
}
function showError(error: Error) {
notifications.show({ message: extractErrorMessage(error), color: "red" });
}
function isForbidden(error: unknown): boolean {
return (error as { response?: { status?: number } })?.response?.status === 403;
}
export function useSiemDestinationsQuery(
enabled = true,
): UseQueryResult<ISiemDestination[], Error> {
return useQuery({
queryKey: SIEM_DESTINATIONS_KEY,
queryFn: getSiemDestinations,
enabled,
retry: (failureCount, error) => !isForbidden(error) && failureCount < 2,
refetchInterval: (query) => (query.state.status === "error" ? false : 15_000),
});
}
function useInvalidateDestinations() {
const queryClient = useQueryClient();
return () => queryClient.invalidateQueries({ queryKey: SIEM_DESTINATIONS_KEY });
}
export function useCreateSiemDestinationMutation() {
const { t } = useTranslation();
const invalidate = useInvalidateDestinations();
return useMutation<ISiemDestination, Error, ISiemDestinationInput>({
mutationFn: createSiemDestination,
onSuccess: () => {
notifications.show({ message: t("Destination created") });
invalidate();
},
onError: showError,
});
}
export function useUpdateSiemDestinationMutation() {
const { t } = useTranslation();
const invalidate = useInvalidateDestinations();
return useMutation<ISiemDestination, Error, IUpdateSiemDestinationInput>({
mutationFn: updateSiemDestination,
onSuccess: () => {
notifications.show({ message: t("Destination updated") });
invalidate();
},
onError: showError,
});
}
export function useDeleteSiemDestinationMutation() {
const { t } = useTranslation();
const invalidate = useInvalidateDestinations();
return useMutation<void, Error, { destinationId: string }>({
mutationFn: deleteSiemDestination,
onSuccess: () => {
notifications.show({ message: t("Destination deleted") });
invalidate();
},
onError: showError,
});
}
export function useRetrySiemDestinationMutation() {
const { t } = useTranslation();
const invalidate = useInvalidateDestinations();
return useMutation<void, Error, { destinationId: string }>({
mutationFn: retrySiemDestination,
onSuccess: () => {
notifications.show({ message: t("Retry scheduled") });
invalidate();
},
onError: showError,
});
}
export function useTestSiemDestinationMutation() {
return useMutation<ISiemTestResult, Error, ITestSiemDestinationInput>({
mutationFn: testSiemDestination,
onError: showError,
});
}
@@ -0,0 +1,46 @@
import api from "@/lib/api-client";
import {
ISiemDestination,
ISiemDestinationInput,
ISiemTestResult,
ITestSiemDestinationInput,
IUpdateSiemDestinationInput,
} from "@/ee/siem/types/siem.types";
export async function getSiemDestinations(): Promise<ISiemDestination[]> {
const req = await api.post<ISiemDestination[]>("/siem/destinations");
return req.data;
}
export async function createSiemDestination(
data: ISiemDestinationInput,
): Promise<ISiemDestination> {
const req = await api.post<ISiemDestination>("/siem/destinations/create", data);
return req.data;
}
export async function updateSiemDestination(
data: IUpdateSiemDestinationInput,
): Promise<ISiemDestination> {
const req = await api.post<ISiemDestination>("/siem/destinations/update", data);
return req.data;
}
export async function deleteSiemDestination(data: {
destinationId: string;
}): Promise<void> {
await api.post("/siem/destinations/delete", data);
}
export async function testSiemDestination(
data: ITestSiemDestinationInput,
): Promise<ISiemTestResult> {
const req = await api.post<ISiemTestResult>("/siem/destinations/test", data);
return req.data;
}
export async function retrySiemDestination(data: {
destinationId: string;
}): Promise<void> {
await api.post("/siem/destinations/retry", data);
}
@@ -0,0 +1,91 @@
export const SIEM_MAX_DESTINATIONS_PER_WORKSPACE = 2;
export type SiemDestinationType = "http" | "splunk_hec" | "datadog";
export type SiemDestinationStatus = "healthy" | "failing";
export const DATADOG_SITES = [
"datadoghq.com",
"datadoghq.eu",
"us3.datadoghq.com",
"us5.datadoghq.com",
"ap1.datadoghq.com",
"ddog-gov.com",
] as const;
export interface ITlsOptions {
rejectUnauthorized: boolean;
}
export interface IHttpConfig {
url: string;
authHeaderName: string;
authHeaderPrefix: string;
format: "json" | "ndjson";
tls?: ITlsOptions;
}
export interface ISplunkHecConfig {
url: string;
index?: string;
source: string;
sourcetype: string;
host?: string;
channelId: string;
tls?: ITlsOptions;
}
export interface IDatadogConfig {
site: string;
service: string;
tags?: string;
}
export type ISiemConfig = IHttpConfig | ISplunkHecConfig | IDatadogConfig;
export interface ISiemDestination {
id: string;
name: string;
type: SiemDestinationType;
enabled: boolean;
status: SiemDestinationStatus;
config: ISiemConfig;
hasSecrets: Record<string, boolean>;
cursorCreatedAt: string;
lastDeliveredAt: string | null;
lastError: string | null;
lastErrorAt: string | null;
consecutiveFailures: number;
nextAttemptAt: string | null;
failingSince: string | null;
createdAt: string;
updatedAt: string;
}
export interface ISiemDestinationInput {
name: string;
type: SiemDestinationType;
config: Record<string, unknown>;
secrets?: Record<string, string>;
enabled?: boolean;
}
export interface IUpdateSiemDestinationInput {
destinationId: string;
name?: string;
config?: Record<string, unknown>;
secrets?: Record<string, string>;
enabled?: boolean;
}
export interface ITestSiemDestinationInput {
type: SiemDestinationType;
config: Record<string, unknown>;
secrets?: Record<string, string>;
destinationId?: string;
}
export interface ISiemTestResult {
delivered: boolean;
error?: string;
statusCode?: number;
}
@@ -16,6 +16,14 @@ export const showAiMenuAtom = atom(false);
export const showLinkMenuAtom = atom(false);
export type LightboxRequest = {
src: string;
type: "image" | "video";
} | null;
const initialLightboxRequest: LightboxRequest = null;
export const lightboxRequestAtom = atom(initialLightboxRequest);
// Current page's edit mode — initialized from the user's saved preference on
// first load, can be toggled locally without persisting to the server.
export const currentPageEditModeAtom = atom<PageEditMode>(PageEditMode.Edit);
@@ -85,6 +85,9 @@ export function AudioMenu({ editor }: EditorMenuProps) {
<BaseBubbleMenu
editor={editor}
pluginKey={`audio-menu`}
ref={(element) => {
if (element) element.style.zIndex = "99";
}}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
@@ -23,11 +23,21 @@ import {
} from "@/features/comment/atoms/comment-atom";
import { useAtom, useAtomValue } from "jotai";
import { v7 as uuid7 } from "uuid";
import { isCellSelection, isEditorReady, isTextSelected } from "@docmost/editor-ext";
import {
isCellSelection,
isEditorReady,
isTextSelected,
} from "@docmost/editor-ext";
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
import { useTranslation } from "react-i18next";
import { showAiMenuAtom, showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
import { userAtom, workspaceAtom } from "@/features/user/atoms/current-user-atom";
import {
showAiMenuAtom,
showLinkMenuAtom,
} from "@/features/editor/atoms/editor-atoms";
import {
userAtom,
workspaceAtom,
} from "@/features/user/atoms/current-user-atom";
export interface BubbleMenuItem {
name: string;
@@ -217,7 +227,12 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
<ActionIcon.Group>
{items.map((item, index) => (
<Tooltip key={index} label={t(item.name)} withArrow>
<Tooltip
key={index}
label={t(item.name)}
withArrow
withinPortal={false}
>
<ActionIcon
key={index}
variant="default"
@@ -226,7 +241,9 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
aria-label={t(item.name)}
className={clsx({ [classes.active]: item.isActive() })}
style={{ border: "none" }}
onClick={() => isEditorReady(props.editor) && item.command()}
onClick={() =>
isEditorReady(props.editor) && item.command()
}
>
<item.icon style={{ width: rem(16) }} stroke={2} />
</ActionIcon>
@@ -256,7 +273,9 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
radius="6px"
aria-label={t(commentItem.name)}
style={{ border: "none" }}
onClick={() => isEditorReady(props.editor) && commentItem.command()}
onClick={() =>
isEditorReady(props.editor) && commentItem.command()
}
>
<IconMessage size={16} stroke={2} />
</ActionIcon>
@@ -129,8 +129,7 @@ function handleColorKeyNav(
grid: "text" | "highlight",
) {
const cols = COLOR_GRID_COLS;
const total =
grid === "text" ? TEXT_COLORS.length : HIGHLIGHT_COLORS.length;
const total = grid === "text" ? TEXT_COLORS.length : HIGHLIGHT_COLORS.length;
const col = index % cols;
if (e.key === "ArrowRight") {
@@ -163,8 +162,7 @@ function handleColorKeyNav(
if (prev >= 0) {
focusSwatch(grid, prev);
} else if (grid === "highlight") {
const lastRowStart =
Math.floor((TEXT_COLORS.length - 1) / cols) * cols;
const lastRowStart = Math.floor((TEXT_COLORS.length - 1) / cols) * cols;
focusSwatch("text", Math.min(lastRowStart + col, TEXT_COLORS.length - 1));
}
return;
@@ -222,7 +220,7 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
withArrow
>
<Popover.Target>
<Tooltip label={t("Text color")} withArrow>
<Tooltip label={t("Text color")} withArrow withinPortal={false}>
<Button
variant="default"
radius="0"
@@ -247,26 +245,26 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
<Popover.Dropdown onMouseDown={(e) => e.preventDefault()}>
<Stack gap="md" p="2px">
<Box>
<Text size="sm" fw={600} mb="xs">
{t("Text color")}
</Text>
<SimpleGrid cols={5} spacing="xs">
{TEXT_COLORS.map(({ name, color }, index) => {
const applyTextColor = () => {
if (!isEditorReady(editor)) return;
if (name === "Default") {
editor.commands.unsetColor();
} else {
editor
.chain()
.focus()
.setColor(color || "")
.run();
}
setIsOpen(false);
};
return (
<Box>
<Text size="sm" fw={600} mb="xs">
{t("Text color")}
</Text>
<SimpleGrid cols={5} spacing="xs">
{TEXT_COLORS.map(({ name, color }, index) => {
const applyTextColor = () => {
if (!isEditorReady(editor)) return;
if (name === "Default") {
editor.commands.unsetColor();
} else {
editor
.chain()
.focus()
.setColor(color || "")
.run();
}
setIsOpen(false);
};
return (
<Tooltip key={index} label={t(name)} withArrow>
<Box
role="button"
@@ -306,34 +304,34 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
A
</Box>
</Tooltip>
);
})}
</SimpleGrid>
</Box>
);
})}
</SimpleGrid>
</Box>
<Box>
<Text size="sm" fw={600} mb="xs">
{t("Highlight color")}
</Text>
<SimpleGrid cols={5} spacing="xs">
{HIGHLIGHT_COLORS.map(({ name, color }, index) => {
const applyHighlight = () => {
if (!isEditorReady(editor)) return;
if (name === "Default") {
editor.commands.unsetHighlight();
} else {
editor
.chain()
.focus()
.toggleMark("highlight", {
color: color || "",
colorName: name.toLowerCase() || "",
})
.run();
}
setIsOpen(false);
};
return (
<Box>
<Text size="sm" fw={600} mb="xs">
{t("Highlight color")}
</Text>
<SimpleGrid cols={5} spacing="xs">
{HIGHLIGHT_COLORS.map(({ name, color }, index) => {
const applyHighlight = () => {
if (!isEditorReady(editor)) return;
if (name === "Default") {
editor.commands.unsetHighlight();
} else {
editor
.chain()
.focus()
.toggleMark("highlight", {
color: color || "",
colorName: name.toLowerCase() || "",
})
.run();
}
setIsOpen(false);
};
return (
<Tooltip key={index} label={t(name)} withArrow>
<Box
role="button"
@@ -378,37 +376,36 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
)}
</Box>
</Tooltip>
);
})}
</SimpleGrid>
</Box>
);
})}
</SimpleGrid>
</Box>
<Button
variant="default"
fullWidth
data-color-grid="remove"
className={classes.removeColor}
onClick={() => {
if (isEditorReady(editor)) {
editor.commands.unsetColor();
editor.commands.unsetHighlight();
}
setIsOpen(false);
}}
onKeyDown={(e) => {
if (e.key === "ArrowUp") {
e.preventDefault();
const lastRowStart =
Math.floor(
(HIGHLIGHT_COLORS.length - 1) / COLOR_GRID_COLS,
) * COLOR_GRID_COLS;
focusSwatch("highlight", lastRowStart);
}
}}
>
{t("Remove color")}
</Button>
</Stack>
<Button
variant="default"
fullWidth
data-color-grid="remove"
className={classes.removeColor}
onClick={() => {
if (isEditorReady(editor)) {
editor.commands.unsetColor();
editor.commands.unsetHighlight();
}
setIsOpen(false);
}}
onKeyDown={(e) => {
if (e.key === "ArrowUp") {
e.preventDefault();
const lastRowStart =
Math.floor((HIGHLIGHT_COLORS.length - 1) / COLOR_GRID_COLS) *
COLOR_GRID_COLS;
focusSwatch("highlight", lastRowStart);
}
}}
>
{t("Remove color")}
</Button>
</Stack>
</Popover.Dropdown>
</Popover>
);
@@ -10,7 +10,7 @@ export const LinkSelector: FC = () => {
const setShowLinkMenu = useSetAtom(showLinkMenuAtom);
return (
<Tooltip label={t("Add link")} withArrow>
<Tooltip label={t("Add link")} withArrow withinPortal={false}>
<ActionIcon
variant="default"
size="lg"
@@ -91,7 +91,12 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
onChange={setIsOpen}
>
<Menu.Target>
<Tooltip label={t("Text align")} withArrow disabled={isOpen}>
<Tooltip
label={t("Text align")}
withArrow
disabled={isOpen}
withinPortal={false}
>
<Button
variant="default"
style={{ border: "none", height: "34px" }}
@@ -121,12 +121,14 @@ 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,6 +257,9 @@ export function ColumnsMenu({ editor }: EditorMenuProps) {
<BaseBubbleMenu
editor={editor}
pluginKey="columns-menu"
ref={(element) => {
if (element) element.style.zIndex = "99";
}}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
@@ -0,0 +1,188 @@
import type { Editor } from "@tiptap/react";
import type { Node as PMNode } from "@tiptap/pm/model";
import Lightbox, { type Slide } from "yet-another-react-lightbox";
import type { LightboxRequest } from "@/features/editor/atoms/editor-atoms";
import { getFileUrl } from "@/lib/config.ts";
import "yet-another-react-lightbox/styles.css";
import Download from "yet-another-react-lightbox/plugins/download";
import Fullscreen from "yet-another-react-lightbox/plugins/fullscreen";
import Video from "yet-another-react-lightbox/plugins/video";
import Zoom from "yet-another-react-lightbox/plugins/zoom";
import { useEffect, useMemo, useState } from "react";
import i18n from "@/i18n.ts";
import { useTranslation } from "react-i18next";
type LightboxViewProps = {
editor: Editor;
open: boolean;
src: string;
type: "image" | "video";
onClose: () => void;
};
function getVideoMimeType(src: string) {
const extension = src.split(/[?#]/, 1)[0].split(".").pop()?.toLowerCase();
switch (extension) {
case "webm":
return "video/webm";
case "ogv":
return "video/ogg";
case "mov":
return "video/quicktime";
case "m4v":
return "video/x-m4v";
default:
return "video/mp4";
}
}
function getFilename(src: string) {
const filename = src.split(/[?#]/, 1)[0].split("/").pop();
if (!filename) return i18n.t("Media");
try {
return decodeURIComponent(filename);
} catch {
return filename;
}
}
function getMedia(rawSrc: string, type?: string, alt?: string): Slide {
const src = getFileUrl(rawSrc);
const filename = getFilename(rawSrc);
if (type === "video") {
return {
type: "video",
sources: [{ src, type: getVideoMimeType(rawSrc) }],
download: { url: src, filename },
};
} else {
return {
type: "image",
src,
alt: alt || undefined,
download: { url: src, filename },
};
}
}
const LIGHTBOX_NODE_TYPES: Record<string, "image" | "video"> = {
image: "image",
video: "video",
drawio: "image",
excalidraw: "image",
};
// video is excluded: clicks there operate the native controls
const CLICK_TO_EXPAND_NODE_TYPES = new Set(["image", "drawio", "excalidraw"]);
export function getLightboxClickRequest(node: PMNode): LightboxRequest {
if (!CLICK_TO_EXPAND_NODE_TYPES.has(node.type.name)) return null;
const src = typeof node.attrs.src === "string" ? node.attrs.src : "";
if (!src) return null;
return { src: getFileUrl(src), type: "image" };
}
function getPageMedia(editor: Editor): Slide[] {
const media: Slide[] = [];
editor.state.doc.descendants((node) => {
const type = LIGHTBOX_NODE_TYPES[node.type.name];
if (!type) return;
const rawSrc = typeof node.attrs.src === "string" ? node.attrs.src : "";
if (!rawSrc) return;
media.push(getMedia(rawSrc, type, node.attrs.alt));
});
return media;
}
export default function LightboxView({
editor,
open,
src,
type,
onClose,
}: LightboxViewProps) {
const { i18n: i18nInstance } = useTranslation();
const selectedSlide = useMemo(
() => getMedia(src, type),
[src, type, i18nInstance.language]
);
const [pageSlides, setPageSlides] = useState<Slide[]>([]);
const [loadedMediaKey, setLoadedMediaKey] = useState<string | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
if (!open) setIsFullscreen(false);
}, [open]);
useEffect(() => {
if (!open) return;
setLoadedMediaKey(null);
const frame = requestAnimationFrame(() => {
const slides = getPageMedia(editor);
setPageSlides(slides);
setLoadedMediaKey(`${type}:${src}`);
});
return () => cancelAnimationFrame(frame);
}, [editor, open, type, src]);
const slides = loadedMediaKey === `${type}:${src}` ? pageSlides : [selectedSlide];
const index = useMemo(() => {
if (!(pageSlides.length > 0)) {
return 0;
}
const idx = slides.findIndex((slide) =>
type === "video"
? "sources" in slide && slide.sources.some((s) => s.src === src)
: "src" in slide && slide.src === src
);
return idx >= 0 ? idx : 0;
}, [slides, src, type]);
return (
<Lightbox
open={open}
close={onClose}
index={index}
slides={slides}
plugins={[Download, Fullscreen, Video, Zoom]}
styles={{
container: { backgroundColor: "rgba(0, 0, 0, 0.8)" },
icon: { width: 24, height: 24 },
toolbar: {
margin: 8,
borderRadius: 8,
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
}}
controller={{ closeOnBackdropClick: !isFullscreen }}
on={{
enterFullscreen: () => setIsFullscreen(true),
exitFullscreen: () => setIsFullscreen(false),
}}
video={{ controls: true, playsInline: true }}
zoom={{
scrollToZoom: true,
maxZoomPixelRatio: 4,
maxZoom: 4,
supports: ["video"],
}}
/>
);
}
@@ -1,6 +1,7 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useSetAtom } from "jotai";
import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import {
@@ -24,6 +25,7 @@ import {
IconDownload,
IconEdit,
IconTrash,
IconZoomIn,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { getDrawioUrl, getFileUrl } from "@/lib/config.ts";
@@ -39,10 +41,12 @@ import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils";
import { IAttachment } from "@/features/attachments/types/attachment.types";
import { modals } from "@mantine/modals";
import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.tsx";
import { lightboxRequestAtom } from "@/features/editor/atoms/editor-atoms";
import classes from "../common/toolbar-menu.module.css";
export function DrawioMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const setLightboxRequest = useSetAtom(lightboxRequestAtom);
const [opened, { open, close }] = useDisclosure(false);
const [initialXML, setInitialXML] = useState<string>("");
const drawioRef = useRef<DrawIoEmbedRef>(null);
@@ -269,6 +273,9 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
<BaseBubbleMenu
editor={editor}
pluginKey={`drawio-menu`}
ref={(element) => {
if (element) element.style.zIndex = "99";
}}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
@@ -328,6 +335,23 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
<div className={classes.divider} />
<Tooltip position="top" label={t("Expand")} withinPortal={false}>
<ActionIcon
onClick={() =>
editorState?.src &&
setLightboxRequest({
src: getFileUrl(editorState.src),
type: "image",
})
}
size="lg"
aria-label={t("Expand")}
variant="subtle"
>
<IconZoomIn size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Edit")} withinPortal={false}>
<ActionIcon
onClick={handleOpen}
@@ -1,6 +1,7 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useSetAtom } from "jotai";
import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import {
@@ -25,6 +26,7 @@ import {
IconDownload,
IconEdit,
IconTrash,
IconZoomIn,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { getFileUrl } from "@/lib/config.ts";
@@ -37,6 +39,7 @@ import ReactClearModal from "react-clear-modal";
import { useHandleLibrary } from "@excalidraw/excalidraw";
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.tsx";
import { lightboxRequestAtom } from "@/features/editor/atoms/editor-atoms";
import classes from "../common/toolbar-menu.module.css";
const ExcalidrawComponent = lazy(() =>
@@ -47,6 +50,7 @@ const ExcalidrawComponent = lazy(() =>
export function ExcalidrawMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const setLightboxRequest = useSetAtom(lightboxRequestAtom);
const [opened, { open, close }] = useDisclosure(false);
const [excalidrawAPI, setExcalidrawAPI] =
useState<ExcalidrawImperativeAPI>(null);
@@ -359,6 +363,23 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
<div className={classes.divider} />
<Tooltip position="top" label={t("Expand")} withinPortal={false}>
<ActionIcon
onClick={() =>
editorState?.src &&
setLightboxRequest({
src: getFileUrl(editorState.src),
type: "image",
})
}
size="lg"
aria-label={t("Expand")}
variant="subtle"
>
<IconZoomIn size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Edit")} withinPortal={false}>
<ActionIcon
onClick={handleOpen}
@@ -1,6 +1,7 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import React, { useCallback, useRef } from "react";
import { useSetAtom } from "jotai";
import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import {
@@ -16,16 +17,19 @@ import {
IconDownload,
IconRefresh,
IconTrash,
IconZoomIn,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { getFileUrl } from "@/lib/config.ts";
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.tsx";
import { lightboxRequestAtom } from "@/features/editor/atoms/editor-atoms";
import classes from "../common/toolbar-menu.module.css";
export function ImageMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null);
const setLightboxRequest = useSetAtom(lightboxRequestAtom);
const editorState = useEditorState({
editor,
@@ -152,6 +156,9 @@ export function ImageMenu({ editor }: EditorMenuProps) {
<BaseBubbleMenu
editor={editor}
pluginKey={`image-menu`}
ref={(element) => {
if (element) element.style.zIndex = "99";
}}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
@@ -207,6 +214,23 @@ export function ImageMenu({ editor }: EditorMenuProps) {
<div className={classes.divider} />
<Tooltip position="top" label={t("Expand")} withinPortal={false}>
<ActionIcon
onClick={() =>
editorState?.src &&
setLightboxRequest({
src: getFileUrl(editorState.src),
type: "image",
})
}
size="lg"
aria-label={t("Expand")}
variant="subtle"
>
<IconZoomIn size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Download")} withinPortal={false}>
<ActionIcon
onClick={handleDownload}
@@ -26,7 +26,12 @@ 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 { buildSharedPageUrl } from "@/features/page/page.utils.ts";
import { usePublicSpacePageQuery } from "@/features/public-space/queries/public-space-query.ts";
import {
buildPageUrl,
buildPublicSpaceUrl,
buildSharedPageUrl,
} from "@/features/page/page.utils.ts";
import { extractPageSlugId } from "@/lib";
import { sanitizeUrl, copyToClipboard, isEditorReady } from "@docmost/editor-ext";
import { normalizeUrl } from "@/lib/utils";
@@ -60,9 +65,10 @@ export default function LinkView(props: MarkViewProps) {
const href = mark.attrs.href as string;
const navigate = useNavigate();
const location = useLocation();
const { shareId, pageSlug } = useParams();
const { shareId, spaceSlug, pageSlug } = useParams();
const { t } = useTranslation();
const isShareRoute = location.pathname.startsWith("/share");
const isPublicSpaceRoute = location.pathname.startsWith("/docs/");
const [popoverState, setPopoverState] = useState<
"closed" | "preview" | "edit"
@@ -84,16 +90,33 @@ export default function LinkView(props: MarkViewProps) {
const activeView = isPopoverVisible ? popoverState : lastOpenState.current;
const { data: linkedPage } = usePageQuery({
pageId: isPopoverVisible && slugId && !isShareRoute ? slugId : null,
pageId:
isPopoverVisible && slugId && !isShareRoute && !isPublicSpaceRoute
? slugId
: null,
});
const { data: sharedPageData } = useSharePageQuery({
pageId: isPopoverVisible && slugId && isShareRoute ? slugId : null,
});
const pageTitle = isShareRoute
? sharedPageData?.page?.title
: linkedPage?.title;
// 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 pendingTitleRef = useRef<string | null>(null);
const titleInputRef = useRef<HTMLInputElement>(null);
@@ -260,6 +283,27 @@ 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);
}
@@ -276,8 +320,11 @@ export default function LinkView(props: MarkViewProps) {
location.pathname,
isInternal,
isShareRoute,
isPublicSpaceRoute,
slugId,
shareId,
spaceSlug,
publicSpacePageData,
pageTitle,
pageSlug,
]);
@@ -319,12 +366,18 @@ 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
? isShareRoute && slugId
? buildSharedPageUrl({ shareId, pageSlugId: slugId, pageTitle })
: href
: normalizeUrl(href),
isInternal ? internalHref() : normalizeUrl(href),
);
const linkTitleInput = (
@@ -374,6 +427,16 @@ export default function LinkView(props: MarkViewProps) {
</>
);
// Targets outside the published space have no public URL, so the label is
// rendered as inert text instead of a link that dead-ends at /login.
if (isUnresolvedPublicLink) {
return (
<span ref={wrapperRef} className={classes.linkWrapper}>
<MarkViewContent />
</span>
);
}
return (
<Popover
opened={isPopoverVisible}
@@ -4,8 +4,10 @@ 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";
@@ -21,17 +23,30 @@ 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 ? slugId : null });
} = usePageQuery({
pageId:
isPageMention && !isShareRoute && !isPublicSpaceRoute ? 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;
@@ -87,7 +102,61 @@ export default function MentionView(props: NodeViewProps) {
</Anchor>
)}
{isPageMention && !isShareRoute && isError && (
{isPageMention && isPublicSpaceRoute && publicPageData?.page && (
<Anchor
component={Link}
fw={500}
to={buildPublicSpaceUrl({
spaceSlug: publicPageData.space?.slug ?? spaceSlug,
pageSlugId: slugId,
pageTitle: publicPageData.page.title || label,
anchorId,
})}
onClick={handleClick}
underline="never"
className={classes.pageMentionLink}
>
<ActionIcon
variant="transparent"
color="gray"
component="span"
size={18}
style={{ verticalAlign: "text-bottom" }}
>
<IconFileDescription size={18} />
</ActionIcon>
<span className={classes.pageMentionText}>
{publicPageData.page.title || label}
</span>
</Anchor>
)}
{/* No public URL: the /p/ resolver redirects members to the page and
funnels anonymous visitors through login first. New tab, so the
redirect chain never rewrites the docs tab's history. */}
{isPageMention && isPublicSpaceRoute && !publicPageData?.page && (
<Anchor
fw={500}
href={buildPageUrl(undefined, slugId, label, anchorId)}
target="_blank"
rel="noopener noreferrer"
underline="never"
className={classes.pageMentionLink}
>
<ActionIcon
variant="transparent"
color="gray"
component="span"
size={18}
style={{ verticalAlign: "text-bottom" }}
>
<IconFileDescription size={18} />
</ActionIcon>
<span className={classes.pageMentionText}>{label}</span>
</Anchor>
)}
{isPageMention && !isShareRoute && !isPublicSpaceRoute && isError && (
<Anchor
component={Link}
fw={500}
@@ -111,7 +180,7 @@ export default function MentionView(props: NodeViewProps) {
</Anchor>
)}
{isPageMention && !isShareRoute && !isError && (
{isPageMention && !isShareRoute && !isPublicSpaceRoute && !isError && (
<Anchor
component={Link}
fw={500}
@@ -106,6 +106,9 @@ 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.setSearchTerm("");
editor.commands.setSearchTerms([""]);
}
};
@@ -117,7 +117,7 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
useEffect(() => {
if (!isEditorReady(editor)) return;
editor.commands.setSearchTerm(searchText);
editor.commands.setSearchTerms([searchText]);
editor.commands.resetIndex();
editor.commands.selectCurrentItem();
}, [searchText]);
@@ -181,8 +181,10 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
const location = useLocation();
useEffect(() => {
closeDialog();
}, [location]);
if (pageFindState.isOpen) {
closeDialog();
}
}, [location.pathname]);
return (
<Dialog
@@ -0,0 +1,205 @@
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;
@@ -0,0 +1,47 @@
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,6 +61,9 @@ 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,59 @@ 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, useParams } from "react-router-dom";
import { Link, useLocation, 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 } = useParams();
const { spaceSlug, shareId, pageSlug } = useParams();
const { t } = useTranslation();
const location = useLocation();
const isPublicSpaceRoute = location.pathname.startsWith("/docs/");
//@ts-ignore
const currentPageId = editor.storage.pageId;
const publicSpaceTreeData = useAtomValue(publicSpaceTreeDataAtom);
// @ts-ignore
const storagePageId = editor.storage.pageId;
const routePageId = extractPageSlugId(pageSlug);
let currentPageId = storagePageId;
if (shareId){
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(
shareId ? null : { pageId: currentPageId },
isPublicView ? null : { pageId: currentPageId },
);
const subpages = useMemo(() => {
@@ -41,17 +70,33 @@ export default function SubpagesView(props: NodeViewProps) {
}));
}
if (isPublicSpaceRoute) {
return publicSpaceSubpages.map((node) => ({
id: node.value,
slugId: node.slugId,
title: node.name,
icon: node.icon,
position: node.position,
}));
}
// Otherwise use the API data
if (!data?.pages) return [];
const allPages = data.pages.flatMap((page) => page.items);
return sortPositionKeys(allPages);
}, [data, shareId, sharedSubpages]);
}, [
data,
shareId,
sharedSubpages,
isPublicSpaceRoute,
publicSpaceSubpages,
]);
if (isLoading && !shareId) {
if (isLoading && !isPublicView) {
return null;
}
if (error && !shareId) {
if (error && !isPublicView) {
return (
<NodeViewWrapper data-drag-handle>
<Text c="dimmed" size="md" py="md">
@@ -89,7 +134,13 @@ export default function SubpagesView(props: NodeViewProps) {
pageSlugId: page.slugId,
pageTitle: page.title,
})
: buildPageUrl(spaceSlug, page.slugId, page.title)
: isPublicSpaceRoute
? buildPublicSpaceUrl({
spaceSlug,
pageSlugId: page.slugId,
pageTitle: page.title,
})
: buildPageUrl(spaceSlug, page.slugId, page.title)
}
underline="never"
className={styles.pageMentionLink}
@@ -18,7 +18,7 @@ export type HeadingLink = {
position: number;
};
const recalculateLinks = (nodePos: NodePos[]) => {
export const recalculateLinks = (nodePos: NodePos[]) => {
const nodes: HTMLElement[] = [];
const links: HeadingLink[] = Array.from(nodePos).reduce<HeadingLink[]>(
@@ -9,6 +9,7 @@ import React, {
} from "react";
import {
lookupTransclusion,
lookupTransclusionForPublicSpace,
lookupTransclusionForShare,
} from "@/features/transclusion/services/transclusion-api";
import type { TransclusionLookup } from "@/features/transclusion/types/transclusion.types";
@@ -38,6 +39,7 @@ const TransclusionLookupContext = createContext<ContextValue | null>(null);
export function TransclusionLookupProvider({
children,
shareId,
spaceSlug,
}: {
children: React.ReactNode;
/**
@@ -47,6 +49,11 @@ 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>());
@@ -55,6 +62,8 @@ 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.
@@ -91,12 +100,18 @@ export function TransclusionLookupProvider({
try {
const activeShareId = shareIdRef.current;
const activeSpaceSlug = spaceSlugRef.current;
const { items } = activeShareId
? await lookupTransclusionForShare({
shareId: activeShareId,
references,
})
: await lookupTransclusion({ references });
: activeSpaceSlug
? await lookupTransclusionForPublicSpace({
spaceSlug: activeSpaceSlug,
references,
})
: await lookupTransclusion({ references });
for (const r of items) {
const key = `${r.sourcePageId}::${r.transclusionId}`;
resultCacheRef.current.set(key, r);
@@ -1,6 +1,7 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback } from "react";
import { useSetAtom } from "jotai";
import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import {
@@ -15,14 +16,17 @@ import {
IconLayoutAlignRight,
IconDownload,
IconTrash,
IconZoomIn,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { getFileUrl } from "@/lib/config.ts";
import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.tsx";
import { lightboxRequestAtom } from "@/features/editor/atoms/editor-atoms";
import classes from "../common/toolbar-menu.module.css";
export function VideoMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const setLightboxRequest = useSetAtom(lightboxRequestAtom);
const editorState = useEditorState({
editor,
@@ -128,6 +132,9 @@ export function VideoMenu({ editor }: EditorMenuProps) {
<BaseBubbleMenu
editor={editor}
pluginKey={`video-menu`}
ref={(element) => {
if (element) element.style.zIndex = "99";
}}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
@@ -183,6 +190,23 @@ export function VideoMenu({ editor }: EditorMenuProps) {
<div className={classes.divider} />
<Tooltip position="top" label={t("Expand")} withinPortal={false}>
<ActionIcon
onClick={() =>
editorState?.src &&
setLightboxRequest({
src: getFileUrl(editorState.src),
type: "video",
})
}
size="lg"
aria-label={t("Expand")}
variant="subtle"
>
<IconZoomIn size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Download")} withinPortal={false}>
<ActionIcon
onClick={handleDownload}
@@ -0,0 +1,112 @@
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();
});
});
@@ -0,0 +1,8 @@
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 { Document } from "@tiptap/extension-document";
import { TiptapDocument } from "@/features/editor/extensions/document";
import { Code } from "@tiptap/extension-code";
import { TextAlign } from "@tiptap/extension-text-align";
import { TaskList, TaskItem } from "@tiptap/extension-list";
@@ -148,9 +148,7 @@ export const mainExtensions = [
codeBlock: false,
code: false,
}),
Document.extend({
content: "block+ footnotes?",
}),
TiptapDocument,
// 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
+55 -12
View File
@@ -33,6 +33,7 @@ import { useAtom, useAtomValue } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import {
currentPageEditModeAtom,
lightboxRequestAtom,
pageEditorAtom,
yjsConnectionStatusAtom,
yjsSyncedAtom,
@@ -53,6 +54,9 @@ import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
import PdfMenu from "@/features/editor/components/pdf/pdf-menu.tsx";
import SubpagesMenu from "@/features/editor/components/subpages/subpages-menu.tsx";
import LightboxView, {
getLightboxClickRequest,
} from "@/features/editor/components/common/lightbox-view";
import {
handleFileDrop,
handlePaste,
@@ -61,11 +65,13 @@ 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 } from "react-router-dom";
import { useParams, useSearchParams } 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";
@@ -184,6 +190,7 @@ function CollabPageEditor({
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
const [lightboxRequest, setLightboxRequest] = useAtom(lightboxRequestAtom);
const [isLocalSynced, setIsLocalSynced] = useState(false);
const [isRemoteSynced, setIsRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
@@ -194,6 +201,7 @@ 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(
@@ -245,6 +253,17 @@ function CollabPageEditor({
return [...mainExtensions, ...collabExtensions(provider, currentUser.user)];
}, [provider, currentUser?.user]);
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
if (pageData) {
queryClient.setQueryData(["pages", slugId], {
...pageData,
content: newContent,
});
}
}, 3000);
const editor = useEditor(
{
extensions,
@@ -305,6 +324,22 @@ function CollabPageEditor({
return handleFileDrop(editorRef.current, event, moved, pageId);
},
handleClickOn: (view, _pos, node) => {
if (view.editable) return false;
const request = getLightboxClickRequest(node);
if (!request) return false;
setLightboxRequest(request);
return true;
},
handleDoubleClickOn: (_view, _pos, node) => {
const request = getLightboxClickRequest(node);
if (!request) return false;
setLightboxRequest(request);
return true;
},
},
onCreate({ editor }) {
if (editor) {
@@ -343,17 +378,6 @@ function CollabPageEditor({
},
});
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
if (pageData) {
queryClient.setQueryData(["pages", slugId], {
...pageData,
content: newContent,
});
}
}, 3000);
const handleActiveCommentEvent = (event) => {
const { commentId, resolved } = event.detail;
@@ -386,6 +410,7 @@ function CollabPageEditor({
setActiveCommentId(null);
setShowCommentPopup(false);
setAsideState({ tab: "", isAsideOpen: false });
setLightboxRequest(null);
}, [pageId]);
const isSynced = isLocalSynced && isRemoteSynced;
@@ -415,6 +440,14 @@ function CollabPageEditor({
const hasConnectedOnceRef = useRef(false);
const [showStatic, setShowStatic] = useState(true);
useSearchNavigationParams({
editor,
isSynced,
pageId,
searchParams,
showStatic,
});
useEffect(() => {
if (
!hasConnectedOnceRef.current &&
@@ -438,6 +471,7 @@ function CollabPageEditor({
{editor && (
<SearchAndReplaceDialog editor={editor} editable={editable} />
)}
{editor && <SearchNavigationDialog editor={editor} />}
{editor && editorIsEditable && (
<div>
@@ -459,6 +493,15 @@ function CollabPageEditor({
{editor && !editorIsEditable && (editable || canComment) && (
<ReadonlyBubbleMenu editor={editor} />
)}
{editor && (
<LightboxView
editor={editor}
open={!!lightboxRequest}
src={lightboxRequest?.src ?? ""}
type={lightboxRequest?.type ?? "image"}
onClose={() => setLightboxRequest(null)}
/>
)}
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
{showReadOnlyCommentPopup && (
<CommentDialog editor={editor} pageId={pageId} readOnly />
@@ -1,15 +1,27 @@
import "@/features/editor/styles/index.css";
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { EditorProvider } from "@tiptap/react";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Editor, EditorProvider } from "@tiptap/react";
import { mainExtensions } from "@/features/editor/extensions/extensions";
import { Document } from "@tiptap/extension-document";
import { Heading, UniqueID } from "@docmost/editor-ext";
import { Text } from "@tiptap/extension-text";
import { Placeholder } from "@tiptap/extension-placeholder";
import { useAtom } from "jotai";
import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import {
lightboxRequestAtom,
readOnlyEditorAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
import { useEditorScroll } from "./hooks/use-editor-scroll";
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
import LightboxView, {
getLightboxClickRequest,
} from "@/features/editor/components/common/lightbox-view";
interface PageEditorProps {
title: string;
@@ -23,6 +35,16 @@ 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({
@@ -31,10 +53,16 @@ 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,
@@ -49,6 +77,11 @@ export default function ReadonlyPageEditor({
isComponentMounted.current = true;
}, []);
useEffect(() => {
if (!isPublicView) return;
setLightboxRequest(null);
}, [pageId, isPublicView]);
const extensions = useMemo(() => {
const excludedExtensions = new Set([
"uniqueID",
@@ -80,7 +113,7 @@ export default function ReadonlyPageEditor({
];
return (
<TransclusionLookupProvider shareId={shareId}>
<TransclusionLookupProvider shareId={shareId} spaceSlug={spaceSlug}>
<div className="page-title">
<EditorProvider
editable={false}
@@ -91,12 +124,27 @@ export default function ReadonlyPageEditor({
></EditorProvider>
</div>
{byline}
<EditorProvider
editable={false}
immediatelyRender={true}
textDirection="auto"
extensions={extensions}
content={content}
editorProps={
isPublicView
? {
handleClickOn: (_view, _pos, node) => {
const request = getLightboxClickRequest(node);
if (!request) return false;
setLightboxRequest(request);
return true;
},
}
: undefined
}
onCreate={({ editor }) => {
if (editor) {
if (pageId) {
@@ -105,13 +153,23 @@ export default function ReadonlyPageEditor({
}
// @ts-ignore
setReadOnlyEditor(editor);
setContentEditor(editor);
handleScrollTo(editor);
editorCreated.current = true;
}
}}
></EditorProvider>
<div style={{ paddingBottom: "20vh" }}></div>
{isPublicView && contentEditor && (
<LightboxView
editor={contentEditor}
open={!!lightboxRequest}
src={lightboxRequest?.src ?? ""}
type={lightboxRequest?.type ?? "image"}
onClose={() => setLightboxRequest(null)}
/>
)}
{trailingSpace && <div style={{ paddingBottom: "20vh" }}></div>}
</TransclusionLookupProvider>
);
}
@@ -38,6 +38,60 @@
line-height: var(--mantine-line-height-md);
}
.media-alt-badge {
position: absolute;
right: 8px;
bottom: 8px;
z-index: 1;
padding: 2px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.03em;
cursor: default;
user-select: none;
background-color: var(--mantine-color-body);
@mixin light {
border: 1px solid var(--mantine-color-gray-3);
color: var(--mantine-color-gray-7);
}
@mixin dark {
border: 1px solid var(--mantine-color-dark-4);
color: var(--mantine-color-dark-1);
}
@media print {
display: none;
}
&::after {
content: attr(data-alt);
display: none;
position: absolute;
right: 0;
bottom: calc(100% + 6px);
width: max-content;
max-width: 320px;
padding: 6px 10px;
border-radius: var(--mantine-radius-default);
font-size: var(--mantine-font-size-sm);
font-weight: 400;
letter-spacing: normal;
line-height: var(--mantine-line-height-sm);
white-space: pre-wrap;
overflow-wrap: break-word;
text-align: start;
background-color: var(--mantine-color-gray-9);
color: var(--mantine-color-white);
}
&:hover::after {
display: block;
}
}
.media-pulse {
animation: media-pulse 1.2s ease-in-out infinite;
@@ -4,7 +4,7 @@
overflow-x: auto;
& table {
overflow-x: hidden;
min-width: 100% !important;
min-width: 700px !important;
}
}
@@ -12,13 +12,19 @@ import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next";
import { IUser } from "@/features/user/types/user.types.ts";
import Paginate from "@/components/common/paginate.tsx";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import { SearchInput } from "@/components/common/search-input.tsx";
import NoTableResults from "@/components/common/no-table-results.tsx";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search.tsx";
export default function GroupMembersList() {
const { t } = useTranslation();
const { groupId } = useParams();
const { cursor, goNext, goPrev } = useCursorPaginate();
const { data, isLoading } = useGroupMembersQuery(groupId, { cursor });
const { search, cursor, goNext, goPrev, handleSearch } =
usePaginateAndSearch();
const { data, isLoading } = useGroupMembersQuery(groupId, {
cursor,
query: search,
});
const removeGroupMember = useRemoveGroupMemberMutation();
const { isAdmin } = useUserRole();
@@ -48,6 +54,7 @@ export default function GroupMembersList() {
return (
<>
<SearchInput onSearch={handleSearch} />
<Table.ScrollContainer minWidth={500}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
@@ -59,55 +66,62 @@ export default function GroupMembersList() {
</Table.Thead>
<Table.Tbody>
{data?.items.map((user: IUser, index: number) => (
<Table.Tr key={index}>
<Table.Td>
<Group gap="sm" wrap="nowrap">
<CustomAvatar avatarUrl={user.avatarUrl} name={user.name} />
<div>
<Text fz="sm" fw={500} lineClamp={1}>
{user.name}
</Text>
<Text fz="xs" c="dimmed">
{user.email}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>
<Badge variant="light">{t("Active")}</Badge>
</Table.Td>
<Table.Td>
{isAdmin && (
<Menu
shadow="xl"
position="bottom-end"
offset={20}
width={200}
withArrow
arrowPosition="center"
>
<Menu.Target>
<ActionIcon
variant="subtle"
c="gray"
aria-label={t("Member actions for {{name}}", {
name: user.name,
})}
>
<IconDots size={20} stroke={2} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={() => openRemoveModal(user.id)}>
{t("Remove group member")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Table.Td>
</Table.Tr>
))}
{data?.items.length > 0 ? (
data?.items.map((user: IUser, index: number) => (
<Table.Tr key={index}>
<Table.Td>
<Group gap="sm" wrap="nowrap">
<CustomAvatar
avatarUrl={user.avatarUrl}
name={user.name}
/>
<div>
<Text fz="sm" fw={500} lineClamp={1}>
{user.name}
</Text>
<Text fz="xs" c="dimmed">
{user.email}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>
<Badge variant="light">{t("Active")}</Badge>
</Table.Td>
<Table.Td>
{isAdmin && (
<Menu
shadow="xl"
position="bottom-end"
offset={20}
width={200}
withArrow
arrowPosition="center"
>
<Menu.Target>
<ActionIcon
variant="subtle"
c="gray"
aria-label={t("Member actions for {{name}}", {
name: user.name,
})}
>
<IconDots size={20} stroke={2} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={() => openRemoveModal(user.id)}>
{t("Remove group member")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Table.Td>
</Table.Tr>
))
) : (
<NoTableResults colSpan={3} />
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
@@ -39,6 +39,7 @@ export function useWorkspaceLabelsQuery(query: string, enabled: boolean) {
queryFn: () => getWorkspaceLabels({ type: "page", query, limit: 50 }),
enabled,
staleTime: 30 * 1000,
placeholderData: keepPreviousData
});
}
@@ -63,6 +63,12 @@ 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 "";
}
@@ -77,6 +83,19 @@ 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]);
@@ -97,7 +116,7 @@ export function NotificationItem({
return (
<UnstyledButton
component={Link}
to={pageUrl ?? ""}
to={linkUrl ?? ""}
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
@@ -124,11 +143,21 @@ export function NotificationItem({
<Text size="sm" lineClamp={2}>
<Trans
i18nKey={getNotificationMessageKey()}
values={{ name: notification.actor?.name }}
values={{
name: isSiemDestination
? destinationName
: 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,7 +9,10 @@ export type NotificationType =
| "page.verification_expired"
| "page.verified"
| "page.approval_requested"
| "page.approval_rejected";
| "page.approval_rejected"
| "siem_destination.failing"
| "siem_destination.disabled"
| "siem_destination.recovered";
export type INotification = {
id: string;
@@ -1,16 +1,63 @@
import classes from "./page-header.module.css";
import PageHeaderMenu from "@/features/page/components/header/page-header-menu.tsx";
import { Group } from "@mantine/core";
import { Badge, Group, Tooltip } from "@mantine/core";
import { IconExternalLink, IconWorld } from "@tabler/icons-react";
import Breadcrumb from "@/features/page/components/breadcrumbs/breadcrumb.tsx";
import { 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}>
<Breadcrumb />
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
<Breadcrumb />
{showPublicBadge && (
<Tooltip label={t("Open public page")} openDelay={250} withArrow>
<Badge
component="a"
href={buildPublicSpaceUrl({
spaceSlug: space.slug,
pageSlugId: page.slugId,
pageTitle: page.title,
})}
target="_blank"
rel="noopener"
size="sm"
variant="light"
leftSection={<IconWorld size={12} />}
rightSection={<IconExternalLink size={11} />}
style={{ flexShrink: 0, cursor: "pointer" }}
>
{t("Public")}
</Badge>
</Tooltip>
)}
</Group>
<Group justify="flex-end" h="100%" px="md" wrap="nowrap" gap="var(--mantine-spacing-xs)">
<PageHeaderMenu readOnly={readOnly} />
+48 -1
View File
@@ -25,11 +25,37 @@ 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) {
@@ -37,6 +63,9 @@ export const buildPageUrl = (
} else {
url = `/s/${spaceName}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
}
url = appendSearchParams(url, search, wholeWord);
return anchorId ? `${url}#${anchorId}` : url;
};
@@ -45,13 +74,31 @@ export const buildSharedPageUrl = (opts: {
pageSlugId: string;
pageTitle?: string;
anchorId?: string;
search?: string[];
wholeWord?: boolean;
}): string => {
const { shareId, pageSlugId, pageTitle, anchorId } = opts;
const { shareId, pageSlugId, pageTitle, anchorId, search, wholeWord } = 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,6 +41,7 @@ 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;
@@ -68,6 +69,7 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
activeId,
renderRow,
indentPerLevel,
rowClassName,
onMove,
onToggle,
readOnly,
@@ -323,7 +325,7 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
style={{ paddingLeft: level * indentPerLevel }}
>
<div
className={styles.node}
className={rowClassName ? `${styles.node} ${rowClassName}` : styles.node}
data-dragging={isDragging || undefined}
data-selected={isSelected || undefined}
data-receiving-drop={
@@ -376,6 +378,7 @@ 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,6 +56,18 @@ 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;
@@ -123,6 +135,9 @@ function DocTreeInner<T extends object>(
renderRow,
indentPerLevel = 16,
rowHeight = 32,
rowClassName,
rowGap,
dynamicRowHeight = false,
onMove,
onToggle,
onSelect,
@@ -132,6 +147,7 @@ function DocTreeInner<T extends object>(
getDragLabel,
uniqueContextId,
emptyState,
footer,
'aria-label': ariaLabel,
} = props;
@@ -197,10 +213,20 @@ 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,
estimateSize: () => rowHeight,
getItemKey: (index) => flat[index].node.id,
estimateSize: (index) => {
const row = flat[index];
const gap = row && rowGap ? rowGap(row.node, row.level, index) : 0;
return rowHeight + gap;
},
overscan: 10,
});
@@ -471,7 +497,12 @@ function DocTreeInner<T extends object>(
);
if (data.length === 0 && emptyState) {
return <div className={styles.treeContainer}>{emptyState}</div>;
return (
<div className={styles.treeContainer}>
{emptyState}
{footer}
</div>
);
}
const virtualItems = virtualizer.getVirtualItems();
@@ -494,6 +525,9 @@ 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}
@@ -501,12 +535,15 @@ 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
@@ -518,6 +555,7 @@ function DocTreeInner<T extends object>(
activeId={effectiveActiveId}
renderRow={renderRow}
indentPerLevel={indentPerLevel}
rowClassName={rowClassName}
onMove={onMove}
onToggle={onToggle}
readOnly={readOnly}
@@ -532,6 +570,7 @@ function DocTreeInner<T extends object>(
);
})}
</ul>
{footer}
</div>
);
}
@@ -0,0 +1,17 @@
import { atom } from "jotai";
import { IPublicSpaceTree } from "@/features/public-space/types/public-space.types.ts";
import { SharedPageTreeNode } from "@/features/share/utils.ts";
export const publicSpaceTreeAtom = atom<IPublicSpaceTree | null>(
null as IPublicSpaceTree | null,
);
export const publicSpaceTreeDataAtom = atom<SharedPageTreeNode[] | null>(
null as SharedPageTreeNode[] | null,
);
export const openPublicSpaceTreeNodesAtom = atom<Record<string, boolean>>({});
export const docsMobileSidebarAtom = atom<boolean>(false);
export const docsMobileTocAtom = atom<boolean>(false);
@@ -0,0 +1,51 @@
.presetRow {
display: flex;
flex-wrap: wrap;
gap: rem(8px);
}
.presetCard {
display: flex;
flex-direction: column;
align-items: center;
gap: rem(6px);
min-width: rem(72px);
padding: rem(10px) rem(12px);
border: 1px solid var(--mantine-color-default-border);
border-radius: rem(8px);
@media (hover: hover) {
&:hover {
background-color: var(--mantine-color-default-hover);
}
}
&:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 1px;
}
}
.presetCard[data-selected="true"] {
border-color: var(--mantine-primary-color-filled);
background-color: var(--mantine-primary-color-light);
}
.presetSwatch {
width: rem(22px);
height: rem(22px);
border-radius: 50%;
border: 1px solid var(--mantine-color-default-border);
flex-shrink: 0;
}
.customSwatch {
display: inline-flex;
align-items: center;
justify-content: center;
width: rem(22px);
height: rem(22px);
border-radius: 50%;
border: 1px dashed var(--mantine-color-default-border);
color: var(--mantine-color-dimmed);
}
@@ -0,0 +1,165 @@
import {
ColorInput,
Group,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { IconColorPicker } from "@tabler/icons-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
DEFAULT_DOCS_PRESET,
DOCS_THEME_PRESETS,
isValidDocsColor,
matchDocsPreset,
} from "@/features/public-space/theme/docs-theme.ts";
import { IPublicSpaceAppearance } from "@/features/public-space/types/public-space.types.ts";
import { usePublishSpaceMutation } from "@/features/public-space/queries/public-space-query.ts";
import { useHasFeature } from "@/ee/hooks/use-feature.ts";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
import { Feature } from "@/ee/features.ts";
import classes from "./appearance-settings.module.css";
type AppearanceSettingsProps = {
spaceId: string;
appearance?: IPublicSpaceAppearance;
};
export default function AppearanceSettings({
spaceId,
appearance,
}: AppearanceSettingsProps) {
const { t } = useTranslation();
const publishMutation = usePublishSpaceMutation();
const hasAppearance = useHasFeature(Feature.PUBLIC_SPACE_APPEARANCE);
const upgradeLabel = useUpgradeLabel();
const matchedPreset = matchDocsPreset(appearance);
const [customOpen, setCustomOpen] = useState(matchedPreset === null);
const [customLight, setCustomLight] = useState(
appearance?.primaryColorLight ?? DEFAULT_DOCS_PRESET.light,
);
const [customDark, setCustomDark] = useState(
appearance?.primaryColorDark ?? DEFAULT_DOCS_PRESET.dark,
);
useEffect(() => {
setCustomOpen(matchDocsPreset(appearance) === null);
setCustomLight(appearance?.primaryColorLight ?? DEFAULT_DOCS_PRESET.light);
setCustomDark(appearance?.primaryColorDark ?? DEFAULT_DOCS_PRESET.dark);
}, [appearance?.primaryColorLight, appearance?.primaryColorDark]);
const saveAppearance = (payload: {
primaryColorLight: string | null;
primaryColorDark: string | null;
}) => {
if (!hasAppearance) return;
publishMutation.mutate({ spaceId, enabled: true, appearance: payload });
};
const selectPreset = (presetId: string) => {
setCustomOpen(false);
const preset = DOCS_THEME_PRESETS.find((item) => item.id === presetId);
if (!preset) return;
if (preset.id === DEFAULT_DOCS_PRESET.id) {
saveAppearance({ primaryColorLight: null, primaryColorDark: null });
return;
}
saveAppearance({
primaryColorLight: preset.light,
primaryColorDark: preset.dark,
});
};
const commitCustom = (light: string, dark: string) => {
if (!isValidDocsColor(light) || !isValidDocsColor(dark)) return;
saveAppearance({ primaryColorLight: light, primaryColorDark: dark });
};
const swatches = DOCS_THEME_PRESETS.flatMap((preset) => [
preset.light,
preset.dark,
]);
return (
<div>
<Text size="md" mt="md">
{t("Appearance")}
</Text>
<Text size="sm" c="dimmed">
{t("Choose the primary color of the public docs site.")}
</Text>
<Tooltip
label={upgradeLabel}
disabled={hasAppearance}
position="top-start"
>
<div className={classes.presetRow} style={{ marginTop: 10 }}>
{DOCS_THEME_PRESETS.map((preset) => {
const selected = !customOpen && matchedPreset?.id === preset.id;
return (
<UnstyledButton
key={preset.id}
className={classes.presetCard}
data-selected={selected || undefined}
aria-pressed={selected}
disabled={!hasAppearance}
onClick={() => selectPreset(preset.id)}
>
<span
className={classes.presetSwatch}
style={{
background: `linear-gradient(135deg, ${preset.light} 50%, ${preset.dark} 50%)`,
}}
/>
<Text size="xs">{t(preset.nameKey)}</Text>
</UnstyledButton>
);
})}
<UnstyledButton
className={classes.presetCard}
data-selected={customOpen || undefined}
aria-pressed={customOpen}
disabled={!hasAppearance}
onClick={() => setCustomOpen(true)}
>
<span className={classes.customSwatch}>
<IconColorPicker size={13} stroke={2} aria-hidden />
</span>
<Text size="xs">{t("Custom")}</Text>
</UnstyledButton>
</div>
</Tooltip>
{customOpen && hasAppearance && (
<Group grow mt="sm" align="flex-start">
<ColorInput
label={t("Light mode color")}
format="hex"
value={customLight}
swatches={swatches}
onChange={setCustomLight}
onChangeEnd={(value) => {
setCustomLight(value);
commitCustom(value, customDark);
}}
/>
<ColorInput
label={t("Dark mode color")}
format="hex"
value={customDark}
swatches={swatches}
onChange={setCustomDark}
onChangeEnd={(value) => {
setCustomDark(value);
commitCustom(customLight, value);
}}
/>
</Group>
)}
</div>
);
}
@@ -0,0 +1,114 @@
import { Menu } from "@mantine/core";
import { useMediaQuery } from "@mantine/hooks";
import { Link, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Fragment, useMemo, type ReactNode } from "react";
import { useDocsSurface } from "@/features/public-space/components/docs/docs-surface-context.tsx";
import { findAncestorTrail } from "@/features/public-space/utils/docs-tree.ts";
import { extractPageSlugId } from "@/lib";
import styles from "./docs.module.css";
type Crumb = {
key: string;
name: string;
url: string;
};
export default function DocsBreadcrumbs() {
const { t } = useTranslation();
const { pageSlug } = useParams();
const { treeData, siteName, homeUrl, getNodeUrl } = useDocsSurface();
const isMobile = useMediaQuery("(max-width: 48em)");
const crumbs = useMemo<Crumb[] | null>(() => {
if (!treeData?.length) return null;
const currentSlugId = pageSlug
? extractPageSlugId(pageSlug)
: treeData[0]?.slugId;
if (!currentSlugId) return null;
const trail = findAncestorTrail(treeData, currentSlugId);
if (trail === null) return null;
const siteCrumbs: Crumb[] =
siteName && homeUrl
? [{ key: "site", name: siteName, url: homeUrl }]
: [];
const list = [
...siteCrumbs,
...trail.map((node) => ({
key: node.slugId,
name: node.name || t("untitled"),
url: getNodeUrl(node),
})),
];
return list.length ? list : null;
}, [treeData, siteName, homeUrl, getNodeUrl, pageSlug, t]);
if (!crumbs) return null;
// Mobile keeps a single line (menu + last crumb, like the app header's
// breadcrumb); desktop collapses the middle beyond 4 crumbs.
const collapsed = crumbs.length > (isMobile ? 1 : 4);
const hidden = !collapsed
? []
: isMobile
? crumbs.slice(0, crumbs.length - 1)
: crumbs.slice(1, crumbs.length - 1);
const items: ReactNode[] = [];
if (collapsed && !isMobile) {
items.push(
<Link key={crumbs[0].key} to={crumbs[0].url} className={styles.crumbLink}>
{crumbs[0].name}
</Link>,
);
}
if (collapsed) {
items.push(
<Menu shadow="md" position="bottom-start" key="hidden">
<Menu.Target>
<button
type="button"
className={styles.crumbEllipsis}
aria-label={t("Show hidden pages")}
>
</button>
</Menu.Target>
<Menu.Dropdown>
{hidden.map((item) => (
<Menu.Item key={item.key} component={Link} to={item.url}>
{item.name}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>,
);
}
const trailing = collapsed ? [crumbs[crumbs.length - 1]] : crumbs;
for (const crumb of trailing) {
items.push(
<Link key={crumb.key} to={crumb.url} className={styles.crumbLink}>
{crumb.name}
</Link>,
);
}
return (
<nav className={styles.breadcrumbs} aria-label={t("Breadcrumb")}>
{items.map((item, index) => (
<Fragment key={index}>
{index > 0 && (
<span className={styles.crumbSeparator} aria-hidden>
/
</span>
)}
{item}
</Fragment>
))}
</nav>
);
}
@@ -0,0 +1,45 @@
import { Button } from "@mantine/core";
import { useAtomValue } from "jotai";
import { useTranslation } from "react-i18next";
import { IconCheck, IconCopy } from "@tabler/icons-react";
import { htmlToMarkdown } from "@docmost/editor-ext";
import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import { useDocsCurrentPage } from "@/features/public-space/hooks/use-docs-current-page.ts";
import { useClipboard } from "@/hooks/use-clipboard";
import styles from "./docs.module.css";
export default function DocsCopyPage() {
const { t } = useTranslation();
const editor = useAtomValue(readOnlyEditorAtom);
const page = useDocsCurrentPage();
const clipboard = useClipboard();
if (!editor) {
return null;
}
const handleCopy = () => {
if (editor.isDestroyed) return;
const markdown = htmlToMarkdown(editor.getHTML());
const title = page?.name ? `# ${page.name}\n\n` : "";
clipboard.copy(`${title}${markdown}`);
};
return (
<Button
variant="default"
size="compact-sm"
className={styles.copyPageButton}
onClick={handleCopy}
leftSection={
clipboard.copied ? (
<IconCheck size={14} stroke={1.8} />
) : (
<IconCopy size={14} stroke={1.8} />
)
}
>
{clipboard.copied ? t("Copied") : t("Copy page")}
</Button>
);
}
@@ -0,0 +1,31 @@
import { Link, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { IconPencil } from "@tabler/icons-react";
import { useAuthenticatedUser } from "@/features/public-space/hooks/use-authenticated-user.ts";
import { useDocsCurrentPage } from "@/features/public-space/hooks/use-docs-current-page.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import styles from "./docs.module.css";
export default function DocsEditPage() {
const { t } = useTranslation();
const { spaceSlug } = useParams();
const page = useDocsCurrentPage();
const { data: currentUser } = useAuthenticatedUser();
if (!currentUser?.user || !page) {
return null;
}
return (
<Link
className={styles.editPageLink}
to={buildPageUrl(spaceSlug, page.slugId, page.name)}
target="_blank"
rel="noopener"
>
<IconPencil size={14} stroke={1.8} aria-hidden />
{t("Edit page")}
</Link>
);
}
@@ -0,0 +1,23 @@
import clsx from "clsx";
import styles from "./docs.module.css";
export default function DocsFooterBranding({
className,
refSource = "public-space",
}: {
className?: string;
refSource?: string;
}) {
return (
<footer className={clsx(styles.footer, className)}>
<a
className={styles.footerBranding}
href={`https://docmost.com?ref=${refSource}`}
target="_blank"
rel="noreferrer"
>
Powered by Docmost
</a>
</footer>
);
}

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