chore(sync): sync updates from docmost/docmost

This commit is contained in:
Forgejo Actions 2026-10-18 04:00:28 +00:00
commit 58b9418ebd
251 changed files with 22385 additions and 1994 deletions

View file

@ -68,3 +68,5 @@ DEBUG_DB=false
# Log http requests
LOG_HTTP=false
# See all environment variables: https://docmost.com/docs/self-hosting/environment-variables

View file

@ -32,6 +32,7 @@ WORKDIR /app
# Copy apps
COPY --from=builder /app/apps/server/dist /app/apps/server/dist
COPY --from=builder /app/apps/client/dist /app/apps/client/dist
COPY --from=builder /app/apps/client/package.json /app/apps/client/package.json
COPY --from=builder /app/apps/server/package.json /app/apps/server/package.json
# Copy packages
@ -51,7 +52,8 @@ RUN chown -R node:node /app
USER node
RUN pnpm install --frozen-lockfile --prod && rm -rf /home/node/.cache/pnpm
# client package.json is only copied for the lockfile check; its deps are bundled in dist
RUN pnpm install --frozen-lockfile --prod --filter '!./apps/client' && rm -rf /home/node/.cache/pnpm
RUN mkdir -p /app/data/storage

View file

@ -37,18 +37,18 @@
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.14.3",
"alfaaz": "1.1.0",
"axios": "1.18.1",
"axios": "1.20.0",
"blueimp-load-image": "5.16.0",
"clsx": "2.1.1",
"file-saver": "2.0.5",
"highlightjs-sap-abap": "0.3.0",
"i18next": "25.10.1",
"i18next-http-backend": "3.0.6",
"i18next-http-backend": "4.0.2",
"jotai": "2.20.1",
"jotai-optics": "0.4.0",
"js-cookie": "3.0.7",
"jwt-decode": "4.0.0",
"katex": "0.16.40",
"katex": "0.19.0",
"lowlight": "3.3.0",
"mantine-form-zod-resolver": "1.3.0",
"mermaid": "11.16.1",

View file

@ -14,6 +14,7 @@
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Sind Sie sicher, dass Sie diesen Benutzer aus der Gruppe entfernen möchten? Der Benutzer verliert den Zugang zu den Ressourcen, auf die diese Gruppe zugreifen kann.",
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Sind Sie sicher, dass Sie diesen Benutzer aus dem Bereich entfernen möchten? Der Benutzer verliert den gesamten Zugang zu diesem Bereich.",
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Sind Sie sicher, dass Sie diese Version wiederherstellen möchten? Alle nicht versionierten Änderungen gehen verloren.",
"Assigned to {{name}}": "Zugewiesen an {{name}}",
"Can become members of groups and spaces in workspace": "Kann Mitglied von Gruppen und Bereichen im Arbeitsbereich werden",
"Can create and edit pages in space.": "Kann Seiten im Bereich erstellen und bearbeiten.",
"Can edit": "Kann bearbeiten",
@ -22,6 +23,8 @@
"Can view": "Kann anzeigen",
"Can view pages in space but not edit.": "Kann Seiten im Bereich anzeigen, aber nicht bearbeiten.",
"Cancel": "Abbrechen",
"Card": "Karte",
"Cards aren't available in this position": "Karten sind an dieser Position nicht verfügbar",
"Change email": "E-Mail ändern",
"Change password": "Passwort ändern",
"Change photo": "Foto ändern",
@ -30,6 +33,8 @@
"Choose your preferred interface language.": "Wählen Sie Ihre bevorzugte Benutzersprache.",
"Choose your preferred page width.": "Wählen Sie Ihre bevorzugte Seitenbreite.",
"Confirm": "Bestätigen",
"Connect to {{name}} to preview": "Mit {{name}} verbinden, um die Vorschau anzuzeigen",
"Connected as {{label}}": "Verbunden als {{label}}",
"Copy as Markdown": "Als Markdown kopieren",
"Copy link": "Link kopieren",
"Create": "Erstellen",
@ -41,11 +46,25 @@
"Dark": "Dunkel",
"Date": "Datum",
"Delete": "Löschen",
"Initiative": "Initiative",
"Integration link actions": "Aktionen für Integrationslinks",
"Last modified by {{name}}": "Zuletzt geändert von {{name}}",
"Open in Slack": "In Slack öffnen",
"Open link": "Link öffnen",
"Paid": "Bezahlt",
"Paste as": "Einfügen als",
"Project": "Projekt",
"Uninstall {{name}}": "{{name}} deinstallieren",
"Disconnect {{name}}": "{{name}} trennen",
"This disconnects your {{name}} account. Links are not enriched for you until you reconnect.": "Dadurch wird die Verbindung zu deinem {{name}}-Konto getrennt. Links werden für dich nicht angereichert, bis du die Verbindung wiederherstellst.",
"This disables the {{name}} integration for the entire workspace. Members' connections are removed and links are not enriched.": "Dadurch wird die {{name}}-Integration für den gesamten Workspace deaktiviert. Die Verbindungen der Mitglieder werden entfernt und Links werden nicht angereichert.",
"This Slack workspace is already connected to another Docmost workspace. Disconnect it there first, or install Docmost into a different Slack workspace.": "Dieser Slack-Workspace ist bereits mit einem anderen Docmost-Workspace verbunden. Trenne die Verbindung dort zuerst oder installiere Docmost in einem anderen Slack-Workspace.",
"Remove from page": "Von Seite entfernen",
"Base options": "Basisoptionen",
"Delete group": "Gruppe löschen",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Sind Sie sicher, dass Sie diese Seite löschen möchten? Dabei werden auch alle Unterseiten und der Seitenverlauf gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
"Description": "Beschreibung",
"Display as": "Anzeigen als",
"Details": "Details",
"e.g ACME": "z. B. ACME",
"e.g ACME Inc": "z. B. ACME GmbH",
@ -77,6 +96,7 @@
"Failed to fetch recent pages": "Fehler beim Abrufen der letzten Seiten",
"Failed to import pages": "Import der Seiten fehlgeschlagen",
"Failed to load page. An error occurred.": "Seite konnte nicht geladen werden. Es ist ein Fehler aufgetreten.",
"Failed to load presentation. An error occurred.": "Präsentation konnte nicht geladen werden. Ein Fehler ist aufgetreten.",
"Failed to update data": "Aktualisierung der Daten fehlgeschlagen",
"Failed to create base": "Basis konnte nicht erstellt werden",
"Failed to update base": "Basis konnte nicht aktualisiert werden",
@ -150,6 +170,8 @@
"page": "Seite",
"Page deleted successfully": "Seite erfolgreich gelöscht",
"Page history": "Seitenverlauf",
"replies": "Antworten",
"reply": "Antwort",
"Select version": "Version auswählen",
"Highlight changes": "Änderungen hervorheben",
"Page import is in progress. Please do not close this tab.": "Der Seitenimport läuft. Bitte schließen Sie diesen Tab nicht.",
@ -161,6 +183,14 @@
"Pending": "Ausstehend",
"Please confirm your action": "Bitte bestätigen Sie Ihre Aktion",
"Preferences": "Einstellungen",
"Presentation mode": "Präsentationsmodus",
"Previous slide": "Vorherige Folie",
"Next slide": "Nächste Folie",
"Exit presentation mode": "Präsentationsmodus beenden",
"Enter fullscreen": "Vollbildmodus aktivieren",
"Exit fullscreen": "Vollbildmodus beenden",
"Laser pointer": "Laserpointer",
"Slide {{current}} of {{total}}": "Folie {{current}} von {{total}}",
"Print PDF": "PDF drucken",
"Profile": "Profil",
"Recently updated": "Kürzlich aktualisiert",
@ -184,6 +214,8 @@
"Invitation sent": "Einladung gesendet",
"Settings": "Einstellungen",
"Setup workspace": "Workspace einrichten",
"show less": "weniger anzeigen",
"show more": "mehr anzeigen",
"Sign In": "Anmelden",
"Sign Up": "Registrieren",
"Slug": "Slug",
@ -207,6 +239,7 @@
"To change your email, you have to enter your password and new email.": "Um Ihre E-Mail-Adresse zu ändern, müssen Sie Ihr Passwort und Ihre neue E-Mail-Adresse eingeben.",
"Toggle full page width": "Volle Seitenbreite umschalten",
"Unable to import pages. Please try again.": "Seiten konnten nicht importiert werden. Bitte versuchen Sie es erneut.",
"Unassigned": "Nicht zugewiesen",
"untitled": "ohne Titel",
"Untitled": "Ohne Titel",
"Updated successfully": "Erfolgreich aktualisiert",
@ -626,6 +659,10 @@
"Restore this base to view its contents.": "Stelle diese Basis wieder her, um ihren Inhalt anzuzeigen.",
"Subpages": "Unterseiten",
"Failed to load subpages": "Unterseiten konnten nicht geladen werden",
"Sort subpages": "Unterseiten sortieren",
"Manual": "Manuell",
"A–Z": "A–Z",
"Recent": "Neueste",
"No subpages": "Keine Unterseiten",
"Subpages (Child pages)": "Unterseiten (untergeordnete Seiten)",
"List all subpages of the current page": "Alle Unterseiten der aktuellen Seite auflisten",
@ -636,6 +673,8 @@
"Search in all your spaces": "In all Ihren Bereichen suchen",
"Type": "Typ",
"Enterprise": "Enterprise",
"Business": "Unternehmen",
"Standard": "Standard",
"Download attachment": "Anhang herunterladen",
"Allowed email domains": "Erlaubte E-Mail-Domains",
"Only users with email addresses from these domains can signup via SSO.": "Nur Benutzer mit E-Mail-Adressen aus diesen Domains können sich per SSO registrieren.",
@ -978,6 +1017,8 @@
"Page menu": "Seitenmenü",
"Expand": "Erweitern",
"Collapse": "Reduzieren",
"Expand {{name}}": "{{name}} erweitern",
"Collapse {{name}}": "{{name}} reduzieren",
"Comment menu": "Kommentarmenü",
"Group menu": "Gruppenmenü",
"Show hidden breadcrumbs": "Ausgeblendete Breadcrumbs anzeigen",
@ -989,6 +1030,9 @@
"Use template": "Vorlage verwenden",
"Preview template: {{title}}": "Vorlage anzeigen: {{title}}",
"Use a template": "Eine Vorlage verwenden",
"Template": "Vorlage",
"Failed to load template": "Vorlage konnte nicht geladen werden",
"This page is no longer empty": "Diese Seite ist nicht mehr leer",
"Search templates...": "Vorlagen suchen...",
"Search spaces...": "Bereiche suchen...",
"No templates found": "Keine Vorlagen gefunden",
@ -1198,8 +1242,8 @@
"Default value": "Standardwert",
"Delete property": "Eigenschaft löschen",
"Delete view": "Ansicht löschen",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Delete {{count}} rows?_one": "1 Zeile löschen?",
"Delete {{count}} rows?_other": "{{count}} Zeilen löschen?",
"Descending": "Absteigend",
"Discard": "Verwerfen",
"Doesn't contain": "Enthält nicht",
@ -1298,10 +1342,10 @@
"Value": "Wert",
"View updated for everyone": "Ansicht für alle aktualisiert",
"You have unsaved changes. Do you want to discard them?": "Sie haben nicht gespeicherte Änderungen. Möchten Sie sie verwerfen?",
"{{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 Zeile gelöscht",
"{{count}} rows deleted_other": "{{count}} Zeilen gelöscht",
"{{count}} selected_one": "1 ausgewählt",
"{{count}} selected_other": "{{count}} ausgewählt",
"Compare": "Vergleichen",
"Compare versions": "Versionen vergleichen",
"Select version from {{date}}": "Version vom {{date}} auswählen",
@ -1313,6 +1357,10 @@
"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",
"Delete attachment": "Anhang löschen",
"Delete {{name}}": "{{name}} löschen",
"Attachment deleted": "Anhang gelöscht",
"Failed to delete attachment": "Löschen des Anhangs fehlgeschlagen",
"Access revoked": "Zugriff widerrufen",
"Authorize application": "Anwendung autorisieren",
"{{name}} wants to access {{workspace}}": "{{name}} möchte auf {{workspace}} zugreifen",
@ -1435,6 +1483,11 @@
"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",
"Integrations": "Integrationen",
"Installed integration": "Integration installiert",
"Uninstalled integration": "Integration deinstalliert",
"Connected integration account": "Integrationskonto verbunden",
"Disconnected integration account": "Integrationskonto getrennt",
"Destination created": "Ziel erstellt",
"Destination deleted": "Ziel gelöscht",
"Destination updated": "Ziel aktualisiert",
@ -1476,5 +1529,49 @@
"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"
"e.g. Splunk prod": "z. B. Splunk Prod",
"Manage workspace integrations.": "Workspace-Integrationen verwalten.",
"No integrations available.": "Keine Integrationen verfügbar.",
"Install": "Installieren",
"Uninstall": "Deinstallieren",
"Failed to start installation": "Installation konnte nicht gestartet werden",
"Integration uninstalled successfully": "Integration erfolgreich deinstalliert",
"Failed to uninstall integration": "Deinstallation der Integration fehlgeschlagen",
"Connections": "Verbindungen",
"Manage the apps you have connected to your account.": "Verwalte die Apps, die du mit deinem Konto verbunden hast.",
"Connect": "Verbinden",
"Connected": "Verbunden",
"Disconnect": "Trennen",
"Reconnect": "Erneut verbinden",
"Connection expired": "Verbindung abgelaufen",
"Integration disconnected": "Integration getrennt",
"Failed to disconnect integration": "Trennen der Integration fehlgeschlagen",
"Failed to start OAuth connection": "OAuth-Verbindung konnte nicht gestartet werden",
"OAuth connection failed. Please try again.": "OAuth-Verbindung fehlgeschlagen. Bitte versuche es erneut.",
"The account you signed in with does not use your Docmost email address. Sign in to the provider with the account that matches your Docmost email and try again.": "Das Konto, mit dem du dich angemeldet hast, verwendet nicht deine Docmost-E-Mail-Adresse. Melde dich beim Anbieter mit dem Konto an, das mit deiner Docmost-E-Mail übereinstimmt, und versuche es erneut.",
"The Slack account you signed in with is not in the Slack workspace connected to Docmost. Sign in to Slack with your account in that workspace and try again.": "Das Slack-Konto, mit dem du dich angemeldet hast, gehört nicht zum mit Docmost verbundenen Slack-Workspace. Melde dich bei Slack mit deinem Konto in diesem Workspace an und versuche es erneut.",
"Connect your {{name}} account so links you paste show rich previews.": "Verbinde dein {{name}}-Konto, damit eingefügte Links umfangreiche Vorschauen anzeigen.",
"Connect {{name}} to Docmost": "{{name}} mit Docmost verbinden",
"Link your Slack account to your Docmost account to:": "Verknüpfe dein Slack-Konto mit deinem Docmost-Konto, um:",
"Search and open Docmost pages from Slack": "Docmost-Seiten aus Slack suchen und öffnen",
"Create pages and save Slack messages to Docmost": "Seiten erstellen und Slack-Nachrichten in Docmost speichern",
"Ask AI questions about your workspace docs": "Der KI Fragen zu deinen Workspace-Dokumenten stellen",
"Get your Docmost notifications as Slack DMs": "Deine Docmost-Benachrichtigungen als Slack-DMs erhalten",
"See previews of Docmost links in Slack and Slack links in Docmost": "Vorschauen von Docmost-Links in Slack und von Slack-Links in Docmost anzeigen",
"You will sign in with Slack to confirm which Slack account is yours. It must use the same email as your Docmost account.": "Du meldest dich mit Slack an, um zu bestätigen, welches Slack-Konto dir gehört. Es muss dieselbe E-Mail-Adresse wie dein Docmost-Konto verwenden.",
"Updated comment": "Kommentar aktualisiert",
"Resolved comment": "Kommentar gelöst",
"Reopened comment": "Kommentar wieder geöffnet",
"Something went wrong": "Etwas ist schiefgelaufen",
"This page failed to load. Reload to try again.": "Diese Seite konnte nicht geladen werden. Lade sie neu, um es erneut zu versuchen.",
"Reload": "Neu laden",
"Tabs": "Registerkarten",
"Insert a multi-tab content block.": "Einen Inhaltsblock mit mehreren Tabs einfügen.",
"Add tab": "Tab hinzufügen",
"Tab actions": "Tab-Aktionen",
"Move left": "Nach links verschieben",
"Move right": "Nach rechts verschieben",
"Delete tab": "Tab löschen",
"Tab label": "Tab-Beschriftung",
"Tab actions for {{name}}": "Tab-Aktionen für {{name}}"
}

View file

@ -14,6 +14,7 @@
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.",
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Are you sure you want to remove this user from the space? The user will lose all access to this space.",
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Are you sure you want to restore this version? Any changes not versioned will be lost.",
"Assigned to {{name}}": "Assigned to {{name}}",
"Can become members of groups and spaces in workspace": "Can become members of groups and spaces in workspace",
"Can create and edit pages in space.": "Can create and edit pages in space.",
"Can edit": "Can edit",
@ -22,6 +23,8 @@
"Can view": "Can view",
"Can view pages in space but not edit.": "Can view pages in space but not edit.",
"Cancel": "Cancel",
"Card": "Card",
"Cards aren't available in this position": "Cards aren't available in this position",
"Change email": "Change email",
"Change password": "Change password",
"Change photo": "Change photo",
@ -30,6 +33,8 @@
"Choose your preferred interface language.": "Choose your preferred interface language.",
"Choose your preferred page width.": "Choose your preferred page width.",
"Confirm": "Confirm",
"Connect to {{name}} to preview": "Connect to {{name}} to preview",
"Connected as {{label}}": "Connected as {{label}}",
"Copy as Markdown": "Copy as Markdown",
"Copy link": "Copy link",
"Create": "Create",
@ -41,11 +46,25 @@
"Dark": "Dark",
"Date": "Date",
"Delete": "Delete",
"Initiative": "Initiative",
"Integration link actions": "Integration link actions",
"Last modified by {{name}}": "Last modified by {{name}}",
"Open in Slack": "Open in Slack",
"Open link": "Open link",
"Paid": "Paid",
"Paste as": "Paste as",
"Project": "Project",
"Uninstall {{name}}": "Uninstall {{name}}",
"Disconnect {{name}}": "Disconnect {{name}}",
"This disconnects your {{name}} account. Links are not enriched for you until you reconnect.": "This disconnects your {{name}} account. Links are not enriched for you until you reconnect.",
"This disables the {{name}} integration for the entire workspace. Members' connections are removed and links are not enriched.": "This disables the {{name}} integration for the entire workspace. Members' connections are removed and links are not enriched.",
"This Slack workspace is already connected to another Docmost workspace. Disconnect it there first, or install Docmost into a different Slack workspace.": "This Slack workspace is already connected to another Docmost workspace. Disconnect it there first, or install Docmost into a different Slack workspace.",
"Remove from page": "Remove from page",
"Base options": "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.": "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.",
"Description": "Description",
"Display as": "Display as",
"Details": "Details",
"e.g ACME": "e.g. ACME",
"e.g ACME Inc": "e.g. ACME Inc",
@ -77,6 +96,7 @@
"Failed to fetch recent pages": "Failed to fetch recent pages",
"Failed to import pages": "Failed to import pages",
"Failed to load page. An error occurred.": "Failed to load page. An error occurred.",
"Failed to load presentation. An error occurred.": "Failed to load presentation. An error occurred.",
"Failed to update data": "Failed to update data",
"Failed to create base": "Failed to create base",
"Failed to update base": "Failed to update base",
@ -150,6 +170,8 @@
"page": "page",
"Page deleted successfully": "Page deleted successfully",
"Page history": "Page history",
"replies": "replies",
"reply": "reply",
"Select version": "Select version",
"Highlight changes": "Highlight changes",
"Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.",
@ -161,6 +183,14 @@
"Pending": "Pending",
"Please confirm your action": "Please confirm your action",
"Preferences": "Preferences",
"Presentation mode": "Presentation mode",
"Previous slide": "Previous slide",
"Next slide": "Next slide",
"Exit presentation mode": "Exit presentation mode",
"Enter fullscreen": "Enter fullscreen",
"Exit fullscreen": "Exit fullscreen",
"Laser pointer": "Laser pointer",
"Slide {{current}} of {{total}}": "Slide {{current}} of {{total}}",
"Print PDF": "Print PDF",
"Profile": "Profile",
"Recently updated": "Recently updated",
@ -184,6 +214,8 @@
"Invitation sent": "Invitation sent",
"Settings": "Settings",
"Setup workspace": "Setup workspace",
"show less": "show less",
"show more": "show more",
"Sign In": "Sign In",
"Sign Up": "Sign Up",
"Slug": "Slug",
@ -207,6 +239,7 @@
"To change your email, you have to enter your password and new email.": "To change your email, you have to enter your password and new email.",
"Toggle full page width": "Toggle full page width",
"Unable to import pages. Please try again.": "Unable to import pages. Please try again.",
"Unassigned": "Unassigned",
"untitled": "untitled",
"Untitled": "Untitled",
"Updated successfully": "Updated successfully",
@ -640,6 +673,8 @@
"Search in all your spaces": "Search in all your spaces",
"Type": "Type",
"Enterprise": "Enterprise",
"Business": "Business",
"Standard": "Standard",
"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 sign up via SSO.",
@ -995,6 +1030,9 @@
"Use template": "Use template",
"Preview template: {{title}}": "Preview template: {{title}}",
"Use a template": "Use a template",
"Template": "Template",
"Failed to load template": "Failed to load template",
"This page is no longer empty": "This page is no longer empty",
"Search templates...": "Search templates...",
"Search spaces...": "Search spaces...",
"No templates found": "No templates found",
@ -1319,6 +1357,10 @@
"No attachments on this page yet.": "No attachments on this page yet.",
"Uploaded by {{name}}": "Uploaded by {{name}}",
"Download {{name}}": "Download {{name}}",
"Delete attachment": "Delete attachment",
"Delete {{name}}": "Delete {{name}}",
"Attachment deleted": "Attachment deleted",
"Failed to delete attachment": "Failed to delete attachment",
"Access revoked": "Access revoked",
"Authorize application": "Authorize application",
"{{name}} wants to access {{workspace}}": "{{name}} wants to access {{workspace}}",
@ -1441,6 +1483,11 @@
"Defaults to this instance's hostname": "Defaults to this instance's hostname",
"Delete destination": "Delete destination",
"Deleted SIEM destination": "Deleted SIEM destination",
"Integrations": "Integrations",
"Installed integration": "Installed integration",
"Uninstalled integration": "Uninstalled integration",
"Connected integration account": "Connected integration account",
"Disconnected integration account": "Disconnected integration account",
"Destination created": "Destination created",
"Destination deleted": "Destination deleted",
"Destination updated": "Destination updated",
@ -1483,6 +1530,41 @@
"Updated SIEM destination": "Updated SIEM destination",
"Verify TLS certificate": "Verify TLS certificate",
"e.g. Splunk prod": "e.g. Splunk prod",
"Manage workspace integrations.": "Manage workspace integrations.",
"No integrations available.": "No integrations available.",
"Install": "Install",
"Uninstall": "Uninstall",
"Failed to start installation": "Failed to start installation",
"Integration uninstalled successfully": "Integration uninstalled successfully",
"Failed to uninstall integration": "Failed to uninstall integration",
"Connections": "Connections",
"Manage the apps you have connected to your account.": "Manage the apps you have connected to your account.",
"Connect": "Connect",
"Connected": "Connected",
"Disconnect": "Disconnect",
"Reconnect": "Reconnect",
"Connection expired": "Connection expired",
"Integration disconnected": "Integration disconnected",
"Failed to disconnect integration": "Failed to disconnect integration",
"Failed to start OAuth connection": "Failed to start OAuth connection",
"OAuth connection failed. Please try again.": "OAuth connection failed. Please try again.",
"The account you signed in with does not use your Docmost email address. Sign in to the provider with the account that matches your Docmost email and try again.": "The account you signed in with does not use your Docmost email address. Sign in to the provider with the account that matches your Docmost email and try again.",
"The Slack account you signed in with is not in the Slack workspace connected to Docmost. Sign in to Slack with your account in that workspace and try again.": "The Slack account you signed in with is not in the Slack workspace connected to Docmost. Sign in to Slack with your account in that workspace and try again.",
"Connect your {{name}} account so links you paste show rich previews.": "Connect your {{name}} account so links you paste show rich previews.",
"Connect {{name}} to Docmost": "Connect {{name}} to Docmost",
"Link your Slack account to your Docmost account to:": "Link your Slack account to your Docmost account to:",
"Search and open Docmost pages from Slack": "Search and open Docmost pages from Slack",
"Create pages and save Slack messages to Docmost": "Create pages and save Slack messages to Docmost",
"Ask AI questions about your workspace docs": "Ask AI questions about your workspace docs",
"Get your Docmost notifications as Slack DMs": "Get your Docmost notifications as Slack DMs",
"See previews of Docmost links in Slack and Slack links in Docmost": "See previews of Docmost links in Slack and Slack links in Docmost",
"You will sign in with Slack to confirm which Slack account is yours. It must use the same email as your Docmost account.": "You will sign in with Slack to confirm which Slack account is yours. It must use the same email as your Docmost account.",
"Updated comment": "Updated comment",
"Resolved comment": "Resolved comment",
"Reopened comment": "Reopened comment",
"Something went wrong": "Something went wrong",
"This page failed to load. Reload to try again.": "This page failed to load. Reload to try again.",
"Reload": "Reload",
"Tabs": "Tabs",
"Insert a multi-tab content block.": "Insert a multi-tab content block.",
"Add tab": "Add tab",

View file

@ -14,6 +14,7 @@
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "¿Está seguro de que desea eliminar a este usuario del grupo? El usuario perderá acceso a los recursos a los que tiene acceso este grupo.",
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "¿Está seguro de que desea eliminar a este usuario del espacio? El usuario perderá todo acceso a este espacio.",
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "¿Está seguro de que desea restaurar esta versión? Cualquier cambio no versionado se perderá.",
"Assigned to {{name}}": "Asignado a {{name}}",
"Can become members of groups and spaces in workspace": "Pueden convertirse en miembros de grupos y espacios en el espacio de trabajo",
"Can create and edit pages in space.": "Puede crear y editar páginas en el espacio.",
"Can edit": "Puede editar",
@ -22,6 +23,8 @@
"Can view": "Puede ver",
"Can view pages in space but not edit.": "Puede ver páginas en el espacio pero no editarlas.",
"Cancel": "Cancelar",
"Card": "Tarjeta",
"Cards aren't available in this position": "Las tarjetas no están disponibles en esta posición",
"Change email": "Cambiar correo electrónico",
"Change password": "Cambiar contraseña",
"Change photo": "Cambiar foto",
@ -30,6 +33,8 @@
"Choose your preferred interface language.": "Elige tu idioma de interfaz preferido.",
"Choose your preferred page width.": "Elige el ancho de página que prefieras.",
"Confirm": "Confirmar",
"Connect to {{name}} to preview": "Conéctate a {{name}} para obtener una vista previa",
"Connected as {{label}}": "Conectado como {{label}}",
"Copy as Markdown": "Copiar como Markdown",
"Copy link": "Copiar enlace",
"Create": "Crear",
@ -41,11 +46,25 @@
"Dark": "Oscuro",
"Date": "Fecha",
"Delete": "Eliminar",
"Initiative": "Iniciativa",
"Integration link actions": "Acciones de enlaces de integración",
"Last modified by {{name}}": "Última modificación realizada por {{name}}",
"Open in Slack": "Abrir en Slack",
"Open link": "Abrir enlace",
"Paid": "De pago",
"Paste as": "Pegar como",
"Project": "Proyecto",
"Uninstall {{name}}": "Desinstalar {{name}}",
"Disconnect {{name}}": "Desconectar {{name}}",
"This disconnects your {{name}} account. Links are not enriched for you until you reconnect.": "Esto desconecta tu cuenta de {{name}}. Los enlaces no se enriquecerán para ti hasta que vuelvas a conectarte.",
"This disables the {{name}} integration for the entire workspace. Members' connections are removed and links are not enriched.": "Esto desactiva la integración de {{name}} para todo el espacio de trabajo. Se eliminan las conexiones de los miembros y los enlaces no se enriquecen.",
"This Slack workspace is already connected to another Docmost workspace. Disconnect it there first, or install Docmost into a different Slack workspace.": "Este espacio de trabajo de Slack ya está conectado a otro espacio de trabajo de Docmost. Desconéctalo allí primero o instala Docmost en otro espacio de trabajo de Slack.",
"Remove from page": "Quitar de la página",
"Base options": "Opciones de la base",
"Delete group": "Eliminar grupo",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "¿Está seguro de que desea eliminar esta página? Esto eliminará sus dependientes y el historial de la página. Esta acción es irreversible.",
"Description": "Descripción",
"Display as": "Mostrar como",
"Details": "Detalles",
"e.g ACME": "p. ej., ACME",
"e.g ACME Inc": "p. ej., ACME Inc",
@ -77,6 +96,7 @@
"Failed to fetch recent pages": "Error al obtener las páginas recientes",
"Failed to import pages": "No se pudieron importar las páginas",
"Failed to load page. An error occurred.": "Error al cargar la página. Se produjo un error.",
"Failed to load presentation. An error occurred.": "No se pudo cargar la presentación. Se produjo un error.",
"Failed to update data": "No se pudo actualizar los datos",
"Failed to create base": "No se pudo crear la base",
"Failed to update base": "No se pudo actualizar la base",
@ -150,6 +170,8 @@
"page": "página",
"Page deleted successfully": "Página eliminada con éxito",
"Page history": "Historial de la página",
"replies": "respuestas",
"reply": "respuesta",
"Select version": "Seleccionar versión",
"Highlight changes": "Resaltar cambios",
"Page import is in progress. Please do not close this tab.": "La importación de la página está en curso. Por favor, no cierre esta pestaña.",
@ -161,6 +183,14 @@
"Pending": "Pendiente",
"Please confirm your action": "Por favor, confirme su acción",
"Preferences": "Preferencias",
"Presentation mode": "Modo de presentación",
"Previous slide": "Diapositiva anterior",
"Next slide": "Siguiente diapositiva",
"Exit presentation mode": "Salir del modo de presentación",
"Enter fullscreen": "Entrar en pantalla completa",
"Exit fullscreen": "Salir de pantalla completa",
"Laser pointer": "Puntero láser",
"Slide {{current}} of {{total}}": "Diapositiva {{current}} de {{total}}",
"Print PDF": "Imprimir PDF",
"Profile": "Perfil",
"Recently updated": "Recientemente actualizado",
@ -184,6 +214,8 @@
"Invitation sent": "Invitación enviada",
"Settings": "Configuración",
"Setup workspace": "Configurar espacio de trabajo",
"show less": "mostrar menos",
"show more": "mostrar más",
"Sign In": "Iniciar sesión",
"Sign Up": "Registrarse",
"Slug": "Slug",
@ -207,6 +239,7 @@
"To change your email, you have to enter your password and new email.": "Para cambiar tu correo electrónico, debes ingresar tu contraseña y nuevo correo electrónico.",
"Toggle full page width": "Alternar ancho completo de la página",
"Unable to import pages. Please try again.": "No se pueden importar las páginas. Por favor, inténtelo de nuevo.",
"Unassigned": "Sin asignar",
"untitled": "sin título",
"Untitled": "Sin título",
"Updated successfully": "Actualizado correctamente",
@ -626,6 +659,10 @@
"Restore this base to view its contents.": "Restaura esta base para ver su contenido.",
"Subpages": "Subpáginas",
"Failed to load subpages": "No se pudieron cargar las subpáginas",
"Sort subpages": "Ordenar subpáginas",
"Manual": "Manual",
"A–Z": "A–Z",
"Recent": "Reciente",
"No subpages": "No hay subpáginas",
"Subpages (Child pages)": "Subpáginas (páginas hijas)",
"List all subpages of the current page": "Listar todas las subpáginas de la página actual",
@ -636,6 +673,8 @@
"Search in all your spaces": "Buscar en todos tus espacios",
"Type": "Tipo",
"Enterprise": "Empresarial",
"Business": "Negocios",
"Standard": "Estándar",
"Download attachment": "Descargar archivo adjunto",
"Allowed email domains": "Dominios de correo electrónico permitidos",
"Only users with email addresses from these domains can signup via SSO.": "Solo los usuarios con direcciones de correo electrónico de estos dominios pueden registrarse mediante SSO.",
@ -978,6 +1017,8 @@
"Page menu": "Menú de la página",
"Expand": "Expandir",
"Collapse": "Contraer",
"Expand {{name}}": "Expandir {{name}}",
"Collapse {{name}}": "Contraer {{name}}",
"Comment menu": "Menú de comentarios",
"Group menu": "Menú del grupo",
"Show hidden breadcrumbs": "Mostrar rutas de navegación ocultas",
@ -989,6 +1030,9 @@
"Use template": "Usar plantilla",
"Preview template: {{title}}": "Vista previa de la plantilla: {{title}}",
"Use a template": "Usar una plantilla",
"Template": "Plantilla",
"Failed to load template": "No se pudo cargar la plantilla",
"This page is no longer empty": "Esta página ya no está vacía",
"Search templates...": "Buscar plantillas...",
"Search spaces...": "Buscar espacios...",
"No templates found": "No se encontraron plantillas",
@ -1313,6 +1357,10 @@
"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}}",
"Delete attachment": "Eliminar archivo adjunto",
"Delete {{name}}": "Eliminar {{name}}",
"Attachment deleted": "Archivo adjunto eliminado",
"Failed to delete attachment": "No se pudo eliminar el archivo adjunto",
"Access revoked": "Acceso revocado",
"Authorize application": "Autorizar aplicación",
"{{name}} wants to access {{workspace}}": "{{name}} quiere acceder a {{workspace}}",
@ -1435,6 +1483,11 @@
"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",
"Integrations": "Integraciones",
"Installed integration": "Integración instalada",
"Uninstalled integration": "Integración desinstalada",
"Connected integration account": "Cuenta de integración conectada",
"Disconnected integration account": "Cuenta de integración desconectada",
"Destination created": "Destino creado",
"Destination deleted": "Destino eliminado",
"Destination updated": "Destino actualizado",
@ -1476,5 +1529,49 @@
"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"
"e.g. Splunk prod": "p. ej., Splunk producción",
"Manage workspace integrations.": "Gestiona las integraciones del espacio de trabajo.",
"No integrations available.": "No hay integraciones disponibles.",
"Install": "Instalar",
"Uninstall": "Desinstalar",
"Failed to start installation": "No se pudo iniciar la instalación",
"Integration uninstalled successfully": "La integración se desinstaló correctamente",
"Failed to uninstall integration": "No se pudo desinstalar la integración",
"Connections": "Conexiones",
"Manage the apps you have connected to your account.": "Gestiona las aplicaciones que has conectado a tu cuenta.",
"Connect": "Conectar",
"Connected": "Conectado",
"Disconnect": "Desconectar",
"Reconnect": "Volver a conectar",
"Connection expired": "La conexión ha caducado",
"Integration disconnected": "Integración desconectada",
"Failed to disconnect integration": "No se pudo desconectar la integración",
"Failed to start OAuth connection": "No se pudo iniciar la conexión OAuth",
"OAuth connection failed. Please try again.": "La conexión OAuth falló. Inténtalo de nuevo.",
"The account you signed in with does not use your Docmost email address. Sign in to the provider with the account that matches your Docmost email and try again.": "La cuenta con la que iniciaste sesión no usa tu dirección de correo electrónico de Docmost. Inicia sesión en el proveedor con la cuenta que coincida con tu correo electrónico de Docmost y vuelve a intentarlo.",
"The Slack account you signed in with is not in the Slack workspace connected to Docmost. Sign in to Slack with your account in that workspace and try again.": "La cuenta de Slack con la que iniciaste sesión no está en el espacio de trabajo de Slack conectado a Docmost. Inicia sesión en Slack con tu cuenta de ese espacio de trabajo y vuelve a intentarlo.",
"Connect your {{name}} account so links you paste show rich previews.": "Conecta tu cuenta de {{name}} para que los enlaces que pegues muestren vistas previas enriquecidas.",
"Connect {{name}} to Docmost": "Conectar {{name}} a Docmost",
"Link your Slack account to your Docmost account to:": "Vincula tu cuenta de Slack a tu cuenta de Docmost para:",
"Search and open Docmost pages from Slack": "Buscar y abrir páginas de Docmost desde Slack",
"Create pages and save Slack messages to Docmost": "Crear páginas y guardar mensajes de Slack en Docmost",
"Ask AI questions about your workspace docs": "Hacer preguntas a la IA sobre los documentos de tu espacio de trabajo",
"Get your Docmost notifications as Slack DMs": "Recibir tus notificaciones de Docmost como mensajes directos de Slack",
"See previews of Docmost links in Slack and Slack links in Docmost": "Ver vistas previas de enlaces de Docmost en Slack y de enlaces de Slack en Docmost",
"You will sign in with Slack to confirm which Slack account is yours. It must use the same email as your Docmost account.": "Iniciarás sesión con Slack para confirmar cuál es tu cuenta de Slack. Debe usar el mismo correo electrónico que tu cuenta de Docmost.",
"Updated comment": "Comentario actualizado",
"Resolved comment": "Comentario resuelto",
"Reopened comment": "Comentario reabierto",
"Something went wrong": "Algo salió mal",
"This page failed to load. Reload to try again.": "No se pudo cargar esta página. Vuelve a cargarla para intentarlo de nuevo.",
"Reload": "Volver a cargar",
"Tabs": "Pestañas",
"Insert a multi-tab content block.": "Inserta un bloque de contenido con varias pestañas.",
"Add tab": "Añadir pestaña",
"Tab actions": "Acciones de pestaña",
"Move left": "Mover a la izquierda",
"Move right": "Mover a la derecha",
"Delete tab": "Eliminar pestaña",
"Tab label": "Etiqueta de la pestaña",
"Tab actions for {{name}}": "Acciones de pestaña para {{name}}"
}

View file

@ -14,6 +14,7 @@
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Êtes-vous sûr de vouloir retirer cet utilisateur du groupe ? L'utilisateur perdra l'accès aux ressources auxquelles ce groupe a accès.",
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Êtes-vous sûr de vouloir retirer cet utilisateur de l'espace ? L'utilisateur perdra tout accès à cet espace.",
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Êtes-vous sûr de vouloir restaurer cette version ? Toutes les modifications non versionnées seront perdues.",
"Assigned to {{name}}": "Attribué à {{name}}",
"Can become members of groups and spaces in workspace": "Peut devenir membre de groupes et d'espaces dans l'espace de travail",
"Can create and edit pages in space.": "Peut créer et modifier des pages dans l'espace.",
"Can edit": "Peut modifier",
@ -22,6 +23,8 @@
"Can view": "Peut voir",
"Can view pages in space but not edit.": "Peut voir les pages dans l'espace mais ne peut pas les modifier.",
"Cancel": "Annuler",
"Card": "Carte",
"Cards aren't available in this position": "Les cartes ne sont pas disponibles à cet emplacement",
"Change email": "Changer le courriel",
"Change password": "Changer le mot de passe",
"Change photo": "Changer la photo",
@ -30,6 +33,8 @@
"Choose your preferred interface language.": "Choisissez votre langue d'interface préférée.",
"Choose your preferred page width.": "Choisissez votre largeur de page préférée.",
"Confirm": "Confirmer",
"Connect to {{name}} to preview": "Connectez-vous à {{name}} pour voir l’aperçu",
"Connected as {{label}}": "Connecté en tant que {{label}}",
"Copy as Markdown": "Copier comme Markdown",
"Copy link": "Copier le lien",
"Create": "Créer",
@ -41,11 +46,25 @@
"Dark": "Sombre",
"Date": "Date",
"Delete": "Supprimer",
"Initiative": "Initiative",
"Integration link actions": "Actions du lien d’intégration",
"Last modified by {{name}}": "Dernière modification par {{name}}",
"Open in Slack": "Ouvrir dans Slack",
"Open link": "Ouvrir le lien",
"Paid": "Payant",
"Paste as": "Coller en tant que",
"Project": "Projet",
"Uninstall {{name}}": "Désinstaller {{name}}",
"Disconnect {{name}}": "Déconnecter {{name}}",
"This disconnects your {{name}} account. Links are not enriched for you until you reconnect.": "Cela déconnecte votre compte {{name}}. Les liens ne seront plus enrichis pour vous tant que vous ne vous reconnecterez pas.",
"This disables the {{name}} integration for the entire workspace. Members' connections are removed and links are not enriched.": "Cela désactive l’intégration {{name}} pour tout l’espace de travail. Les connexions des membres sont supprimées et les liens ne sont plus enrichis.",
"This Slack workspace is already connected to another Docmost workspace. Disconnect it there first, or install Docmost into a different Slack workspace.": "Cet espace de travail Slack est déjà connecté à un autre espace de travail Docmost. Déconnectez-le d’abord là-bas, ou installez Docmost dans un autre espace de travail Slack.",
"Remove from page": "Supprimer de la page",
"Base options": "Options de la base",
"Delete group": "Supprimer groupe",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Êtes-vous sûr de vouloir supprimer cette page ? Cela supprimera ses enfants et l'historique de la page. Cette action est irréversible.",
"Description": "Description",
"Display as": "Afficher comme",
"Details": "Détails",
"e.g ACME": "p. ex. ACME",
"e.g ACME Inc": "p. ex. ACME Inc",
@ -77,6 +96,7 @@
"Failed to fetch recent pages": "Échec de la récupération des pages récentes",
"Failed to import pages": "Échec de l'importation des pages",
"Failed to load page. An error occurred.": "Échec du chargement de la page. Une erreur s'est produite.",
"Failed to load presentation. An error occurred.": "Échec du chargement de la présentation. Une erreur s’est produite.",
"Failed to update data": "Échec de la mise à jour des données",
"Failed to create base": "Échec de la création de la base",
"Failed to update base": "Échec de la mise à jour de la base",
@ -150,6 +170,8 @@
"page": "page",
"Page deleted successfully": "Page supprimée avec succès",
"Page history": "Historique de la page",
"replies": "réponses",
"reply": "réponse",
"Select version": "Sélectionner la version",
"Highlight changes": "Mettre en évidence les changements",
"Page import is in progress. Please do not close this tab.": "L'importation de la page est en cours. Veuillez ne pas fermer cet onglet.",
@ -161,6 +183,14 @@
"Pending": "En attente",
"Please confirm your action": "Veuillez confirmer votre action",
"Preferences": "Préférences",
"Presentation mode": "Mode présentation",
"Previous slide": "Diapositive précédente",
"Next slide": "Diapositive suivante",
"Exit presentation mode": "Quitter le mode présentation",
"Enter fullscreen": "Entrer en plein écran",
"Exit fullscreen": "Quitter le plein écran",
"Laser pointer": "Pointeur laser",
"Slide {{current}} of {{total}}": "Diapositive {{current}} sur {{total}}",
"Print PDF": "Imprimer PDF",
"Profile": "Profil",
"Recently updated": "Récemment mis à jour",
@ -184,6 +214,8 @@
"Invitation sent": "Invitation envoyée",
"Settings": "Paramètres",
"Setup workspace": "Configurer l’espace de travail",
"show less": "afficher moins",
"show more": "afficher plus",
"Sign In": "Se connecter",
"Sign Up": "S’inscrire",
"Slug": "Slug",
@ -207,6 +239,7 @@
"To change your email, you have to enter your password and new email.": "Pour changer votre email, vous devez entrer votre mot de passe et votre nouvel email.",
"Toggle full page width": "Basculer la largeur complète de la page",
"Unable to import pages. Please try again.": "Impossible d'importer les pages. Veuillez réessayer.",
"Unassigned": "Non attribué",
"untitled": "sans titre",
"Untitled": "Sans titre",
"Updated successfully": "Mise à jour réussie",
@ -626,6 +659,10 @@
"Restore this base to view its contents.": "Restaurez cette base pour afficher son contenu.",
"Subpages": "Sous-pages",
"Failed to load subpages": "Échec du chargement des sous-pages",
"Sort subpages": "Trier les sous-pages",
"Manual": "Manuel",
"A–Z": "A–Z",
"Recent": "Récent",
"No subpages": "Aucune sous-page",
"Subpages (Child pages)": "Sous-pages (pages enfants)",
"List all subpages of the current page": "Lister toutes les sous-pages de la page actuelle",
@ -636,6 +673,8 @@
"Search in all your spaces": "Rechercher dans tous vos espaces",
"Type": "Type",
"Enterprise": "Entreprise",
"Business": "Entreprise",
"Standard": "Standard",
"Download attachment": "Télécharger la pièce jointe",
"Allowed email domains": "Domaines e-mail autorisés",
"Only users with email addresses from these domains can signup via SSO.": "Seuls les utilisateurs disposant d’adresses e-mail provenant de ces domaines peuvent s’inscrire via l’authentification unique (SSO).",
@ -978,6 +1017,8 @@
"Page menu": "Menu de la page",
"Expand": "Développer",
"Collapse": "Réduire",
"Expand {{name}}": "Développer {{name}}",
"Collapse {{name}}": "Réduire {{name}}",
"Comment menu": "Menu du commentaire",
"Group menu": "Menu du groupe",
"Show hidden breadcrumbs": "Afficher les fils d’Ariane masqués",
@ -989,6 +1030,9 @@
"Use template": "Utiliser le modèle",
"Preview template: {{title}}": "Aperçu du modèle : {{title}}",
"Use a template": "Utiliser un modèle",
"Template": "Modèle",
"Failed to load template": "Échec du chargement du modèle",
"This page is no longer empty": "Cette page n’est plus vide",
"Search templates...": "Rechercher des modèles...",
"Search spaces...": "Rechercher des espaces...",
"No templates found": "Aucun modèle trouvé",
@ -1313,6 +1357,10 @@
"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}}",
"Delete attachment": "Supprimer la pièce jointe",
"Delete {{name}}": "Supprimer {{name}}",
"Attachment deleted": "Pièce jointe supprimée",
"Failed to delete attachment": "Échec de la suppression de la pièce jointe",
"Access revoked": "Accès révoqué",
"Authorize application": "Autoriser l’application",
"{{name}} wants to access {{workspace}}": "{{name}} souhaite accéder à {{workspace}}",
@ -1421,7 +1469,7 @@
"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",
"{{count}} published spaces": "{{count}} espaces publiés",
"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",
@ -1435,6 +1483,11 @@
"Defaults to this instance's hostname": "Utilise par défaut le nom d’hôte de cette instance",
"Delete destination": "Supprimer la destination",
"Deleted SIEM destination": "Destination SIEM supprimée",
"Integrations": "Intégrations",
"Installed integration": "Intégration installée",
"Uninstalled integration": "Intégration désinstallée",
"Connected integration account": "Compte d’intégration connecté",
"Disconnected integration account": "Compte d’intégration déconnecté",
"Destination created": "Destination créée",
"Destination deleted": "Destination supprimée",
"Destination updated": "Destination mise à jour",
@ -1476,5 +1529,49 @@
"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"
"e.g. Splunk prod": "p. ex. Splunk prod",
"Manage workspace integrations.": "Gérez les intégrations de l’espace de travail.",
"No integrations available.": "Aucune intégration disponible.",
"Install": "Installer",
"Uninstall": "Désinstaller",
"Failed to start installation": "Échec du démarrage de l’installation",
"Integration uninstalled successfully": "Intégration désinstallée avec succès",
"Failed to uninstall integration": "Échec de la désinstallation de l’intégration",
"Connections": "Connexions",
"Manage the apps you have connected to your account.": "Gérez les applications que vous avez connectées à votre compte.",
"Connect": "Connecter",
"Connected": "Connecté",
"Disconnect": "Déconnecter",
"Reconnect": "Reconnecter",
"Connection expired": "Connexion expirée",
"Integration disconnected": "Intégration déconnectée",
"Failed to disconnect integration": "Échec de la déconnexion de l’intégration",
"Failed to start OAuth connection": "Échec du démarrage de la connexion OAuth",
"OAuth connection failed. Please try again.": "La connexion OAuth a échoué. Veuillez réessayer.",
"The account you signed in with does not use your Docmost email address. Sign in to the provider with the account that matches your Docmost email and try again.": "Le compte avec lequel vous vous êtes connecté n’utilise pas votre adresse e-mail Docmost. Connectez-vous au fournisseur avec le compte qui correspond à votre adresse e-mail Docmost, puis réessayez.",
"The Slack account you signed in with is not in the Slack workspace connected to Docmost. Sign in to Slack with your account in that workspace and try again.": "Le compte Slack avec lequel vous vous êtes connecté ne fait pas partie de l’espace de travail Slack connecté à Docmost. Connectez-vous à Slack avec votre compte dans cet espace de travail, puis réessayez.",
"Connect your {{name}} account so links you paste show rich previews.": "Connectez votre compte {{name}} afin que les liens que vous collez affichent des aperçus enrichis.",
"Connect {{name}} to Docmost": "Connecter {{name}} à Docmost",
"Link your Slack account to your Docmost account to:": "Liez votre compte Slack à votre compte Docmost pour :",
"Search and open Docmost pages from Slack": "Rechercher et ouvrir des pages Docmost depuis Slack",
"Create pages and save Slack messages to Docmost": "Créer des pages et enregistrer des messages Slack dans Docmost",
"Ask AI questions about your workspace docs": "Poser des questions à l’IA sur les documents de votre espace de travail",
"Get your Docmost notifications as Slack DMs": "Recevoir vos notifications Docmost sous forme de messages privés Slack",
"See previews of Docmost links in Slack and Slack links in Docmost": "Voir les aperçus des liens Docmost dans Slack et des liens Slack dans Docmost",
"You will sign in with Slack to confirm which Slack account is yours. It must use the same email as your Docmost account.": "Vous vous connecterez avec Slack pour confirmer quel compte Slack est le vôtre. Il doit utiliser la même adresse e-mail que votre compte Docmost.",
"Updated comment": "Commentaire mis à jour",
"Resolved comment": "Commentaire résolu",
"Reopened comment": "Commentaire rouvert",
"Something went wrong": "Une erreur s’est produite",
"This page failed to load. Reload to try again.": "Cette page n’a pas pu être chargée. Rechargez-la pour réessayer.",
"Reload": "Recharger",
"Tabs": "Onglets",
"Insert a multi-tab content block.": "Insérer un bloc de contenu à onglets multiples.",
"Add tab": "Ajouter un onglet",
"Tab actions": "Actions de l’onglet",
"Move left": "Déplacer vers la gauche",
"Move right": "Déplacer vers la droite",
"Delete tab": "Supprimer l’onglet",
"Tab label": "Libellé de l’onglet",
"Tab actions for {{name}}": "Actions de l’onglet pour {{name}}"
}

View file

@ -14,6 +14,7 @@
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Sei sicuro di voler rimuovere questo utente dal gruppo? L'utente perderà l'accesso alle risorse accessibili da questo gruppo.",
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Sei sicuro di voler rimuovere questo utente dallo spazio? L'utente perderà tutti gli accessi a questo spazio.",
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Sei sicuro di voler ripristinare questa versione? Qualsiasi modifica non versionata verrà persa.",
"Assigned to {{name}}": "Assegnato a {{name}}",
"Can become members of groups and spaces in workspace": "Può diventare membro di gruppi e spazi nell'area di lavoro",
"Can create and edit pages in space.": "Può creare e modificare le pagine nello spazio.",
"Can edit": "Può modificare",
@ -22,6 +23,8 @@
"Can view": "Può visualizzare",
"Can view pages in space but not edit.": "Può visualizzare le pagine nello spazio ma non può modificarle.",
"Cancel": "Annulla",
"Card": "Scheda",
"Cards aren't available in this position": "Le schede non sono disponibili in questa posizione",
"Change email": "Cambia email",
"Change password": "Cambia password",
"Change photo": "Cambia foto",
@ -30,6 +33,8 @@
"Choose your preferred interface language.": "Scegli la lingua da utilizzare per l'interfaccia.",
"Choose your preferred page width.": "Scegli la larghezza della pagina che preferisci.",
"Confirm": "Conferma",
"Connect to {{name}} to preview": "Connettiti a {{name}} per visualizzare l'anteprima",
"Connected as {{label}}": "Connesso come {{label}}",
"Copy as Markdown": "Copia come Markdown",
"Copy link": "Copia link",
"Create": "Crea",
@ -41,11 +46,25 @@
"Dark": "Scuro",
"Date": "Data",
"Delete": "Elimina",
"Initiative": "Iniziativa",
"Integration link actions": "Azioni dei collegamenti di integrazione",
"Last modified by {{name}}": "Ultima modifica di {{name}}",
"Open in Slack": "Apri in Slack",
"Open link": "Apri collegamento",
"Paid": "A pagamento",
"Paste as": "Incolla come",
"Project": "Progetto",
"Uninstall {{name}}": "Disinstalla {{name}}",
"Disconnect {{name}}": "Disconnetti {{name}}",
"This disconnects your {{name}} account. Links are not enriched for you until you reconnect.": "Questo disconnette il tuo account {{name}}. I collegamenti non verranno arricchiti finché non ti riconnetti.",
"This disables the {{name}} integration for the entire workspace. Members' connections are removed and links are not enriched.": "Questo disabilita l'integrazione {{name}} per l'intero spazio di lavoro. Le connessioni dei membri vengono rimosse e i collegamenti non vengono arricchiti.",
"This Slack workspace is already connected to another Docmost workspace. Disconnect it there first, or install Docmost into a different Slack workspace.": "Questo spazio di lavoro Slack è già connesso a un altro spazio di lavoro Docmost. Disconnettilo prima lì oppure installa Docmost in un altro spazio di lavoro Slack.",
"Remove from page": "Rimuovi dalla pagina",
"Base options": "Opzioni della base",
"Delete group": "Elimina gruppo",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Sei sicuro di voler eliminare questa pagina? Verranno cancellate anche le sue sottopagine e la cronologia. Questa azione è irreversibile.",
"Description": "Descrizione",
"Display as": "Visualizza come",
"Details": "Dettagli",
"e.g ACME": "es. ACME",
"e.g ACME Inc": "es. ACME Inc",
@ -77,6 +96,7 @@
"Failed to fetch recent pages": "Impossibile recuperare le pagine recenti",
"Failed to import pages": "Impossibile importare le pagine",
"Failed to load page. An error occurred.": "Il caricamento della pagina è fallito. Si è verificato un errore.",
"Failed to load presentation. An error occurred.": "Impossibile caricare la presentazione. Si è verificato un errore.",
"Failed to update data": "Impossibile aggiornare i dati",
"Failed to create base": "Impossibile creare la base",
"Failed to update base": "Impossibile aggiornare la base",
@ -150,6 +170,8 @@
"page": "pagina",
"Page deleted successfully": "Pagina eliminata con successo",
"Page history": "Cronologia della pagina",
"replies": "risposte",
"reply": "risposta",
"Select version": "Seleziona versione",
"Highlight changes": "Evidenzia modifiche",
"Page import is in progress. Please do not close this tab.": "L'importazione della pagina è in corso. Si prega di non chiudere questa scheda.",
@ -161,6 +183,14 @@
"Pending": "In sospeso",
"Please confirm your action": "Si prega di confermare la propria azione",
"Preferences": "Preferenze",
"Presentation mode": "Modalità presentazione",
"Previous slide": "Diapositiva precedente",
"Next slide": "Diapositiva successiva",
"Exit presentation mode": "Esci dalla modalità presentazione",
"Enter fullscreen": "Entra a schermo intero",
"Exit fullscreen": "Esci da schermo intero",
"Laser pointer": "Puntatore laser",
"Slide {{current}} of {{total}}": "Diapositiva {{current}} di {{total}}",
"Print PDF": "Stampa PDF",
"Profile": "Profilo",
"Recently updated": "Aggiornato di recente",
@ -184,6 +214,8 @@
"Invitation sent": "Invito inviato",
"Settings": "Impostazioni",
"Setup workspace": "Configura workspace",
"show less": "mostra meno",
"show more": "mostra di più",
"Sign In": "Accedi",
"Sign Up": "Registrati",
"Slug": "Slug",
@ -207,6 +239,7 @@
"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 intera della pagina",
"Unable to import pages. Please try again.": "Impossibile importare le pagine. Riprova.",
"Unassigned": "Non assegnato",
"untitled": "senza titolo",
"Untitled": "Senza titolo",
"Updated successfully": "Aggiornato con successo",
@ -626,6 +659,10 @@
"Restore this base to view its contents.": "Ripristina questa base per visualizzarne i contenuti.",
"Subpages": "Sottopagine",
"Failed to load subpages": "Caricamento delle sottopagine non riuscito",
"Sort subpages": "Ordina sottopagine",
"Manual": "Manuale",
"A–Z": "A–Z",
"Recent": "Recenti",
"No subpages": "Nessuna sottopagina",
"Subpages (Child pages)": "Sottopagine (Pagine figlie)",
"List all subpages of the current page": "Elenca tutte le sottopagine della pagina corrente",
@ -636,6 +673,8 @@
"Search in all your spaces": "Cerca in tutti i tuoi spazi",
"Type": "Tipo",
"Enterprise": "Enterprise",
"Business": "Aziendale",
"Standard": "Standard",
"Download attachment": "Scarica allegato",
"Allowed email domains": "Domini email consentiti",
"Only users with email addresses from these domains can signup via SSO.": "Solo gli utenti con indirizzi email di questi domini possono registrarsi tramite SSO.",
@ -978,6 +1017,8 @@
"Page menu": "Menu della pagina",
"Expand": "Espandi",
"Collapse": "Comprimi",
"Expand {{name}}": "Espandi {{name}}",
"Collapse {{name}}": "Comprimi {{name}}",
"Comment menu": "Menu dei commenti",
"Group menu": "Menu del gruppo",
"Show hidden breadcrumbs": "Mostra breadcrumb nascosti",
@ -989,6 +1030,9 @@
"Use template": "Usa modello",
"Preview template: {{title}}": "Anteprima modello: {{title}}",
"Use a template": "Usa un modello",
"Template": "Modello",
"Failed to load template": "Impossibile caricare il modello",
"This page is no longer empty": "Questa pagina non è più vuota",
"Search templates...": "Cerca modelli...",
"Search spaces...": "Cerca spazi...",
"No templates found": "Nessun modello trovato",
@ -1313,6 +1357,10 @@
"No attachments on this page yet.": "Ancora nessun allegato in questa pagina.",
"Uploaded by {{name}}": "Caricato da {{name}}",
"Download {{name}}": "Scarica {{name}}",
"Delete attachment": "Elimina allegato",
"Delete {{name}}": "Elimina {{name}}",
"Attachment deleted": "Allegato eliminato",
"Failed to delete attachment": "Impossibile eliminare l'allegato",
"Access revoked": "Accesso revocato",
"Authorize application": "Autorizza applicazione",
"{{name}} wants to access {{workspace}}": "{{name}} vuole accedere a {{workspace}}",
@ -1420,8 +1468,8 @@
"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",
"1 published space": "1 spazio pubblicato",
"{{count}} published spaces": "{{count}} spazi pubblicati",
"Actions": "Azioni",
"Add destination": "Aggiungi destinazione",
"Are you sure you want to delete the destination": "Sei sicuro di voler eliminare la destinazione",
@ -1435,6 +1483,11 @@
"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",
"Integrations": "Integrazioni",
"Installed integration": "Integrazione installata",
"Uninstalled integration": "Integrazione disinstallata",
"Connected integration account": "Account di integrazione connesso",
"Disconnected integration account": "Account di integrazione disconnesso",
"Destination created": "Destinazione creata",
"Destination deleted": "Destinazione eliminata",
"Destination updated": "Destinazione aggiornata",
@ -1476,5 +1529,49 @@
"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"
"e.g. Splunk prod": "ad es. Splunk produzione",
"Manage workspace integrations.": "Gestisci le integrazioni dello spazio di lavoro.",
"No integrations available.": "Nessuna integrazione disponibile.",
"Install": "Installa",
"Uninstall": "Disinstalla",
"Failed to start installation": "Impossibile avviare l'installazione",
"Integration uninstalled successfully": "Integrazione disinstallata con successo",
"Failed to uninstall integration": "Impossibile disinstallare l'integrazione",
"Connections": "Connessioni",
"Manage the apps you have connected to your account.": "Gestisci le app che hai connesso al tuo account.",
"Connect": "Connetti",
"Connected": "Connesso",
"Disconnect": "Disconnetti",
"Reconnect": "Riconnetti",
"Connection expired": "Connessione scaduta",
"Integration disconnected": "Integrazione disconnessa",
"Failed to disconnect integration": "Impossibile disconnettere l'integrazione",
"Failed to start OAuth connection": "Impossibile avviare la connessione OAuth",
"OAuth connection failed. Please try again.": "Connessione OAuth non riuscita. Riprova.",
"The account you signed in with does not use your Docmost email address. Sign in to the provider with the account that matches your Docmost email and try again.": "L'account con cui hai effettuato l'accesso non usa il tuo indirizzo email di Docmost. Accedi al provider con l'account che corrisponde alla tua email di Docmost e riprova.",
"The Slack account you signed in with is not in the Slack workspace connected to Docmost. Sign in to Slack with your account in that workspace and try again.": "L'account Slack con cui hai effettuato l'accesso non appartiene allo spazio di lavoro Slack connesso a Docmost. Accedi a Slack con il tuo account in quello spazio di lavoro e riprova.",
"Connect your {{name}} account so links you paste show rich previews.": "Connetti il tuo account {{name}} in modo che i collegamenti che incolli mostrino anteprime avanzate.",
"Connect {{name}} to Docmost": "Connetti {{name}} a Docmost",
"Link your Slack account to your Docmost account to:": "Collega il tuo account Slack al tuo account Docmost per:",
"Search and open Docmost pages from Slack": "Cercare e aprire pagine di Docmost da Slack",
"Create pages and save Slack messages to Docmost": "Creare pagine e salvare messaggi di Slack in Docmost",
"Ask AI questions about your workspace docs": "Fare domande all'IA sui documenti del tuo spazio di lavoro",
"Get your Docmost notifications as Slack DMs": "Ricevere le notifiche di Docmost come messaggi diretti di Slack",
"See previews of Docmost links in Slack and Slack links in Docmost": "Vedere anteprime dei collegamenti di Docmost in Slack e dei collegamenti di Slack in Docmost",
"You will sign in with Slack to confirm which Slack account is yours. It must use the same email as your Docmost account.": "Accederai con Slack per confermare quale account Slack è il tuo. Deve usare la stessa email del tuo account Docmost.",
"Updated comment": "Commento aggiornato",
"Resolved comment": "Commento risolto",
"Reopened comment": "Commento riaperto",
"Something went wrong": "Qualcosa è andato storto",
"This page failed to load. Reload to try again.": "Impossibile caricare questa pagina. Ricaricala per riprovare.",
"Reload": "Ricarica",
"Tabs": "Schede",
"Insert a multi-tab content block.": "Inserisci un blocco di contenuto con più schede.",
"Add tab": "Aggiungi scheda",
"Tab actions": "Azioni scheda",
"Move left": "Sposta a sinistra",
"Move right": "Sposta a destra",
"Delete tab": "Elimina scheda",
"Tab label": "Etichetta scheda",
"Tab actions for {{name}}": "Azioni scheda per {{name}}"
}

View file

@ -14,6 +14,7 @@
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "このユーザをグループから削除してもよろしいですか? ユーザはこのグループがアクセス権を持つリソースにアクセスできなくなります。",
"Are you sure you want to remove this user from the 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.": "このバージョンを復元してもよろしいですか? バージョン管理されていない変更は失われます。",
"Assigned to {{name}}": "{{name}} に割り当て済み",
"Can become members of groups and spaces in workspace": "ワークスペース内のグループやスペースのメンバーになれます",
"Can create and edit pages in space.": "スペース内のページを作成・編集できます",
"Can edit": "編集可能",
@ -22,6 +23,8 @@
"Can view": "閲覧可能",
"Can view pages in space but not edit.": "スペース内のページを閲覧できますが編集はできません",
"Cancel": "キャンセル",
"Card": "カード",
"Cards aren't available in this position": "この位置ではカードは利用できません",
"Change email": "メールアドレスの変更",
"Change password": "パスワードの変更",
"Change photo": "画像の変更",
@ -30,6 +33,8 @@
"Choose your preferred interface language.": "お好みの言語を選択してください",
"Choose your preferred page width.": "お好みのページ幅を選択してください",
"Confirm": "確認",
"Connect to {{name}} to preview": "プレビューするには {{name}} に接続してください",
"Connected as {{label}}": "{{label}} として接続済み",
"Copy as Markdown": "Markdownとしてコピー",
"Copy link": "リンクをコピー",
"Create": "新規作成",
@ -41,11 +46,25 @@
"Dark": "ダーク",
"Date": "日付",
"Delete": "削除",
"Initiative": "イニシアチブ",
"Integration link actions": "連携リンクのアクション",
"Last modified by {{name}}": "最終更新者: {{name}}",
"Open in Slack": "Slack で開く",
"Open link": "リンクを開く",
"Paid": "有料",
"Paste as": "次の形式で貼り付け",
"Project": "プロジェクト",
"Uninstall {{name}}": "{{name}} をアンインストール",
"Disconnect {{name}}": "{{name}} との接続を解除",
"This disconnects your {{name}} account. Links are not enriched for you until you reconnect.": "これにより {{name}} アカウントとの接続が解除されます。再接続するまで、リンクはリッチ化されません。",
"This disables the {{name}} integration for the entire workspace. Members' connections are removed and links are not enriched.": "これにより、ワークスペース全体で {{name}} 連携が無効になります。メンバーの接続は削除され、リンクはリッチ化されません。",
"This Slack workspace is already connected to another Docmost workspace. Disconnect it there first, or install Docmost into a different Slack workspace.": "この Slack ワークスペースは、すでに別の Docmost ワークスペースに接続されています。まずそちらで接続を解除するか、別の Slack ワークスペースに Docmost をインストールしてください。",
"Remove from page": "ページから削除",
"Base options": "ベースのオプション",
"Delete group": "グループを削除",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "このページを削除してもよろしいですか?子ページとページ履歴も削除されます。この操作は取り消せません。",
"Description": "説明",
"Display as": "表示形式",
"Details": "詳細",
"e.g ACME": "例: ACME",
"e.g ACME Inc": "例: ACME Inc",
@ -77,6 +96,7 @@
"Failed to fetch recent pages": "最近のページを取得できませんでした",
"Failed to import pages": "ページのインポートに失敗しました",
"Failed to load page. An error occurred.": "ページの読み込みに失敗しました。エラーが発生しました。",
"Failed to load presentation. An error occurred.": "プレゼンテーションの読み込みに失敗しました。エラーが発生しました。",
"Failed to update data": "データの更新に失敗しました",
"Failed to create base": "ベースの作成に失敗しました",
"Failed to update base": "ベースの更新に失敗しました",
@ -150,6 +170,8 @@
"page": "ページ",
"Page deleted successfully": "ページを削除しました",
"Page history": "ページ履歴",
"replies": "返信",
"reply": "返信",
"Select version": "バージョンを選択",
"Highlight changes": "変更を強調表示",
"Page import is in progress. Please do not close this tab.": "ページをインポート中です。このタブを閉じないでください",
@ -161,6 +183,14 @@
"Pending": "保留中",
"Please confirm your action": "アクションを確認してください",
"Preferences": "設定",
"Presentation mode": "プレゼンテーションモード",
"Previous slide": "前のスライド",
"Next slide": "次のスライド",
"Exit presentation mode": "プレゼンテーションモードを終了",
"Enter fullscreen": "全画面表示にする",
"Exit fullscreen": "全画面表示を終了",
"Laser pointer": "レーザーポインター",
"Slide {{current}} of {{total}}": "スライド {{current}} / {{total}}",
"Print PDF": "PDFを印刷",
"Profile": "プロフィール",
"Recently updated": "最近の更新",
@ -184,6 +214,8 @@
"Invitation sent": "招待を送信しました",
"Settings": "設定",
"Setup workspace": "ワークスペースを設定",
"show less": "表示を減らす",
"show more": "さらに表示",
"Sign In": "サインイン",
"Sign Up": "サインアップ",
"Slug": "スラッグ",
@ -207,6 +239,7 @@
"To change your email, you have to enter your password and new email.": "メールアドレスを変更するには、パスワードと新しいメールアドレスを入力してください",
"Toggle full page width": "ページ全幅表示を切り替え",
"Unable to import pages. Please try again.": "ページをインポートできませんでした。もう一度お試しください",
"Unassigned": "未割り当て",
"untitled": "無題",
"Untitled": "無題",
"Updated successfully": "正常に更新されました",
@ -626,6 +659,10 @@
"Restore this base to view its contents.": "内容を表示するには、このベースを復元してください。",
"Subpages": "サブページ",
"Failed to load subpages": "サブページの読み込みに失敗しました",
"Sort subpages": "サブページを並べ替え",
"Manual": "手動",
"A–Z": "A~Z",
"Recent": "最近",
"No subpages": "サブページはありません",
"Subpages (Child pages)": "サブページ(子ページ)",
"List all subpages of the current page": "現在のページのすべてのサブページを一覧表示",
@ -636,6 +673,8 @@
"Search in all your spaces": "すべてのスペースを検索",
"Type": "タイプ",
"Enterprise": "エンタープライズ",
"Business": "ビジネス",
"Standard": "スタンダード",
"Download attachment": "添付ファイルをダウンロード",
"Allowed email domains": "許可されたメールドメイン",
"Only users with email addresses from these domains can signup via SSO.": "これらのドメインのメールアドレスを持つユーザーのみが SSO 経由でサインアップできます。",
@ -978,6 +1017,8 @@
"Page menu": "ページメニュー",
"Expand": "展開",
"Collapse": "折りたたむ",
"Expand {{name}}": "{{name}} を展開",
"Collapse {{name}}": "{{name}} を折りたたむ",
"Comment menu": "コメントメニュー",
"Group menu": "グループメニュー",
"Show hidden breadcrumbs": "非表示のパンくずリストを表示",
@ -989,6 +1030,9 @@
"Use template": "テンプレートを使用",
"Preview template: {{title}}": "テンプレートをプレビュー: {{title}}",
"Use a template": "テンプレートを使用",
"Template": "テンプレート",
"Failed to load template": "テンプレートの読み込みに失敗しました",
"This page is no longer empty": "このページはもう空ではありません",
"Search templates...": "テンプレートを検索…",
"Search spaces...": "スペースを検索…",
"No templates found": "テンプレートが見つかりません",
@ -1199,7 +1243,7 @@
"Delete property": "プロパティを削除",
"Delete view": "ビューを削除",
"Delete {{count}} rows?_one": "1行を削除しますか?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Delete {{count}} rows?_other": "{{count}} 行を削除しますか?",
"Descending": "降順",
"Discard": "破棄",
"Doesn't contain": "含まない",
@ -1299,9 +1343,9 @@
"View updated for everyone": "ビューが全員向けに更新されました",
"You have unsaved changes. Do you want to discard them?": "未保存の変更があります。破棄しますか?",
"{{count}} rows deleted_one": "1行を削除しました",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} rows deleted_other": "{{count}} 行を削除しました",
"{{count}} selected_one": "1件を選択中",
"{{count}} selected_other": "{{count}} selected",
"{{count}} selected_other": "{{count}} 件を選択中",
"Compare": "比較",
"Compare versions": "バージョンを比較",
"Select version from {{date}}": "{{date}} のバージョンを選択",
@ -1313,6 +1357,10 @@
"No attachments on this page yet.": "このページにはまだ添付ファイルがありません。",
"Uploaded by {{name}}": "アップロード者: {{name}}",
"Download {{name}}": "{{name}} をダウンロード",
"Delete attachment": "添付ファイルを削除",
"Delete {{name}}": "{{name}} を削除",
"Attachment deleted": "添付ファイルを削除しました",
"Failed to delete attachment": "添付ファイルの削除に失敗しました",
"Access revoked": "アクセスが取り消されました",
"Authorize application": "アプリケーションを認可",
"{{name}} wants to access {{workspace}}": "{{name}} が {{workspace}} へのアクセスを求めています",
@ -1421,7 +1469,7 @@
"Guides, references and answers across all our spaces.": "すべてのスペースにわたるガイド、リファレンス、回答。",
"Search documentation...": "ドキュメントを検索…",
"1 published space": "1 件の公開スペース",
"{{count}} published spaces": "{{count}} published spaces",
"{{count}} published spaces": "公開済みのスペース {{count}} 件",
"Actions": "操作",
"Add destination": "宛先を追加",
"Are you sure you want to delete the destination": "この宛先を削除してもよろしいですか",
@ -1435,6 +1483,11 @@
"Defaults to this instance's hostname": "デフォルトではこのインスタンスのホスト名が使用されます",
"Delete destination": "宛先を削除",
"Deleted SIEM destination": "SIEM 宛先を削除しました",
"Integrations": "連携",
"Installed integration": "インストール済みの連携",
"Uninstalled integration": "アンインストールされた連携",
"Connected integration account": "接続済みの連携アカウント",
"Disconnected integration account": "切断された連携アカウント",
"Destination created": "宛先を作成しました",
"Destination deleted": "宛先を削除しました",
"Destination updated": "宛先を更新しました",
@ -1476,5 +1529,49 @@
"Test event delivered to {{name}}": "テストイベントを {{name}} に配信しました",
"Updated SIEM destination": "SIEM 宛先を更新しました",
"Verify TLS certificate": "TLS 証明書を検証",
"e.g. Splunk prod": "例: Splunk 本番"
"e.g. Splunk prod": "例: Splunk 本番",
"Manage workspace integrations.": "ワークスペースの連携を管理します。",
"No integrations available.": "利用可能な連携はありません。",
"Install": "インストール",
"Uninstall": "アンインストール",
"Failed to start installation": "インストールの開始に失敗しました",
"Integration uninstalled successfully": "連携を正常にアンインストールしました",
"Failed to uninstall integration": "連携のアンインストールに失敗しました",
"Connections": "接続",
"Manage the apps you have connected to your account.": "アカウントに接続したアプリを管理します。",
"Connect": "接続",
"Connected": "接続済み",
"Disconnect": "切断",
"Reconnect": "再接続",
"Connection expired": "接続の有効期限が切れました",
"Integration disconnected": "連携が切断されました",
"Failed to disconnect integration": "連携の切断に失敗しました",
"Failed to start OAuth connection": "OAuth 接続の開始に失敗しました",
"OAuth connection failed. Please try again.": "OAuth 接続に失敗しました。もう一度お試しください。",
"The account you signed in with does not use your Docmost email address. Sign in to the provider with the account that matches your Docmost email and try again.": "サインインしたアカウントは、Docmost のメールアドレスを使用していません。Docmost のメールアドレスと一致するアカウントでプロバイダーにサインインして、もう一度お試しください。",
"The Slack account you signed in with is not in the Slack workspace connected to Docmost. Sign in to Slack with your account in that workspace and try again.": "サインインした Slack アカウントは、Docmost に接続されている Slack ワークスペースに属していません。そのワークスペース内のアカウントで Slack にサインインして、もう一度お試しください。",
"Connect your {{name}} account so links you paste show rich previews.": "貼り付けたリンクにリッチプレビューを表示するには、{{name}} アカウントを接続してください。",
"Connect {{name}} to Docmost": "{{name}} を Docmost に接続",
"Link your Slack account to your Docmost account to:": "Slack アカウントを Docmost アカウントにリンクすると、次のことができます:",
"Search and open Docmost pages from Slack": "Slack から Docmost ページを検索して開く",
"Create pages and save Slack messages to Docmost": "ページを作成し、Slack メッセージを Docmost に保存する",
"Ask AI questions about your workspace docs": "ワークスペースのドキュメントについて AI に質問する",
"Get your Docmost notifications as Slack DMs": "Docmost の通知を Slack のダイレクトメッセージで受け取る",
"See previews of Docmost links in Slack and Slack links in Docmost": "Slack で Docmost リンクのプレビューを表示し、Docmost で Slack リンクのプレビューを表示する",
"You will sign in with Slack to confirm which Slack account is yours. It must use the same email as your Docmost account.": "どの Slack アカウントが自分のものかを確認するために、Slack でサインインします。Docmost アカウントと同じメールアドレスを使用している必要があります。",
"Updated comment": "更新されたコメント",
"Resolved comment": "解決済みのコメント",
"Reopened comment": "再オープンされたコメント",
"Something went wrong": "問題が発生しました",
"This page failed to load. Reload to try again.": "このページの読み込みに失敗しました。再読み込みしてもう一度お試しください。",
"Reload": "再読み込み",
"Tabs": "タブ",
"Insert a multi-tab content block.": "複数タブのコンテンツブロックを挿入します。",
"Add tab": "タブを追加",
"Tab actions": "タブのアクション",
"Move left": "左に移動",
"Move right": "右に移動",
"Delete tab": "タブを削除",
"Tab label": "タブラベル",
"Tab actions for {{name}}": "{{name}} のタブアクション"
}

View file

@ -14,6 +14,7 @@
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "이 사용자를 그룹에서 제거하시겠습니까? 사용자는 이 그룹이 가진 모든 권한을 잃게 됩니다.",
"Are you sure you want to remove this user from the 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.": "이 버전으로 복원하시겠습니까? 저장되지 않은 모든 변경사항이 손실됩니다.",
"Assigned to {{name}}": "{{name}}에 할당됨",
"Can become members of groups and spaces in workspace": "워크스페이스의 그룹 및 스페이스의 멤버가 될 수 있음",
"Can create and edit pages in space.": "스페이스에서 페이지를 생성하고 편집할 수 있습니다.",
"Can edit": "편집할 수 있음",
@ -22,6 +23,8 @@
"Can view": "볼 수 있음",
"Can view pages in space but not edit.": "스페이스의 페이지를 볼 수 있지만, 편집은 할 수 없음.",
"Cancel": "취소",
"Card": "카드",
"Cards aren't available in this position": "이 위치에서는 카드를 사용할 수 없습니다",
"Change email": "이메일 변경",
"Change password": "비밀번호 변경",
"Change photo": "사진 변경",
@ -30,6 +33,8 @@
"Choose your preferred interface language.": "선호하는 인터페이스 언어를 선택하세요.",
"Choose your preferred page width.": "선호하는 페이지 너비를 선택하세요.",
"Confirm": "확인",
"Connect to {{name}} to preview": "미리 보려면 {{name}}에 연결하세요",
"Connected as {{label}}": "{{label}}로 연결됨",
"Copy as Markdown": "Markdown으로 복사",
"Copy link": "링크 복사",
"Create": "생성",
@ -41,11 +46,25 @@
"Dark": "어두운",
"Date": "날짜",
"Delete": "삭제",
"Initiative": "이니셔티브",
"Integration link actions": "통합 링크 작업",
"Last modified by {{name}}": "마지막 수정자: {{name}}",
"Open in Slack": "Slack에서 열기",
"Open link": "링크 열기",
"Paid": "유료",
"Paste as": "다음으로 붙여넣기",
"Project": "프로젝트",
"Uninstall {{name}}": "{{name}} 제거",
"Disconnect {{name}}": "{{name}} 연결 해제",
"This disconnects your {{name}} account. Links are not enriched for you until you reconnect.": "이 작업을 수행하면 {{name}} 계정 연결이 해제됩니다. 다시 연결할 때까지 링크에 대한 풍부한 미리보기가 제공되지 않습니다.",
"This disables the {{name}} integration for the entire workspace. Members' connections are removed and links are not enriched.": "이 작업을 수행하면 전체 워크스페이스에서 {{name}} 통합이 비활성화됩니다. 멤버의 연결이 제거되고 링크에 대한 풍부한 미리보기가 제공되지 않습니다.",
"This Slack workspace is already connected to another Docmost workspace. Disconnect it there first, or install Docmost into a different Slack workspace.": "이 Slack 워크스페이스는 이미 다른 Docmost 워크스페이스에 연결되어 있습니다. 먼저 այնտեղ서 연결을 해제하거나, 다른 Slack 워크스페이스에 Docmost를 설치하세요.",
"Remove from page": "페이지에서 제거",
"Base options": "베이스 옵션",
"Delete group": "그룹 삭제",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "이 페이지를 삭제하시겠습니까? 하위 페이지와 페이지 기록이 모두 삭제됩니다. 이 작업은 되돌릴 수 없습니다.",
"Description": "설명",
"Display as": "다음으로 표시",
"Details": "세부사항",
"e.g ACME": "예: ACME",
"e.g ACME Inc": "예: ACME Inc",
@ -77,6 +96,7 @@
"Failed to fetch recent pages": "최근 페이지 불러오기 실패",
"Failed to import pages": "페이지 가져오기 실패",
"Failed to load page. An error occurred.": "페이지 불러오기 실패. 오류가 발생했습니다.",
"Failed to load presentation. An error occurred.": "프레젠테이션을 불러오지 못했습니다. 오류가 발생했습니다.",
"Failed to update data": "데이터 갱신 실패",
"Failed to create base": "베이스를 만들지 못했습니다",
"Failed to update base": "베이스를 업데이트하지 못했습니다",
@ -150,6 +170,8 @@
"page": "페이지",
"Page deleted successfully": "페이지 삭제됨",
"Page history": "페이지 기록",
"replies": "답글",
"reply": "답글",
"Select version": "버전 선택",
"Highlight changes": "변경 사항 강조",
"Page import is in progress. Please do not close this tab.": "페이지 가져오기가 진행 중입니다. 이 탭을 닫지 마세요.",
@ -161,6 +183,14 @@
"Pending": "대기 중",
"Please confirm your action": "작업을 확인해 주세요",
"Preferences": "설정",
"Presentation mode": "프레젠테이션 모드",
"Previous slide": "이전 슬라이드",
"Next slide": "다음 슬라이드",
"Exit presentation mode": "프레젠테이션 모드 종료",
"Enter fullscreen": "전체 화면으로 전환",
"Exit fullscreen": "전체 화면 종료",
"Laser pointer": "레이저 포인터",
"Slide {{current}} of {{total}}": "슬라이드 {{total}}개 중 {{current}}",
"Print PDF": "PDF로 인쇄",
"Profile": "프로필",
"Recently updated": "최근 업데이트",
@ -184,6 +214,8 @@
"Invitation sent": "초대를 보냈습니다",
"Settings": "설정",
"Setup workspace": "워크스페이스 설정",
"show less": "간단히 보기",
"show more": "더 보기",
"Sign In": "로그인",
"Sign Up": "회원가입",
"Slug": "슬러그",
@ -207,6 +239,7 @@
"To change your email, you have to enter your password and new email.": "이메일을 변경하려면 기존 비밀번호와 새 이메일을 입력해야 합니다.",
"Toggle full page width": "전체 페이지 너비 전환",
"Unable to import pages. Please try again.": "페이지를 가져올 수 없습니다. 다시 시도해주세요.",
"Unassigned": "할당되지 않음",
"untitled": "제목 없음",
"Untitled": "제목 없음",
"Updated successfully": "업데이트 완료",
@ -626,6 +659,10 @@
"Restore this base to view its contents.": "이 베이스를 복원하여 내용을 보세요.",
"Subpages": "하위 페이지",
"Failed to load subpages": "하위 페이지를 불러오지 못했습니다",
"Sort subpages": "하위 페이지 정렬",
"Manual": "수동",
"A–Z": "가나다순",
"Recent": "최근순",
"No subpages": "하위 페이지가 없습니다",
"Subpages (Child pages)": "하위 페이지 (자식 페이지)",
"List all subpages of the current page": "현재 페이지의 모든 하위 페이지 나열",
@ -636,6 +673,8 @@
"Search in all your spaces": "모든 스페이스에서 검색",
"Type": "유형",
"Enterprise": "엔터프라이즈",
"Business": "비즈니스",
"Standard": "스탠다드",
"Download attachment": "첨부 파일 다운로드",
"Allowed email domains": "허용된 이메일 도메인",
"Only users with email addresses from these domains can signup via SSO.": "이 도메인의 이메일 주소를 가진 사용자만 SSO를 통해 가입할 수 있습니다.",
@ -978,6 +1017,8 @@
"Page menu": "페이지 메뉴",
"Expand": "펼치기",
"Collapse": "접기",
"Expand {{name}}": "{{name}} 펼치기",
"Collapse {{name}}": "{{name}} 접기",
"Comment menu": "댓글 메뉴",
"Group menu": "그룹 메뉴",
"Show hidden breadcrumbs": "숨겨진 이동 경로 표시",
@ -989,6 +1030,9 @@
"Use template": "템플릿 사용",
"Preview template: {{title}}": "템플릿 미리보기: {{title}}",
"Use a template": "템플릿 사용",
"Template": "템플릿",
"Failed to load template": "템플릿을 불러오지 못했습니다",
"This page is no longer empty": "이 페이지는 더 이상 비어 있지 않습니다",
"Search templates...": "템플릿 검색...",
"Search spaces...": "스페이스 검색...",
"No templates found": "템플릿을 찾을 수 없습니다",
@ -1313,6 +1357,10 @@
"No attachments on this page yet.": "이 페이지에는 아직 첨부 파일이 없습니다.",
"Uploaded by {{name}}": "업로드한 사람: {{name}}",
"Download {{name}}": "{{name}} 다운로드",
"Delete attachment": "첨부 파일 삭제",
"Delete {{name}}": "{{name}} 삭제",
"Attachment deleted": "첨부 파일이 삭제되었습니다",
"Failed to delete attachment": "첨부 파일 삭제 실패",
"Access revoked": "액세스가 취소되었습니다",
"Authorize application": "애플리케이션 승인",
"{{name}} wants to access {{workspace}}": "{{name}}에서 {{workspace}}에 액세스하려고 합니다",
@ -1435,6 +1483,11 @@
"Defaults to this instance's hostname": "기본값은 인스턴스의 호스트명입니다",
"Delete destination": "대상 삭제",
"Deleted SIEM destination": "SIEM 대상 삭제됨",
"Integrations": "통합",
"Installed integration": "설치된 통합",
"Uninstalled integration": "제거된 통합",
"Connected integration account": "연결된 통합 계정",
"Disconnected integration account": "연결 해제된 통합 계정",
"Destination created": "대상 생성됨",
"Destination deleted": "대상 삭제됨",
"Destination updated": "대상 업데이트됨",
@ -1476,5 +1529,49 @@
"Test event delivered to {{name}}": "테스트 이벤트가 {{name}}에 전송되었습니다",
"Updated SIEM destination": "SIEM 대상 업데이트됨",
"Verify TLS certificate": "TLS 인증서 검증",
"e.g. Splunk prod": "예: Splunk 운영"
"e.g. Splunk prod": "예: Splunk 운영",
"Manage workspace integrations.": "워크스페이스 통합을 관리합니다.",
"No integrations available.": "사용 가능한 통합이 없습니다.",
"Install": "설치",
"Uninstall": "제거",
"Failed to start installation": "설치를 시작하지 못했습니다",
"Integration uninstalled successfully": "통합이 성공적으로 제거되었습니다",
"Failed to uninstall integration": "통합 제거에 실패했습니다",
"Connections": "연결",
"Manage the apps you have connected to your account.": "계정에 연결한 앱을 관리합니다.",
"Connect": "연결",
"Connected": "연결됨",
"Disconnect": "연결 해제",
"Reconnect": "다시 연결",
"Connection expired": "연결 만료됨",
"Integration disconnected": "통합 연결이 해제되었습니다",
"Failed to disconnect integration": "통합 연결 해제에 실패했습니다",
"Failed to start OAuth connection": "OAuth 연결을 시작하지 못했습니다",
"OAuth connection failed. Please try again.": "OAuth 연결에 실패했습니다. 다시 시도해 주세요.",
"The account you signed in with does not use your Docmost email address. Sign in to the provider with the account that matches your Docmost email and try again.": "로그인한 계정은 Docmost 이메일 주소를 사용하지 않습니다. Docmost 이메일과 일치하는 계정으로 제공업체에 로그인한 다음 다시 시도하세요.",
"The Slack account you signed in with is not in the Slack workspace connected to Docmost. Sign in to Slack with your account in that workspace and try again.": "로그인한 Slack 계정은 Docmost에 연결된 Slack 워크스페이스에 속해 있지 않습니다. 해당 워크스페이스의 계정으로 Slack에 로그인한 다음 다시 시도하세요.",
"Connect your {{name}} account so links you paste show rich previews.": "붙여넣은 링크에 풍부한 미리보기가 표시되도록 {{name}} 계정을 연결하세요.",
"Connect {{name}} to Docmost": "{{name}}를 Docmost에 연결",
"Link your Slack account to your Docmost account to:": "다음을 위해 Slack 계정을 Docmost 계정에 연결하세요:",
"Search and open Docmost pages from Slack": "Slack에서 Docmost 페이지를 검색하고 열기",
"Create pages and save Slack messages to Docmost": "페이지를 만들고 Slack 메시지를 Docmost에 저장",
"Ask AI questions about your workspace docs": "워크스페이스 문서에 대해 AI에 질문하기",
"Get your Docmost notifications as Slack DMs": "Docmost 알림을 Slack DM으로 받기",
"See previews of Docmost links in Slack and Slack links in Docmost": "Slack에서 Docmost 링크 미리보기와 Docmost에서 Slack 링크 미리보기 보기",
"You will sign in with Slack to confirm which Slack account is yours. It must use the same email as your Docmost account.": "어떤 Slack 계정이 본인 것인지 확인하기 위해 Slack으로 로그인하게 됩니다. 이 계정은 Docmost 계정과 동일한 이메일을 사용해야 합니다.",
"Updated comment": "댓글이 업데이트됨",
"Resolved comment": "댓글이 해결됨",
"Reopened comment": "댓글이 다시 열림",
"Something went wrong": "문제가 발생했습니다",
"This page failed to load. Reload to try again.": "이 페이지를 불러오지 못했습니다. 다시 시도하려면 새로고침하세요.",
"Reload": "새로고침",
"Tabs": "탭",
"Insert a multi-tab content block.": "여러 탭이 있는 콘텐츠 블록을 삽입합니다.",
"Add tab": "탭 추가",
"Tab actions": "탭 작업",
"Move left": "왼쪽으로 이동",
"Move right": "오른쪽으로 이동",
"Delete tab": "탭 삭제",
"Tab label": "탭 레이블",
"Tab actions for {{name}}": "{{name}} 탭 작업"
}

View file

@ -14,6 +14,7 @@
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Weet je zeker dat je deze groep wilt verwijderen? Leden verliezen toegang tot documenten waar deze groep toegang toe heeft.",
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Weet u zeker dat u deze gebruiker van de ruimte wilt verwijderen? De gebruiker zal alle toegang tot deze ruimte verliezen.",
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Weet u zeker dat u deze versie wilt herstellen? Wijzigingen die geen versie hebben zullen verloren gaan.",
"Assigned to {{name}}": "Toegewezen aan {{name}}",
"Can become members of groups and spaces in workspace": "Kunnen lid worden van groepen en ruimtes in de werkruimte",
"Can create and edit pages in space.": "Kan pagina's in de ruimte maken en bewerken.",
"Can edit": "Kan bewerken",
@ -22,6 +23,8 @@
"Can view": "Kan bekijken",
"Can view pages in space but not edit.": "Kan pagina's in de ruimte bekijken maar niet bewerken.",
"Cancel": "Annuleren",
"Card": "Kaart",
"Cards aren't available in this position": "Kaarten zijn niet beschikbaar op deze positie",
"Change email": "Wijzig e-mailadres",
"Change password": "Wijzig wachtwoord",
"Change photo": "Wijzig foto",
@ -30,6 +33,8 @@
"Choose your preferred interface language.": "Kies uw gewenste interfacetaal.",
"Choose your preferred page width.": "Kies uw gewenste paginabreedte.",
"Confirm": "Bevestig",
"Connect to {{name}} to preview": "Verbind met {{name}} om een voorbeeld te bekijken",
"Connected as {{label}}": "Verbonden als {{label}}",
"Copy as Markdown": "Kopiëren als Markdown",
"Copy link": "Link kopiëren",
"Create": "Aanmaken",
@ -41,11 +46,25 @@
"Dark": "Donker",
"Date": "Datum",
"Delete": "Verwijderen",
"Initiative": "Initiatief",
"Integration link actions": "Acties voor integratiekoppelingen",
"Last modified by {{name}}": "Laatst gewijzigd door {{name}}",
"Open in Slack": "Openen in Slack",
"Open link": "Link openen",
"Paid": "Betaald",
"Paste as": "Plakken als",
"Project": "Project",
"Uninstall {{name}}": "{{name}} verwijderen",
"Disconnect {{name}}": "{{name}} ontkoppelen",
"This disconnects your {{name}} account. Links are not enriched for you until you reconnect.": "Hiermee wordt je {{name}}-account ontkoppeld. Links worden voor jou niet verrijkt totdat je opnieuw verbinding maakt.",
"This disables the {{name}} integration for the entire workspace. Members' connections are removed and links are not enriched.": "Hiermee wordt de {{name}}-integratie uitgeschakeld voor de hele werkruimte. De verbindingen van leden worden verwijderd en links worden niet verrijkt.",
"This Slack workspace is already connected to another Docmost workspace. Disconnect it there first, or install Docmost into a different Slack workspace.": "Deze Slack-werkruimte is al verbonden met een andere Docmost-werkruimte. Ontkoppel die daar eerst, of installeer Docmost in een andere Slack-werkruimte.",
"Remove from page": "Van pagina verwijderen",
"Base options": "Basisopties",
"Delete group": "Groep verwijderen",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Weet u zeker dat u deze pagina wilt verwijderen? Dit zal de subpagina's en paginageschiedenis verwijderen. Deze actie kan niet ongedaan gemaakt worden.",
"Description": "Beschrijving",
"Display as": "Weergeven als",
"Details": "Details",
"e.g ACME": "bijv. ACME",
"e.g ACME Inc": "bijv. ACME Inc",
@ -77,6 +96,7 @@
"Failed to fetch recent pages": "Kan recente pagina's niet ophalen",
"Failed to import pages": "Pagina's importeren mislukt",
"Failed to load page. An error occurred.": "Laden van pagina mislukt. Er is een fout opgetreden.",
"Failed to load presentation. An error occurred.": "Presentatie laden mislukt. Er is een fout opgetreden.",
"Failed to update data": "Bijwerken van gegevens mislukt",
"Failed to create base": "Kan basis niet aanmaken",
"Failed to update base": "Kan basis niet bijwerken",
@ -150,6 +170,8 @@
"page": "pagina",
"Page deleted successfully": "Pagina succesvol verwijderd",
"Page history": "Pagina geschiedenis",
"replies": "antwoorden",
"reply": "antwoord",
"Select version": "Selecteer versie",
"Highlight changes": "Wijzigingen markeren",
"Page import is in progress. Please do not close this tab.": "Importeren van pagina's is bezig. Sluit dit tabblad niet.",
@ -161,6 +183,14 @@
"Pending": "Wachtende",
"Please confirm your action": "Bevestig alstublieft uw actie",
"Preferences": "Voorkeuren",
"Presentation mode": "Presentatiemodus",
"Previous slide": "Vorige dia",
"Next slide": "Volgende dia",
"Exit presentation mode": "Presentatiemodus afsluiten",
"Enter fullscreen": "Volledig scherm openen",
"Exit fullscreen": "Volledig scherm afsluiten",
"Laser pointer": "Laserpointer",
"Slide {{current}} of {{total}}": "Dia {{current}} van {{total}}",
"Print PDF": "PDF afdrukken",
"Profile": "Profiel",
"Recently updated": "Recent bijgewerkt",
@ -184,6 +214,8 @@
"Invitation sent": "Uitnodiging verzonden",
"Settings": "Instellingen",
"Setup workspace": "Werkruimte instellen",
"show less": "minder weergeven",
"show more": "meer weergeven",
"Sign In": "Inloggen",
"Sign Up": "Registreren",
"Slug": "Slug",
@ -207,6 +239,7 @@
"To change your email, you have to enter your password and new email.": "Om uw e-mailadres te wijzigen, moet u uw wachtwoord en nieuwe e-mail invullen.",
"Toggle full page width": "Volledige paginabreedte in-/uitschakelen",
"Unable to import pages. Please try again.": "Pagina's importeren is niet gelukt. Probeer het opnieuw.",
"Unassigned": "Niet toegewezen",
"untitled": "zonder titel",
"Untitled": "Zonder titel",
"Updated successfully": "Succesvol bijgewerkt",
@ -626,6 +659,10 @@
"Restore this base to view its contents.": "Herstel deze basis om de inhoud te bekijken.",
"Subpages": "Subpagina's",
"Failed to load subpages": "Subpagina's laden mislukt",
"Sort subpages": "Subpagina's sorteren",
"Manual": "Handmatig",
"A–Z": "A–Z",
"Recent": "Recent",
"No subpages": "Geen subpagina's",
"Subpages (Child pages)": "Subpagina's (onderliggende pagina's)",
"List all subpages of the current page": "Toon alle subpagina's van de huidige pagina",
@ -636,6 +673,8 @@
"Search in all your spaces": "Zoek in al je ruimtes",
"Type": "Type",
"Enterprise": "Enterprise",
"Business": "Bedrijf",
"Standard": "Standaard",
"Download attachment": "Bijlage downloaden",
"Allowed email domains": "Toegestane e-maildomeinen",
"Only users with email addresses from these domains can signup via SSO.": "Alleen gebruikers met e-mailadressen van deze domeinen kunnen zich via SSO registreren.",
@ -978,6 +1017,8 @@
"Page menu": "Paginamenu",
"Expand": "Uitvouwen",
"Collapse": "Samenvouwen",
"Expand {{name}}": "{{name}} uitvouwen",
"Collapse {{name}}": "{{name}} samenvouwen",
"Comment menu": "Reactiemenu",
"Group menu": "Groepsmenu",
"Show hidden breadcrumbs": "Verborgen broodkruimels weergeven",
@ -989,6 +1030,9 @@
"Use template": "Sjabloon gebruiken",
"Preview template: {{title}}": "Voorbeeld van sjabloon: {{title}}",
"Use a template": "Een sjabloon gebruiken",
"Template": "Sjabloon",
"Failed to load template": "Sjabloon laden mislukt",
"This page is no longer empty": "Deze pagina is niet langer leeg",
"Search templates...": "Sjablonen zoeken...",
"Search spaces...": "Werkruimtes zoeken...",
"No templates found": "Geen sjablonen gevonden",
@ -1313,6 +1357,10 @@
"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",
"Delete attachment": "Bijlage verwijderen",
"Delete {{name}}": "{{name}} verwijderen",
"Attachment deleted": "Bijlage verwijderd",
"Failed to delete attachment": "Verwijderen van bijlage mislukt",
"Access revoked": "Toegang ingetrokken",
"Authorize application": "Applicatie autoriseren",
"{{name}} wants to access {{workspace}}": "{{name}} wil toegang tot {{workspace}}",
@ -1435,6 +1483,11 @@
"Defaults to this instance's hostname": "Standaard ingesteld op de hostnaam van deze instantie",
"Delete destination": "Bestemming verwijderen",
"Deleted SIEM destination": "SIEM-bestemming verwijderd",
"Integrations": "Integraties",
"Installed integration": "Geïnstalleerde integratie",
"Uninstalled integration": "Gedeïnstalleerde integratie",
"Connected integration account": "Verbonden integratieaccount",
"Disconnected integration account": "Ontkoppeld integratieaccount",
"Destination created": "Bestemming aangemaakt",
"Destination deleted": "Bestemming verwijderd",
"Destination updated": "Bestemming bijgewerkt",
@ -1476,5 +1529,49 @@
"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"
"e.g. Splunk prod": "bijv. Splunk prod",
"Manage workspace integrations.": "Beheer werkruimte-integraties.",
"No integrations available.": "Geen integraties beschikbaar.",
"Install": "Installeren",
"Uninstall": "Verwijderen",
"Failed to start installation": "Starten van installatie mislukt",
"Integration uninstalled successfully": "Integratie succesvol verwijderd",
"Failed to uninstall integration": "Verwijderen van integratie mislukt",
"Connections": "Verbindingen",
"Manage the apps you have connected to your account.": "Beheer de apps die je met je account hebt verbonden.",
"Connect": "Verbinden",
"Connected": "Verbonden",
"Disconnect": "Ontkoppelen",
"Reconnect": "Opnieuw verbinden",
"Connection expired": "Verbinding verlopen",
"Integration disconnected": "Integratie ontkoppeld",
"Failed to disconnect integration": "Ontkoppelen van integratie mislukt",
"Failed to start OAuth connection": "Starten van OAuth-verbinding mislukt",
"OAuth connection failed. Please try again.": "OAuth-verbinding mislukt. Probeer het opnieuw.",
"The account you signed in with does not use your Docmost email address. Sign in to the provider with the account that matches your Docmost email and try again.": "Het account waarmee je bent ingelogd gebruikt niet je Docmost-e-mailadres. Log in bij de provider met het account dat overeenkomt met je Docmost-e-mailadres en probeer het opnieuw.",
"The Slack account you signed in with is not in the Slack workspace connected to Docmost. Sign in to Slack with your account in that workspace and try again.": "Het Slack-account waarmee je bent ingelogd bevindt zich niet in de Slack-werkruimte die met Docmost is verbonden. Log in bij Slack met je account in die werkruimte en probeer het opnieuw.",
"Connect your {{name}} account so links you paste show rich previews.": "Verbind je {{name}}-account zodat links die je plakt rijke voorbeelden tonen.",
"Connect {{name}} to Docmost": "{{name}} verbinden met Docmost",
"Link your Slack account to your Docmost account to:": "Koppel je Slack-account aan je Docmost-account om:",
"Search and open Docmost pages from Slack": "Docmost-pagina's zoeken en openen vanuit Slack",
"Create pages and save Slack messages to Docmost": "Pagina's maken en Slack-berichten opslaan in Docmost",
"Ask AI questions about your workspace docs": "AI-vragen stellen over de documenten in je werkruimte",
"Get your Docmost notifications as Slack DMs": "Je Docmost-meldingen ontvangen als Slack-DM's",
"See previews of Docmost links in Slack and Slack links in Docmost": "Voorbeelden van Docmost-links in Slack en Slack-links in Docmost bekijken",
"You will sign in with Slack to confirm which Slack account is yours. It must use the same email as your Docmost account.": "Je logt in met Slack om te bevestigen welk Slack-account van jou is. Het moet hetzelfde e-mailadres gebruiken als je Docmost-account.",
"Updated comment": "Reactie bijgewerkt",
"Resolved comment": "Reactie opgelost",
"Reopened comment": "Reactie heropend",
"Something went wrong": "Er is iets misgegaan",
"This page failed to load. Reload to try again.": "Deze pagina kon niet worden geladen. Laad opnieuw om het opnieuw te proberen.",
"Reload": "Opnieuw laden",
"Tabs": "Tabbladen",
"Insert a multi-tab content block.": "Voeg een inhoudsblok met meerdere tabbladen in.",
"Add tab": "Tabblad toevoegen",
"Tab actions": "Tabbladacties",
"Move left": "Naar links verplaatsen",
"Move right": "Naar rechts verplaatsen",
"Delete tab": "Tabblad verwijderen",
"Tab label": "Tabbladlabel",
"Tab actions for {{name}}": "Tabbladacties voor {{name}}"
}

View file

@ -14,6 +14,7 @@
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Tem certeza de que deseja remover este usuário do grupo? O usuário perderá acesso aos recursos que este grupo possui.",
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Tem certeza de que deseja remover este usuário do espaço? O usuário perderá todo acesso a este espaço.",
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Tem certeza de que deseja restaurar esta versão? Quaisquer alterações não versionadas serão perdidas.",
"Assigned to {{name}}": "Atribuído a {{name}}",
"Can become members of groups and spaces in workspace": "Pode se tornar membro de grupos e espaços no workspace",
"Can create and edit pages in space.": "Pode criar e editar páginas no espaço.",
"Can edit": "Pode editar",
@ -22,6 +23,8 @@
"Can view": "Pode visualizar",
"Can view pages in space but not edit.": "Pode visualizar páginas no espaço, mas não editar.",
"Cancel": "Cancelar",
"Card": "Cartão",
"Cards aren't available in this position": "Os cartões não estão disponíveis nesta posição",
"Change email": "Alterar email",
"Change password": "Alterar senha",
"Change photo": "Alterar foto",
@ -30,6 +33,8 @@
"Choose your preferred interface language.": "Escolha o idioma da interface.",
"Choose your preferred page width.": "Escolha a largura preferida da página.",
"Confirm": "Confirmar",
"Connect to {{name}} to preview": "Conecte-se a {{name}} para visualizar",
"Connected as {{label}}": "Conectado como {{label}}",
"Copy as Markdown": "Copiar como Markdown",
"Copy link": "Copiar link",
"Create": "Criar",
@ -41,11 +46,25 @@
"Dark": "Escuro",
"Date": "Data",
"Delete": "Excluir",
"Initiative": "Iniciativa",
"Integration link actions": "Ações de link de integração",
"Last modified by {{name}}": "Última modificação por {{name}}",
"Open in Slack": "Abrir no Slack",
"Open link": "Abrir link",
"Paid": "Pago",
"Paste as": "Colar como",
"Project": "Projeto",
"Uninstall {{name}}": "Desinstalar {{name}}",
"Disconnect {{name}}": "Desconectar {{name}}",
"This disconnects your {{name}} account. Links are not enriched for you until you reconnect.": "Isso desconecta sua conta do {{name}}. Os links não serão enriquecidos para você até que você se reconecte.",
"This disables the {{name}} integration for the entire workspace. Members' connections are removed and links are not enriched.": "Isso desativa a integração com {{name}} para todo o espaço de trabalho. As conexões dos membros são removidas e os links não são enriquecidos.",
"This Slack workspace is already connected to another Docmost workspace. Disconnect it there first, or install Docmost into a different Slack workspace.": "Este espaço de trabalho do Slack já está conectado a outro espaço de trabalho do Docmost. Desconecte-o lá primeiro ou instale o Docmost em um espaço de trabalho diferente do Slack.",
"Remove from page": "Remover da página",
"Base options": "Opções da base",
"Delete group": "Excluir grupo",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Você tem certeza que quer deletar essa página? Isso irá deletar todas as páginas filhas e to o histórico. Esta ação é irreversível.",
"Description": "Descrição",
"Display as": "Exibir como",
"Details": "Detalhes",
"e.g ACME": "ex.: ACME",
"e.g ACME Inc": "ex.: ACME Inc",
@ -77,6 +96,7 @@
"Failed to fetch recent pages": "Falha ao buscar páginas recentes",
"Failed to import pages": "Falha ao importar páginas",
"Failed to load page. An error occurred.": "Falha ao carregar página. Ocorreu um erro.",
"Failed to load presentation. An error occurred.": "Falha ao carregar a apresentação. Ocorreu um erro.",
"Failed to update data": "Falha ao atualizar dados",
"Failed to create base": "Falha ao criar base",
"Failed to update base": "Falha ao atualizar base",
@ -150,6 +170,8 @@
"page": "página",
"Page deleted successfully": "Página excluída com sucesso",
"Page history": "Histórico da página",
"replies": "respostas",
"reply": "resposta",
"Select version": "Selecionar versão",
"Highlight changes": "Destacar alterações",
"Page import is in progress. Please do not close this tab.": "A importação da página está em andamento. Por favor, não feche esta aba.",
@ -161,6 +183,14 @@
"Pending": "Pendente",
"Please confirm your action": "Por favor, confirme sua ação",
"Preferences": "Preferências",
"Presentation mode": "Modo de apresentação",
"Previous slide": "Slide anterior",
"Next slide": "Próximo slide",
"Exit presentation mode": "Sair do modo de apresentação",
"Enter fullscreen": "Entrar em tela cheia",
"Exit fullscreen": "Sair da tela cheia",
"Laser pointer": "Apontador laser",
"Slide {{current}} of {{total}}": "Slide {{current}} de {{total}}",
"Print PDF": "Imprimir PDF",
"Profile": "Perfil",
"Recently updated": "Atualizado recentemente",
@ -184,6 +214,8 @@
"Invitation sent": "Convite enviado",
"Settings": "Configurações",
"Setup workspace": "Configurar workspace",
"show less": "mostrar menos",
"show more": "mostrar mais",
"Sign In": "Entrar",
"Sign Up": "Cadastrar-se",
"Slug": "Slug",
@ -207,6 +239,7 @@
"To change your email, you have to enter your password and new email.": "Para alterar seu email, você precisa inserir sua senha e o novo email.",
"Toggle full page width": "Alternar largura total da página",
"Unable to import pages. Please try again.": "Não foi possível importar as páginas. Por favor, tente novamente.",
"Unassigned": "Não atribuído",
"untitled": "sem título",
"Untitled": "Sem título",
"Updated successfully": "Atualizado com sucesso",
@ -626,6 +659,10 @@
"Restore this base to view its contents.": "Restaure esta base para visualizar seu conteúdo.",
"Subpages": "Subpáginas",
"Failed to load subpages": "Falha ao carregar as subpáginas",
"Sort subpages": "Ordenar subpáginas",
"Manual": "Manual",
"A–Z": "A–Z",
"Recent": "Recentes",
"No subpages": "Nenhuma subpágina",
"Subpages (Child pages)": "Subpáginas (páginas filhas)",
"List all subpages of the current page": "Listar todas as subpáginas da página atual",
@ -636,6 +673,8 @@
"Search in all your spaces": "Pesquisar em todos os seus espaços",
"Type": "Tipo",
"Enterprise": "Enterprise",
"Business": "Negócios",
"Standard": "Padrão",
"Download attachment": "Baixar anexo",
"Allowed email domains": "Domínios de e-mail permitidos",
"Only users with email addresses from these domains can signup via SSO.": "Somente usuários com endereços de e-mail desses domínios podem se cadastrar via SSO.",
@ -978,6 +1017,8 @@
"Page menu": "Menu da página",
"Expand": "Expandir",
"Collapse": "Recolher",
"Expand {{name}}": "Expandir {{name}}",
"Collapse {{name}}": "Recolher {{name}}",
"Comment menu": "Menu de comentários",
"Group menu": "Menu do grupo",
"Show hidden breadcrumbs": "Mostrar breadcrumbs ocultos",
@ -989,6 +1030,9 @@
"Use template": "Usar modelo",
"Preview template: {{title}}": "Visualizar modelo: {{title}}",
"Use a template": "Usar um modelo",
"Template": "Modelo",
"Failed to load template": "Falha ao carregar o modelo",
"This page is no longer empty": "Esta página não está mais vazia",
"Search templates...": "Pesquisar modelos...",
"Search spaces...": "Pesquisar espaços...",
"No templates found": "Nenhum modelo encontrado",
@ -1198,8 +1242,8 @@
"Default value": "Valor padrão",
"Delete property": "Excluir propriedade",
"Delete view": "Excluir visualização",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Delete {{count}} rows?_one": "Excluir 1 linha?",
"Delete {{count}} rows?_other": "Excluir {{count}} linhas?",
"Descending": "Decrescente",
"Discard": "Descartar",
"Doesn't contain": "Não contém",
@ -1298,10 +1342,10 @@
"Value": "Valor",
"View updated for everyone": "Visualização atualizada para todos",
"You have unsaved changes. Do you want to discard them?": "Você tem alterações não salvas. Deseja descartá-las?",
"{{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 linha excluída",
"{{count}} rows deleted_other": "{{count}} linhas excluídas",
"{{count}} selected_one": "1 selecionado",
"{{count}} selected_other": "{{count}} selecionados",
"Compare": "Comparar",
"Compare versions": "Comparar versões",
"Select version from {{date}}": "Selecionar versão de {{date}}",
@ -1313,6 +1357,10 @@
"No attachments on this page yet.": "Ainda não há anexos nesta página.",
"Uploaded by {{name}}": "Enviado por {{name}}",
"Download {{name}}": "Baixar {{name}}",
"Delete attachment": "Excluir anexo",
"Delete {{name}}": "Excluir {{name}}",
"Attachment deleted": "Anexo excluído",
"Failed to delete attachment": "Falha ao excluir anexo",
"Access revoked": "Acesso revogado",
"Authorize application": "Autorizar aplicativo",
"{{name}} wants to access {{workspace}}": "{{name}} quer acessar {{workspace}}",
@ -1435,6 +1483,11 @@
"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",
"Integrations": "Integrações",
"Installed integration": "Integração instalada",
"Uninstalled integration": "Integração desinstalada",
"Connected integration account": "Conta de integração conectada",
"Disconnected integration account": "Conta de integração desconectada",
"Destination created": "Destino criado",
"Destination deleted": "Destino excluído",
"Destination updated": "Destino atualizado",
@ -1476,5 +1529,49 @@
"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"
"e.g. Splunk prod": "ex.: Splunk produção",
"Manage workspace integrations.": "Gerencie as integrações do espaço de trabalho.",
"No integrations available.": "Nenhuma integração disponível.",
"Install": "Instalar",
"Uninstall": "Desinstalar",
"Failed to start installation": "Falha ao iniciar a instalação",
"Integration uninstalled successfully": "Integração desinstalada com sucesso",
"Failed to uninstall integration": "Falha ao desinstalar a integração",
"Connections": "Conexões",
"Manage the apps you have connected to your account.": "Gerencie os aplicativos que você conectou à sua conta.",
"Connect": "Conectar",
"Connected": "Conectado",
"Disconnect": "Desconectar",
"Reconnect": "Reconectar",
"Connection expired": "Conexão expirada",
"Integration disconnected": "Integração desconectada",
"Failed to disconnect integration": "Falha ao desconectar a integração",
"Failed to start OAuth connection": "Falha ao iniciar a conexão OAuth",
"OAuth connection failed. Please try again.": "Falha na conexão OAuth. Tente novamente.",
"The account you signed in with does not use your Docmost email address. Sign in to the provider with the account that matches your Docmost email and try again.": "A conta com a qual você entrou não usa seu endereço de e-mail do Docmost. Entre no provedor com a conta que corresponde ao seu e-mail do Docmost e tente novamente.",
"The Slack account you signed in with is not in the Slack workspace connected to Docmost. Sign in to Slack with your account in that workspace and try again.": "A conta do Slack com a qual você entrou não está no espaço de trabalho do Slack conectado ao Docmost. Entre no Slack com sua conta nesse espaço de trabalho e tente novamente.",
"Connect your {{name}} account so links you paste show rich previews.": "Conecte sua conta do {{name}} para que os links que você colar exibam visualizações avançadas.",
"Connect {{name}} to Docmost": "Conectar {{name}} ao Docmost",
"Link your Slack account to your Docmost account to:": "Vincule sua conta do Slack à sua conta do Docmost para:",
"Search and open Docmost pages from Slack": "Pesquisar e abrir páginas do Docmost a partir do Slack",
"Create pages and save Slack messages to Docmost": "Criar páginas e salvar mensagens do Slack no Docmost",
"Ask AI questions about your workspace docs": "Fazer perguntas à IA sobre os documentos do seu espaço de trabalho",
"Get your Docmost notifications as Slack DMs": "Receber suas notificações do Docmost como mensagens diretas no Slack",
"See previews of Docmost links in Slack and Slack links in Docmost": "Ver visualizações de links do Docmost no Slack e de links do Slack no Docmost",
"You will sign in with Slack to confirm which Slack account is yours. It must use the same email as your Docmost account.": "Você entrará com o Slack para confirmar qual conta do Slack é sua. Ela deve usar o mesmo e-mail da sua conta do Docmost.",
"Updated comment": "Comentário atualizado",
"Resolved comment": "Comentário resolvido",
"Reopened comment": "Comentário reaberto",
"Something went wrong": "Algo deu errado",
"This page failed to load. Reload to try again.": "Falha ao carregar esta página. Recarregue para tentar novamente.",
"Reload": "Recarregar",
"Tabs": "Abas",
"Insert a multi-tab content block.": "Insira um bloco de conteúdo com várias abas.",
"Add tab": "Adicionar aba",
"Tab actions": "Ações da aba",
"Move left": "Mover para a esquerda",
"Move right": "Mover para a direita",
"Delete tab": "Excluir aba",
"Tab label": "Rótulo da aba",
"Tab actions for {{name}}": "Ações da aba para {{name}}"
}

View file

@ -14,6 +14,7 @@
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Вы уверены, что хотите удалить этого пользователя из группы? Пользователь потеряет доступ к материалам, к которым есть доступ у этой группы.",
"Are you sure you want to remove this user from the 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.": "Вы уверены, что хотите восстановить эту версию? Все не зафиксированные изменения будут потеряны.",
"Assigned to {{name}}": "Назначено: {{name}}",
"Can become members of groups and spaces in workspace": "Могут становиться участниками групп и пространств в рабочей области",
"Can create and edit pages in space.": "Может создавать и редактировать страницы в пространстве.",
"Can edit": "Может изменять",
@ -22,6 +23,8 @@
"Can view": "Может просматривать",
"Can view pages in space but not edit.": "Может просматривать страницы в пространстве, но не может их редактировать.",
"Cancel": "Отменить",
"Card": "Карточка",
"Cards aren't available in this position": "Карточки недоступны в этом месте",
"Change email": "Изменить электронную почту",
"Change password": "Изменить пароль",
"Change photo": "Изменить фото",
@ -30,6 +33,8 @@
"Choose your preferred interface language.": "Выберите предпочитаемый язык интерфейса.",
"Choose your preferred page width.": "Выберите предпочитаемую ширину страницы.",
"Confirm": "Подтвердить",
"Connect to {{name}} to preview": "Подключитесь к {{name}}, чтобы просмотреть",
"Connected as {{label}}": "Подключено как {{label}}",
"Copy as Markdown": "Копировать как Markdown",
"Copy link": "Копировать ссылку",
"Create": "Создать",
@ -41,11 +46,25 @@
"Dark": "Темная",
"Date": "Дата",
"Delete": "Удалить",
"Initiative": "Инициатива",
"Integration link actions": "Действия со ссылками интеграции",
"Last modified by {{name}}": "Последнее изменение: {{name}}",
"Open in Slack": "Открыть в Slack",
"Open link": "Открыть ссылку",
"Paid": "Платный",
"Paste as": "Вставить как",
"Project": "Проект",
"Uninstall {{name}}": "Удалить {{name}}",
"Disconnect {{name}}": "Отключить {{name}}",
"This disconnects your {{name}} account. Links are not enriched for you until you reconnect.": "Это отключит вашу учётную запись {{name}}. Ссылки не будут обогащаться, пока вы не подключитесь снова.",
"This disables the {{name}} integration for the entire workspace. Members' connections are removed and links are not enriched.": "Это отключит интеграцию {{name}} для всего рабочего пространства. Подключения участников будут удалены, а ссылки не будут обогащаться.",
"This Slack workspace is already connected to another Docmost workspace. Disconnect it there first, or install Docmost into a different Slack workspace.": "Это рабочее пространство Slack уже подключено к другому рабочему пространству Docmost. Сначала отключите его там или установите Docmost в другое рабочее пространство Slack.",
"Remove from page": "Удалить со страницы",
"Base options": "Параметры базы",
"Delete group": "Удалить группу",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Вы уверены, что хотите удалить эту страницу? Это удалит её дочерние страницы, а также историю страницы. Это действие необратимо.",
"Description": "Описание",
"Display as": "Отображать как",
"Details": "Подробности",
"e.g ACME": "например, ACME",
"e.g ACME Inc": "например, ACME Inc",
@ -77,6 +96,7 @@
"Failed to fetch recent pages": "Не удалось получить недавние страницы",
"Failed to import pages": "Не удалось импортировать страницы",
"Failed to load page. An error occurred.": "Не удалось загрузить страницу. Произошла ошибка.",
"Failed to load presentation. An error occurred.": "Не удалось загрузить презентацию. Произошла ошибка.",
"Failed to update data": "Не удалось обновить данные",
"Failed to create base": "Не удалось создать базу",
"Failed to update base": "Не удалось обновить базу",
@ -150,6 +170,8 @@
"page": "страница",
"Page deleted successfully": "Страница успешно удалена",
"Page history": "История страницы",
"replies": "ответов",
"reply": "ответ",
"Select version": "Выбрать версию",
"Highlight changes": "Выделить изменения",
"Page import is in progress. Please do not close this tab.": "Импорт страницы в процессе. Пожалуйста, не закрывайте эту вкладку.",
@ -161,6 +183,14 @@
"Pending": "В ожидании",
"Please confirm your action": "Пожалуйста, подтвердите ваше действие",
"Preferences": "Настройки",
"Presentation mode": "Режим презентации",
"Previous slide": "Предыдущий слайд",
"Next slide": "Следующий слайд",
"Exit presentation mode": "Выйти из режима презентации",
"Enter fullscreen": "Перейти в полноэкранный режим",
"Exit fullscreen": "Выйти из полноэкранного режима",
"Laser pointer": "Лазерная указка",
"Slide {{current}} of {{total}}": "Слайд {{current}} из {{total}}",
"Print PDF": "Печать PDF",
"Profile": "Профиль",
"Recently updated": "Обновлено недавно",
@ -184,6 +214,8 @@
"Invitation sent": "Приглашение отправлено",
"Settings": "Настройки",
"Setup workspace": "Настроить рабочее пространство",
"show less": "показать меньше",
"show more": "показать больше",
"Sign In": "Войти",
"Sign Up": "Зарегистрироваться",
"Slug": "Слаг",
@ -207,6 +239,7 @@
"To change your email, you have to enter your password and new email.": "Чтобы изменить электронную почту, вам нужно ввести пароль и новый адрес.",
"Toggle full page width": "Переключить полную ширину страницы",
"Unable to import pages. Please try again.": "Не удалось импортировать страницы. Пожалуйста, попробуйте ещё раз.",
"Unassigned": "Не назначено",
"untitled": "без названия",
"Untitled": "Без названия",
"Updated successfully": "Успешно обновлено",
@ -626,6 +659,10 @@
"Restore this base to view its contents.": "Восстановите эту базу, чтобы просмотреть её содержимое.",
"Subpages": "Подстраницы",
"Failed to load subpages": "Не удалось загрузить подстраницы",
"Sort subpages": "Сортировать подстраницы",
"Manual": "Вручную",
"A–Z": "А–Я",
"Recent": "Недавние",
"No subpages": "Нет подстраниц",
"Subpages (Child pages)": "Подстраницы (дочерние страницы)",
"List all subpages of the current page": "Показать все подстраницы текущей страницы",
@ -636,6 +673,8 @@
"Search in all your spaces": "Искать во всех ваших пространствах",
"Type": "Тип",
"Enterprise": "Корпоративный",
"Business": "Бизнес",
"Standard": "Стандарт",
"Download attachment": "Скачать вложение",
"Allowed email domains": "Разрешённые домены электронной почты",
"Only users with email addresses from these domains can signup via SSO.": "Только пользователи с адресами электронной почты из этих доменов могут зарегистрироваться через SSO.",
@ -978,6 +1017,8 @@
"Page menu": "Меню страницы",
"Expand": "Развернуть",
"Collapse": "Свернуть",
"Expand {{name}}": "Развернуть {{name}}",
"Collapse {{name}}": "Свернуть {{name}}",
"Comment menu": "Меню комментария",
"Group menu": "Меню группы",
"Show hidden breadcrumbs": "Показать скрытые хлебные крошки",
@ -989,6 +1030,9 @@
"Use template": "Использовать шаблон",
"Preview template: {{title}}": "Предпросмотр шаблона: {{title}}",
"Use a template": "Использовать шаблон",
"Template": "Шаблон",
"Failed to load template": "Не удалось загрузить шаблон",
"This page is no longer empty": "Эта страница больше не пуста",
"Search templates...": "Поиск шаблонов...",
"Search spaces...": "Поиск пространств...",
"No templates found": "Шаблоны не найдены",
@ -1313,6 +1357,10 @@
"No attachments on this page yet.": "На этой странице пока нет вложений.",
"Uploaded by {{name}}": "Загружено пользователем {{name}}",
"Download {{name}}": "Скачать {{name}}",
"Delete attachment": "Удалить вложение",
"Delete {{name}}": "Удалить {{name}}",
"Attachment deleted": "Вложение удалено",
"Failed to delete attachment": "Не удалось удалить вложение",
"Access revoked": "Доступ отозван",
"Authorize application": "Авторизовать приложение",
"{{name}} wants to access {{workspace}}": "{{name}} запрашивает доступ к {{workspace}}",
@ -1435,6 +1483,11 @@
"Defaults to this instance's hostname": "По умолчанию используется имя хоста этого экземпляра",
"Delete destination": "Удалить место назначения",
"Deleted SIEM destination": "Удалено место назначения SIEM",
"Integrations": "Интеграции",
"Installed integration": "Установленная интеграция",
"Uninstalled integration": "Интеграция удалена",
"Connected integration account": "Подключена учётная запись интеграции",
"Disconnected integration account": "Отключена учётная запись интеграции",
"Destination created": "Место назначения создано",
"Destination deleted": "Место назначения удалено",
"Destination updated": "Место назначения обновлено",
@ -1476,5 +1529,49 @@
"Test event delivered to {{name}}": "Тестовое событие доставлено в {{name}}",
"Updated SIEM destination": "Обновлено место назначения SIEM",
"Verify TLS certificate": "Проверять сертификат TLS",
"e.g. Splunk prod": "например, Splunk prod"
"e.g. Splunk prod": "например, Splunk prod",
"Manage workspace integrations.": "Управляйте интеграциями рабочего пространства.",
"No integrations available.": "Нет доступных интеграций.",
"Install": "Установить",
"Uninstall": "Удалить",
"Failed to start installation": "Не удалось начать установку",
"Integration uninstalled successfully": "Интеграция успешно удалена",
"Failed to uninstall integration": "Не удалось удалить интеграцию",
"Connections": "Подключения",
"Manage the apps you have connected to your account.": "Управляйте приложениями, которые вы подключили к своей учётной записи.",
"Connect": "Подключить",
"Connected": "Подключено",
"Disconnect": "Отключить",
"Reconnect": "Подключить снова",
"Connection expired": "Срок действия подключения истёк",
"Integration disconnected": "Интеграция отключена",
"Failed to disconnect integration": "Не удалось отключить интеграцию",
"Failed to start OAuth connection": "Не удалось начать OAuth-подключение",
"OAuth connection failed. Please try again.": "Не удалось установить OAuth-подключение. Повторите попытку.",
"The account you signed in with does not use your Docmost email address. Sign in to the provider with the account that matches your Docmost email and try again.": "Учётная запись, с которой вы вошли, не использует ваш адрес электронной почты Docmost. Войдите у провайдера с учётной записью, которая использует тот же адрес электронной почты, что и ваша учётная запись Docmost, и повторите попытку.",
"The Slack account you signed in with is not in the Slack workspace connected to Docmost. Sign in to Slack with your account in that workspace and try again.": "Учётная запись Slack, с которой вы вошли, не состоит в рабочем пространстве Slack, подключённом к Docmost. Войдите в Slack с учётной записью из этого рабочего пространства и повторите попытку.",
"Connect your {{name}} account so links you paste show rich previews.": "Подключите свою учётную запись {{name}}, чтобы вставляемые вами ссылки отображались с расширенным предпросмотром.",
"Connect {{name}} to Docmost": "Подключить {{name}} к Docmost",
"Link your Slack account to your Docmost account to:": "Свяжите свою учётную запись Slack с учётной записью Docmost, чтобы:",
"Search and open Docmost pages from Slack": "Искать и открывать страницы Docmost из Slack",
"Create pages and save Slack messages to Docmost": "Создавать страницы и сохранять сообщения Slack в Docmost",
"Ask AI questions about your workspace docs": "Задавать ИИ вопросы о документах вашего рабочего пространства",
"Get your Docmost notifications as Slack DMs": "Получать уведомления Docmost в виде личных сообщений Slack",
"See previews of Docmost links in Slack and Slack links in Docmost": "Просматривать предпросмотр ссылок Docmost в Slack и ссылок Slack в Docmost",
"You will sign in with Slack to confirm which Slack account is yours. It must use the same email as your Docmost account.": "Вы войдёте через Slack, чтобы подтвердить, какая учётная запись Slack принадлежит вам. Она должна использовать тот же адрес электронной почты, что и ваша учётная запись Docmost.",
"Updated comment": "Комментарий обновлён",
"Resolved comment": "Комментарий решён",
"Reopened comment": "Комментарий снова открыт",
"Something went wrong": "Что-то пошло не так",
"This page failed to load. Reload to try again.": "Не удалось загрузить эту страницу. Перезагрузите её и попробуйте снова.",
"Reload": "Перезагрузить",
"Tabs": "Вкладки",
"Insert a multi-tab content block.": "Вставить блок содержимого с несколькими вкладками.",
"Add tab": "Добавить вкладку",
"Tab actions": "Действия с вкладкой",
"Move left": "Переместить влево",
"Move right": "Переместить вправо",
"Delete tab": "Удалить вкладку",
"Tab label": "Название вкладки",
"Tab actions for {{name}}": "Действия с вкладкой для {{name}}"
}

View file

@ -14,6 +14,7 @@
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Ви впевнені, що хочете видалити цього користувача з групи? Користувач втратить доступ до матеріалів, до яких ця група має доступ.",
"Are you sure you want to remove this user from the 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.": "Ви впевнені, що хочете відновити цю версію? Усі не збережені зміни будуть втрачені.",
"Assigned to {{name}}": "Призначено для {{name}}",
"Can become members of groups and spaces in workspace": "Можуть ставати учасниками груп та просторів у робочій області",
"Can create and edit pages in space.": "Може створювати та редагувати сторінки в просторі.",
"Can edit": "Може редагувати",
@ -22,6 +23,8 @@
"Can view": "Може переглядати",
"Can view pages in space but not edit.": "Може переглядати сторінки в просторі, але не може їх редагувати.",
"Cancel": "Скасувати",
"Card": "Картка",
"Cards aren't available in this position": "Картки недоступні в цьому місці",
"Change email": "Змінити електронну пошту",
"Change password": "Змінити пароль",
"Change photo": "Змінити фото",
@ -30,6 +33,8 @@
"Choose your preferred interface language.": "Оберіть бажану мову інтерфейсу.",
"Choose your preferred page width.": "Оберіть бажану ширину сторінки.",
"Confirm": "Підтвердити",
"Connect to {{name}} to preview": "Підключіться до {{name}}, щоб переглянути",
"Connected as {{label}}": "Підключено як {{label}}",
"Copy as Markdown": "Скопіювати як Markdown",
"Copy link": "Копіювати посилання",
"Create": "Створити",
@ -41,11 +46,25 @@
"Dark": "Темна",
"Date": "Дата",
"Delete": "Видалити",
"Initiative": "Ініціатива",
"Integration link actions": "Дії посилань інтеграції",
"Last modified by {{name}}": "Останні зміни вніс {{name}}",
"Open in Slack": "Відкрити у Slack",
"Open link": "Відкрити посилання",
"Paid": "Платний",
"Paste as": "Вставити як",
"Project": "Проєкт",
"Uninstall {{name}}": "Видалити {{name}}",
"Disconnect {{name}}": "Відключити {{name}}",
"This disconnects your {{name}} account. Links are not enriched for you until you reconnect.": "Це відключить ваш обліковий запис {{name}}. Посилання не збагачуватимуться для вас, доки ви не підключитеся знову.",
"This disables the {{name}} integration for the entire workspace. Members' connections are removed and links are not enriched.": "Це вимкне інтеграцію {{name}} для всього робочого простору. Підключення учасників буде видалено, а посилання не збагачуватимуться.",
"This Slack workspace is already connected to another Docmost workspace. Disconnect it there first, or install Docmost into a different Slack workspace.": "Цей робочий простір Slack уже підключено до іншого робочого простору Docmost. Спочатку відключіть його там або встановіть Docmost в іншому робочому просторі Slack.",
"Remove from page": "Видалити зі сторінки",
"Base options": "Параметри бази",
"Delete group": "Видалити групу",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Ви впевнені, що хочете видалити цю сторінку? Це видалить її дочірні сторінки, а також історію сторінки. Ця дія необоротна.",
"Description": "Опис",
"Display as": "Відображати як",
"Details": "Деталі",
"e.g ACME": "напр., ACME",
"e.g ACME Inc": "напр., ACME Inc",
@ -77,6 +96,7 @@
"Failed to fetch recent pages": "Не вдалося отримати нещодавні сторінки",
"Failed to import pages": "Не вдалося імпортувати сторінки",
"Failed to load page. An error occurred.": "Не вдалося завантажити сторінку. Сталася помилка.",
"Failed to load presentation. An error occurred.": "Не вдалося завантажити презентацію. Сталася помилка.",
"Failed to update data": "Не вдалося оновити дані",
"Failed to create base": "Не вдалося створити базу",
"Failed to update base": "Не вдалося оновити базу",
@ -150,6 +170,8 @@
"page": "сторінка",
"Page deleted successfully": "Сторінку успішно видалено",
"Page history": "Історія сторінки",
"replies": "відповіді",
"reply": "відповідь",
"Select version": "Вибрати версію",
"Highlight changes": "Підсвітити зміни",
"Page import is in progress. Please do not close this tab.": "Імпорт сторінки в процесі. Будь ласка, не закривайте цю вкладку.",
@ -161,6 +183,14 @@
"Pending": "В очікуванні",
"Please confirm your action": "Будь ласка, підтвердіть вашу дію",
"Preferences": "Налаштування",
"Presentation mode": "Режим презентації",
"Previous slide": "Попередній слайд",
"Next slide": "Наступний слайд",
"Exit presentation mode": "Вийти з режиму презентації",
"Enter fullscreen": "Увійти в повноекранний режим",
"Exit fullscreen": "Вийти з повноекранного режиму",
"Laser pointer": "Лазерна указка",
"Slide {{current}} of {{total}}": "Слайд {{current}} з {{total}}",
"Print PDF": "Друк PDF",
"Profile": "Профіль",
"Recently updated": "Нещодавно оновлено",
@ -184,6 +214,8 @@
"Invitation sent": "Запрошення надіслано",
"Settings": "Налаштування",
"Setup workspace": "Налаштувати робочий простір",
"show less": "показати менше",
"show more": "показати більше",
"Sign In": "Увійти",
"Sign Up": "Зареєструватися",
"Slug": "Slug",
@ -207,6 +239,7 @@
"To change your email, you have to enter your password and new email.": "Щоб змінити електронну пошту, вам потрібно ввести пароль і нову адресу.",
"Toggle full page width": "Перемкнути повну ширину сторінки",
"Unable to import pages. Please try again.": "Не вдалося імпортувати сторінки. Будь ласка, спробуйте ще раз.",
"Unassigned": "Не призначено",
"untitled": "без назви",
"Untitled": "Без назви",
"Updated successfully": "Успішно оновлено",
@ -626,6 +659,10 @@
"Restore this base to view its contents.": "Відновіть цю базу, щоб переглянути її вміст.",
"Subpages": "Підсторінки",
"Failed to load subpages": "Не вдалося завантажити підсторінки",
"Sort subpages": "Сортувати підсторінки",
"Manual": "Вручну",
"A–Z": "А–Я",
"Recent": "Недавні",
"No subpages": "Немає підсторінок",
"Subpages (Child pages)": "Підсторінки (дочірні сторінки)",
"List all subpages of the current page": "Показати всі підсторінки поточної сторінки",
@ -636,6 +673,8 @@
"Search in all your spaces": "Шукати в усіх ваших просторах",
"Type": "Тип",
"Enterprise": "Enterprise",
"Business": "Бізнес",
"Standard": "Стандарт",
"Download attachment": "Завантажити вкладення",
"Allowed email domains": "Дозволені домени електронної пошти",
"Only users with email addresses from these domains can signup via SSO.": "Лише користувачі з адресами електронної пошти з цих доменів можуть зареєструватися через SSO.",
@ -978,6 +1017,8 @@
"Page menu": "Меню сторінки",
"Expand": "Розгорнути",
"Collapse": "Згорнути",
"Expand {{name}}": "Розгорнути {{name}}",
"Collapse {{name}}": "Згорнути {{name}}",
"Comment menu": "Меню коментаря",
"Group menu": "Меню групи",
"Show hidden breadcrumbs": "Показати приховані \"хлібні крихти\"",
@ -989,6 +1030,9 @@
"Use template": "Використати шаблон",
"Preview template: {{title}}": "Попередній перегляд шаблону: {{title}}",
"Use a template": "Використати шаблон",
"Template": "Шаблон",
"Failed to load template": "Не вдалося завантажити шаблон",
"This page is no longer empty": "Ця сторінка більше не порожня",
"Search templates...": "Шукати шаблони...",
"Search spaces...": "Шукати простори...",
"No templates found": "Шаблони не знайдено",
@ -1198,8 +1242,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": "Не містить",
@ -1298,10 +1342,10 @@
"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}}",
@ -1313,6 +1357,10 @@
"No attachments on this page yet.": "На цій сторінці ще немає вкладень.",
"Uploaded by {{name}}": "Завантажено користувачем {{name}}",
"Download {{name}}": "Завантажити {{name}}",
"Delete attachment": "Видалити вкладення",
"Delete {{name}}": "Видалити {{name}}",
"Attachment deleted": "Вкладення видалено",
"Failed to delete attachment": "Не вдалося видалити вкладення",
"Access revoked": "Доступ відкликано",
"Authorize application": "Авторизувати застосунок",
"{{name}} wants to access {{workspace}}": "{{name}} хоче отримати доступ до {{workspace}}",
@ -1435,6 +1483,11 @@
"Defaults to this instance's hostname": "За замовчуванням використовується ім’я хоста цього екземпляра",
"Delete destination": "Видалити призначення",
"Deleted SIEM destination": "Видалено призначення SIEM",
"Integrations": "Інтеграції",
"Installed integration": "Установлена інтеграція",
"Uninstalled integration": "Інтеграцію видалено",
"Connected integration account": "Обліковий запис інтеграції підключено",
"Disconnected integration account": "Обліковий запис інтеграції відключено",
"Destination created": "Призначення створено",
"Destination deleted": "Призначення видалено",
"Destination updated": "Призначення оновлено",
@ -1476,5 +1529,49 @@
"Test event delivered to {{name}}": "Тестову подію доставлено до {{name}}",
"Updated SIEM destination": "Оновлено призначення SIEM",
"Verify TLS certificate": "Перевіряти сертифікат TLS",
"e.g. Splunk prod": "напр., Splunk prod"
"e.g. Splunk prod": "напр., Splunk prod",
"Manage workspace integrations.": "Керуйте інтеграціями робочого простору.",
"No integrations available.": "Немає доступних інтеграцій.",
"Install": "Установити",
"Uninstall": "Видалити",
"Failed to start installation": "Не вдалося розпочати встановлення",
"Integration uninstalled successfully": "Інтеграцію успішно видалено",
"Failed to uninstall integration": "Не вдалося видалити інтеграцію",
"Connections": "Підключення",
"Manage the apps you have connected to your account.": "Керуйте програмами, які ви підключили до свого облікового запису.",
"Connect": "Підключити",
"Connected": "Підключено",
"Disconnect": "Відключити",
"Reconnect": "Підключити знову",
"Connection expired": "Термін дії підключення минув",
"Integration disconnected": "Інтеграцію відключено",
"Failed to disconnect integration": "Не вдалося відключити інтеграцію",
"Failed to start OAuth connection": "Не вдалося розпочати OAuth-підключення",
"OAuth connection failed. Please try again.": "Не вдалося виконати OAuth-підключення. Спробуйте ще раз.",
"The account you signed in with does not use your Docmost email address. Sign in to the provider with the account that matches your Docmost email and try again.": "Обліковий запис, з яким ви ввійшли, не використовує вашу адресу електронної пошти Docmost. Увійдіть у сервіс через обліковий запис, який відповідає вашій адресі електронної пошти Docmost, і спробуйте ще раз.",
"The Slack account you signed in with is not in the Slack workspace connected to Docmost. Sign in to Slack with your account in that workspace and try again.": "Обліковий запис Slack, з яким ви ввійшли, не входить до робочого простору Slack, підключеного до Docmost. Увійдіть у Slack через свій обліковий запис у цьому робочому просторі й спробуйте ще раз.",
"Connect your {{name}} account so links you paste show rich previews.": "Підключіть свій обліковий запис {{name}}, щоб вставлені вами посилання показували розгорнуті прев’ю.",
"Connect {{name}} to Docmost": "Підключити {{name}} до Docmost",
"Link your Slack account to your Docmost account to:": "Зв’яжіть свій обліковий запис Slack зі своїм обліковим записом Docmost, щоб:",
"Search and open Docmost pages from Slack": "Шукати та відкривати сторінки Docmost зі Slack",
"Create pages and save Slack messages to Docmost": "Створювати сторінки та зберігати повідомлення Slack у Docmost",
"Ask AI questions about your workspace docs": "Ставити ШІ запитання про документи вашого робочого простору",
"Get your Docmost notifications as Slack DMs": "Отримувати сповіщення Docmost як особисті повідомлення Slack",
"See previews of Docmost links in Slack and Slack links in Docmost": "Переглядати прев’ю посилань Docmost у Slack і посилань Slack у Docmost",
"You will sign in with Slack to confirm which Slack account is yours. It must use the same email as your Docmost account.": "Ви ввійдете через Slack, щоб підтвердити, який обліковий запис Slack належить вам. Він має використовувати ту саму адресу електронної пошти, що й ваш обліковий запис Docmost.",
"Updated comment": "Коментар оновлено",
"Resolved comment": "Коментар позначено як вирішений",
"Reopened comment": "Коментар знову відкрито",
"Something went wrong": "Щось пішло не так",
"This page failed to load. Reload to try again.": "Не вдалося завантажити цю сторінку. Перезавантажте її, щоб спробувати ще раз.",
"Reload": "Перезавантажити",
"Tabs": "Вкладки",
"Insert a multi-tab content block.": "Вставити блок вмісту з кількома вкладками.",
"Add tab": "Додати вкладку",
"Tab actions": "Дії вкладки",
"Move left": "Перемістити ліворуч",
"Move right": "Перемістити праворуч",
"Delete tab": "Видалити вкладку",
"Tab label": "Назва вкладки",
"Tab actions for {{name}}": "Дії вкладки для {{name}}"
}

View file

@ -14,6 +14,7 @@
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "您确定要从群组中移除这个用户吗?该用户将失去对该群组可访问资源的访问权限。",
"Are you sure you want to remove this user from the 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.": "您确定要恢复此版本吗?任何未版本化的更改将会丢失。",
"Assigned to {{name}}": "已分配给 {{name}}",
"Can become members of groups and spaces in workspace": "可以成为工作区中群组和空间的成员",
"Can create and edit pages in space.": "能够在空间中创建和编辑页面",
"Can edit": "可以编辑",
@ -22,6 +23,8 @@
"Can view": "可以查看",
"Can view pages in space but not edit.": "能够在空间中读取页面,但不允许编辑",
"Cancel": "取消",
"Card": "卡片",
"Cards aren't available in this position": "此位置无法使用卡片",
"Change email": "更改电子邮箱",
"Change password": "更改密码",
"Change photo": "更改照片",
@ -30,6 +33,8 @@
"Choose your preferred interface language.": "选择您喜欢的界面语言。",
"Choose your preferred page width.": "选择您喜欢的页面宽度。",
"Confirm": "确认",
"Connect to {{name}} to preview": "连接到 {{name}} 以预览",
"Connected as {{label}}": "已连接为 {{label}}",
"Copy as Markdown": "复制为Markdown",
"Copy link": "复制链接",
"Create": "创建",
@ -41,11 +46,25 @@
"Dark": "深色",
"Date": "日期",
"Delete": "删除",
"Initiative": "计划",
"Integration link actions": "集成链接操作",
"Last modified by {{name}}": "最后修改者:{{name}}",
"Open in Slack": "在 Slack 中打开",
"Open link": "打开链接",
"Paid": "付费",
"Paste as": "粘贴为",
"Project": "项目",
"Uninstall {{name}}": "卸载 {{name}}",
"Disconnect {{name}}": "断开 {{name}}",
"This disconnects your {{name}} account. Links are not enriched for you until you reconnect.": "这将断开你的 {{name}} 账户。在你重新连接之前,链接将不会为你显示丰富预览。",
"This disables the {{name}} integration for the entire workspace. Members' connections are removed and links are not enriched.": "这将为整个工作区禁用 {{name}} 集成。成员的连接将被移除,链接也不会显示丰富预览。",
"This Slack workspace is already connected to another Docmost workspace. Disconnect it there first, or install Docmost into a different Slack workspace.": "此 Slack 工作区已连接到另一个 Docmost 工作区。请先在那边断开连接,或者将 Docmost 安装到其他 Slack 工作区。",
"Remove from page": "从页面中移除",
"Base options": "数据库选项",
"Delete group": "删除群组",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "您确定要删除这个页面吗?这将删除其子页面和页面历史记录。此操作不可逆。",
"Description": "描述",
"Display as": "显示为",
"Details": "详情",
"e.g ACME": "例如 ACME",
"e.g ACME Inc": "例如 ACME Inc",
@ -77,6 +96,7 @@
"Failed to fetch recent pages": "获取最近页面失败",
"Failed to import pages": "导入页面失败",
"Failed to load page. An error occurred.": "页面加载失败。发生了一个错误。",
"Failed to load presentation. An error occurred.": "加载演示文稿失败。发生错误。",
"Failed to update data": "数据更新失败",
"Failed to create base": "创建数据库失败",
"Failed to update base": "更新数据库失败",
@ -150,6 +170,8 @@
"page": "个页面",
"Page deleted successfully": "页面已成功删除",
"Page history": "页面历史",
"replies": "回复",
"reply": "回复",
"Select version": "选择版本",
"Highlight changes": "突出显示更改",
"Page import is in progress. Please do not close this tab.": "页面导入正在进行中。请不要关闭此标签页。",
@ -161,6 +183,14 @@
"Pending": "待定",
"Please confirm your action": "请确认您的操作",
"Preferences": "偏好设置",
"Presentation mode": "演示模式",
"Previous slide": "上一张幻灯片",
"Next slide": "下一张幻灯片",
"Exit presentation mode": "退出演示模式",
"Enter fullscreen": "进入全屏",
"Exit fullscreen": "退出全屏",
"Laser pointer": "激光笔",
"Slide {{current}} of {{total}}": "第{{current}}张,共{{total}}张",
"Print PDF": "打印 PDF",
"Profile": "个人资料",
"Recently updated": "最近更新",
@ -184,6 +214,8 @@
"Invitation sent": "邀请已发送",
"Settings": "设置",
"Setup workspace": "设置工作区",
"show less": "收起",
"show more": "显示更多",
"Sign In": "登录",
"Sign Up": "注册",
"Slug": "标识符",
@ -207,6 +239,7 @@
"To change your email, you have to enter your password and new email.": "要更改您的电子邮箱,您需要输入密码和新的电子邮箱地址。",
"Toggle full page width": "切换整页宽度",
"Unable to import pages. Please try again.": "无法导入页面。请重试。",
"Unassigned": "未分配",
"untitled": "未命名",
"Untitled": "未命名",
"Updated successfully": "更新成功",
@ -626,6 +659,10 @@
"Restore this base to view its contents.": "恢复此数据库以查看其内容。",
"Subpages": "子页面",
"Failed to load subpages": "加载子页面失败",
"Sort subpages": "对子页面排序",
"Manual": "手动",
"A–Z": "A–Z",
"Recent": "最近",
"No subpages": "没有子页面",
"Subpages (Child pages)": "子页面(下级页面)",
"List all subpages of the current page": "列出当前页面的所有子页面",
@ -636,6 +673,8 @@
"Search in all your spaces": "在您的所有空间中搜索",
"Type": "类型",
"Enterprise": "企业版",
"Business": "商业版",
"Standard": "标准版",
"Download attachment": "下载附件",
"Allowed email domains": "允许的邮箱域名",
"Only users with email addresses from these domains can signup via SSO.": "只有使用这些域名邮箱地址的用户才能通过 SSO 注册。",
@ -978,6 +1017,8 @@
"Page menu": "页面菜单",
"Expand": "展开",
"Collapse": "折叠",
"Expand {{name}}": "展开{{name}}",
"Collapse {{name}}": "折叠{{name}}",
"Comment menu": "评论菜单",
"Group menu": "群组菜单",
"Show hidden breadcrumbs": "显示隐藏的面包屑",
@ -989,6 +1030,9 @@
"Use template": "使用模板",
"Preview template: {{title}}": "预览模板:{{title}}",
"Use a template": "使用模板",
"Template": "模板",
"Failed to load template": "加载模板失败",
"This page is no longer empty": "此页面不再为空",
"Search templates...": "搜索模板……",
"Search spaces...": "搜索空间……",
"No templates found": "未找到模板",
@ -1313,6 +1357,10 @@
"No attachments on this page yet.": "此页面上还没有附件。",
"Uploaded by {{name}}": "由 {{name}} 上传",
"Download {{name}}": "下载 {{name}}",
"Delete attachment": "删除附件",
"Delete {{name}}": "删除 {{name}}",
"Attachment deleted": "附件已删除",
"Failed to delete attachment": "删除附件失败",
"Access revoked": "访问权限已撤销",
"Authorize application": "授权应用程序",
"{{name}} wants to access {{workspace}}": "{{name}} 想要访问 {{workspace}}",
@ -1435,6 +1483,11 @@
"Defaults to this instance's hostname": "默认为此实例的主机名",
"Delete destination": "删除目标",
"Deleted SIEM destination": "已删除 SIEM 目标",
"Integrations": "集成",
"Installed integration": "已安装的集成",
"Uninstalled integration": "已卸载的集成",
"Connected integration account": "已连接集成账户",
"Disconnected integration account": "已断开集成账户",
"Destination created": "目标已创建",
"Destination deleted": "目标已删除",
"Destination updated": "目标已更新",
@ -1476,5 +1529,49 @@
"Test event delivered to {{name}}": "测试事件已发送到 {{name}}",
"Updated SIEM destination": "已更新 SIEM 目标",
"Verify TLS certificate": "验证 TLS 证书",
"e.g. Splunk prod": "例如:Splunk 生产环境"
"e.g. Splunk prod": "例如:Splunk 生产环境",
"Manage workspace integrations.": "管理工作区集成。",
"No integrations available.": "没有可用的集成。",
"Install": "安装",
"Uninstall": "卸载",
"Failed to start installation": "启动安装失败",
"Integration uninstalled successfully": "集成卸载成功",
"Failed to uninstall integration": "卸载集成失败",
"Connections": "连接",
"Manage the apps you have connected to your account.": "管理你已连接到账户的应用。",
"Connect": "连接",
"Connected": "已连接",
"Disconnect": "断开连接",
"Reconnect": "重新连接",
"Connection expired": "连接已过期",
"Integration disconnected": "集成已断开",
"Failed to disconnect integration": "断开集成失败",
"Failed to start OAuth connection": "启动 OAuth 连接失败",
"OAuth connection failed. Please try again.": "OAuth 连接失败。请重试。",
"The account you signed in with does not use your Docmost email address. Sign in to the provider with the account that matches your Docmost email and try again.": "你登录使用的账户未使用你的 Docmost 电子邮箱地址。请使用与 Docmost 电子邮箱匹配的账户登录该服务提供商后重试。",
"The Slack account you signed in with is not in the Slack workspace connected to Docmost. Sign in to Slack with your account in that workspace and try again.": "你登录使用的 Slack 账户不属于连接到 Docmost 的 Slack 工作区。请使用该工作区中的账户登录 Slack 后重试。",
"Connect your {{name}} account so links you paste show rich previews.": "连接你的 {{name}} 账户,这样你粘贴的链接就会显示丰富预览。",
"Connect {{name}} to Docmost": "将 {{name}} 连接到 Docmost",
"Link your Slack account to your Docmost account to:": "将你的 Slack 账户链接到你的 Docmost 账户,以便:",
"Search and open Docmost pages from Slack": "从 Slack 搜索并打开 Docmost 页面",
"Create pages and save Slack messages to Docmost": "创建页面并将 Slack 消息保存到 Docmost",
"Ask AI questions about your workspace docs": "就你的工作区文档向 AI 提问",
"Get your Docmost notifications as Slack DMs": "通过 Slack 私信接收你的 Docmost 通知",
"See previews of Docmost links in Slack and Slack links in Docmost": "在 Slack 中查看 Docmost 链接预览,也可在 Docmost 中查看 Slack 链接预览",
"You will sign in with Slack to confirm which Slack account is yours. It must use the same email as your Docmost account.": "你将使用 Slack 登录,以确认哪个 Slack 账户是你的账户。它必须使用与你的 Docmost 账户相同的电子邮箱。",
"Updated comment": "已更新评论",
"Resolved comment": "已解决评论",
"Reopened comment": "已重新打开评论",
"Something went wrong": "出现了一些问题",
"This page failed to load. Reload to try again.": "此页面加载失败。请重新加载后重试。",
"Reload": "重新加载",
"Tabs": "标签页",
"Insert a multi-tab content block.": "插入一个多标签页内容区块。",
"Add tab": "添加标签页",
"Tab actions": "标签页操作",
"Move left": "左移",
"Move right": "右移",
"Delete tab": "删除标签页",
"Tab label": "标签页标签",
"Tab actions for {{name}}": "{{name}}的标签页操作"
}

View file

@ -1,89 +1,98 @@
import { lazy, Suspense, useEffect } from "react";
import { Suspense, useEffect } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import Layout from "@/components/layouts/global/layout.tsx";
import { Error404 } from "@/components/ui/error-404.tsx";
import { isCloud } from "@/lib/config.ts";
import { lazyWithPrefetch, prefetchLazyComponents } from "@/lib/lazy-prefetch.ts";
import { useTranslation } from "react-i18next";
import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx";
import { useTrackOrigin } from "@/hooks/use-track-origin";
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(
const SetupWorkspace = lazyWithPrefetch(() => import("@/pages/auth/setup-workspace.tsx"));
const LoginPage = lazyWithPrefetch(() => import("@/pages/auth/login"));
const Home = lazyWithPrefetch(() => import("@/pages/dashboard/home"));
const Page = lazyWithPrefetch(() => import("@/pages/page/page"), {
prefetchFirst: true,
});
const AccountSettings = lazyWithPrefetch(
() => import("@/pages/settings/account/account-settings"),
);
const WorkspaceMembers = lazy(
const WorkspaceMembers = lazyWithPrefetch(
() => import("@/pages/settings/workspace/workspace-members"),
);
const WorkspaceSettings = lazy(
const WorkspaceSettings = lazyWithPrefetch(
() => 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(
const Groups = lazyWithPrefetch(() => import("@/pages/settings/group/groups"));
const GroupInfo = lazyWithPrefetch(() => import("./pages/settings/group/group-info"));
const Spaces = lazyWithPrefetch(() => import("@/pages/settings/space/spaces.tsx"));
const AccountPreferences = lazyWithPrefetch(
() => 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(
const SpaceHome = lazyWithPrefetch(() => import("@/pages/space/space-home.tsx"));
const PageRedirect = lazyWithPrefetch(() => import("@/pages/page/page-redirect.tsx"));
const InviteSignup = lazyWithPrefetch(() => import("@/pages/auth/invite-signup.tsx"));
const ForgotPassword = lazyWithPrefetch(() => import("@/pages/auth/forgot-password.tsx"));
const PasswordReset = lazyWithPrefetch(() => import("./pages/auth/password-reset"));
const Billing = lazyWithPrefetch(() => import("@/ee/billing/pages/billing.tsx"));
const CloudLogin = lazyWithPrefetch(() => import("@/ee/pages/cloud-login.tsx"));
const CreateWorkspace = lazyWithPrefetch(() => import("@/ee/pages/create-workspace.tsx"));
const Security = lazyWithPrefetch(() => import("@/ee/security/pages/security.tsx"));
const License = lazyWithPrefetch(() => import("@/ee/licence/pages/license.tsx"));
const SharedPage = lazyWithPrefetch(() => import("@/pages/share/shared-page.tsx"));
const PdfRenderPage = lazyWithPrefetch(() => import("@/ee/pdf-export/pdf-render-page.tsx"));
const Shares = lazyWithPrefetch(() => import("@/pages/settings/shares/shares.tsx"));
const ShareLayout = lazyWithPrefetch(
() => import("@/features/share/components/share-layout.tsx"),
);
const ShareRedirect = lazy(() => import("@/pages/share/share-redirect.tsx"));
const PublicSpacePage = lazy(
const ShareRedirect = lazyWithPrefetch(() => import("@/pages/share/share-redirect.tsx"));
const PublicSpacePage = lazyWithPrefetch(
() => import("@/pages/public-space/public-space-page.tsx"),
);
const PublicSpaceLayout = lazy(
const PublicSpaceLayout = lazyWithPrefetch(
() => import("@/features/public-space/components/public-space-layout.tsx"),
);
const PublicSpaceDirectoryPage = lazy(
const PublicSpaceDirectoryPage = lazyWithPrefetch(
() => import("@/pages/public-space/public-space-directory-page.tsx"),
);
const SpacesPage = lazy(() => import("@/pages/spaces/spaces.tsx"));
const MfaChallengePage = lazy(() =>
const SpacesPage = lazyWithPrefetch(() => import("@/pages/spaces/spaces.tsx"));
const MfaChallengePage = lazyWithPrefetch(() =>
import("@/ee/mfa/pages/mfa-challenge-page").then((m) => ({
default: m.MfaChallengePage,
})),
);
const MfaSetupRequiredPage = lazy(() =>
const MfaSetupRequiredPage = lazyWithPrefetch(() =>
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(
const SpaceTrash = lazyWithPrefetch(() => import("@/pages/space/space-trash.tsx"));
const UserApiKeys = lazyWithPrefetch(() => import("@/ee/api-key/pages/user-api-keys"));
const WorkspaceApiKeys = lazyWithPrefetch(
() => 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(
const AiSettings = lazyWithPrefetch(() => import("@/ee/ai/pages/ai-settings.tsx"));
const BasePage = lazyWithPrefetch(() => import("@/ee/base/pages/base-page.tsx"));
const AuditLogs = lazyWithPrefetch(() => import("@/ee/audit/pages/audit-logs.tsx"));
const VerifiedPages = lazyWithPrefetch(
() => import("@/ee/page-verification/pages/verified-pages.tsx"),
);
const TemplateList = lazy(() => import("@/ee/template/pages/template-list"));
const TemplateEditor = lazy(
const TemplateList = lazyWithPrefetch(() => import("@/ee/template/pages/template-list"));
const TemplateEditor = lazyWithPrefetch(
() => 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"));
const FavoritesPage = lazyWithPrefetch(() => import("@/pages/favorites/favorites-page"));
const AiChat = lazyWithPrefetch(() => import("@/ee/ai-chat/pages/ai-chat.tsx"));
const VerifyEmail = lazyWithPrefetch(() => import("@/ee/pages/verify-email.tsx"));
const LabelPage = lazyWithPrefetch(() => import("@/pages/label/label-page"));
const OAuthConsent = lazyWithPrefetch(() => import("@/ee/oauth/pages/oauth-consent.tsx"));
const Integrations = lazyWithPrefetch(
() => import("@/features/integration/pages/integrations.tsx"),
);
const Connections = lazyWithPrefetch(
() => import("@/features/integration/pages/connections.tsx"),
);
export default function App() {
const { t } = useTranslation();
@ -91,8 +100,11 @@ export default function App() {
useTrackOrigin();
useEffect(() => {
// warm the editor chunk so opening a page doesn't wait on the network
const timer = setTimeout(() => import("@/pages/page/page"), 3000);
// the dev server would transform every route module on each reload
if (import.meta.env.DEV) {
return;
}
const timer = setTimeout(prefetchLazyComponents, 3000);
return () => clearTimeout(timer);
}, []);
@ -140,6 +152,12 @@ export default function App() {
<Route path={"/pdf-render/:pageId"} element={<PdfRenderPage />} />
<Route path={"/share/:shareId"} element={<ShareRedirect />} />
<Route path={"/p/:pageSlug"} element={<PageRedirect />} />
<Route
path={"/integrations/slack/link"}
element={
<Navigate to="/settings/account/connections?connect=slack" replace />
}
/>
<Route element={<Layout />}>
<Route path={"/home"} element={<Home />} />
@ -173,6 +191,7 @@ export default function App() {
path={"account/api-keys/authorized-apps"}
element={<UserApiKeys />}
/>
<Route path={"account/connections"} element={<Connections />} />
<Route path={"workspace"} element={<WorkspaceSettings />} />
<Route path={"members"} element={<WorkspaceMembers />} />
<Route path={"api-keys"} element={<WorkspaceApiKeys />} />
@ -190,6 +209,7 @@ export default function App() {
element={<Navigate to="/settings/audit/siem" replace />}
/>
<Route path={"verifications"} element={<VerifiedPages />} />
<Route path={"integrations"} element={<Integrations />} />
{!isCloud() && <Route path={"license"} element={<License />} />}
{isCloud() && <Route path={"billing"} element={<Billing />} />}
</Route>

View file

@ -0,0 +1,29 @@
import { rem } from '@mantine/core';
type Props = {
size?: number | string;
};
export function AzureDevOpsIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 18 18"
style={{ width: rem(size), height: rem(size) }}
>
<defs>
<linearGradient id="azure-devops-a" x1="9" x2="9" y1="16.97" y2="1.03" gradientUnits="userSpaceOnUse">
<stop offset="0" stopColor="#0078d4" />
<stop offset="0.16" stopColor="#1380da" />
<stop offset="0.53" stopColor="#3c91e5" />
<stop offset="0.82" stopColor="#559cec" />
<stop offset="1" stopColor="#5ea0ef" />
</linearGradient>
</defs>
<path
fill="url(#azure-devops-a)"
d="M17,4v9.74l-4,3.28-6.2-2.26V17L3.29,12.41l10.23.8V4.44Zm-3.41.49L7.85,1V3.29L2.58,4.84,1,6.87v4.61l2.26,1V6.57Z"
/>
</svg>
);
}

View file

@ -0,0 +1,22 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function GithubIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
style={{ width: rem(size), height: rem(size) }}
>
<path
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
d="M256 6.3C114.6 6.3 0 120.9 0 262.3c0 113.3 73.3 209 175 242.9 12.8 2.2 17.6-5.4 17.6-12.2 0-6.1-.3-26.2-.3-47.7-64.3 11.8-81-15.7-86.1-30.1-2.9-7.4-15.4-30.1-26.2-36.2-9-4.8-21.8-16.6-.3-17 20.2-.3 34.6 18.6 39.4 26.2 23 38.7 59.8 27.8 74.6 21.1 2.2-16.6 9-27.8 16.3-34.2-57-6.4-116.5-28.5-116.5-126.4 0-27.8 9.9-50.9 26.2-68.8-2.6-6.4-11.5-32.6 2.6-67.8 0 0 21.4-6.7 70.4 26.2 20.5-5.8 42.2-8.6 64-8.6s43.5 2.9 64 8.6c49-33.3 70.4-26.2 70.4-26.2 14.1 35.2 5.1 61.4 2.6 67.8 16.3 17.9 26.2 40.6 26.2 68.8 0 98.2-59.8 120-116.8 126.4 9.3 8 17.3 23.4 17.3 47.4 0 34.2-.3 61.8-.3 70.4 0 6.7 4.8 14.7 17.6 12.2C438.7 471.3 512 375.3 512 262.3c0-141.4-114.6-256-256-256"
/>
</svg>
);
}

View file

@ -0,0 +1,32 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function GitlabIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="93.97 97.52 192.05 184.99"
style={{ width: rem(size), height: rem(size) }}
>
<path
fill="#e24329"
d="m282.83 170.73-.27-.69-26.14-68.22a6.8 6.8 0 0 0-2.69-3.24 7 7 0 0 0-8 .43 7 7 0 0 0-2.32 3.52l-17.65 54h-71.47l-17.65-54a6.86 6.86 0 0 0-2.32-3.53 7 7 0 0 0-8-.43 6.87 6.87 0 0 0-2.69 3.24L97.44 170l-.26.69a48.54 48.54 0 0 0 16.1 56.1l.09.07.24.17 39.82 29.82 19.7 14.91 12 9.06a8.07 8.07 0 0 0 9.76 0l12-9.06 19.7-14.91 40.06-30 .1-.08a48.56 48.56 0 0 0 16.08-56.04"
/>
<path
fill="#fc6d26"
d="m282.83 170.73-.27-.69a88.3 88.3 0 0 0-35.15 15.8L190 229.25c19.55 14.79 36.57 27.64 36.57 27.64l40.06-30 .1-.08a48.56 48.56 0 0 0 16.1-56.08"
/>
<path
fill="#fca326"
d="m153.43 256.89 19.7 14.91 12 9.06a8.07 8.07 0 0 0 9.76 0l12-9.06 19.7-14.91S209.55 244 190 229.25c-19.55 14.75-36.57 27.64-36.57 27.64"
/>
<path
fill="#fc6d26"
d="M132.58 185.84A88.2 88.2 0 0 0 97.44 170l-.26.69a48.54 48.54 0 0 0 16.1 56.1l.09.07.24.17 39.82 29.82L190 229.21Z"
/>
</svg>
);
}

View file

@ -0,0 +1,28 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function GoogleDocsIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 47 65"
style={{ width: rem(size), height: rem(size) }}
>
<path
fill="#4285F4"
d="M29.375 0H4.406C1.983 0 0 1.994 0 4.432v56.136C0 63.006 1.983 65 4.406 65h38.188C45.017 65 47 63.006 47 60.568v-42.84L36.719 10.34z"
/>
<path
fill="#A1C2FA"
d="M29.375 0v13.295c0 2.449 1.972 4.432 4.406 4.432H47z"
/>
<path
fill="#F1F1F1"
d="M11.75 47.273h23.5v-2.955h-23.5zm0 5.909h17.625v-2.955H11.75zm0-20.682v2.955h23.5V32.5zm0 8.864h23.5v-2.955h-23.5z"
/>
</svg>
);
}

View file

@ -1,10 +1,17 @@
export { AirtableIcon } from "./airtable-icon.tsx";
export { AzureDevOpsIcon } from "./azure-devops-icon.tsx";
export { FigmaIcon } from "./figma-icon.tsx";
export { GithubIcon } from "./github-icon.tsx";
export { GitlabIcon } from "./gitlab-icon.tsx";
export { GoogleDocsIcon } from "./google-docs-icon.tsx";
export { GoogleDriveIcon } from "./google-drive-icon.tsx";
export { GoogleSheetsIcon } from "./google-sheets-icon.tsx";
export { JiraIcon } from "./jira-icon.tsx";
export { LinearIcon } from "./linear-icon.tsx";
export { TypeformIcon } from "./typeform-icon.tsx";
export { VimeoIcon } from "./vimeo-icon.tsx";
export { MiroIcon } from "./miro-icon.tsx";
export { GoogleDriveIcon } from "./google-drive-icon.tsx";
export { GoogleSheetsIcon } from "./google-sheets-icon.tsx";
export { SlackIcon } from "./slack-icon.tsx";
export { FramerIcon } from "./framer-icon.tsx";
export { LoomIcon } from "./loom-icon.tsx";
export { YoutubeIcon } from "./youtube-icon.tsx";

View file

@ -0,0 +1,38 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function JiraIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
style={{ width: rem(size), height: rem(size) }}
>
<defs>
<linearGradient id="jira-a" x1="380.896" x2="279.145" y1="390.609" y2="282.574" gradientTransform="matrix(1 0 0 -1 0 514)" gradientUnits="userSpaceOnUse">
<stop offset=".176" stopColor="#0052cc" />
<stop offset="1" stopColor="#2684ff" />
</linearGradient>
<linearGradient id="jira-b" x1="265.965" x2="148.117" y1="270.661" y2="152.607" gradientTransform="matrix(1 0 0 -1 0 514)" gradientUnits="userSpaceOnUse">
<stop offset=".176" stopColor="#0052cc" />
<stop offset="1" stopColor="#2684ff" />
</linearGradient>
</defs>
<path
fill="#2684ff"
d="M490.6 7.4H243.9c0 59.6 49.9 108 111.2 108h45.6v42.2c0 59.6 49.9 108 111.2 108V28.1c.1-11.7-9.2-20.7-21.3-20.7"
/>
<path
fill="url(#jira-a)"
d="M368.7 126.5H121.9c0 59.6 49.9 108 111.2 108h45.6v42.9c0 59.6 49.9 108 111.2 108V147.3c.2-11.1-9.1-20.8-21.2-20.8"
/>
<path
fill="url(#jira-b)"
d="M246.7 246.3H0c0 59.6 49.9 108 111.2 108h45.6v42.2c0 59.6 49.9 108 111.2 108V267.1c.1-11.8-9.9-20.8-21.3-20.8"
/>
</svg>
);
}

View file

@ -0,0 +1,21 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function LinearIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 100 100"
style={{ width: rem(size), height: rem(size) }}
>
<path
fill="currentColor"
d="M1.22541 61.5228c-.2225-.9485.90748-1.5459 1.59638-.857L39.3342 97.1782c.6889.6889.0915 1.8189-.857 1.5964C20.0515 94.4522 5.54779 79.9485 1.22541 61.5228ZM.00189135 46.8891c-.01764375.2833.08887215.5599.28957165.7606L52.3503 99.7085c.2007.2007.4773.3075.7606.2896 2.3692-.1476 4.6938-.46 6.9624-.9259.7645-.157 1.0301-1.0963.4782-1.6481L2.57595 39.4485c-.55186-.5519-1.49117-.2863-1.648174.4782-.465915 2.2686-.77832 4.5932-.92588465 6.9624ZM4.21093 29.7054c-.16649.3738-.08169.8106.20765 1.1l64.77602 64.776c.2894.2894.7262.3742 1.1.2077 1.7861-.7956 3.5171-1.6927 5.1855-2.684.5521-.328.6373-1.0867.1832-1.5407L8.43566 24.3367c-.45409-.4541-1.21271-.3689-1.54074.1832-.99132 1.6684-1.88843 3.3994-2.68399 5.1855ZM12.6587 18.074c-.3701-.3701-.393-.9637-.0443-1.3541C21.7795 6.45931 35.1114 0 49.9519 0 77.5927 0 100 22.4073 100 50.0481c0 14.8405-6.4593 28.1724-16.7199 37.3375-.3903.3487-.984.3258-1.3542-.0443L12.6587 18.074Z"
/>
</svg>
);
}

View file

@ -8,11 +8,20 @@ export function MiroIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
viewBox="3 2 395 395"
style={{ width: rem(size), height: rem(size) }}
>
<path
d="M17.392 0H13.9L17 4.808 10.444 0H6.949l3.102 6.3L3.494 0H0l3.05 8.131L0 24h3.494L10.05 6.985 6.949 24h3.494L17 5.494 13.899 24h3.493L24 3.672 17.392 0z" />
fill="#FFDD33"
d="M3 100.754C3 46.2604 47.2435 2 101.754 2H299.246C353.756 2 398 46.2435 398 100.754V298.246C398 352.756 353.756 397 299.246 397H101.754C47.2435 397 3 352.756 3 298.246V100.754Z"
/>
<path
fill="#1C1C1E"
fillRule="evenodd"
clipRule="evenodd"
d="M265.573 77.3491H229.74L259.629 129.85L193.906 77.3491H158.072L190.934 141.468L122.238 77.3491H86.4041L122.238 159.031L86.4041 322.377H122.238L190.934 147.396L158.072 322.377H193.906L259.629 135.693L229.74 322.377H265.573L331.297 118.232L265.573 77.4335V77.3491Z"
/>
</svg>
);
}

View file

@ -0,0 +1,32 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function SlackIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
style={{ width: rem(size), height: rem(size) }}
>
<path
d="M107.9 323.6c0 29.7-24 53.8-53.8 53.8S.3 353.4.3 323.6c0-29.7 24-53.8 53.8-53.8h53.8zm26.9 0c0-29.7 24-53.8 53.8-53.8s53.8 24 53.8 53.8V458c0 29.7-24 53.8-53.8 53.8s-53.8-24-53.8-53.8z"
fill="#e01e5a"
/>
<path
d="M188.6 107.7c-29.7 0-53.8-24-53.8-53.8S158.8.1 188.6.1s53.8 24 53.8 53.8v53.8zm0 27.3c29.7 0 53.8 24 53.8 53.8s-24 53.8-53.8 53.8H53.8C24 242.6 0 218.5 0 188.8S24 135 53.8 135z"
fill="#36c5f0"
/>
<path
d="M404.1 188.8c0-29.7 24-53.8 53.8-53.8s53.8 24 53.8 53.8-24 53.8-53.8 53.8h-53.8zm-26.9 0c0 29.7-24 53.8-53.8 53.8-29.7 0-53.8-24-53.8-53.8V54c0-29.7 24-53.8 53.8-53.8s53.8 24 53.8 53.8z"
fill="#2eb67d"
/>
<path
d="M323.4 404.3c29.7 0 53.8 24 53.8 53.8 0 29.7-24 53.8-53.8 53.8-29.7 0-53.8-24-53.8-53.8v-53.8zm0-26.9c-29.7 0-53.8-24-53.8-53.8s24-53.8 53.8-53.8h134.8c29.7 0 53.8 24 53.8 53.8 0 29.7-24 53.8-53.8 53.8z"
fill="#ecb22e"
/>
</svg>
);
}

View file

@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
import { useAtomValue } from "jotai";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx";
import { lazyWithPrefetch } from "@/lib/lazy-prefetch.ts";
const CommentListWithTabs = lazy(
() => import("@/features/comment/components/comment-list-with-tabs.tsx"),
@ -16,7 +17,7 @@ const TableOfContents = lazy(() =>
"@/features/editor/components/table-of-contents/table-of-contents.tsx"
).then((m) => ({ default: m.TableOfContents })),
);
const AsideChatPanel = lazy(
const AsideChatPanel = lazyWithPrefetch(
() => import("@/ee/ai-chat/components/aside-chat-panel"),
);
const PageDetailsAside = lazy(() =>

View file

@ -11,8 +11,9 @@ import {
sidebarWidthAtom,
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
import { lazyWithPrefetch } from "@/lib/lazy-prefetch.ts";
const AiChatSidebar = React.lazy(
const AiChatSidebar = lazyWithPrefetch(
() => import("@/ee/ai-chat/components/ai-chat-sidebar.tsx"),
);
import { AppHeader } from "@/components/layouts/global/app-header.tsx";

View file

@ -6,6 +6,7 @@ import { isCloud } from "@/lib/config.ts";
import { SearchSpotlight } from "@/features/search/components/search-spotlight.tsx";
import React from "react";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { AppErrorBoundary } from "@/components/ui/app-error-boundary.tsx";
export default function Layout() {
const { spaceSlug } = useParams();
@ -14,7 +15,9 @@ export default function Layout() {
return (
<UserProvider>
<GlobalAppShell>
<Outlet />
<AppErrorBoundary>
<Outlet />
</AppErrorBoundary>
</GlobalAppShell>
{isCloud() && <PosthogUser />}
<SearchSpotlight spaceId={space?.id} />

View file

@ -13,6 +13,7 @@ import {
IconKey,
IconWorld,
IconSparkles,
IconPlug,
IconHistory,
IconShieldCheck,
} from "@tabler/icons-react";
@ -43,6 +44,7 @@ import AppVersion from "@/components/settings/app-version.tsx";
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import { useSettingsNavigation } from "@/hooks/use-settings-navigation";
import { useInstalledIntegrations } from "@/features/integration/queries/integration-query";
type DataItem = {
label: string;
@ -51,6 +53,7 @@ type DataItem = {
feature?: string;
role?: "admin" | "owner";
env?: "cloud" | "selfhosted";
requiresInstalledIntegration?: boolean;
};
type DataGroup = {
@ -74,6 +77,12 @@ const groupedData: DataGroup[] = [
path: "/settings/account/api-keys",
feature: Feature.API_KEYS,
},
{
label: "Connections",
icon: IconPlug,
path: "/settings/account/connections",
requiresInstalledIntegration: true,
},
],
},
{
@ -125,6 +134,12 @@ const groupedData: DataGroup[] = [
role: "owner",
env: "selfhosted",
},
{
label: "Integrations",
icon: IconPlug,
path: "/settings/integrations",
role: "admin",
},
],
},
{
@ -154,6 +169,8 @@ export default function SettingsSidebar() {
setActive(location.pathname);
}, [location.pathname]);
const { data: installedIntegrations } = useInstalledIntegrations();
const hasFeature = (f: string) =>
entitlements?.features?.includes(f) ?? false;
@ -162,6 +179,9 @@ export default function SettingsSidebar() {
if (item.env === "selfhosted" && isCloud()) return false;
if (item.role === "admin" && !isAdmin) return false;
if (item.role === "owner" && !isOwner) return false;
if (item.requiresInstalledIntegration && !installedIntegrations?.length) {
return false;
}
return true;
};

View file

@ -0,0 +1,78 @@
import { MantineProvider } from "@mantine/core";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { Link, MemoryRouter, Route, Routes } from "react-router-dom";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { AppErrorBoundary } from "./app-error-boundary.tsx";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (value: string) => value }),
}));
function setMediaMatch() {
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}
function Broken(): never {
throw new TypeError("Failed to fetch dynamically imported module");
}
function renderAt(path: string) {
return render(
<MantineProvider>
<MemoryRouter initialEntries={[path]}>
<Link to="/home">Home</Link>
<AppErrorBoundary>
<Routes>
<Route path="/broken" element={<Broken />} />
<Route path="/home" element={<p>Home page</p>} />
</Routes>
</AppErrorBoundary>
</MemoryRouter>
</MantineProvider>,
);
}
describe("AppErrorBoundary", () => {
beforeAll(() => {
setMediaMatch();
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
it("renders its children when nothing throws", () => {
renderAt("/home");
expect(screen.getByText("Home page")).toBeTruthy();
});
it("shows a reload prompt and keeps siblings mounted when a route throws", () => {
vi.spyOn(console, "error").mockImplementation(() => {});
renderAt("/broken");
expect(screen.getByText("Something went wrong")).toBeTruthy();
expect(screen.getByRole("button", { name: "Reload" })).toBeTruthy();
expect(screen.getByText("Home")).toBeTruthy();
});
it("recovers when the user navigates elsewhere", () => {
vi.spyOn(console, "error").mockImplementation(() => {});
renderAt("/broken");
fireEvent.click(screen.getByText("Home"));
expect(screen.getByText("Home page")).toBeTruthy();
expect(screen.queryByText("Something went wrong")).toBeNull();
});
});

View file

@ -0,0 +1,41 @@
import { ReactNode } from "react";
import { useLocation } from "react-router-dom";
import { ErrorBoundary } from "react-error-boundary";
import { Button } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { EmptyState } from "@/components/ui/empty-state.tsx";
type AppErrorBoundaryProps = {
children: ReactNode;
};
export function AppErrorBoundary({ children }: AppErrorBoundaryProps) {
const { t } = useTranslation();
const { pathname } = useLocation();
return (
<ErrorBoundary
resetKeys={[pathname]}
fallback={
<EmptyState
icon={IconAlertTriangle}
title={t("Something went wrong")}
description={t("This page failed to load. Reload to try again.")}
action={
<Button
variant="default"
size="sm"
mt="xs"
onClick={() => window.location.reload()}
>
{t("Reload")}
</Button>
}
/>
}
>
{children}
</ErrorBoundary>
);
}

View file

@ -8,12 +8,13 @@ import {
import { useClickOutside, useDisclosure, useWindowEvent } from "@mantine/hooks";
import { Suspense } from "react";
import { useTranslation } from "react-i18next";
import { lazyWithPrefetch } from "@/lib/lazy-prefetch.ts";
// Load the picker module AND the emoji data in parallel inside the lazy
// resolution, then bind the data into the component. React.lazy only finishes
// suspending once both are in memory, so the Suspense boundary hides the
// Remove button until the Picker can render with real content.
const Picker = React.lazy(async () => {
const Picker = lazyWithPrefetch(async () => {
const [pickerModule, dataModule] = await Promise.all([
import("@slidoapp/emoji-mart-react"),
import("@slidoapp/emoji-mart-data"),

View file

@ -188,22 +188,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({
setPendingAttachments((prev) => prev.filter((a) => a.id !== id));
}, []);
const handleSubmit = useCallback(() => {
if (!editor || editor.isDestroyed || isStreaming) return;
const json = editor.getJSON();
const text = editorJsonToText(json).trim();
const readyAttachments = pendingAttachments.filter((a) => !a.uploading);
if (!text && readyAttachments.length === 0) return;
const mentions = extractMentions(json);
onSendRef.current(text, mentions, readyAttachments);
editor.commands.clearContent();
editor.commands.focus();
setPendingAttachments([]);
}, [isStreaming, pendingAttachments]);
const handleSubmitRef = useRef(handleSubmit);
handleSubmitRef.current = handleSubmit;
const handleSubmitRef = useRef<() => void>(() => {});
const editor = useEditor({
extensions: [
@ -276,6 +261,22 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({
},
});
const handleSubmit = useCallback(() => {
if (!editor || editor.isDestroyed || isStreaming) return;
const json = editor.getJSON();
const text = editorJsonToText(json).trim();
const readyAttachments = pendingAttachments.filter((a) => !a.uploading);
if (!text && readyAttachments.length === 0) return;
const mentions = extractMentions(json);
onSendRef.current(text, mentions, readyAttachments);
editor.commands.clearContent();
editor.commands.focus();
setPendingAttachments([]);
}, [editor, isStreaming, pendingAttachments]);
handleSubmitRef.current = handleSubmit;
useEffect(() => {
if (editor && !editor.isDestroyed && autofocus) {
editor.commands.focus();

View file

@ -34,6 +34,11 @@ export const auditEventLabels: Record<string, string> = {
"api_key.updated": "Updated API key",
"api_key.deleted": "Deleted API key",
"integration.installed": "Installed integration",
"integration.uninstalled": "Uninstalled integration",
"integration.connected": "Connected integration account",
"integration.disconnected": "Disconnected integration account",
"scim_token.created": "Created SCIM token",
"scim_token.updated": "Updated SCIM token",
"scim_token.deleted": "Deleted SCIM token",
@ -52,8 +57,13 @@ export const auditEventLabels: Record<string, string> = {
"group.member_added": "Added group member",
"group.member_removed": "Removed group member",
"comment.updated": "Updated comment",
"comment.resolved": "Resolved comment",
"comment.reopened": "Reopened comment",
"comment.deleted": "Deleted comment",
"attachment.deleted": "Deleted attachment",
"page.trashed": "Trashed page",
"page.deleted": "Deleted page",
"page.restored": "Restored page",
@ -145,9 +155,18 @@ export const eventFilterOptions: EventGroup[] = [
{
group: "Comment",
items: [
{ value: "comment.updated", label: "Updated comment" },
{ value: "comment.resolved", label: "Resolved comment" },
{ value: "comment.reopened", label: "Reopened comment" },
{ value: "comment.deleted", label: "Deleted comment" },
],
},
{
group: "Attachment",
items: [
{ value: "attachment.deleted", label: "Deleted attachment" },
],
},
{
group: "Page",
items: [
@ -195,6 +214,15 @@ export const eventFilterOptions: EventGroup[] = [
{ value: "api_key.deleted", label: "Deleted API key" },
],
},
{
group: "Integrations",
items: [
{ value: "integration.installed", label: "Installed integration" },
{ value: "integration.uninstalled", label: "Uninstalled integration" },
{ value: "integration.connected", label: "Connected integration account" },
{ value: "integration.disconnected", label: "Disconnected integration account" },
],
},
{
group: "SCIM token",
items: [

View file

@ -0,0 +1,117 @@
import { describe, expect, it, vi } from "vitest";
import { InfiniteData, QueryClient } from "@tanstack/react-query";
import type { Socket } from "socket.io-client";
import type { IPagination } from "@/lib/types";
import type { IBaseRow } from "@/ee/base/types/base.types";
vi.mock("@/main.tsx", async () => ({
queryClient: new (await import("@tanstack/react-query")).QueryClient(),
}));
const { retainBaseSubscription } = await import("./use-base-socket");
function createFakeSocket() {
const handlers = new Set<(raw: unknown) => void>();
const emitted: unknown[] = [];
const socket = {
on: vi.fn((_event: string, handler: (raw: unknown) => void) => {
handlers.add(handler);
return socket;
}),
off: vi.fn((_event: string, handler: (raw: unknown) => void) => {
handlers.delete(handler);
return socket;
}),
emit: vi.fn((_event: string, payload: unknown) => {
emitted.push(payload);
return socket;
}),
};
return {
socket: socket as unknown as Socket,
handlers,
emitted,
receive: (raw: unknown) => handlers.forEach((handler) => handler(raw)),
};
}
function seedRows(queryClient: QueryClient, pageId: string) {
queryClient.setQueryData<InfiniteData<IPagination<IBaseRow>>>(
["base-rows", pageId, "view-1"],
{ pages: [{ items: [], meta: {} } as IPagination<IBaseRow>], pageParams: [null] },
);
}
describe("retainBaseSubscription", () => {
it("joins the base room once for several views and leaves after the last one", () => {
const { socket, emitted, handlers } = createFakeSocket();
const queryClient = new QueryClient();
const releaseFirst = retainBaseSubscription(socket, "page-1", queryClient);
const releaseSecond = retainBaseSubscription(socket, "page-1", queryClient);
expect(emitted).toEqual([{ operation: "base:subscribe", pageId: "page-1" }]);
expect(handlers.size).toBe(1);
releaseFirst();
expect(emitted).toHaveLength(1);
expect(handlers.size).toBe(1);
releaseSecond();
expect(emitted).toEqual([
{ operation: "base:subscribe", pageId: "page-1" },
{ operation: "base:unsubscribe", pageId: "page-1" },
]);
expect(handlers.size).toBe(0);
});
it("applies a created row once while two views of the base are mounted", () => {
const { socket, receive } = createFakeSocket();
const queryClient = new QueryClient();
seedRows(queryClient, "page-1");
const releaseFirst = retainBaseSubscription(socket, "page-1", queryClient);
const releaseSecond = retainBaseSubscription(socket, "page-1", queryClient);
receive({ operation: "base:row:created", pageId: "page-1", row: { id: "row-1" } });
const rows = queryClient.getQueryData<InfiniteData<IPagination<IBaseRow>>>([
"base-rows",
"page-1",
"view-1",
]);
expect(rows.pages[0].items.map((row) => row.id)).toEqual(["row-1"]);
releaseFirst();
releaseSecond();
});
it("subscribes again when a view mounts after the last one left", () => {
const { socket, emitted } = createFakeSocket();
const queryClient = new QueryClient();
retainBaseSubscription(socket, "page-1", queryClient)();
const release = retainBaseSubscription(socket, "page-1", queryClient);
expect(emitted).toEqual([
{ operation: "base:subscribe", pageId: "page-1" },
{ operation: "base:unsubscribe", pageId: "page-1" },
{ operation: "base:subscribe", pageId: "page-1" },
]);
release();
});
it("keeps separate subscriptions per base", () => {
const { socket, emitted } = createFakeSocket();
const queryClient = new QueryClient();
const releaseFirst = retainBaseSubscription(socket, "page-1", queryClient);
const releaseSecond = retainBaseSubscription(socket, "page-2", queryClient);
releaseFirst();
expect(emitted).toEqual([
{ operation: "base:subscribe", pageId: "page-1" },
{ operation: "base:subscribe", pageId: "page-2" },
{ operation: "base:unsubscribe", pageId: "page-1" },
]);
releaseSecond();
});
});

View file

@ -1,6 +1,7 @@
import { useEffect } from "react";
import { useAtomValue, getDefaultStore } from "jotai";
import { useQueryClient, InfiniteData } from "@tanstack/react-query";
import { useQueryClient, InfiniteData, QueryClient } from "@tanstack/react-query";
import type { Socket } from "socket.io-client";
import { socketAtom } from "@/features/websocket/atoms/socket-atom";
import {
IBase,
@ -137,86 +138,54 @@ export function markRequestIdOutbound(requestId: string): void {
}
}
// Realtime bridge for a single base. Joins the base-{pageId} room on mount,
// leaves on unmount, and reconciles React Query caches on inbound events.
export function useBaseSocket(pageId: string | undefined): void {
const socket = useAtomValue(socketAtom);
const queryClient = useQueryClient();
function createBaseEventHandler(
pageId: string,
queryClient: QueryClient,
): (raw: unknown) => void {
return (raw: unknown) => {
if (!raw || typeof raw !== "object") return;
const event = raw as BaseInboundEvent;
if (event.pageId !== pageId) return;
useEffect(() => {
if (!socket || !pageId) return;
const requestId = (event as any).requestId as string | undefined;
if (requestId && outboundRequestIds.has(requestId)) {
outboundRequestIds.delete(requestId);
return;
}
socket.emit("message", { operation: "base:subscribe", pageId });
const handler = (raw: unknown) => {
if (!raw || typeof raw !== "object") return;
const event = raw as BaseInboundEvent;
if (event.pageId !== pageId) return;
const requestId = (event as any).requestId as string | undefined;
if (requestId && outboundRequestIds.has(requestId)) {
outboundRequestIds.delete(requestId);
return;
switch (event.operation) {
case "base:row:created": {
const e = event as BaseRowCreated;
const baseForCreate = queryClient.getQueryData<IBase>(["bases", pageId]);
const hasKanbanForCreate = (baseForCreate?.views ?? []).some((v) => v.type === "kanban");
if (hasKanbanForCreate) {
invalidateBaseRows(pageId);
} else {
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
{ queryKey: ["base-rows", pageId] },
(old) => {
if (!old) return old;
const lastPageIndex = old.pages.length - 1;
return {
...old,
pages: old.pages.map((page, index) =>
index === lastPageIndex
? { ...page, items: [...page.items, e.row] }
: page,
),
};
},
);
}
break;
}
switch (event.operation) {
case "base:row:created": {
const e = event as BaseRowCreated;
const baseForCreate = queryClient.getQueryData<IBase>(["bases", pageId]);
const hasKanbanForCreate = (baseForCreate?.views ?? []).some((v) => v.type === "kanban");
if (hasKanbanForCreate) {
invalidateBaseRows(pageId);
} else {
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
{ queryKey: ["base-rows", pageId] },
(old) => {
if (!old) return old;
const lastPageIndex = old.pages.length - 1;
return {
...old,
pages: old.pages.map((page, index) =>
index === lastPageIndex
? { ...page, items: [...page.items, e.row] }
: page,
),
};
},
);
}
break;
}
case "base:row:updated": {
const e = event as BaseRowUpdated;
const baseForUpdate = queryClient.getQueryData<IBase>(["bases", pageId]);
const hasKanbanForUpdate = (baseForUpdate?.views ?? []).some((v) => v.type === "kanban");
if (hasKanbanForUpdate) {
invalidateBaseRows(pageId);
} else {
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
{ queryKey: ["base-rows", pageId] },
(old) =>
!old
? old
: {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.map((row) =>
row.id === e.rowId
? {
...row,
cells: { ...row.cells, ...e.updatedCells },
}
: row,
),
})),
},
);
}
break;
}
case "base:row:deleted": {
const e = event as BaseRowDeleted;
case "base:row:updated": {
const e = event as BaseRowUpdated;
const baseForUpdate = queryClient.getQueryData<IBase>(["bases", pageId]);
const hasKanbanForUpdate = (baseForUpdate?.views ?? []).some((v) => v.type === "kanban");
if (hasKanbanForUpdate) {
invalidateBaseRows(pageId);
} else {
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
{ queryKey: ["base-rows", pageId] },
(old) =>
@ -226,157 +195,224 @@ export function useBaseSocket(pageId: string | undefined): void {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.filter((row) => row.id !== e.rowId),
items: page.items.map((row) =>
row.id === e.rowId
? {
...row,
cells: { ...row.cells, ...e.updatedCells },
}
: row,
),
})),
},
);
const store = getDefaultStore();
const selectedIdsAtom = selectedRowIdsAtomFamily(pageId);
const current = store.get(selectedIdsAtom);
if (current.has(e.rowId)) {
const next = new Set(current);
next.delete(e.rowId);
store.set(selectedIdsAtom, next);
}
break;
}
case "base:rows:deleted": {
const e = event as BaseRowsDeleted;
const removeSet = new Set(e.rowIds);
break;
}
case "base:row:deleted": {
const e = event as BaseRowDeleted;
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
{ queryKey: ["base-rows", pageId] },
(old) =>
!old
? old
: {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.filter((row) => row.id !== e.rowId),
})),
},
);
const store = getDefaultStore();
const selectedIdsAtom = selectedRowIdsAtomFamily(pageId);
const current = store.get(selectedIdsAtom);
if (current.has(e.rowId)) {
const next = new Set(current);
next.delete(e.rowId);
store.set(selectedIdsAtom, next);
}
break;
}
case "base:rows:deleted": {
const e = event as BaseRowsDeleted;
const removeSet = new Set(e.rowIds);
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
{ queryKey: ["base-rows", pageId] },
(old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.filter((row) => !removeSet.has(row.id)),
})),
};
},
);
const store = getDefaultStore();
const selectedIdsAtom = selectedRowIdsAtomFamily(pageId);
const current = store.get(selectedIdsAtom);
if (current.size > 0) {
let changed = false;
const next = new Set(current);
for (const id of e.rowIds) {
if (next.delete(id)) changed = true;
}
if (changed) store.set(selectedIdsAtom, next);
}
break;
}
case "base:row:reordered": {
const e = event as BaseRowReordered;
const baseForReorder = queryClient.getQueryData<IBase>(["bases", pageId]);
const hasKanbanForReorder = (baseForReorder?.views ?? []).some((v) => v.type === "kanban");
if (hasKanbanForReorder) {
invalidateBaseRows(pageId);
} else {
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
{ queryKey: ["base-rows", pageId] },
(old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.filter((row) => !removeSet.has(row.id)),
})),
};
},
(old) =>
!old
? old
: {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.map((row) =>
row.id === e.rowId
? { ...row, position: e.position }
: row,
),
})),
},
);
const store = getDefaultStore();
const selectedIdsAtom = selectedRowIdsAtomFamily(pageId);
const current = store.get(selectedIdsAtom);
if (current.size > 0) {
let changed = false;
const next = new Set(current);
for (const id of e.rowIds) {
if (next.delete(id)) changed = true;
}
if (changed) store.set(selectedIdsAtom, next);
}
break;
}
case "base:row:reordered": {
const e = event as BaseRowReordered;
const baseForReorder = queryClient.getQueryData<IBase>(["bases", pageId]);
const hasKanbanForReorder = (baseForReorder?.views ?? []).some((v) => v.type === "kanban");
if (hasKanbanForReorder) {
invalidateBaseRows(pageId);
} else {
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
{ queryKey: ["base-rows", pageId] },
(old) =>
!old
? old
: {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.map((row) =>
row.id === e.rowId
? { ...row, position: e.position }
: row,
),
})),
},
);
}
break;
}
case "base:rows:updated": {
const e = event as BaseRowsUpdated;
// Only refetch if the batch touches rows currently in cache; formula
// backfills emit one event per 500 rows so this avoids redundant fetches.
const updatedIds = new Set(e.rowIds);
const caches = queryClient.getQueriesData<
InfiniteData<IPagination<IBaseRow>>
>({ queryKey: ["base-rows", pageId] });
let touchesCache = false;
outer: for (const [, data] of caches) {
if (!data) continue;
for (const page of data.pages) {
for (const row of page.items) {
if (updatedIds.has(row.id)) {
touchesCache = true;
break outer;
}
break;
}
case "base:rows:updated": {
const e = event as BaseRowsUpdated;
// Only refetch if the batch touches rows currently in cache; formula
// backfills emit one event per 500 rows so this avoids redundant fetches.
const updatedIds = new Set(e.rowIds);
const caches = queryClient.getQueriesData<
InfiniteData<IPagination<IBaseRow>>
>({ queryKey: ["base-rows", pageId] });
let touchesCache = false;
outer: for (const [, data] of caches) {
if (!data) continue;
for (const page of data.pages) {
for (const row of page.items) {
if (updatedIds.has(row.id)) {
touchesCache = true;
break outer;
}
}
}
if (touchesCache) {
queryClient.invalidateQueries({ queryKey: ["base-rows", pageId] });
}
break;
}
case "base:schema:bumped": {
// Worker committed a type conversion or cell GC; re-fetch under the new schema.
if (touchesCache) {
queryClient.invalidateQueries({ queryKey: ["base-rows", pageId] });
}
break;
}
case "base:schema:bumped": {
// Worker committed a type conversion or cell GC; re-fetch under the new schema.
queryClient.invalidateQueries({ queryKey: ["base-rows", pageId] });
queryClient.invalidateQueries({ queryKey: ["bases", pageId] });
break;
}
case "base:subscribed": {
const e = event as BaseSubscribed;
const cached = queryClient.getQueryData<IBase>(["bases", pageId]);
if (cached && cached.baseSchemaVersion !== e.schemaVersion) {
queryClient.invalidateQueries({ queryKey: ["base-rows", pageId] });
queryClient.invalidateQueries({ queryKey: ["bases", pageId] });
break;
}
case "base:subscribed": {
const e = event as BaseSubscribed;
const cached = queryClient.getQueryData<IBase>(["bases", pageId]);
if (cached && cached.baseSchemaVersion !== e.schemaVersion) {
queryClient.invalidateQueries({ queryKey: ["base-rows", pageId] });
queryClient.invalidateQueries({ queryKey: ["bases", pageId] });
}
break;
}
case "base:formula:recompute:started": {
const e = event as BaseFormulaRecomputeStarted;
const store = getDefaultStore();
store.set(formulaRecomputeAtom, {
...store.get(formulaRecomputeAtom),
[e.jobId]: e.propertyIds,
});
break;
}
case "base:formula:recompute:completed": {
const e = event as BaseFormulaRecomputeCompleted;
const store = getDefaultStore();
const current = store.get(formulaRecomputeAtom);
if (e.jobId in current) {
const next = { ...current };
delete next[e.jobId];
store.set(formulaRecomputeAtom, next);
}
break;
}
case "base:property:created":
case "base:property:updated":
case "base:property:deleted":
case "base:property:reordered":
case "base:view:created":
case "base:view:updated":
case "base:view:deleted": {
// Schema/metadata events only affect properties/views, not cell data.
queryClient.invalidateQueries({ queryKey: ["bases", pageId] });
break;
}
default:
break;
break;
}
};
case "base:formula:recompute:started": {
const e = event as BaseFormulaRecomputeStarted;
const store = getDefaultStore();
store.set(formulaRecomputeAtom, {
...store.get(formulaRecomputeAtom),
[e.jobId]: e.propertyIds,
});
break;
}
case "base:formula:recompute:completed": {
const e = event as BaseFormulaRecomputeCompleted;
const store = getDefaultStore();
const current = store.get(formulaRecomputeAtom);
if (e.jobId in current) {
const next = { ...current };
delete next[e.jobId];
store.set(formulaRecomputeAtom, next);
}
break;
}
case "base:property:created":
case "base:property:updated":
case "base:property:deleted":
case "base:property:reordered":
case "base:view:created":
case "base:view:updated":
case "base:view:deleted": {
// Schema/metadata events only affect properties/views, not cell data.
queryClient.invalidateQueries({ queryKey: ["bases", pageId] });
break;
}
default:
break;
}
};
}
socket.on("message", handler);
type BaseSubscription = {
count: number;
handler: (raw: unknown) => void;
};
return () => {
socket.off("message", handler);
socket.emit("message", { operation: "base:unsubscribe", pageId });
const baseSubscriptions = new WeakMap<Socket, Map<string, BaseSubscription>>();
// One room subscription and cache handler per base, shared by every mounted view.
export function retainBaseSubscription(
socket: Socket,
pageId: string,
queryClient: QueryClient,
): () => void {
const subscriptions =
baseSubscriptions.get(socket) ?? new Map<string, BaseSubscription>();
baseSubscriptions.set(socket, subscriptions);
let subscription = subscriptions.get(pageId);
if (!subscription) {
subscription = {
count: 0,
handler: createBaseEventHandler(pageId, queryClient),
};
subscriptions.set(pageId, subscription);
socket.on("message", subscription.handler);
socket.emit("message", { operation: "base:subscribe", pageId });
}
subscription.count += 1;
const retained = subscription;
return () => {
retained.count -= 1;
if (retained.count > 0) return;
subscriptions.delete(pageId);
socket.off("message", retained.handler);
socket.emit("message", { operation: "base:unsubscribe", pageId });
};
}
// Realtime bridge for a base: reconciles React Query caches on inbound events.
export function useBaseSocket(pageId: string | undefined): void {
const socket = useAtomValue(socketAtom);
const queryClient = useQueryClient();
useEffect(() => {
if (!socket || !pageId) return;
return retainBaseSubscription(socket, pageId, queryClient);
}, [socket, pageId, queryClient]);
}

View file

@ -22,6 +22,8 @@ export const Feature = {
PERSONAL_SPACES: 'spaces:personal',
DOCX_EXPORT: 'export:docx',
BASES: 'bases',
INTEGRATIONS: 'integrations',
INTEGRATIONS_ADVANCED: 'integrations:advanced',
OAUTH: 'oauth',
AI_CONTROLS: 'ai:controls',
MCP_CONTROLS: 'mcp:controls',

View file

@ -36,6 +36,7 @@ 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 { deleteLocalPageData } from "@/lib/local-page-data.ts";
import {
approveOAuthAuthorization,
getOAuthAuthorizeInfo,
@ -226,6 +227,7 @@ function ConsentCard({ info, currentUser, params }: ConsentCardProps) {
}
setCurrentUser(RESET);
try {
await deleteLocalPageData();
await logout();
} finally {
window.location.replace(

View file

@ -35,6 +35,7 @@ export default function AllowedDomains() {
emailDomains: data.emailDomains,
});
setWorkspace(updatedWorkspace);
form.setValues({ emailDomains: updatedWorkspace.emailDomains });
notifications.show({
message: t("Updated successfully"),

View file

@ -34,6 +34,8 @@ type TemplatePickerModalProps = {
onClose: () => void;
/** Pre-select this space in the destination picker after a template is chosen. */
initialSpaceId?: string;
/** Receives the chosen template instead of creating a page from it. */
onUse?: (template: ITemplate) => void;
};
type ScopeFilter = "current" | "all";
@ -42,6 +44,7 @@ export default function TemplatePickerModal({
opened,
onClose,
initialSpaceId,
onUse,
}: TemplatePickerModalProps) {
const { t } = useTranslation();
const navigate = useNavigate();
@ -101,6 +104,11 @@ export default function TemplatePickerModal({
};
const handleQuickUse = (tpl: ITemplate) => {
if (onUse) {
onUse(tpl);
handleClose();
return;
}
if (initialSpaceId) {
createInInitialSpace(tpl);
return;
@ -114,6 +122,11 @@ export default function TemplatePickerModal({
};
const handlePreviewUse = () => {
if (onUse && previewTemplate) {
onUse(previewTemplate);
handleClose();
return;
}
if (initialSpaceId && previewTemplate) {
createInInitialSpace(previewTemplate);
return;

View file

@ -23,6 +23,10 @@ import { useTranslation } from "react-i18next";
import { invalidateOnCreatePage } from "@/features/page/queries/page-query.ts";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { treeModel } from "@/features/page/tree/model/tree-model";
import {
spaceRoots,
updateSpaceRoots,
} from "@/features/page/tree/utils/utils.ts";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { IPage } from "@/features/page/types/page.types.ts";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
@ -185,15 +189,14 @@ export function useUseTemplateMutation() {
children: [],
};
// Only mutate the tree atom and broadcast if it currently represents
// this space. Cross-space template-use (e.g., from the gallery picking
// Only mutate the tree atom and broadcast if this space's tree is
// loaded. Cross-space template-use (e.g., from the gallery picking
// a different space) lets the target space's clients pick up the new
// page on their next React Query refetch (focus, navigation, etc.).
// Without this guard we'd both pollute the local tree and send a wrong
// `index` to remote clients in the target space.
const current = store.get(treeDataAtom);
const treeIsForThisSpace = current[0]?.spaceId === page.spaceId;
if (!treeIsForThisSpace) return;
const current = spaceRoots(store.get(treeDataAtom), page.spaceId);
if (current.length === 0) return;
const lastIndex =
parentId === null
@ -201,7 +204,9 @@ export function useUseTemplateMutation() {
: (treeModel.find(current, parentId)?.children?.length ?? 0);
setTreeData((prev) =>
treeModel.insert(prev, parentId, newNode, lastIndex),
updateSpaceRoots(prev, page.spaceId, (roots) =>
treeModel.insert(roots, parentId, newNode, lastIndex),
),
);
setTimeout(() => {

View file

@ -0,0 +1,16 @@
import { Editor } from "@tiptap/core";
import { ITemplate } from "@/ee/template/types/template.types";
export function applyTemplateToPage(
editor: Editor,
titleEditor: Editor,
template: ITemplate,
): boolean {
if (!editor.isEmpty) return false;
if (titleEditor.isEmpty && template.title) {
titleEditor.commands.setContent(template.title, { emitUpdate: true });
}
editor.commands.setContent(template.content);
return true;
}

View file

@ -10,25 +10,38 @@ import {
Text,
Tooltip,
} from "@mantine/core";
import { IconDownload } from "@tabler/icons-react";
import { IconDownload, IconTrash } from "@tabler/icons-react";
import { modals } from "@mantine/modals";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
import { SearchInput } from "@/components/common/search-input.tsx";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { usePageAttachmentsQuery } from "@/features/attachments/queries/attachment-query.ts";
import {
useDeleteAttachmentMutation,
usePageAttachmentsQuery,
} from "@/features/attachments/queries/attachment-query.ts";
import { IPageAttachment } from "@/features/attachments/types/attachment.types.ts";
import { AttachmentFileIcon } from "@/features/attachments/components/attachment-file-icon.tsx";
import { formatBytes } from "@/lib";
import { getFileUrl } from "@/lib/config.ts";
import { formattedDate } from "@/lib/time.ts";
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
import {
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type.ts";
interface PageAttachmentsModalProps {
pageId: string;
readOnly?: boolean;
open: boolean;
onClose: () => void;
}
export default function PageAttachmentsModal({
pageId,
readOnly,
open,
onClose,
}: PageAttachmentsModalProps) {
@ -42,12 +55,17 @@ export default function PageAttachmentsModal({
size={800}
closeButtonProps={{ "aria-label": t("Close") }}
>
<PageAttachmentsList pageId={pageId} />
<PageAttachmentsList pageId={pageId} readOnly={readOnly} />
</Modal>
);
}
function PageAttachmentsList({ pageId }: { pageId: string }) {
type PageAttachmentsListProps = {
pageId: string;
readOnly?: boolean;
};
function PageAttachmentsList({ pageId, readOnly }: PageAttachmentsListProps) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const {
@ -59,6 +77,14 @@ function PageAttachmentsList({ pageId }: { pageId: string }) {
hasNextPage,
isFetchingNextPage,
} = usePageAttachmentsQuery(pageId, search);
const deleteAttachmentMutation = useDeleteAttachmentMutation(pageId);
const { spaceSlug } = useParams();
const { data: space } = useSpaceQuery(spaceSlug);
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
const canDelete =
!readOnly &&
spaceAbility.can(SpaceCaslAction.Manage, SpaceCaslSubject.Settings);
const attachments = useMemo(
() => data?.pages.flatMap((page) => page.items) ?? [],
@ -86,6 +112,24 @@ function PageAttachmentsList({ pageId }: { pageId: string }) {
const handleSearch = useCallback((value: string) => setSearch(value), []);
const handleDelete = (attachment: IPageAttachment) => {
modals.openConfirmModal({
title: t("Delete attachment"),
centered: true,
children: (
<Text size="sm">
{t(
"Are you sure you want to delete '{{title}}'? This action cannot be undone.",
{ title: attachment.fileName },
)}
</Text>
),
labels: { confirm: t("Delete"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => deleteAttachmentMutation.mutate(attachment.id),
});
};
return (
<>
<SearchInput
@ -114,7 +158,11 @@ function PageAttachmentsList({ pageId }: { pageId: string }) {
) : (
<ScrollArea.Autosize mah={480} type="scroll" scrollbarSize={5}>
{attachments.map((attachment) => (
<AttachmentRow key={attachment.id} attachment={attachment} />
<AttachmentRow
key={attachment.id}
attachment={attachment}
onDelete={canDelete ? handleDelete : undefined}
/>
))}
{hasNextPage && <div ref={loadMoreRef} style={{ height: 1 }} />}
{isFetchingNextPage && (
@ -128,7 +176,12 @@ function PageAttachmentsList({ pageId }: { pageId: string }) {
);
}
function AttachmentRow({ attachment }: { attachment: IPageAttachment }) {
type AttachmentRowProps = {
attachment: IPageAttachment;
onDelete?: (attachment: IPageAttachment) => void;
};
function AttachmentRow({ attachment, onDelete }: AttachmentRowProps) {
const { t } = useTranslation();
const fileUrl = getFileUrl(attachment.url);
@ -186,6 +239,19 @@ function AttachmentRow({ attachment }: { attachment: IPageAttachment }) {
<IconDownload size={18} />
</ActionIcon>
</Tooltip>
{onDelete && (
<Tooltip label={t("Delete attachment")} withArrow>
<ActionIcon
variant="subtle"
color="red"
aria-label={t("Delete {{name}}", { name: attachment.fileName })}
onClick={() => onDelete(attachment)}
>
<IconTrash size={18} />
</ActionIcon>
</Tooltip>
)}
</Group>
);
}

View file

@ -3,8 +3,15 @@ import {
keepPreviousData,
useInfiniteQuery,
UseInfiniteQueryResult,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import { getPageAttachments } from "@/features/attachments/services/attachment-service.ts";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import {
deleteAttachment,
getPageAttachments,
} from "@/features/attachments/services/attachment-service.ts";
import { IPageAttachment } from "@/features/attachments/types/attachment.types.ts";
import { IPagination } from "@/lib/types.ts";
@ -23,3 +30,35 @@ export function usePageAttachmentsQuery(
getNextPageParam: (lastPage) => lastPage.meta?.nextCursor ?? undefined,
});
}
export function useDeleteAttachmentMutation(pageId: string) {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, string>({
mutationFn: (attachmentId) => deleteAttachment(attachmentId),
onSuccess: (_data, attachmentId) => {
queryClient.setQueriesData<InfiniteData<IPagination<IPageAttachment>>>(
{ queryKey: ["page-attachments", pageId] },
(old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.filter((item) => item.id !== attachmentId),
})),
};
},
);
notifications.show({ message: t("Attachment deleted") });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to delete attachment"),
color: "red",
});
},
});
}

View file

@ -15,6 +15,10 @@ export async function getPageAttachments(
return req.data;
}
export async function deleteAttachment(attachmentId: string): Promise<void> {
await api.post("/attachments/delete", { attachmentId });
}
async function compressAndResizeIcon(
file: File,
type: AvatarIconType,

View file

@ -27,6 +27,7 @@ import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts";
import { RESET } from "jotai/utils";
import { useTranslation } from "react-i18next";
import { isCloud } from "@/lib/config.ts";
import { deleteLocalPageData } from "@/lib/local-page-data.ts";
import { exchangeTokenRedirectUrl, getHostnameUrl } from "@/ee/utils.ts";
export default function useAuth() {
@ -165,6 +166,7 @@ export default function useAuth() {
const handleLogout = async () => {
setCurrentUser(RESET);
await deleteLocalPageData();
await logout();
window.location.replace(`${APP_ROUTE.AUTH.LOGIN}?logout=1`);
};

View file

@ -4,11 +4,11 @@ import { CopyButton } from "@/components/common/copy-button";
import { useEffect, useState } from "react";
import { IconCheck, IconCopy } from "@tabler/icons-react";
import classes from "./code-block.module.css";
import React from "react";
import { Suspense } from "react";
import { useTranslation } from "react-i18next";
import { lazyWithPrefetch } from "@/lib/lazy-prefetch.ts";
const MermaidView = React.lazy(
const MermaidView = lazyWithPrefetch(
() => import("@/features/editor/components/code-block/mermaid-view.tsx"),
);

View file

@ -0,0 +1,342 @@
import { IntegrationCard, IntegrationMention } from "@docmost/editor-ext";
import { Editor, Node, type JSONContent } from "@tiptap/core";
import { StarterKit } from "@tiptap/starter-kit";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { queryClient } from "@/main.tsx";
import { TiptapDocument } from "@/features/editor/extensions/document";
import {
IntegrationPasteMenuExtension,
integrationPasteMenuKey,
} from "@/features/editor/extensions/integration-paste-menu";
import {
convertIntegrationDisplay,
type IntegrationDisplay,
} from "@/features/editor/components/integration-link/integration-display";
import { handlePaste } from "./editor-paste-handler";
vi.mock("@/main.tsx", async () => {
const { QueryClient } = await import("@tanstack/react-query");
return { queryClient: new QueryClient() };
});
const PR_URL = "https://github.com/org/repo/pull/12";
const text = (value: string, marks?: JSONContent["marks"]): JSONContent => ({
type: "text",
text: value,
...(marks && { marks }),
});
const paragraph = (...content: JSONContent[]): JSONContent => ({
type: "paragraph",
...(content.length && { content }),
});
const card = (): JSONContent => ({
type: "integrationCard",
attrs: { url: PR_URL, provider: "github" },
});
const mention = (): JSONContent => ({
type: "integrationMention",
attrs: { url: PR_URL, provider: "github" },
});
// The real footnote nodes bring plugins that clash with editor-ext's CJS build in vitest.
const Footnotes = Node.create({
name: "footnotes",
group: "",
content: "footnote*",
isolating: true,
renderHTML() {
return ["ol", { class: "footnotes" }, 0];
},
});
const Footnote = Node.create({
name: "footnote",
content: "paragraph+",
isolating: true,
renderHTML() {
return ["li", 0];
},
});
let editor: Editor;
function createEditor(content: object[], cursor: number) {
editor = new Editor({
extensions: [
TiptapDocument,
StarterKit.configure({ document: false }),
Footnotes,
Footnote,
IntegrationCard,
IntegrationMention,
IntegrationPasteMenuExtension,
],
content: { type: "doc", content },
});
editor.commands.setTextSelection(cursor);
return editor;
}
function paste(text: string, html = "") {
const event = {
clipboardData: {
getData: (type: string) =>
type === "text/plain" ? text : type === "text/html" ? html : "",
files: [],
},
preventDefault: vi.fn(),
};
const handled = handlePaste(
editor,
event as unknown as ClipboardEvent,
"page-id",
);
return { handled, preventDefault: event.preventDefault };
}
function choosePasteOption(display: IntegrationDisplay) {
const { pos, joinBefore, joinAfter } = integrationPasteMenuKey.getState(
editor.state,
);
return convertIntegrationDisplay(
editor,
{ kind: "node", pos, joinBefore, joinAfter },
display,
);
}
function hasIntegrationCard() {
let found = false;
editor.state.doc.descendants((node) => {
if (node.type.name === "integrationCard") found = true;
});
return found;
}
function expectPassThrough(text = PR_URL, html = "") {
const before = editor.getJSON();
const { handled, preventDefault } = paste(text, html);
expect(handled).toBe(false);
expect(preventDefault).not.toHaveBeenCalled();
expect(hasIntegrationCard()).toBe(false);
expect(editor.getJSON()).toEqual(before);
}
describe("handlePaste integration links", () => {
beforeEach(() => {
queryClient.setQueryData(
["installed-integrations"],
[{ type: "github", unfurlHosts: ["github.com"] }],
);
});
afterEach(() => {
editor?.destroy();
queryClient.clear();
});
it("inserts a card in a paragraph when the provider is installed", () => {
createEditor([paragraph(text("Hello"))], 6);
const { handled, preventDefault } = paste(PR_URL);
expect(handled).toBe(true);
expect(preventDefault).toHaveBeenCalled();
const pasteMenu = integrationPasteMenuKey.getState(editor.state);
expect(pasteMenu).not.toBeNull();
expect(editor.state.doc.nodeAt(pasteMenu.pos)?.type.name).toBe(
"integrationCard",
);
expect(editor.state.doc.nodeAt(pasteMenu.pos)?.attrs).toMatchObject({
url: PR_URL,
provider: "github",
});
});
it("pastes a GitHub-like path on a host GitHub does not serve as a link", () => {
createEditor([paragraph(text("Hello"))], 6);
expectPassThrough("https://gitea.example.com/team/app/pulls/7");
});
it.each(["integrationCard", "integrationMention"])(
"leaves a copied %s to ProseMirror instead of pasting its URL as a card",
(type) => {
createEditor([paragraph(text("Hello world"))], 7);
expectPassThrough(
PR_URL,
`<p data-pm-slice="1 1 []"><span data-type="${type}" data-url="${PR_URL}" data-provider="github"></span></p>`,
);
},
);
it("inserts a card for a repo root on a GitHub Enterprise host", () => {
queryClient.setQueryData(
["installed-integrations"],
[{ type: "github", unfurlHosts: ["github.acme.com"] }],
);
createEditor([paragraph(text("Hello"))], 6);
const { handled } = paste("https://github.acme.com/org/repo");
expect(handled).toBe(true);
expect(hasIntegrationCard()).toBe(true);
});
it("splits the paragraph around a card pasted mid-sentence", () => {
createEditor([paragraph(text("Hello world"))], 7);
const { handled, preventDefault } = paste(PR_URL);
expect(handled).toBe(true);
expect(preventDefault).toHaveBeenCalled();
expect(editor.getJSON().content).toEqual([
paragraph(text("Hello ")),
card(),
paragraph(text("world")),
]);
});
it("rejoins the sentence when a mid-sentence paste becomes a mention", () => {
createEditor([paragraph(text("Fix in today"))], 8);
paste(PR_URL);
expect(choosePasteOption("mention")).toBe(true);
expect(editor.getJSON().content).toEqual([
paragraph(text("Fix in "), mention(), text(" today")),
]);
expect(editor.state.selection.from).toBe(10);
});
it("adds no second space when the rest of the sentence starts with one", () => {
createEditor([paragraph(text("Fix in today"))], 7);
paste(PR_URL);
expect(choosePasteOption("mention")).toBe(true);
expect(editor.getJSON().content).toEqual([
paragraph(text("Fix in"), mention(), text(" today")),
]);
});
it("rejoins the sentence when a mid-sentence paste becomes a URL", () => {
createEditor([paragraph(text("Fix in today"))], 8);
paste(PR_URL);
expect(choosePasteOption("url")).toBe(true);
expect(editor.getJSON().content).toHaveLength(1);
expect(editor.getJSON().content?.[0]).toMatchObject(
paragraph(
text("Fix in "),
text(PR_URL, [{ type: "link", attrs: { href: PR_URL } }]),
text("today"),
),
);
});
it("keeps an end-of-paragraph paste turned into a mention in its paragraph", () => {
createEditor([paragraph(text("Fix in ")), paragraph(text("Next"))], 8);
paste(PR_URL);
expect(choosePasteOption("mention")).toBe(true);
expect(editor.getJSON().content).toEqual([
paragraph(text("Fix in "), mention(), text(" ")),
paragraph(text("Next")),
]);
});
it("joins a start-of-paragraph paste turned into a mention with the rest of the line", () => {
createEditor([paragraph(text("today"))], 1);
paste(PR_URL);
expect(choosePasteOption("mention")).toBe(true);
expect(editor.getJSON().content).toEqual([
paragraph(mention(), text(" today")),
]);
});
it("keeps a paste on an empty line in its own paragraph when it becomes a mention", () => {
createEditor(
[paragraph(text("Before")), paragraph(), paragraph(text("After"))],
9,
);
paste(PR_URL);
expect(choosePasteOption("mention")).toBe(true);
expect(editor.getJSON().content).toEqual([
paragraph(text("Before")),
paragraph(mention(), text(" ")),
paragraph(text("After")),
]);
});
it("pastes a mention inside a footnote, which cannot hold a card", () => {
createEditor(
[
paragraph(text("Body")),
{
type: "footnotes",
content: [
{
type: "footnote",
content: [paragraph(text("See "))],
},
],
},
],
13,
);
const { handled, preventDefault } = paste(PR_URL);
expect(handled).toBe(true);
expect(preventDefault).toHaveBeenCalled();
expect(hasIntegrationCard()).toBe(false);
expect(editor.getJSON().content?.[1]).toMatchObject({
type: "footnotes",
content: [
{
type: "footnote",
content: [paragraph(text("See "), mention())],
},
],
});
});
it("leaves a code block paste to the default handler", () => {
createEditor(
[
{
type: "codeBlock",
content: [text("const a = 1;")],
},
],
4,
);
expectPassThrough();
});
it("leaves a heading paste to the default handler", () => {
createEditor(
[
{
type: "heading",
attrs: { level: 2 },
content: [text("Title")],
},
],
3,
);
expectPassThrough();
});
it("leaves an inline code paste to the default handler", () => {
createEditor([paragraph(text("run "), text("cmd", [{ type: "code" }]))], 6);
expect(editor.isActive("code")).toBe(true);
expectPassThrough();
});
});

View file

@ -5,6 +5,11 @@ import { uploadPdfAction } from "../pdf/upload-pdf-action";
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
import { Editor } from "@tiptap/core";
import { matchIntegrationLink } from "@docmost/editor-ext";
import { integrationPasteMenuKey } from "@/features/editor/extensions/integration-paste-menu";
import { canPlaceIntegrationCard } from "@/features/editor/components/integration-link/integration-display";
import { queryClient } from "@/main.tsx";
import { Integration } from "@/features/integration/types/integration.types";
import {
getAttachmentInfo,
uploadFile,
@ -22,6 +27,24 @@ const ATTACHMENT_NODE_TYPES = [
const ATTACHMENT_URL_RE = /\/api\/files\/([0-9a-f-]+)\//;
// copied cards and mentions paste back as nodes
const INTEGRATION_NODE_HTML_RE = /data-type="integration(Card|Mention)"/;
// Reads the cache the page editor prefetches; a cold cache pastes an ordinary link.
function matchInstalledIntegrationLink(url: string) {
const installed =
queryClient.getQueryData<Integration[]>(["installed-integrations"]) ?? [];
const unfurlHosts = Object.fromEntries(
installed
.filter((integration) => integration.unfurlHosts)
.map((integration) => [integration.type, integration.unfurlHosts]),
);
const match = matchIntegrationLink(url, unfurlHosts);
return match && installed.some((i) => i.type === match.provider)
? match
: null;
}
export const handlePaste = (
editor: Editor,
event: ClipboardEvent,
@ -30,6 +53,84 @@ export const handlePaste = (
) => {
const clipboardData = event.clipboardData.getData("text/plain");
const integrationMatch = matchInstalledIntegrationLink(clipboardData.trim());
if (
integrationMatch &&
!INTEGRATION_NODE_HTML_RE.test(event.clipboardData.getData("text/html")) &&
editor.state.selection.empty &&
editor.state.selection.$from.parent.type.name === "paragraph" &&
!editor.isActive("code")
) {
const pastedUrl = clipboardData.trim();
const { $from } = editor.state.selection;
if (canPlaceIntegrationCard(editor, $from.pos)) {
event.preventDefault();
editor
.chain()
.focus()
.setIntegrationCard({
url: pastedUrl,
provider: integrationMatch.provider,
})
// Set the meta in this transaction (BubbleMenu ignores meta-only ones) and find the node by changed range, not url.
.command(({ tr }) => {
let start: number | null = null;
let end: number | null = null;
tr.mapping.maps.forEach((map, index) => {
const rest = tr.mapping.slice(index + 1);
map.forEach((_oldStart, _oldEnd, newStart, newEnd) => {
const mappedStart = rest.map(newStart, -1);
const mappedEnd = rest.map(newEnd, 1);
start =
start === null ? mappedStart : Math.min(start, mappedStart);
end = end === null ? mappedEnd : Math.max(end, mappedEnd);
});
});
if (start === null || end === null) return true;
let pastedPos: number | null = null;
tr.doc.nodesBetween(
start,
Math.min(end, tr.doc.content.size),
(node, pos) => {
if (node.type.name === "integrationCard") {
pastedPos = pos;
}
},
);
if (pastedPos !== null) {
tr.setMeta(integrationPasteMenuKey, {
pos: pastedPos,
joinBefore: $from.parentOffset > 0,
joinAfter: $from.parentOffset < $from.parent.content.size,
});
}
return true;
})
.run();
return true;
}
// Containers such as footnotes only hold paragraphs, but a mention still fits.
const mentionType = editor.schema.nodes.integrationMention;
if (
mentionType &&
$from.parent.canReplaceWith($from.index(), $from.index(), mentionType)
) {
event.preventDefault();
editor
.chain()
.focus()
.setIntegrationMention({
url: pastedUrl,
provider: integrationMatch.provider,
})
.run();
return true;
}
}
if (INTERNAL_LINK_REGEX.test(clipboardData)) {
// we have to do this validation here to allow the default link extension to takeover if needs be
event.preventDefault();

View file

@ -1,31 +1,54 @@
import { useEffect, useState } from "react";
import { lazy, Suspense, useEffect, useState } from "react";
import { Button } from "@mantine/core";
import { IconTable, IconLayoutKanban } from "@tabler/icons-react";
import { useDisclosure } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import {
IconTable,
IconLayoutKanban,
IconTemplate,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useAtomValue } from "jotai";
import { ErrorBoundary } from "react-error-boundary";
import { useConvertPageToBaseMutation } from "@/ee/base/queries/base-query";
import {
pageEditorAtom,
titleEditorAtom,
yjsSyncedAtom,
} from "@/features/editor/atoms/editor-atoms";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { ITemplate } from "@/ee/template/types/template.types";
import { getTemplateById } from "@/ee/template/services/template-service.ts";
import { applyTemplateToPage } from "@/ee/template/template.utils";
import classes from "./empty-page-get-started.module.css";
const TemplatePickerModal = lazy(
() => import("@/ee/template/components/template-picker-modal"),
);
type EmptyPageGetStartedProps = {
pageId: string;
spaceId: string;
editable: boolean;
};
export function EmptyPageGetStarted({
pageId,
spaceId,
editable,
}: EmptyPageGetStartedProps) {
const { t } = useTranslation();
const editor = useAtomValue(pageEditorAtom);
const titleEditor = useAtomValue(titleEditorAtom);
const isSynced = useAtomValue(yjsSyncedAtom);
const hasBases = useHasFeature(Feature.BASES);
const hasTemplates = useHasFeature(Feature.TEMPLATES);
const convertMutation = useConvertPageToBaseMutation();
const [
templatePickerOpened,
{ open: openTemplatePicker, close: closeTemplatePicker },
] = useDisclosure(false);
const [isEmpty, setIsEmpty] = useState(false);
useEffect(() => {
@ -40,43 +63,106 @@ export function EmptyPageGetStarted({
};
}, [editor]);
if (!editable || !hasBases || !editor || !isSynced || !isEmpty) return null;
const handleUseTemplate = async (selected: ITemplate) => {
let template: ITemplate;
try {
template = await getTemplateById(selected.id);
} catch {
notifications.show({
message: t("Failed to load template"),
color: "red",
});
return;
}
if (
!editor ||
editor.isDestroyed ||
!titleEditor ||
titleEditor.isDestroyed
) {
return;
}
if (!applyTemplateToPage(editor, titleEditor, template)) {
notifications.show({
message: t("This page is no longer empty"),
color: "red",
});
}
};
const chips = [
{
key: "base",
label: t("Base"),
icon: IconTable,
onClick: () => convertMutation.mutate({ pageId }),
disabled: convertMutation.isPending,
},
{
key: "kanban",
label: t("Kanban"),
icon: IconLayoutKanban,
onClick: () => convertMutation.mutate({ pageId, template: "kanban" }),
disabled: convertMutation.isPending,
},
...(hasBases
? [
{
key: "base",
label: t("Base"),
icon: IconTable,
onClick: () => convertMutation.mutate({ pageId }),
disabled: convertMutation.isPending,
},
{
key: "kanban",
label: t("Kanban"),
icon: IconLayoutKanban,
onClick: () =>
convertMutation.mutate({ pageId, template: "kanban" }),
disabled: convertMutation.isPending,
},
]
: []),
...(hasTemplates
? [
{
key: "template",
label: t("Template"),
icon: IconTemplate,
onClick: openTemplatePicker,
disabled: false,
},
]
: []),
];
const showChips =
editable && !!editor && isSynced && isEmpty && chips.length > 0;
return (
<div className={classes.wrapper} contentEditable={false}>
<span className={classes.label}>{t("Get started with")}</span>
<div className={classes.chipRow}>
{chips.map((chip) => (
<Button
key={chip.key}
variant="default"
size="xs"
radius="xl"
leftSection={<chip.icon size={16} />}
onClick={chip.onClick}
disabled={chip.disabled}
>
{chip.label}
</Button>
))}
</div>
</div>
<>
{showChips && (
<div className={classes.wrapper} contentEditable={false}>
<span className={classes.label}>{t("Get started with")}</span>
<div className={classes.chipRow}>
{chips.map((chip) => (
<Button
key={chip.key}
variant="default"
size="xs"
radius="xl"
leftSection={<chip.icon size={16} />}
onClick={chip.onClick}
disabled={chip.disabled}
>
{chip.label}
</Button>
))}
</div>
</div>
)}
{hasTemplates && templatePickerOpened && (
<ErrorBoundary fallbackRender={() => null}>
<Suspense fallback={null}>
<TemplatePickerModal
opened={templatePickerOpened}
onClose={closeTemplatePicker}
initialSpaceId={spaceId}
onUse={handleUseTemplate}
/>
</Suspense>
</ErrorBoundary>
)}
</>
);
}

View file

@ -1,7 +1,8 @@
import { lazy, Suspense } from "react";
import { Suspense } from "react";
import { lazyWithPrefetch } from "@/lib/lazy-prefetch.ts";
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
const ExcalidrawMenu = lazy(
const ExcalidrawMenu = lazyWithPrefetch(
() => import("@/features/editor/components/excalidraw/excalidraw-menu.tsx"),
);

View file

@ -1,7 +1,8 @@
import { lazy, Suspense } from "react";
import { Suspense } from "react";
import { lazyWithPrefetch } from "@/lib/lazy-prefetch.ts";
import { NodeViewProps } from "@tiptap/react";
const ExcalidrawView = lazy(
const ExcalidrawView = lazyWithPrefetch(
() => import("@/features/editor/components/excalidraw/excalidraw-view.tsx"),
);

View file

@ -0,0 +1,50 @@
// Light-scheme badge text; hexes replace shade 9 for hues that miss AA 4.5:1.
const BADGE_TEXT_LIGHT: Record<string, string> = {
dark: "var(--mantine-color-dark-9)",
gray: "var(--mantine-color-gray-9)",
red: "var(--mantine-color-red-9)",
pink: "var(--mantine-color-pink-9)",
grape: "var(--mantine-color-grape-9)",
violet: "var(--mantine-color-violet-9)",
indigo: "var(--mantine-color-indigo-9)",
blue: "var(--mantine-color-blue-9)",
cyan: "var(--mantine-color-cyan-9)",
teal: "var(--mantine-color-teal-9)",
green: "#277c38",
lime: "#4e7e0b",
yellow: "#ad5900",
orange: "#c3410e",
};
export function badgeTextColor(color?: string): string | undefined {
if (!color) return undefined;
const light = BADGE_TEXT_LIGHT[color];
if (!light) return undefined;
// Dark scheme keeps Mantine's own light-variant text.
return `light-dark(${light}, var(--mantine-color-${color}-light-color))`;
}
export function toBadgeColor(raw?: string): string {
if (!raw) return "gray";
const hex = raw.toLowerCase().replace("#", "");
if (/^[0-9a-f]{6}$/.test(hex)) {
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2 / 255;
if (max - min < 30) return l > 0.6 ? "gray" : "dark";
if (r > g && r > b) return g > 160 ? "orange" : "red";
if (g > r && g > b) return r > 160 ? "lime" : "green";
if (b > r && b > g) return r > 100 ? "violet" : "blue";
if (r > 200 && g > 200) return "yellow";
if (r > 200 && b > 200) return "pink";
if (g > 200 && b > 200) return "cyan";
return "gray";
}
// Only palette names with a contrast-checked text colour pass through.
return Object.prototype.hasOwnProperty.call(BADGE_TEXT_LIGHT, raw)
? raw
: "gray";
}

View file

@ -0,0 +1,27 @@
.card {
max-width: 100%;
cursor: pointer;
transition: background-color 150ms ease;
margin: 4px 0;
}
.card:hover {
background-color: var(--mantine-color-gray-0);
}
:global([data-mantine-color-scheme="dark"]) .card:hover {
background-color: var(--mantine-color-dark-5);
}
.thumbnail {
display: block;
width: 100%;
max-height: 320px;
object-fit: cover;
background-color: var(--mantine-color-gray-0);
}
:global([data-mantine-color-scheme="dark"]) .thumbnail {
background-color: var(--mantine-color-dark-6);
}

View file

@ -0,0 +1,586 @@
import { NodeViewWrapper } from "@tiptap/react";
import {
Card,
Group,
Text,
Badge,
Avatar,
Skeleton,
Anchor,
Stack,
Button,
} from "@mantine/core";
import { useCallback, useState, memo } from "react";
import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications";
import { describeIntegrationLink, isHttpUrl } from "@docmost/editor-ext";
import { getIntegrationIcon } from "@/features/integration/components/integration-icons";
import { getOAuthAuthorizeUrl } from "@/features/integration/services/integration-service";
import { timeAgo } from "@/lib/time";
import { useUnfurl } from "./use-unfurl";
import { badgeTextColor, toBadgeColor } from "./badge-color";
import classes from "./integration-card-view.module.css";
const SLACK_TEXT_CLAMP_LINES = 4;
function SlackMessageCard({
url,
unfurlData,
onSelect,
}: {
url: string;
unfurlData: Record<string, any>;
onSelect: React.MouseEventHandler<HTMLElement>;
}) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const meta = unfurlData.metadata ?? {};
const postedAt = meta.ts ? new Date(parseFloat(meta.ts) * 1000) : null;
const text: string = unfurlData.description ?? "";
const isLong =
text.length > 280 || text.split("\n").length > SLACK_TEXT_CLAMP_LINES;
const footer = [
meta.replyCount
? `${meta.replyCount} ${meta.replyCount === 1 ? t("reply") : t("replies")}`
: null,
unfurlData.status,
meta.teamName,
]
.filter(Boolean)
.join(" • ");
return (
<NodeViewWrapper data-drag-handle="" onClickCapture={onSelect}>
<Card className={classes.card} withBorder padding="sm" radius="sm">
<Group gap="sm" wrap="nowrap" align="flex-start">
<Avatar
src={unfurlData.authorAvatarUrl}
size={28}
radius="xl"
style={{ flexShrink: 0 }}
>
{(unfurlData.author ?? "?").charAt(0)}
</Avatar>
<Stack gap={4} style={{ flex: 1, minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600} truncate>
{unfurlData.author}
</Text>
{postedAt && (
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{timeAgo(postedAt)}
</Text>
)}
</Group>
{text && (
<Text
size="sm"
lineClamp={expanded ? undefined : SLACK_TEXT_CLAMP_LINES}
style={{ whiteSpace: "pre-wrap" }}
>
{text}
</Text>
)}
{isLong && (
<Text
size="xs"
fw={600}
role="button"
tabIndex={0}
aria-expanded={expanded}
style={{ cursor: "pointer", width: "fit-content" }}
onClick={() => setExpanded((v) => !v)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setExpanded((v) => !v);
}
}}
>
{expanded ? t("show less") : t("show more")}
</Text>
)}
{footer && (
<Text size="xs" c="dimmed" truncate>
{footer}
</Text>
)}
</Stack>
<Anchor
data-integration-action="open"
href={url}
target="_blank"
rel="noopener"
aria-label={t("Open in Slack")}
style={{ flexShrink: 0, lineHeight: 0 }}
>
{getIntegrationIcon("slack", 18)}
</Anchor>
</Group>
</Card>
</NodeViewWrapper>
);
}
function JiraIssueCard({
url,
unfurlData,
onSelect,
}: {
url: string;
unfurlData: Record<string, any>;
onSelect: React.MouseEventHandler<HTMLElement>;
}) {
const { t } = useTranslation();
const meta = unfurlData.metadata ?? {};
const infoLine = [
meta.issueKey,
unfurlData.author
? t("Assigned to {{name}}", { name: unfurlData.author })
: t("Unassigned"),
meta.updatedAt
? t("Updated {{time}}", { time: timeAgo(new Date(meta.updatedAt)) })
: null,
]
.filter(Boolean)
.join(" • ");
return (
<NodeViewWrapper data-drag-handle="" onClickCapture={onSelect}>
<Card
className={classes.card}
withBorder
padding="sm"
radius="sm"
component="a"
href={url}
target="_blank"
rel="noopener"
style={{ textDecoration: "none", color: "inherit" }}
>
<Group gap="sm" wrap="nowrap">
{unfurlData.authorAvatarUrl ? (
<Avatar
src={unfurlData.authorAvatarUrl}
size={28}
radius="xl"
style={{ flexShrink: 0 }}
/>
) : (
<div style={{ flexShrink: 0 }}>
{getIntegrationIcon("jira", 28)}
</div>
)}
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={600} truncate>
{unfurlData.title}
</Text>
{unfurlData.status && (
<Badge
size="xs"
variant="light"
color={toBadgeColor(unfurlData.statusColor)}
c={badgeTextColor(toBadgeColor(unfurlData.statusColor))}
style={{ flexShrink: 0 }}
>
{unfurlData.status}
</Badge>
)}
</Group>
<Group gap={4} wrap="nowrap">
{meta.issueTypeIconUrl && (
<img
src={meta.issueTypeIconUrl}
width={14}
height={14}
alt=""
style={{ flexShrink: 0 }}
/>
)}
<Text size="xs" c="dimmed" truncate>
{infoLine}
</Text>
</Group>
</Stack>
<div style={{ flexShrink: 0, alignSelf: "center" }}>
{getIntegrationIcon("jira", 18)}
</div>
</Group>
</Card>
</NodeViewWrapper>
);
}
function FigmaFileCard({
url,
unfurlData,
onSelect,
}: {
url: string;
unfurlData: Record<string, any>;
onSelect: React.MouseEventHandler<HTMLElement>;
}) {
const { t } = useTranslation();
// Figma thumbnail urls are pre-signed and expire.
const [thumbnailFailed, setThumbnailFailed] = useState(false);
const meta = unfurlData.metadata ?? {};
const thumbnailUrl: string | undefined = meta.thumbnailUrl;
const showThumbnail = Boolean(thumbnailUrl) && !thumbnailFailed;
const subtitle = [
unfurlData.author
? t("Last modified by {{name}}", { name: unfurlData.author })
: unfurlData.description,
meta.lastModified ? timeAgo(new Date(meta.lastModified)) : null,
]
.filter(Boolean)
.join(" • ");
return (
<NodeViewWrapper data-drag-handle="" onClickCapture={onSelect}>
<Card
className={classes.card}
withBorder
padding="sm"
radius="sm"
component="a"
href={url}
target="_blank"
rel="noopener"
style={{ textDecoration: "none", color: "inherit" }}
>
{showThumbnail && (
<Card.Section withBorder>
<img
src={thumbnailUrl}
alt=""
loading="lazy"
className={classes.thumbnail}
onError={() => setThumbnailFailed(true)}
/>
</Card.Section>
)}
<Group gap="sm" wrap="nowrap" mt={showThumbnail ? "sm" : undefined}>
<Avatar
src={unfurlData.authorAvatarUrl}
size={28}
radius="xl"
style={{ flexShrink: 0 }}
>
{(unfurlData.author ?? unfurlData.title ?? "F").charAt(0)}
</Avatar>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{unfurlData.title}
</Text>
{subtitle && (
<Text size="xs" c="dimmed" truncate>
{subtitle}
</Text>
)}
</Stack>
<div style={{ flexShrink: 0, alignSelf: "center" }}>
{getIntegrationIcon("figma", 18)}
</div>
</Group>
</Card>
</NodeViewWrapper>
);
}
function IntegrationCardView(props: any) {
const { editor, getPos, node } = props;
const { url, provider } = node.attrs;
const safeUrl = isHttpUrl(url) ? url : "";
const { t } = useTranslation();
const unfurl = useUnfurl(safeUrl);
const [connecting, setConnecting] = useState(false);
const needsConnection =
unfurl.state === "needsConnection" ? unfurl.needsConnection : null;
const selectForEditing = useCallback(
(event: React.MouseEvent<HTMLElement>) => {
if (!editor.isEditable || event.metaKey || event.ctrlKey) return;
const target = event.target as Element;
if (
target.closest(
"button, [role='button'], [data-integration-action='open']",
)
) {
return;
}
const pos = getPos();
if (typeof pos !== "number") return;
event.preventDefault();
event.stopPropagation();
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.setNodeSelection(pos)
.run();
},
[editor, getPos],
);
const handleConnect = useCallback(
async (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (!needsConnection) return;
setConnecting(true);
try {
const result = await getOAuthAuthorizeUrl({
integrationId: needsConnection.integrationId,
returnPath: window.location.pathname,
});
window.location.href = result.authorizationUrl;
} catch (error) {
setConnecting(false);
notifications.show({
message:
error?.["response"]?.data?.message ||
t("Failed to start OAuth connection"),
color: "red",
});
}
},
[needsConnection, t],
);
if (needsConnection) {
return (
<NodeViewWrapper data-drag-handle="" onClickCapture={selectForEditing}>
<Card
className={classes.card}
withBorder
padding="sm"
radius="sm"
component="a"
href={safeUrl}
target="_blank"
rel="noopener"
style={{ textDecoration: "none", color: "inherit" }}
>
<Group gap="sm" wrap="nowrap">
<div style={{ flexShrink: 0 }}>
{getIntegrationIcon(provider, 28)}
</div>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{needsConnection.title}
</Text>
{needsConnection.description && (
<Text size="xs" c="dimmed" lineClamp={1}>
{needsConnection.description}
</Text>
)}
</Stack>
<Button
size="xs"
variant="filled"
color="dark"
loading={connecting}
onClick={handleConnect}
style={{ flexShrink: 0 }}
>
{t("Connect to {{name}} to preview", {
name: needsConnection.integrationName,
})}
</Button>
</Group>
</Card>
</NodeViewWrapper>
);
}
if (unfurl.state === "loading") {
return (
<NodeViewWrapper data-drag-handle="" onClickCapture={selectForEditing}>
<Card className={classes.card} withBorder padding="sm" radius="sm">
<Group gap="sm">
<Skeleton circle height={24} />
<Stack gap={4} style={{ flex: 1 }}>
<Skeleton height={14} width="60%" />
<Skeleton height={10} width="80%" />
</Stack>
</Group>
</Card>
</NodeViewWrapper>
);
}
if (unfurl.state !== "loaded") {
// Without unfurl data, describe the link from its url.
const described = describeIntegrationLink(safeUrl);
if (described) {
return (
<NodeViewWrapper data-drag-handle="" onClickCapture={selectForEditing}>
<Card
className={classes.card}
withBorder
padding="sm"
radius="sm"
component="a"
href={safeUrl}
target="_blank"
rel="noopener"
style={{ textDecoration: "none", color: "inherit" }}
>
<Group gap="sm" wrap="nowrap">
<div style={{ flexShrink: 0 }}>
{getIntegrationIcon(provider, 28)}
</div>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{described.title}
</Text>
{described.description && (
<Text size="xs" c="dimmed" lineClamp={1}>
{described.description}
</Text>
)}
</Stack>
</Group>
</Card>
</NodeViewWrapper>
);
}
return (
<NodeViewWrapper data-drag-handle="" onClickCapture={selectForEditing}>
<Card className={classes.card} withBorder padding="sm" radius="sm">
{safeUrl ? (
<Anchor href={safeUrl} target="_blank" rel="noopener" size="sm">
{safeUrl}
</Anchor>
) : (
<Text size="sm">{url}</Text>
)}
</Card>
</NodeViewWrapper>
);
}
const unfurlData = unfurl.data;
// A message unfurl without metadata.type is identified by metadata.ts.
const slackMeta = provider === "slack" ? unfurlData.metadata : null;
if (
slackMeta?.type === "message" ||
(slackMeta && !slackMeta.type && slackMeta.ts)
) {
return (
<SlackMessageCard
url={safeUrl}
unfurlData={unfurlData}
onSelect={selectForEditing}
/>
);
}
if (provider === "jira" && unfurlData.metadata?.issueKey) {
return (
<JiraIssueCard
url={safeUrl}
unfurlData={unfurlData}
onSelect={selectForEditing}
/>
);
}
if (provider === "figma") {
return (
<FigmaFileCard
url={safeUrl}
unfurlData={unfurlData}
onSelect={selectForEditing}
/>
);
}
return (
<NodeViewWrapper data-drag-handle="" onClickCapture={selectForEditing}>
<Card
className={classes.card}
withBorder
padding="sm"
radius="sm"
component="a"
href={safeUrl}
target="_blank"
rel="noopener"
style={{ textDecoration: "none", color: "inherit" }}
>
<Group gap="sm" wrap="nowrap">
{unfurlData.authorAvatarUrl ? (
<Avatar
src={unfurlData.authorAvatarUrl}
size={28}
radius="xl"
style={{ flexShrink: 0 }}
/>
) : (
<div style={{ flexShrink: 0 }}>
{getIntegrationIcon(provider, 28)}
</div>
)}
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={600} truncate>
{unfurlData.title}
</Text>
{unfurlData.status && (
<Badge
size="xs"
variant="light"
color={toBadgeColor(unfurlData.statusColor)}
c={badgeTextColor(toBadgeColor(unfurlData.statusColor))}
style={{ flexShrink: 0 }}
>
{unfurlData.status}
</Badge>
)}
</Group>
{unfurlData.description && (
<Text size="xs" c="dimmed" lineClamp={1}>
{unfurlData.description}
</Text>
)}
</Stack>
{provider && (
<div style={{ flexShrink: 0, alignSelf: "center" }}>
{getIntegrationIcon(provider, 18)}
</div>
)}
</Group>
</Card>
</NodeViewWrapper>
);
}
export default memo(IntegrationCardView);

View file

@ -0,0 +1,191 @@
.picker {
display: inline-flex;
align-items: center;
gap: 2px;
flex: 0 0 auto;
}
.option {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
min-width: 32px;
height: 32px;
padding: 0;
border-radius: 6px;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-3));
background: transparent;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition:
color 120ms ease,
background-color 120ms ease;
}
.option svg {
width: 18px;
height: 18px;
}
.option:hover:not([aria-disabled="true"]):not([aria-checked="true"]) {
background: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-5)
);
}
.option[aria-checked="true"] {
color: light-dark(var(--mantine-color-blue-7), var(--mantine-color-blue-3));
background: light-dark(
var(--mantine-color-blue-0),
color-mix(in srgb, var(--mantine-color-blue-9) 55%, transparent)
);
}
.option:focus-visible {
outline: 2px solid var(--mantine-color-blue-6);
outline-offset: -2px;
}
.option[aria-disabled="true"] {
opacity: 0.42;
cursor: not-allowed;
}
.menuTrigger {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 1px;
width: 40px;
min-width: 40px;
height: 32px;
padding: 0 4px;
border-radius: 6px;
color: light-dark(var(--mantine-color-blue-7), var(--mantine-color-blue-3));
background: light-dark(
var(--mantine-color-blue-0),
color-mix(in srgb, var(--mantine-color-blue-9) 55%, transparent)
);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition:
color 120ms ease,
background-color 120ms ease;
}
.menuTrigger:hover {
background: light-dark(
var(--mantine-color-blue-1),
color-mix(in srgb, var(--mantine-color-blue-8) 70%, transparent)
);
}
.menuTrigger:focus-visible {
outline: 2px solid var(--mantine-color-blue-6);
outline-offset: -2px;
}
.menuChevron {
margin-inline-end: -2px;
}
.menuDropdown {
border-color: light-dark(
var(--mantine-color-gray-3),
var(--mantine-color-dark-4)
);
border-radius: 9px;
}
.menuLabel {
padding: 5px 9px 4px;
color: var(--mantine-color-dimmed);
font-size: var(--mantine-font-size-xs);
font-weight: 600;
}
.menuItem {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) 16px;
align-items: center;
gap: 9px;
width: 100%;
min-height: 36px;
padding: 6px 9px;
border-radius: 6px;
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-gray-2));
font-size: var(--mantine-font-size-sm);
text-align: start;
cursor: pointer;
}
.menuItem:hover:not(:disabled) {
background: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-5)
);
}
.menuItem[aria-checked="true"] {
color: light-dark(var(--mantine-color-blue-7), var(--mantine-color-blue-3));
background: light-dark(
var(--mantine-color-blue-0),
color-mix(in srgb, var(--mantine-color-blue-9) 45%, transparent)
);
}
.menuItem:focus-visible {
outline: 2px solid var(--mantine-color-blue-6);
outline-offset: -2px;
}
.menuItem:disabled {
opacity: 0.44;
cursor: not-allowed;
}
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (prefers-reduced-motion: reduce) {
.option,
.menuTrigger {
transition: none;
}
}
@media (pointer: coarse) {
.option {
width: 44px;
min-width: 44px;
height: 44px;
border-radius: 8px;
}
.option svg {
width: 21px;
height: 21px;
}
.menuTrigger {
width: 50px;
min-width: 50px;
height: 44px;
border-radius: 8px;
}
.menuItem {
min-height: 44px;
}
}

View file

@ -0,0 +1,188 @@
import { MantineProvider } from "@mantine/core";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
IntegrationDisplayMenu,
IntegrationDisplayPicker,
} from "./integration-display-picker";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (value: string) => value }),
}));
function setMediaMatch() {
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}
function renderPicker(
props: Partial<React.ComponentProps<typeof IntegrationDisplayPicker>> = {},
) {
const onChange = vi.fn();
render(
<MantineProvider>
<IntegrationDisplayPicker
current="card"
label="Display as"
canUseCard
onChange={onChange}
{...props}
/>
</MantineProvider>,
);
return { onChange };
}
function renderMenu(
props: Partial<React.ComponentProps<typeof IntegrationDisplayMenu>> = {},
) {
const onChange = vi.fn();
render(
<MantineProvider>
<IntegrationDisplayMenu
current="url"
label="Display as"
canUseCard
onChange={onChange}
{...props}
/>
</MantineProvider>,
);
return { onChange };
}
describe("IntegrationDisplayPicker", () => {
beforeEach(setMediaMatch);
afterEach(cleanup);
it("exposes every display mode directly and identifies the selected mode", () => {
renderPicker({ current: "mention" });
expect(screen.getByRole("radiogroup", { name: "Display as" })).toBeTruthy();
const card = screen.getByRole("radio", { name: "Card" });
const mention = screen.getByRole("radio", { name: "Mention" });
const url = screen.getByRole("radio", { name: "URL" });
expect(card.getAttribute("aria-checked")).toBe("false");
expect(card.getAttribute("tabindex")).toBe("-1");
expect(mention.getAttribute("aria-checked")).toBe("true");
expect(mention.getAttribute("tabindex")).toBe("0");
expect(url.getAttribute("aria-checked")).toBe("false");
});
it("changes mode with standard radio-group arrow navigation", () => {
const { onChange } = renderPicker({ current: "card" });
const card = screen.getByRole("radio", { name: "Card" });
card.focus();
fireEvent.keyDown(card, { key: "ArrowRight" });
expect(document.activeElement).toBe(
screen.getByRole("radio", { name: "Mention" }),
);
expect(onChange).toHaveBeenCalledWith("mention");
});
it("keeps an unavailable card understandable and skips it during navigation", () => {
const { onChange } = renderPicker({
current: "url",
canUseCard: false,
});
const card = screen.getByRole("radio", { name: "Card" });
expect(card.getAttribute("aria-disabled")).toBe("true");
const reasonId = card.getAttribute("aria-describedby");
expect(reasonId).toBeTruthy();
expect(document.getElementById(reasonId!)?.textContent).toBe(
"Cards aren't available in this position",
);
fireEvent.click(card);
expect(onChange).not.toHaveBeenCalled();
const url = screen.getByRole("radio", { name: "URL" });
url.focus();
fireEvent.keyDown(url, { key: "ArrowRight" });
expect(document.activeElement).toBe(
screen.getByRole("radio", { name: "Mention" }),
);
expect(onChange).toHaveBeenCalledWith("mention");
});
it("reports activation of the current mode so the paste nudge can close", () => {
const { onChange } = renderPicker({ current: "card" });
fireEvent.click(screen.getByRole("radio", { name: "Card" }));
expect(onChange).toHaveBeenCalledWith("card");
});
});
describe("IntegrationDisplayMenu", () => {
beforeEach(setMediaMatch);
afterEach(cleanup);
it("identifies the current display and changes it from the dropdown", async () => {
const { onChange } = renderMenu();
const trigger = screen.getByRole("button", { name: "Display as: URL" });
expect(trigger.getAttribute("aria-expanded")).toBe("false");
fireEvent.click(trigger);
expect(trigger.getAttribute("aria-expanded")).toBe("true");
expect(
(
await screen.findByRole("menuitemradio", {
name: "URL",
hidden: true,
})
).getAttribute("aria-checked"),
).toBe("true");
fireEvent.click(
screen.getByRole("menuitemradio", { name: "Mention", hidden: true }),
);
expect(onChange).toHaveBeenCalledWith("mention");
});
it("supports keyboard opening, option navigation, and Escape", async () => {
renderMenu({ canUseCard: false });
const trigger = screen.getByRole("button", { name: "Display as: URL" });
trigger.focus();
fireEvent.keyDown(trigger, { key: "ArrowDown" });
const url = await screen.findByRole("menuitemradio", {
name: "URL",
hidden: true,
});
await waitFor(() => expect(document.activeElement).toBe(url));
fireEvent.keyDown(url, { key: "ArrowDown" });
expect(document.activeElement).toBe(
screen.getByRole("menuitemradio", { name: "Mention", hidden: true }),
);
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
await waitFor(() => expect(document.activeElement).toBe(trigger));
expect(trigger.getAttribute("aria-expanded")).toBe("false");
});
});

View file

@ -0,0 +1,366 @@
import { Menu, Tooltip, UnstyledButton } from "@mantine/core";
import {
IconAt,
IconCheck,
IconChevronDown,
IconLink,
type IconProps,
} from "@tabler/icons-react";
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";
import { useTranslation } from "react-i18next";
import type { IntegrationDisplay } from "./integration-display";
import classes from "./integration-display-picker.module.css";
export type IntegrationDisplayPickerProps = {
current: IntegrationDisplay;
label: string;
canUseCard: boolean;
onChange: (display: IntegrationDisplay) => void;
};
const DISPLAY_ORDER: IntegrationDisplay[] = ["card", "mention", "url"];
const DISPLAY_ICONS = {
card: CardDisplayIcon,
mention: IconAt,
url: IconLink,
} as const;
function CardDisplayIcon({ size = 18, stroke = 1.75, ...props }: IconProps) {
return (
<svg
{...props}
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeLinecap="round"
>
<rect x="3" y="5" width="18" height="14" rx="2" />
<line x1="7" y1="10" x2="17" y2="10" />
<line x1="7" y1="14" x2="13" y2="14" />
</svg>
);
}
function useDisplayLabels() {
const { t } = useTranslation();
return {
labels: {
card: t("Card"),
mention: t("Mention"),
url: t("URL"),
} satisfies Record<IntegrationDisplay, string>,
getCardUnavailableLabel: () => t("Cards aren't available in this position"),
};
}
export function IntegrationDisplayPicker({
current,
label,
canUseCard,
onChange,
}: IntegrationDisplayPickerProps) {
const { labels, getCardUnavailableLabel } = useDisplayLabels();
const cardReasonId = useId();
const optionRefs = useRef<
Record<IntegrationDisplay, HTMLButtonElement | null>
>({
card: null,
mention: null,
url: null,
});
const cardUnavailableLabel = getCardUnavailableLabel();
const isUnavailable = (value: IntegrationDisplay) =>
value === "card" && !canUseCard;
const choose = (value: IntegrationDisplay) => {
if (isUnavailable(value)) return;
onChange(value);
};
const handleKeyDown = (
event: KeyboardEvent<HTMLButtonElement>,
value: IntegrationDisplay,
) => {
if (
![
"ArrowLeft",
"ArrowRight",
"ArrowUp",
"ArrowDown",
"Home",
"End",
].includes(event.key)
) {
return;
}
event.preventDefault();
event.stopPropagation();
const available = DISPLAY_ORDER.filter(
(display) => !isUnavailable(display),
);
const currentIndex = available.indexOf(value);
const nextIndex =
event.key === "Home"
? 0
: event.key === "End"
? available.length - 1
: ["ArrowRight", "ArrowDown"].includes(event.key)
? (currentIndex + 1 + available.length) % available.length
: (currentIndex - 1 + available.length) % available.length;
const next = available[nextIndex];
if (!next) return;
optionRefs.current[next]?.focus();
onChange(next);
};
return (
<div
className={classes.picker}
role="radiogroup"
aria-label={label}
data-integration-display-picker=""
>
{DISPLAY_ORDER.map((value) => {
const Icon = DISPLAY_ICONS[value];
const selected = value === current;
const unavailable = isUnavailable(value);
const tooltipLabel = unavailable ? cardUnavailableLabel : labels[value];
return (
<Tooltip
key={value}
label={tooltipLabel}
withArrow
withinPortal={false}
openDelay={350}
>
<UnstyledButton
ref={(element) => {
optionRefs.current[value] = element;
}}
type="button"
role="radio"
aria-label={labels[value]}
aria-checked={selected}
aria-disabled={unavailable || undefined}
aria-describedby={unavailable ? cardReasonId : undefined}
tabIndex={selected ? 0 : -1}
className={classes.option}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => {
event.stopPropagation();
choose(value);
}}
onKeyDown={(event) => handleKeyDown(event, value)}
>
<Icon size={18} stroke={1.75} aria-hidden="true" />
</UnstyledButton>
</Tooltip>
);
})}
{!canUseCard ? (
<span id={cardReasonId} className={classes.visuallyHidden}>
{cardUnavailableLabel}
</span>
) : null}
</div>
);
}
export function IntegrationDisplayMenu({
current,
label,
canUseCard,
onChange,
}: IntegrationDisplayPickerProps) {
const { labels, getCardUnavailableLabel } = useDisplayLabels();
const [opened, setOpened] = useState(false);
const cardReasonId = useId();
const triggerRef = useRef<HTMLButtonElement>(null);
const optionRefs = useRef<
Record<IntegrationDisplay, HTMLButtonElement | null>
>({
card: null,
mention: null,
url: null,
});
const CurrentIcon = DISPLAY_ICONS[current];
const cardUnavailableLabel = getCardUnavailableLabel();
const availableOptions = DISPLAY_ORDER.filter(
(value) => value !== "card" || canUseCard,
);
const initialOption = availableOptions.includes(current)
? current
: availableOptions[0];
useEffect(() => {
if (!opened) return;
let focusFrame = 0;
const layoutFrame = requestAnimationFrame(() => {
focusFrame = requestAnimationFrame(() => {
if (initialOption) optionRefs.current[initialOption]?.focus();
});
});
return () => {
cancelAnimationFrame(layoutFrame);
cancelAnimationFrame(focusFrame);
};
}, [initialOption, opened]);
const openFromKeyboard = () => {
setOpened(true);
};
const closeAndRestoreFocus = () => {
setOpened(false);
triggerRef.current?.focus();
};
const handleOptionKeyDown = (
event: KeyboardEvent<HTMLButtonElement>,
value: IntegrationDisplay,
) => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
closeAndRestoreFocus();
return;
}
if (!["ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) {
return;
}
event.preventDefault();
event.stopPropagation();
const currentIndex = availableOptions.indexOf(value);
const nextIndex =
event.key === "Home"
? 0
: event.key === "End"
? availableOptions.length - 1
: event.key === "ArrowDown"
? (currentIndex + 1 + availableOptions.length) %
availableOptions.length
: (currentIndex - 1 + availableOptions.length) %
availableOptions.length;
const next = availableOptions[nextIndex];
if (next) optionRefs.current[next]?.focus();
};
return (
<Menu
opened={opened}
onChange={setOpened}
position="bottom-end"
offset={6}
width={190}
shadow="md"
withinPortal
>
<Menu.Target>
<UnstyledButton
ref={triggerRef}
type="button"
className={classes.menuTrigger}
aria-label={`${label}: ${labels[current]}`}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => {
event.stopPropagation();
}}
onKeyDown={(event) => {
if (
!opened &&
["ArrowDown", "ArrowUp", "Enter", " "].includes(event.key)
) {
event.preventDefault();
event.stopPropagation();
openFromKeyboard();
}
}}
>
<CurrentIcon size={18} stroke={1.75} aria-hidden="true" />
<IconChevronDown
className={classes.menuChevron}
size={13}
stroke={2}
aria-hidden="true"
/>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown
aria-label={label}
p={4}
className={classes.menuDropdown}
data-integration-display-menu-dropdown=""
onMouseDown={(event) => event.stopPropagation()}
>
<div className={classes.menuLabel}>{label}</div>
{DISPLAY_ORDER.map((value) => {
const Icon = DISPLAY_ICONS[value];
const selected = value === current;
const unavailable = value === "card" && !canUseCard;
return (
<UnstyledButton
key={value}
ref={(element) => {
optionRefs.current[value] = element;
}}
type="button"
role="menuitemradio"
aria-checked={selected}
aria-describedby={unavailable ? cardReasonId : undefined}
disabled={unavailable}
className={classes.menuItem}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => {
event.stopPropagation();
setOpened(false);
onChange(value);
}}
onKeyDown={(event) => handleOptionKeyDown(event, value)}
>
<Icon size={18} stroke={1.75} aria-hidden="true" />
<span>{labels[value]}</span>
{selected ? (
<IconCheck size={16} stroke={2} aria-hidden="true" />
) : (
<span aria-hidden="true" />
)}
</UnstyledButton>
);
})}
{!canUseCard ? (
<span id={cardReasonId} className={classes.visuallyHidden}>
{cardUnavailableLabel}
</span>
) : null}
</Menu.Dropdown>
</Menu>
);
}

View file

@ -0,0 +1,290 @@
import { IntegrationCard, IntegrationMention } from "@docmost/editor-ext";
import { Editor, type JSONContent } from "@tiptap/core";
import TiptapLink from "@tiptap/extension-link";
import { StarterKit } from "@tiptap/starter-kit";
import { afterEach, describe, expect, it } from "vitest";
import {
convertIntegrationDisplay,
getIntegrationCardAvailability,
getSelectedIntegrationDisplay,
} from "./integration-display";
const url = "https://github.com/docmost/docmost/pull/2475";
const text = (value: string, marks?: JSONContent["marks"]): JSONContent => ({
type: "text",
text: value,
...(marks && { marks }),
});
const paragraph = (...content: JSONContent[]): JSONContent => ({
type: "paragraph",
...(content.length && { content }),
});
const card = (): JSONContent => ({
type: "integrationCard",
attrs: { url, provider: "github" },
});
const mention = (): JSONContent => ({
type: "integrationMention",
attrs: { url, provider: "github" },
});
const IntegrationAwareLink = TiptapLink.extend({
addAttributes() {
return {
...this.parent?.(),
integrationProvider: { default: null },
};
},
});
let activeEditor: Editor;
afterEach(() => activeEditor?.destroy());
function createEditor(...content: JSONContent[]) {
activeEditor = new Editor({
extensions: [
StarterKit.configure({ link: false }),
IntegrationAwareLink,
IntegrationCard,
IntegrationMention,
],
content: { type: "doc", content },
});
return activeEditor;
}
describe("convertIntegrationDisplay", () => {
it("turns a card into an inline mention without losing its source", () => {
const editor = createEditor(card());
expect(
convertIntegrationDisplay(editor, { kind: "node", pos: 0 }, "mention"),
).toBe(true);
expect(editor.getJSON()).toEqual({
type: "doc",
content: [paragraph(mention(), text(" "))],
});
});
it("turns a card into a normal linked URL", () => {
const editor = createEditor(card());
expect(
convertIntegrationDisplay(editor, { kind: "node", pos: 0 }, "url"),
).toBe(true);
expect(editor.getJSON().content?.[0]).toMatchObject(
paragraph(
text(url, [
{
type: "link",
attrs: { href: url, integrationProvider: "github" },
},
]),
),
);
});
it("keeps the paragraphs around a card apart when it becomes a mention", () => {
const editor = createEditor(
paragraph(text("Intro")),
card(),
paragraph(text("Outro")),
);
expect(
convertIntegrationDisplay(editor, { kind: "node", pos: 7 }, "mention"),
).toBe(true);
expect(editor.getJSON().content).toEqual([
paragraph(text("Intro")),
paragraph(mention(), text(" ")),
paragraph(text("Outro")),
]);
});
it("turns a standalone mention back into a card and ignores surrounding whitespace", () => {
const editor = createEditor(paragraph(text(" "), mention(), text(" ")));
expect(
convertIntegrationDisplay(editor, { kind: "node", pos: 2 }, "card"),
).toBe(true);
expect(editor.getJSON().content?.[0]).toEqual(card());
});
it("splits inline prose around a mention converted to a card", () => {
const editor = createEditor(
paragraph(text("Review "), mention(), text(" before release.")),
);
expect(
convertIntegrationDisplay(editor, { kind: "node", pos: 8 }, "card"),
).toBe(true);
expect(editor.getJSON()).toEqual({
type: "doc",
content: [
paragraph(text("Review")),
card(),
paragraph(text("before release.")),
],
});
});
it("turns a mention inside prose into a URL without disturbing the sentence", () => {
const editor = createEditor(
paragraph(text("Review "), mention(), text(" before release.")),
);
expect(
convertIntegrationDisplay(editor, { kind: "node", pos: 8 }, "url"),
).toBe(true);
expect(editor.getText()).toBe(`Review ${url} before release.`);
expect(editor.getJSON().content?.[0]?.content?.[1]).toMatchObject(
text(url, [
{
type: "link",
attrs: { href: url, integrationProvider: "github" },
},
]),
);
});
it("turns an existing linked URL into a mention in place", () => {
const from = 8;
const editor = createEditor(
paragraph(
text("Review "),
text(url, [{ type: "link", attrs: { href: url } }]),
text(" before release."),
),
);
expect(
convertIntegrationDisplay(
editor,
{ kind: "link", from, to: from + url.length, url, provider: "github" },
"mention",
),
).toBe(true);
expect(editor.getJSON().content?.[0]?.content?.[1]).toEqual(mention());
expect(editor.getText()).toBe(`Review ${url} before release.`);
});
it("converts an entire link when inline formatting splits it into text nodes", () => {
const splitAt = 19;
const editor = createEditor(
paragraph(
text(url.slice(0, splitAt), [{ type: "link", attrs: { href: url } }]),
text(url.slice(splitAt), [
{ type: "link", attrs: { href: url } },
{ type: "bold" },
]),
),
);
expect(
convertIntegrationDisplay(
editor,
{
kind: "link",
from: 1,
to: 1 + url.length,
url,
provider: "github",
},
"mention",
),
).toBe(true);
expect(editor.getJSON().content?.[0]?.content).toEqual([mention()]);
});
it("turns a standalone linked URL into a card", () => {
const editor = createEditor(
paragraph(text(url, [{ type: "link", attrs: { href: url } }]), text(" ")),
);
expect(
convertIntegrationDisplay(
editor,
{ kind: "link", from: 1, to: 1 + url.length, url, provider: "github" },
"card",
),
).toBe(true);
expect(editor.getJSON().content?.[0]).toEqual(card());
});
it("splits a paragraph around a linked URL converted to a card", () => {
const before = "Review ";
const after = " before release.";
const editor = createEditor(
paragraph(
text(before),
text(url, [{ type: "link", attrs: { href: url } }]),
text(after),
),
);
const from = 1 + before.length;
expect(
convertIntegrationDisplay(
editor,
{ kind: "link", from, to: from + url.length, url, provider: "github" },
"card",
),
).toBe(true);
expect(editor.getJSON()).toEqual({
type: "doc",
content: [
paragraph(text("Review")),
card(),
paragraph(text("before release.")),
],
});
});
it("reports whether an inline source can safely become a card", () => {
const editor = createEditor(
paragraph(text("Review "), mention()),
paragraph(mention()),
);
expect(
getIntegrationCardAvailability(editor, { kind: "node", pos: 8 }),
).toEqual({ canUseCard: true });
expect(
getIntegrationCardAvailability(editor, { kind: "node", pos: 11 }),
).toEqual({ canUseCard: true });
});
it("distinguishes unsupported containers from inline prose", () => {
const editor = createEditor({
type: "bulletList",
content: [
{
type: "listItem",
content: [paragraph(mention())],
},
],
});
expect(
getIntegrationCardAvailability(editor, { kind: "node", pos: 3 }),
).toEqual({ canUseCard: false });
});
it("resolves the selected rich integration to its current display", () => {
const editor = createEditor(card(), paragraph(mention()));
editor.commands.setNodeSelection(0);
expect(getSelectedIntegrationDisplay(editor)).toEqual({
current: "card",
source: { kind: "node", pos: 0 },
url,
});
editor.commands.setNodeSelection(2);
expect(getSelectedIntegrationDisplay(editor)).toEqual({
current: "mention",
source: { kind: "node", pos: 2 },
url,
});
});
});

View file

@ -0,0 +1,309 @@
import { getMarkRange, type Editor } from "@tiptap/core";
import { Fragment, type Node as ProseMirrorNode } from "@tiptap/pm/model";
import { NodeSelection } from "@tiptap/pm/state";
export type IntegrationDisplay = "card" | "mention" | "url";
export type IntegrationCardAvailability = { canUseCard: boolean };
export type IntegrationDisplaySource =
| {
kind: "node";
pos: number;
// Set when a paste split a paragraph around the node.
joinBefore?: boolean;
joinAfter?: boolean;
}
| {
kind: "link";
from: number;
to: number;
url: string;
provider: string;
};
export type SelectedIntegrationDisplay = {
current: Exclude<IntegrationDisplay, "url">;
source: Extract<IntegrationDisplaySource, { kind: "node" }>;
url: string;
};
export function getSelectedIntegrationDisplay(
editor: Editor,
): SelectedIntegrationDisplay | null {
const { selection } = editor.state;
if (!(selection instanceof NodeSelection)) return null;
const current =
selection.node.type.name === "integrationCard"
? "card"
: selection.node.type.name === "integrationMention"
? "mention"
: null;
if (!current || typeof selection.node.attrs.url !== "string") return null;
return {
current,
source: { kind: "node", pos: selection.from },
url: selection.node.attrs.url,
};
}
function getCardPlacement(
editor: Editor,
from: number,
to: number,
attrs: Record<string, unknown>,
):
| {
canPlace: true;
range: { from: number; to: number };
content: Fragment;
}
| {
canPlace: false;
range: null;
content: null;
} {
const $from = editor.state.doc.resolve(from);
if ($from.parent.type.name !== "paragraph") {
return { canPlace: false, range: null, content: null };
}
const parentStart = $from.start();
const parentEnd = parentStart + $from.parent.content.size;
if (from < parentStart || to > parentEnd || from > to) {
return { canPlace: false, range: null, content: null };
}
const depth = $from.depth;
const parentContainer = $from.node(depth - 1);
const parentIndex = $from.index(depth - 1);
const cardType = editor.state.schema.nodes.integrationCard;
if (!cardType) {
return { canPlace: false, range: null, content: null };
}
const before = trimInlineBoundary(
$from.parent.content.cut(0, from - parentStart),
"end",
);
const after = trimInlineBoundary(
$from.parent.content.cut(to - parentStart),
"start",
);
const replacement: ProseMirrorNode[] = [];
if (before.size) replacement.push($from.parent.copy(before));
replacement.push(cardType.create(attrs));
if (after.size) replacement.push($from.parent.copy(after));
const content = Fragment.fromArray(replacement);
if (!parentContainer.canReplace(parentIndex, parentIndex + 1, content)) {
return { canPlace: false, range: null, content: null };
}
return {
canPlace: true,
range: { from: $from.before(depth), to: $from.after(depth) },
content,
};
}
function trimInlineBoundary(
fragment: Fragment,
boundary: "start" | "end",
): Fragment {
const children: ProseMirrorNode[] = [];
fragment.forEach((child) => children.push(child));
while (children.length) {
const index = boundary === "start" ? 0 : children.length - 1;
const child = children[index];
if (!child?.isText) break;
const text =
boundary === "start"
? child.text?.replace(/^\s+/, "")
: child.text?.replace(/\s+$/, "");
if (text) {
children[index] = child.type.schema.text(text, child.marks);
break;
}
children.splice(index, 1);
}
return Fragment.fromArray(children);
}
function replaceWithCard(
editor: Editor,
placement: Extract<ReturnType<typeof getCardPlacement>, { canPlace: true }>,
): boolean {
return editor.commands.command(({ tr }) => {
tr.replaceWith(placement.range.from, placement.range.to, placement.content);
return true;
});
}
export function getIntegrationCardAvailability(
editor: Editor,
source: IntegrationDisplaySource,
): IntegrationCardAvailability {
if (source.kind === "link") {
const placement = getCardPlacement(editor, source.from, source.to, {
url: source.url,
provider: source.provider,
});
return { canUseCard: placement.canPlace };
}
const node = editor.state.doc.nodeAt(source.pos);
if (node?.type.name === "integrationCard") {
return { canUseCard: true };
}
if (node?.type.name !== "integrationMention") {
return { canUseCard: false };
}
const placement = getCardPlacement(
editor,
source.pos,
source.pos + node.nodeSize,
{ ...node.attrs },
);
return { canUseCard: placement.canPlace };
}
export function canPlaceIntegrationCard(editor: Editor, pos: number): boolean {
return getCardPlacement(editor, pos, pos, {}).canPlace;
}
export function convertIntegrationDisplay(
editor: Editor,
source: IntegrationDisplaySource,
target: IntegrationDisplay,
): boolean {
if (source.kind === "link") {
const linkType = editor.state.schema.marks.link;
const sourceRange = linkType
? getMarkRange(editor.state.doc.resolve(source.from), linkType, {
href: source.url,
})
: null;
if (
!sourceRange ||
sourceRange.from !== source.from ||
sourceRange.to !== source.to
) {
return false;
}
if (target === "card") {
const paragraphRange = getCardPlacement(editor, source.from, source.to, {
url: source.url,
provider: source.provider,
});
if (!paragraphRange.canPlace) return false;
return replaceWithCard(editor, paragraphRange);
}
if (target !== "mention") return false;
return editor.commands.insertContentAt(
{ from: source.from, to: source.to },
{
type: "integrationMention",
attrs: { url: source.url, provider: source.provider },
},
);
}
const node = editor.state.doc.nodeAt(source.pos);
if (!node) return false;
if (node.type.name === "integrationMention" && target === "card") {
const paragraphRange = getCardPlacement(
editor,
source.pos,
source.pos + node.nodeSize,
{ ...node.attrs },
);
if (!paragraphRange.canPlace) return false;
return replaceWithCard(editor, paragraphRange);
}
if (node.type.name === "integrationMention" && target === "url") {
return editor.commands.insertContentAt(
{ from: source.pos, to: source.pos + node.nodeSize },
{
type: "text",
text: node.attrs.url,
marks: [
{
type: "link",
attrs: {
href: node.attrs.url,
integrationProvider: node.attrs.provider,
},
},
],
},
);
}
if (node.type.name !== "integrationCard") return false;
const $card = editor.state.doc.resolve(source.pos);
const before = source.joinBefore ? $card.nodeBefore : null;
const after = source.joinAfter
? $card.parent.maybeChild($card.index() + 1)
: null;
const joinBefore = before?.type.name === "paragraph";
const joinAfter = after?.type.name === "paragraph";
const spaceFollows = joinAfter && /^\s/.test(after?.textContent ?? "");
const content =
target === "mention"
? [
{ type: "integrationMention", attrs: { ...node.attrs } },
...(spaceFollows ? [] : [{ type: "text", text: " " }]),
]
: target === "url"
? [
{
type: "text",
text: node.attrs.url,
marks: [
{
type: "link",
attrs: {
href: node.attrs.url,
integrationProvider: node.attrs.provider,
},
},
],
},
]
: null;
if (!content) return false;
return editor
.chain()
.insertContentAt(
{ from: source.pos, to: source.pos + node.nodeSize },
{
type: "paragraph",
attrs: (joinBefore ? before : joinAfter ? after : null)?.attrs,
content,
},
)
.command(({ tr }) => {
const paragraph = tr.doc.nodeAt(source.pos);
if (paragraph?.type.name !== "paragraph") return false;
if (joinAfter) tr.join(source.pos + paragraph.nodeSize);
if (joinBefore) tr.join(source.pos);
return true;
})
.run();
}

View file

@ -0,0 +1,345 @@
import {
describeIntegrationLink,
matchIntegrationLink,
} from "@docmost/editor-ext";
import { describe, expect, it } from "vitest";
const AZURE = "https://dev.azure.com/contoso";
const LEGACY_AZURE = "https://contoso.visualstudio.com";
const JIRA_HOSTS = { jira: ["jira.acme.com"] };
// Exercise matching through the public API; regex order and counts are implementation details.
describe("integration link matching", () => {
it.each([
["https://github.com/docmost/docmost", "github", "github-repo"],
["https://gitlab.com/docmost/docmost", "gitlab", "gitlab-project"],
["https://code.example.com/o/r/pull/1", "github", "github-pr"],
["https://code.example.com/o/r/-/merge_requests/1", "gitlab", "gitlab-mr"],
])("identifies %s as %s", (url, provider, type) => {
expect(matchIntegrationLink(url)).toMatchObject({ provider, type });
});
it.each([
"https://example.com/foo/bar",
"https://github.com/o/r/-/merge_requests/1",
"https://gitlab.com/o/r/pull/1",
])("does not misclassify %s", (url) => {
expect(matchIntegrationLink(url)).toBeNull();
});
it("restricts GitHub links to the configured Enterprise host", () => {
const hosts = { github: ["github.acme.com"] };
expect(
matchIntegrationLink("https://github.acme.com/o/r", hosts),
).toMatchObject({ provider: "github", type: "github-repo" });
expect(
matchIntegrationLink("https://github.acme.com/o/r/pull/1", hosts),
).toMatchObject({ provider: "github", type: "github-pr" });
expect(
matchIntegrationLink("https://github.com/o/r/pull/1", hosts),
).toBeNull();
expect(
matchIntegrationLink("https://gitea.example.com/o/r/pull/1", hosts),
).toBeNull();
});
it("recognizes a self-managed GitLab project root", () => {
expect(
matchIntegrationLink("https://gitlab.acme.com/g/p", {
gitlab: ["gitlab.acme.com"],
}),
).toMatchObject({ provider: "gitlab", type: "gitlab-project" });
});
it("matches Jira wildcard subdomains without accepting lookalike hosts", () => {
const hosts = { jira: ["*.atlassian.net", "*.jira.com"] };
expect(
matchIntegrationLink("https://acme.atlassian.net/browse/AB-1", hosts),
).toMatchObject({ provider: "jira", type: "jira-issue" });
for (const host of [
"atlassian.net",
"evilatlassian.net",
"jira.acme.com",
]) {
expect(
matchIntegrationLink(`https://${host}/browse/AB-1`, hosts),
).toBeNull();
}
});
it("keeps default matching for providers without a configured host", () => {
expect(
matchIntegrationLink("https://code.example.com/o/r/pull/1", JIRA_HOSTS),
).toMatchObject({ provider: "github", type: "github-pr" });
expect(
matchIntegrationLink("https://acme.slack.com/archives/C123", {}),
).toMatchObject({ provider: "slack", type: "slack-channel" });
});
it.each([
"https://github.com/o/r/pull/1\n",
"https://github.com/o/r/pulls?" + "a".repeat(9000),
])("rejects whitespace or oversized input: %s", (url) => {
expect(matchIntegrationLink(url)).toBeNull();
});
it.each([null, undefined])("rejects a link mark without an href: %s", (url) => {
expect(matchIntegrationLink(url)).toBeNull();
});
it.each([
["https://github.com/o/r/pulls?q=is%3Aopen", "github-pulls-list"],
["https://github.com/o/r/pulls/extra", "github-pulls-list"],
["https://github.com/o/r/releases/tag/v1.0.0", "github-releases-list"],
])("recognizes the collection at %s", (url, type) => {
expect(matchIntegrationLink(url)?.type).toBe(type);
});
it("does not treat a longer segment as a collection", () => {
expect(matchIntegrationLink("https://github.com/o/r/pullsx")).toBeNull();
});
});
describe("Azure DevOps links", () => {
it.each([
[
`${AZURE}/Fabrikam/_git/web-app/pullrequest/318`,
"azure-devops-pr",
["contoso", "Fabrikam", "web-app", "318"],
],
[
`${AZURE}/Fabrikam/_workitems/edit/318`,
"azure-devops-work-item",
["contoso", "Fabrikam", "318"],
],
[
`${AZURE}/Fabrikam/_boards/board/?workitem=318`,
"azure-devops-work-item",
["contoso", "Fabrikam", "318"],
],
[
`${LEGACY_AZURE}/Fabrikam/_git/web-app/pullrequest/318`,
"azure-devops-pr",
["contoso", "Fabrikam", "web-app", "318"],
],
[
`${LEGACY_AZURE}/DefaultCollection/Fabrikam/_workitems/edit/318`,
"azure-devops-work-item",
["contoso", "Fabrikam", "318"],
],
[
`${LEGACY_AZURE}/DefaultCollection/Fabrikam/_boards/board/?workitem=318`,
"azure-devops-work-item",
["contoso", "Fabrikam", "318"],
],
[
`${AZURE}/_git/Fabrikam/pullrequest/318`,
"azure-devops-pr",
["contoso", undefined, "Fabrikam", "318"],
],
[
`${LEGACY_AZURE}/_workitems/edit/318`,
"azure-devops-work-item",
["contoso", undefined, "318"],
],
] as const)(
"captures routing fields from %s and rejects invalid IDs",
(url, type, groups) => {
const result = matchIntegrationLink(url);
expect(result).toMatchObject({ provider: "azure_devops", type });
expect(result?.match.slice(1)).toEqual(groups);
expect(matchIntegrationLink(`${url}x`)).toBeNull();
expect(matchIntegrationLink(`${url}00000000`)).toBeNull();
},
);
it("matches Azure links when other providers report their hosts", () => {
expect(
matchIntegrationLink(`${AZURE}/Fabrikam/_git/web-app/pullrequest/318`, {
github: ["github.com"],
jira: ["*.atlassian.net"],
}),
).toMatchObject({ provider: "azure_devops", type: "azure-devops-pr" });
expect(
matchIntegrationLink(
"https://dev.azure.com/browse/AB-1/_workitems/edit/5",
),
).toMatchObject({
provider: "azure_devops",
type: "azure-devops-work-item",
});
});
it.each([
"http://dev.azure.com/contoso/Fabrikam/_workitems/edit/1",
"http://contoso.visualstudio.com/Fabrikam/_workitems/edit/1",
"https://dev.azure.com.evil.com/contoso/Fabrikam/_workitems/edit/1",
"https://contoso.visualstudio.com.evil.com/Fabrikam/_workitems/edit/1",
"https://dev.azure.com/con_toso/Fabrikam/_workitems/edit/1",
"https://dev.azure.com/con%2Ftoso/Fabrikam/_workitems/edit/1",
`${AZURE}/_Fabrikam/_workitems/edit/1`,
`${AZURE}/Fabrikam/_git/web-app?workitem=42`,
`${AZURE}/Fabrikam/_boardsx/board/?workitem=42`,
`${AZURE}/Fabrikam/_boards/board/?xworkitem=42`,
`${LEGACY_AZURE}/Fabrikam/boards/board/?workitem=42`,
])("rejects an unsupported or misleading URL: %s", (url) => {
expect(matchIntegrationLink(url)).toBeNull();
});
it("reads work items from board, backlog and sprint hubs on both hosts", () => {
for (const host of [AZURE, LEGACY_AZURE]) {
for (const hub of [
"_boards/board",
"_backlogs/backlog",
"_sprints/taskboard",
]) {
const url = `${host}/Fabrikam/${hub}/Team?showParents=true&workitem=42`;
expect(matchIntegrationLink(url)?.match.slice(1), url).toEqual([
"contoso",
"Fabrikam",
"42",
]);
}
}
});
it.each([
[
`${AZURE}/Fabrikam%20Fiber/_workitems/edit/1234`,
"Work Item #1234",
"contoso/Fabrikam Fiber",
],
[
`${AZURE}/Fabrikam/_git/web%20app/pullrequest/318`,
"Pull Request #318",
"contoso/Fabrikam/web app",
],
[
`${AZURE}/_git/Fabrikam/pullrequest/9`,
"Pull Request #9",
"contoso/Fabrikam",
],
[`${AZURE}/_workitems/edit/7`, "Work Item #7", "contoso"],
])("describes %s without a connection", (url, title, description) => {
expect(describeIntegrationLink(url)).toEqual({
provider: "azure_devops",
title,
description,
});
});
it.each([
["Fab%0Arikam", "Fab%0Arikam"],
["Fab%E2%80%AErikam", "Fab%E2%80%AErikam"],
["Fab%E2%80%A8rikam", "Fab%E2%80%A8rikam"],
["Fab%ZZ", "Fab%ZZ"],
["Fab\u202erikam", "Fab%E2%80%AErikam"],
["a%2Fb", "a%2Fb"],
["Caf%C3%A9", "Café"],
])("keeps decoded project names safe: %s", (project, expected) => {
expect(
describeIntegrationLink(`${AZURE}/${project}/_workitems/edit/1`)
?.description,
).toBe(`contoso/${expected}`);
});
});
describe("URL path boundaries", () => {
// Representative providers and failure classes instead of every route permutation.
it.each([
"https://github.com/a/b/pull/1/../../../../evil/x/pull/2",
"https://github.com/a/b/pull/1/%2e%2e/evil",
"https://github.com/a/b/pull/1\\..\\evil",
"https://gitlab.com/a/b/../../evil/x/-/issues/1",
"https://linear.app/acme/issue/ABC-1/../../../evil/issue/XYZ-2",
"https://acme.atlassian.net/browse/AB-1/%2e%2e/browse/EVIL-2",
`${AZURE}/Fabrikam/_git/web-app/pullrequest/318/../../evil`,
`${LEGACY_AZURE}/Fabrikam/_workitems/edit/1\\..\\evil`,
])("rejects paths the browser resolves elsewhere: %s", (url) => {
expect(matchIntegrationLink(url)).toBeNull();
});
it.each([
["https://github.com/o/.github/pull/1", "github-pr"],
["https://github.com/o/my..repo/issues/2", "github-issue"],
[
"https://github.com/o/r/blob/main/.github/workflows/ci.yml#L1-L3",
"github-file",
],
["https://github.com/o/r/pulls?q=../../x", "github-pulls-list"],
["https://linear.app/acme/issue/ABC-1#comment-1/../..", "linear-issue"],
["https://acme.atlassian.net/browse/AB-1?atlOrigin=x/../..", "jira-issue"],
[
`${AZURE}/Fabrikam/_git/web-app/pullrequest/318?_a=files&path=/../x`,
"azure-devops-pr",
],
])("allows dots in names, query strings and fragments: %s", (url, type) => {
expect(matchIntegrationLink(url)?.type).toBe(type);
});
it("applies the same path guard on an Enterprise host", () => {
expect(
matchIntegrationLink("https://github.acme.com/a/b/pull/1/../../evil", {
github: ["github.acme.com"],
}),
).toBeNull();
});
it.each([
"https://github.com/o/r?x=/pull/1",
"https://github.com/o/r#x/pull/1",
"https://github.com/o?x=/r/issues/1",
])("does not read a route from an owner or repository query: %s", (url) => {
expect(matchIntegrationLink(url)).toBeNull();
});
it("captures ordinary dots, dashes and underscores in repository names", () => {
expect(
matchIntegrationLink(
"https://github.com/my-org_1/repo.name-x/pull/1",
)?.match.slice(1),
).toEqual(["my-org_1", "repo.name-x", "1"]);
});
});
describe("Jira Data Center context paths", () => {
it.each([
"https://jira.acme.com/jira/browse/AB-1",
"https://jira.acme.com/tools/jira/browse/AB-1",
"https://jira.acme.com/jira/secure/RapidBoard.jspa?rapidView=1&selectedIssue=AB-1",
])("reads the issue under an installed context path: %s", (url) => {
const result = matchIntegrationLink(url, JIRA_HOSTS);
expect(result).toMatchObject({ provider: "jira", type: "jira-issue" });
expect(result?.match.slice(1)).toEqual(["AB-1"]);
expect(matchIntegrationLink(url)).toBeNull();
expect(matchIntegrationLink(url, { jira: ["jira.other.com"] })).toBeNull();
});
it("takes the first issue key after the context path", () => {
expect(
matchIntegrationLink(
"https://jira.acme.com/jira/browse/AB-1/browse/CD-2",
JIRA_HOSTS,
)?.match[1],
).toBe("AB-1");
});
it("handles an uppercase Cloud host when matching a selected board issue", () => {
expect(
matchIntegrationLink(
"https://ACME.Atlassian.net/jira/software/projects/AB/boards/1?selectedIssue=AB-12",
{
jira: ["*.atlassian.net"],
},
),
).toMatchObject({ provider: "jira", type: "jira-issue" });
});
it.each([
"https://jira.acme.com/jira/../browse/AB-1",
"https://jira.acme.com/jira/%2e%2e/browse/AB-1",
"https://jira.acme.com/jira\\..\\browse/AB-1",
])("rejects context paths the browser resolves elsewhere: %s", (url) => {
expect(matchIntegrationLink(url, JIRA_HOSTS)).toBeNull();
});
});

View file

@ -0,0 +1,122 @@
import { IntegrationCard, IntegrationMention } from "@docmost/editor-ext";
import { Editor, generateText } from "@tiptap/core";
import { StarterKit } from "@tiptap/starter-kit";
import { afterEach, describe, expect, it } from "vitest";
const PR_URL = "https://github.com/o/r/pull/1";
const SCRIPT_URLS = [" JaVaScRiPt:alert(1)", "java\tscript:alert(1)"];
const ENCODED_URL = "https://github.com/o/r/issues?q=label%3A%22a%2520b%22";
let editor: Editor;
function createEditor(content: string | Record<string, unknown> = "") {
editor = new Editor({
extensions: [StarterKit, IntegrationCard, IntegrationMention],
content,
});
return editor;
}
function findAttrs(typeName: string) {
const found: Record<string, unknown>[] = [];
editor.state.doc.descendants((node) => {
if (node.type.name === typeName) found.push(node.attrs);
});
return found[0];
}
afterEach(() => editor?.destroy());
describe.each([
{ typeName: "integrationCard", tag: "div", command: "setIntegrationCard" },
{
typeName: "integrationMention",
tag: "span",
command: "setIntegrationMention",
},
] as const)("$typeName url attr", ({ typeName, tag, command }) => {
it.each(SCRIPT_URLS)("stores an empty url when inserting %j", (url) => {
createEditor();
editor.commands[command]({ url, provider: "github" });
expect(findAttrs(typeName)).toMatchObject({ url: "" });
});
it("preserves percent encoding when inserting", () => {
createEditor();
editor.commands[command]({ url: ENCODED_URL, provider: "github" });
expect(findAttrs(typeName)).toMatchObject({ url: ENCODED_URL });
});
it("trims whitespace around an inserted url", () => {
createEditor();
editor.commands[command]({ url: ` ${PR_URL}\n`, provider: "github" });
expect(findAttrs(typeName)).toMatchObject({ url: PR_URL });
});
it("preserves percent encoding through HTML", () => {
createEditor(
`<p>x</p><${tag} data-type="${typeName}" data-url="${ENCODED_URL}"></${tag}>`,
);
expect(findAttrs(typeName)).toMatchObject({ url: ENCODED_URL });
expect(editor.getHTML()).toContain(`href="${ENCODED_URL}"`);
});
it("drops a non-http data-url when parsing HTML", () => {
createEditor(
`<p>x</p><${tag} data-type="${typeName}" data-url="file:///x" data-provider="github"></${tag}>`,
);
expect(findAttrs(typeName)).toMatchObject({ url: "", provider: "github" });
});
it("renders no unsafe href for JSON content that bypassed parsing", () => {
const node = { type: typeName, attrs: { url: "file:///etc/passwd" } };
createEditor({
type: "doc",
content: [
typeName === "integrationCard"
? node
: { type: "paragraph", content: [node] },
],
});
const html = editor.getHTML();
expect(html).not.toContain("file:");
expect(html).toContain('href=""');
});
});
describe("integration node plain text", () => {
it("renders each node as its url", () => {
const issueUrl = "https://github.com/o/r/issues/2";
const text = generateText(
{
type: "doc",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "See " },
{
type: "integrationMention",
attrs: { url: PR_URL, provider: "github" },
},
{ type: "text", text: " today" },
],
},
{
type: "integrationCard",
attrs: { url: issueUrl, provider: "github" },
},
],
},
[StarterKit, IntegrationCard, IntegrationMention],
);
expect(text).toBe(`See ${PR_URL} today\n\n${issueUrl}`);
});
});

View file

@ -0,0 +1,53 @@
.mention {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 100%;
vertical-align: baseline;
text-decoration: none;
color: inherit;
border-radius: var(--mantine-radius-sm);
}
:global(.node-integrationMention) .mention {
border-bottom: none !important;
font-weight: 400;
}
.mention:hover {
background-color: var(--mantine-color-gray-0);
}
:global([data-mantine-color-scheme="dark"]) .mention:hover {
background-color: var(--mantine-color-dark-5);
}
/* Text runs share the paragraph baseline; icons and badges center on the line. */
.mentionMeta,
.mentionText {
align-self: baseline;
}
.mentionMeta {
color: var(--mantine-color-dimmed);
white-space: nowrap;
}
.mentionText {
text-decoration: underline;
text-decoration-thickness: 0.07em;
text-decoration-color: light-dark(
var(--mantine-color-dark-0),
var(--mantine-color-dark-2)
);
text-underline-offset: 0.2em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 340px;
font-weight: 500;
}
.mentionIcon {
flex-shrink: 0;
}

View file

@ -0,0 +1,259 @@
import { NodeViewWrapper } from "@tiptap/react";
import {
Avatar,
Badge,
Button,
Group,
HoverCard,
Stack,
Text,
} from "@mantine/core";
import { memo, useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications";
import { describeIntegrationLink, isHttpUrl } from "@docmost/editor-ext";
import { getIntegrationIcon } from "@/features/integration/components/integration-icons";
import { getOAuthAuthorizeUrl } from "@/features/integration/services/integration-service";
import { useUnfurl } from "./use-unfurl";
import { badgeTextColor, toBadgeColor } from "./badge-color";
import classes from "./integration-mention-view.module.css";
function shortUrl(url: string): string {
try {
const parsed = new URL(url);
return `${parsed.host}${parsed.pathname}`;
} catch {
return url;
}
}
function IntegrationMentionView(props: any) {
const { editor, getPos, node } = props;
const { url, provider } = node.attrs;
const safeUrl = isHttpUrl(url) ? url : "";
const { t } = useTranslation();
const unfurl = useUnfurl(safeUrl);
const data = unfurl.state === "loaded" ? unfurl.data : null;
const needsConnection =
unfurl.state === "needsConnection" ? unfurl.needsConnection : null;
const [connecting, setConnecting] = useState(false);
const selectForEditing = useCallback(
(event: React.MouseEvent<HTMLAnchorElement>) => {
if (!editor.isEditable || event.metaKey || event.ctrlKey) return;
const pos = getPos();
if (typeof pos !== "number") return;
event.preventDefault();
event.stopPropagation();
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.setNodeSelection(pos)
.run();
},
[editor, getPos],
);
const handleConnect = useCallback(
async (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (!needsConnection) return;
setConnecting(true);
try {
const result = await getOAuthAuthorizeUrl({
integrationId: needsConnection.integrationId,
returnPath: window.location.pathname,
});
window.location.href = result.authorizationUrl;
} catch (error) {
setConnecting(false);
notifications.show({
message:
error?.["response"]?.data?.message ||
t("Failed to start OAuth connection"),
color: "red",
});
}
},
[needsConnection, t],
);
const meta = data?.metadata ?? {};
const isSlackMessage =
provider === "slack" &&
(meta.type === "message" || (!meta.type && meta.ts));
const issueNumber = meta.iid ?? meta.number;
const typeLabel =
meta.type === "project"
? t("Project")
: meta.type === "initiative"
? t("Initiative")
: null;
const statusBadge = data?.status ? (
<Badge
size="xs"
variant="light"
color={toBadgeColor(data.statusColor)}
c={badgeTextColor(toBadgeColor(data.statusColor))}
className={classes.mentionIcon}
>
{data.status}
</Badge>
) : null;
let content;
if (!data) {
// Without unfurl data, describe the link from its url.
content = (
<>
{getIntegrationIcon(provider, 14)}
<span className={classes.mentionText}>
{needsConnection?.title ??
describeIntegrationLink(safeUrl)?.title ??
(safeUrl ? shortUrl(safeUrl) : url)}
</span>
</>
);
} else if (isSlackMessage) {
content = (
<>
<Avatar
src={data.authorAvatarUrl}
size={16}
radius="xl"
className={classes.mentionIcon}
>
{(data.author ?? "?").charAt(0)}
</Avatar>
{data.author && (
<span className={classes.mentionMeta}>{data.author}</span>
)}
<span className={classes.mentionText}>
{(data.description ?? "").split("\n")[0] || shortUrl(url)}
</span>
{getIntegrationIcon("slack", 14)}
{data.status && (
<Badge
size="xs"
variant="light"
color="gray"
c={badgeTextColor("gray")}
tt="none"
className={classes.mentionIcon}
>
{data.status}
</Badge>
)}
</>
);
} else if (meta.issueKey) {
// Jira issue
content = (
<>
{meta.issueTypeIconUrl ? (
<img
src={meta.issueTypeIconUrl}
width={14}
height={14}
alt=""
className={classes.mentionIcon}
/>
) : (
getIntegrationIcon(provider, 14)
)}
<span className={classes.mentionMeta}>{meta.issueKey}</span>
<span className={classes.mentionText}>{data.title}</span>
{statusBadge}
{meta.issueTypeIconUrl && getIntegrationIcon(provider, 14)}
</>
);
} else if (issueNumber) {
content = (
<>
{getIntegrationIcon(provider, 14)}
<span className={classes.mentionMeta}>#{issueNumber}</span>
<span className={classes.mentionText}>{data.title}</span>
{statusBadge}
</>
);
} else if (typeLabel) {
content = (
<>
{getIntegrationIcon(provider, 14)}
<span className={classes.mentionMeta}>{typeLabel}</span>
<span className={classes.mentionText}>{data.title}</span>
{statusBadge}
</>
);
} else {
content = (
<>
{getIntegrationIcon(provider, 14)}
<span className={classes.mentionText}>
{data.title || shortUrl(url)}
</span>
{statusBadge}
</>
);
}
const anchor = (
<a
href={safeUrl || undefined}
target="_blank"
rel="noopener"
title={url}
className={classes.mention}
onClick={selectForEditing}
>
{content}
</a>
);
return (
<NodeViewWrapper as="span" style={{ display: "inline" }}>
{needsConnection ? (
<HoverCard
position="bottom-start"
shadow="md"
radius="md"
openDelay={150}
closeDelay={150}
>
<HoverCard.Target>{anchor}</HoverCard.Target>
<HoverCard.Dropdown p="sm">
<Stack gap="xs">
<Group gap={6} wrap="nowrap">
{getIntegrationIcon(provider, 16)}
<Text size="sm" fw={500}>
{needsConnection.integrationName}
</Text>
</Group>
<Button
size="xs"
variant="filled"
color="dark"
leftSection={getIntegrationIcon(provider, 16)}
loading={connecting}
onClick={handleConnect}
>
{t("Connect to {{name}} to preview", {
name: needsConnection.integrationName,
})}
</Button>
</Stack>
</HoverCard.Dropdown>
</HoverCard>
) : (
anchor
)}
</NodeViewWrapper>
);
}
export default memo(IntegrationMentionView);

View file

@ -0,0 +1,71 @@
.toolbar {
display: inline-flex;
align-items: center;
gap: 1px;
padding: 4px;
border: 1px solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
border-radius: 10px;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-2));
background: light-dark(
var(--mantine-color-white),
var(--mantine-color-dark-6)
);
box-shadow: 0 5px 18px light-dark(rgba(31, 35, 40, 0.12), rgba(0, 0, 0, 0.36));
}
.divider {
width: 1px;
height: 22px;
margin: 0 3px;
flex: 0 0 1px;
background: light-dark(
var(--mantine-color-gray-3),
var(--mantine-color-dark-3)
);
}
.actionButton {
--ai-size: 32px !important;
flex: 0 0 32px;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-2));
border-radius: 8px;
-webkit-tap-highlight-color: transparent;
}
.actionButton svg {
width: 18px;
height: 18px;
}
.actionButton:focus-visible {
outline: 2px solid var(--mantine-color-blue-6);
outline-offset: -2px;
}
.pasteOption {
min-height: 30px;
}
@media (pointer: coarse) {
.pasteOption {
min-height: 44px;
}
.actionButton {
--ai-size: 44px !important;
flex-basis: 44px;
}
.actionButton svg {
width: 21px;
height: 21px;
}
.divider {
height: 26px;
margin-inline: 4px;
}
}

View file

@ -0,0 +1,213 @@
import { MantineProvider } from "@mantine/core";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { ComponentProps } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { IntegrationPasteMenu } from "./integration-paste-menu";
const mocks = vi.hoisted(() => ({
pasteState: null as {
pos: number;
joinBefore?: boolean;
joinAfter?: boolean;
} | null,
selectedDisplay: null as Record<string, unknown> | null,
convertDisplay: vi.fn(),
hasFocus: true,
blurHandler: null as ((props: { event: FocusEvent }) => void) | null,
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (value: string) => value }),
}));
vi.mock("@tiptap/react", () => ({
posToDOMRect: vi.fn(),
useEditorState: ({ editor, selector }: Record<string, any>) =>
selector({ editor }),
}));
vi.mock("@tiptap/react/menus", () => ({
BubbleMenu: ({ children }: { children: React.ReactNode }) => children,
}));
vi.mock("@/features/editor/extensions/integration-paste-menu", () => ({
integrationPasteMenuKey: {
getState: () => mocks.pasteState,
},
}));
vi.mock("./integration-display", () => ({
convertIntegrationDisplay: mocks.convertDisplay,
getIntegrationCardAvailability: () => ({ canUseCard: true }),
getSelectedIntegrationDisplay: () => mocks.selectedDisplay,
}));
function setMediaMatch() {
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}
function renderMenu(nodeType = "integrationCard") {
const editor = {
state: {
doc: {
nodeAt: () => ({
type: { name: nodeType },
attrs: {
url: "https://gitlab.com/gitlab-org/gitlab/-/issues/609576",
},
nodeSize: 1,
}),
},
tr: { setMeta: vi.fn() },
},
view: {
dispatch: vi.fn(),
nodeDOM: vi.fn(),
hasFocus: () => mocks.hasFocus,
},
commands: { focus: vi.fn() },
isDestroyed: false,
on: (name: string, handler: (props: { event: FocusEvent }) => void) => {
if (name === "blur") mocks.blurHandler = handler;
},
off: vi.fn(),
};
render(
<MantineProvider>
<IntegrationPasteMenu
editor={
editor as unknown as ComponentProps<
typeof IntegrationPasteMenu
>["editor"]
}
/>
</MantineProvider>,
);
return editor;
}
describe("IntegrationPasteMenu", () => {
beforeEach(() => {
setMediaMatch();
mocks.pasteState = null;
mocks.selectedDisplay = null;
mocks.convertDisplay.mockReset();
mocks.hasFocus = true;
mocks.blurHandler = null;
});
afterEach(cleanup);
it("shows the original vertical Paste as chooser only for a fresh paste", () => {
mocks.pasteState = { pos: 4 };
renderMenu();
const chooser = screen.getByRole("listbox", { name: "Paste as" });
expect(chooser).toBeTruthy();
expect(chooser.getAttribute("aria-orientation")).toBe("vertical");
expect(screen.getByRole("option", { name: "Card" })).toBeTruthy();
expect(screen.getByRole("option", { name: "Mention" })).toBeTruthy();
expect(screen.getByRole("option", { name: "URL" })).toBeTruthy();
expect(
screen.queryByRole("toolbar", { name: "Integration link actions" }),
).toBeNull();
});
it("keeps the horizontal display toolbar for an existing node", () => {
mocks.selectedDisplay = {
current: "mention",
source: { kind: "node", pos: 4 },
url: "https://gitlab.com/gitlab-org/gitlab/-/issues/609576",
};
renderMenu("integrationMention");
expect(
screen.getByRole("toolbar", { name: "Integration link actions" }),
).toBeTruthy();
expect(screen.getByRole("radiogroup", { name: "Display as" })).toBeTruthy();
expect(screen.queryByRole("listbox", { name: "Paste as" })).toBeNull();
});
it("handles Paste as keys while the editor has focus", () => {
mocks.pasteState = { pos: 4 };
renderMenu();
fireEvent.keyDown(window, { key: "ArrowDown" });
fireEvent.keyDown(window, { key: "Enter" });
expect(mocks.convertDisplay).toHaveBeenCalledWith(
expect.anything(),
{ kind: "node", pos: 4 },
"mention",
);
});
it("hands the paste split to the conversion so the sentence can rejoin", () => {
mocks.pasteState = { pos: 4, joinBefore: true, joinAfter: false };
renderMenu();
fireEvent.click(screen.getByRole("option", { name: "URL" }));
expect(mocks.convertDisplay).toHaveBeenCalledWith(
expect.anything(),
{ kind: "node", pos: 4, joinBefore: true, joinAfter: false },
"url",
);
});
it("leaves keys alone when focus is outside the editor and the prompt", () => {
mocks.pasteState = { pos: 4 };
mocks.hasFocus = false;
renderMenu();
const enter = new KeyboardEvent("keydown", {
key: "Enter",
cancelable: true,
});
window.dispatchEvent(enter);
expect(enter.defaultPrevented).toBe(false);
expect(mocks.convertDisplay).not.toHaveBeenCalled();
});
it("dismisses the prompt when the editor loses focus elsewhere", () => {
mocks.pasteState = { pos: 4 };
const editor = renderMenu();
mocks.blurHandler?.({
event: new FocusEvent("blur", { relatedTarget: document.body }),
});
expect(editor.state.tr.setMeta).toHaveBeenCalledWith(expect.anything(), null);
expect(editor.view.dispatch).toHaveBeenCalled();
});
it("keeps the prompt when focus moves into it", () => {
mocks.pasteState = { pos: 4 };
const editor = renderMenu();
mocks.blurHandler?.({
event: new FocusEvent("blur", {
relatedTarget: screen.getByRole("option", { name: "Card" }),
}),
});
expect(editor.view.dispatch).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,335 @@
import {
ActionIcon,
Button,
Paper,
Stack,
Text,
Tooltip,
VisuallyHidden,
} from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { IconCopy, IconExternalLink } from "@tabler/icons-react";
import { posToDOMRect, useEditorState } from "@tiptap/react";
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { copyToClipboard } from "@docmost/editor-ext";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
import { integrationPasteMenuKey } from "@/features/editor/extensions/integration-paste-menu";
import {
convertIntegrationDisplay,
getIntegrationCardAvailability,
getSelectedIntegrationDisplay,
type IntegrationDisplay,
type IntegrationDisplaySource,
} from "./integration-display";
import { IntegrationDisplayPicker } from "./integration-display-picker";
import classes from "./integration-paste-menu.module.css";
const PASTE_OPTIONS: { value: IntegrationDisplay; label: string }[] = [
{ value: "card", label: "Card" },
{ value: "mention", label: "Mention" },
{ value: "url", label: "URL" },
];
type DisplayTarget = {
current: Exclude<IntegrationDisplay, "url">;
source: Extract<IntegrationDisplaySource, { kind: "node" }>;
url: string;
isPaste: boolean;
};
function getDisplayTarget(
editor: EditorMenuProps["editor"],
): DisplayTarget | null {
const pasteState = integrationPasteMenuKey.getState(editor.state);
if (pasteState) {
const node = editor.state.doc.nodeAt(pasteState.pos);
const current =
node?.type.name === "integrationCard"
? "card"
: node?.type.name === "integrationMention"
? "mention"
: null;
if (current && typeof node?.attrs.url === "string") {
return {
current,
source: {
kind: "node",
pos: pasteState.pos,
joinBefore: pasteState.joinBefore,
joinAfter: pasteState.joinAfter,
},
url: node.attrs.url,
isPaste: true,
};
}
}
const selected = getSelectedIntegrationDisplay(editor);
return selected ? { ...selected, isPaste: false } : null;
}
export function IntegrationPasteMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const [selectedPasteIndex, setSelectedPasteIndex] = useState(0);
const pasteOptionIdPrefix = useId();
const promptRef = useRef<HTMLDivElement>(null);
const target = useEditorState({
editor,
selector: ({ editor: currentEditor }) =>
currentEditor ? getDisplayTarget(currentEditor) : null,
});
const findTarget = useCallback(() => getDisplayTarget(editor), [editor]);
const shouldShow = useCallback(() => Boolean(findTarget()), [findTarget]);
const getReferencedVirtualElement = useCallback(() => {
const currentTarget = findTarget();
if (!currentTarget) return undefined;
const { pos } = currentTarget.source;
const node = editor.state.doc.nodeAt(pos);
if (!node) return undefined;
const dom = editor.view.nodeDOM(pos) as HTMLElement | null;
const domRect =
dom?.getBoundingClientRect?.() ??
posToDOMRect(editor.view, pos, pos + node.nodeSize);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}, [editor, findTarget]);
const dismissPastePrompt = useCallback(() => {
if (!integrationPasteMenuKey.getState(editor.state)) return;
editor.view.dispatch(
editor.state.tr.setMeta(integrationPasteMenuKey, null),
);
requestAnimationFrame(() => {
const activeElement = document.activeElement;
if (
!activeElement ||
activeElement === document.body ||
!activeElement.isConnected
) {
editor.commands.focus(undefined, { scrollIntoView: false });
}
});
}, [editor]);
const changeDisplay = useCallback(
(display: IntegrationDisplay) => {
const currentTarget = findTarget();
if (!currentTarget) return;
dismissPastePrompt();
if (display === currentTarget.current) {
editor.commands.focus(undefined, { scrollIntoView: false });
return;
}
if (convertIntegrationDisplay(editor, currentTarget.source, display)) {
editor.commands.focus(undefined, { scrollIntoView: false });
}
},
[dismissPastePrompt, editor, findTarget],
);
useEffect(() => {
if (target?.isPaste) setSelectedPasteIndex(0);
}, [target?.isPaste, target?.source.pos]);
// Focus stays in the editor, so capture keys before ProseMirror moves past the atom node.
useEffect(() => {
if (!target?.isPaste) return;
const ownsFocus = () =>
editor.view.hasFocus() ||
Boolean(promptRef.current?.contains(document.activeElement));
const onKeyDown = (event: KeyboardEvent) => {
if (!ownsFocus()) return;
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
dismissPastePrompt();
return;
}
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
event.stopPropagation();
const delta = event.key === "ArrowDown" ? 1 : -1;
setSelectedPasteIndex(
(current) =>
(current + delta + PASTE_OPTIONS.length) % PASTE_OPTIONS.length,
);
return;
}
if (event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
const option = PASTE_OPTIONS[selectedPasteIndex];
if (option) changeDisplay(option.value);
}
};
// Leaving the editor dismisses the prompt, unless focus moved into the prompt itself.
const onBlur = ({ event }: { event: FocusEvent }) => {
const next = event.relatedTarget;
if (next instanceof Node && promptRef.current?.contains(next)) return;
if (editor.isDestroyed) return;
editor.view.dispatch(
editor.state.tr.setMeta(integrationPasteMenuKey, null),
);
};
window.addEventListener("keydown", onKeyDown, true);
editor.on("blur", onBlur);
return () => {
window.removeEventListener("keydown", onKeyDown, true);
editor.off("blur", onBlur);
};
}, [
changeDisplay,
dismissPastePrompt,
editor,
selectedPasteIndex,
target?.isPaste,
]);
const copyLink = useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
const currentTarget = findTarget();
if (!currentTarget) return;
copyToClipboard(currentTarget.url);
notifications.show({ message: t("Link copied") });
},
[findTarget, t],
);
const cardAvailability = target
? getIntegrationCardAvailability(editor, target.source)
: null;
return (
<BaseBubbleMenu
editor={editor}
pluginKey="integration-display-menu"
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
placement:
target?.isPaste || target?.current !== "card"
? "bottom-start"
: "top-end",
offset: 10,
flip: true,
shift: true,
}}
shouldShow={shouldShow}
>
{target?.isPaste ? (
<Paper
ref={promptRef}
shadow="md"
radius="md"
withBorder
p={4}
miw={140}
role="listbox"
aria-label={t("Paste as")}
aria-orientation="vertical"
aria-activedescendant={`${pasteOptionIdPrefix}-${PASTE_OPTIONS[selectedPasteIndex].value}`}
>
<VisuallyHidden role="status" aria-live="polite" aria-atomic="true">
{t(PASTE_OPTIONS[selectedPasteIndex].label)}
</VisuallyHidden>
<Text size="xs" c="dimmed" px={8} py={4}>
{t("Paste as")}
</Text>
<Stack gap={2}>
{PASTE_OPTIONS.map((option, index) => (
<Button
key={option.value}
id={`${pasteOptionIdPrefix}-${option.value}`}
role="option"
aria-selected={index === selectedPasteIndex}
variant={index === selectedPasteIndex ? "light" : "subtle"}
color="gray"
size="compact-sm"
fullWidth
justify="flex-start"
className={classes.pasteOption}
onMouseEnter={() => setSelectedPasteIndex(index)}
onFocus={() => setSelectedPasteIndex(index)}
onClick={() => changeDisplay(option.value)}
>
{t(option.label)}
</Button>
))}
</Stack>
</Paper>
) : target ? (
<div
className={classes.toolbar}
role="toolbar"
aria-label={t("Integration link actions")}
>
<IntegrationDisplayPicker
key={`${target.source.pos}-selected`}
current={target.current}
label={t("Display as")}
canUseCard={cardAvailability?.canUseCard ?? false}
onChange={changeDisplay}
/>
<span className={classes.divider} aria-hidden="true" />
<Tooltip label={t("Open link")} withArrow withinPortal={false}>
<ActionIcon
component="a"
href={target.url}
target="_blank"
rel="noopener noreferrer"
variant="subtle"
color="gray"
size={32}
className={classes.actionButton}
aria-label={t("Open link")}
>
<IconExternalLink size={18} stroke={1.75} aria-hidden="true" />
</ActionIcon>
</Tooltip>
{target.current === "mention" ? (
<Tooltip label={t("Copy link")} withArrow withinPortal={false}>
<ActionIcon
variant="subtle"
color="gray"
size={32}
className={classes.actionButton}
aria-label={t("Copy link")}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={copyLink}
>
<IconCopy size={18} stroke={1.75} aria-hidden="true" />
</ActionIcon>
</Tooltip>
) : null}
</div>
) : null}
</BaseBubbleMenu>
);
}

View file

@ -0,0 +1,118 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createStore, Provider } from "jotai";
import { ReactNode } from "react";
import { useUnfurl } from "./use-unfurl";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { unfurlUrl } from "@/features/integration/services/integration-service";
import {
UnfurlNeedsConnection,
UnfurlResult,
} from "@/features/integration/types/integration.types";
vi.mock("@/features/integration/services/integration-service", () => ({
unfurlUrl: vi.fn(),
}));
const mockedUnfurlUrl = vi.mocked(unfurlUrl);
const ISSUE_URL = "https://github.com/acme/repo/issues/42";
const loadedResult: UnfurlResult = {
title: "Fix race condition in file watcher",
url: ISSUE_URL,
provider: "github",
status: "open",
};
const needsConnectionResult: UnfurlNeedsConnection = {
needsConnection: true,
integrationId: "int-1",
integrationType: "github",
integrationName: "GitHub",
title: "GitHub link",
description: "github.com/acme/repo/issues/42",
};
function createWrapper(loggedIn: boolean) {
const store = createStore();
if (loggedIn) {
store.set(currentUserAtom, { user: { id: "user-1" } } as any);
}
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return ({ children }: { children: ReactNode }) => (
<Provider store={store}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</Provider>
);
}
describe("useUnfurl", () => {
beforeEach(() => {
localStorage.clear();
vi.resetAllMocks();
});
it("never fetches for anonymous viewers and reports anonymous", () => {
const { result } = renderHook(() => useUnfurl(ISSUE_URL), {
wrapper: createWrapper(false),
});
expect(result.current.state).toBe("anonymous");
expect(mockedUnfurlUrl).not.toHaveBeenCalled();
});
it("starts loading then exposes the unfurl result", async () => {
mockedUnfurlUrl.mockResolvedValue(loadedResult);
const { result } = renderHook(() => useUnfurl(ISSUE_URL), {
wrapper: createWrapper(true),
});
expect(result.current.state).toBe("loading");
await waitFor(() => expect(result.current.state).toBe("loaded"));
expect(
result.current.state === "loaded" && result.current.data,
).toEqual(loadedResult);
expect(mockedUnfurlUrl).toHaveBeenCalledWith({ url: ISSUE_URL });
});
it("maps a needsConnection response without treating it as an error", async () => {
mockedUnfurlUrl.mockResolvedValue(needsConnectionResult);
const { result } = renderHook(() => useUnfurl(ISSUE_URL), {
wrapper: createWrapper(true),
});
await waitFor(() =>
expect(result.current.state).toBe("needsConnection"),
);
expect(
result.current.state === "needsConnection" &&
result.current.needsConnection,
).toEqual(needsConnectionResult);
});
it("maps a null result (no matching provider) to error", async () => {
mockedUnfurlUrl.mockResolvedValue(null);
const { result } = renderHook(() => useUnfurl(ISSUE_URL), {
wrapper: createWrapper(true),
});
await waitFor(() => expect(result.current.state).toBe("error"));
});
it("maps a rejected request to error", async () => {
mockedUnfurlUrl.mockRejectedValue(new Error("boom"));
const { result } = renderHook(() => useUnfurl(ISSUE_URL), {
wrapper: createWrapper(true),
});
await waitFor(() => expect(result.current.state).toBe("error"));
});
});

View file

@ -0,0 +1,45 @@
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { unfurlUrl } from "@/features/integration/services/integration-service";
import {
UnfurlNeedsConnection,
UnfurlResult,
} from "@/features/integration/types/integration.types";
const UNFURL_STALE_TIME = 5 * 60 * 1000; // mirrors the server-side Redis TTL
export type UnfurlState =
| { state: "anonymous" }
| { state: "loading" }
| { state: "error" }
| { state: "needsConnection"; needsConnection: UnfurlNeedsConnection }
| { state: "loaded"; data: UnfurlResult };
// Fetched per viewer and never written into the doc, so each viewer's third-party access applies.
export function useUnfurl(url: string): UnfurlState {
const currentUser = useAtomValue(currentUserAtom);
const isAuthenticated = Boolean(currentUser?.user);
const query = useQuery({
queryKey: ["unfurl", url],
queryFn: () => unfurlUrl({ url }),
enabled: isAuthenticated && Boolean(url),
staleTime: UNFURL_STALE_TIME,
retry: false,
});
if (!isAuthenticated || !url) {
return { state: "anonymous" };
}
if (query.isPending) {
return { state: "loading" };
}
if (query.isError || !query.data) {
return { state: "error" };
}
if ("needsConnection" in query.data) {
return { state: "needsConnection", needsConnection: query.data };
}
return { state: "loaded", data: query.data };
}

View file

@ -0,0 +1,225 @@
import { MantineProvider } from "@mantine/core";
import {
cleanup,
fireEvent,
render,
screen,
within,
} from "@testing-library/react";
import type { ComponentProps } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import LinkView from "./link-view";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (value: string) => value }),
}));
vi.mock("react-router-dom", () => ({
useLocation: () => ({ pathname: "/s/example/p/page" }),
useNavigate: () => vi.fn(),
useParams: () => ({}),
}));
const installedIntegrations = vi.hoisted(() => [
{ type: "github", unfurlHosts: ["github.com"] },
]);
vi.mock("@/features/integration/queries/integration-query", () => ({
useInstalledIntegrations: () => ({ data: installedIntegrations }),
}));
vi.mock("@/features/page/queries/page-query.ts", () => ({
usePageQuery: () => ({ data: undefined }),
}));
vi.mock("@/features/share/queries/share-query.ts", () => ({
useSharePageQuery: () => ({ data: undefined }),
}));
vi.mock("@/features/public-space/queries/public-space-query.ts", () => ({
usePublicSpacePageQuery: () => ({ data: undefined }),
}));
vi.mock("@/features/editor/components/link/link-editor-panel.tsx", () => ({
LinkEditorPanel: () => null,
}));
function setMediaMatch() {
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}
function renderDetachedLinkView(
attrs: Record<string, unknown> = {
href: "https://github.com/docmost/docmost/pull/2475",
integrationProvider: "github",
internal: false,
},
) {
const props = {
editor: {
isEditable: true,
state: {
doc: {
content: { size: 100 },
resolve: (position: number) => {
if (position < 0) {
throw new RangeError(`Position ${position} out of range`);
}
return {};
},
},
},
view: { posAtDOM: () => -1 },
},
mark: {
attrs,
type: { name: "link" },
},
};
const renderView = () => (
<MantineProvider>
<LinkView {...(props as unknown as ComponentProps<typeof LinkView>)} />
</MantineProvider>
);
return { ...render(renderView()), renderView };
}
describe("LinkView", () => {
beforeEach(setMediaMatch);
afterEach(cleanup);
it("does not resolve the mark position while the popover is closed", () => {
const { rerender, renderView } = renderDetachedLinkView();
expect(() => rerender(renderView())).not.toThrow();
});
it("does not resolve a negative mark-view position after opening", () => {
const { container } = renderDetachedLinkView();
const link = container.querySelector("a");
expect(link).not.toBeNull();
expect(() => fireEvent.click(link!)).not.toThrow();
});
it("opens URL-current display choices from the existing link action row", async () => {
const { container } = renderDetachedLinkView();
const link = container.querySelector("a");
const popoverTarget = container.querySelector('[aria-haspopup="dialog"]');
expect(link).not.toBeNull();
expect(popoverTarget).not.toBeNull();
fireEvent.click(link!);
const menu = await screen.findByRole("dialog", { hidden: true });
const menuQueries = within(menu);
const display = menuQueries.getByRole("button", {
name: "Display as: URL",
hidden: true,
});
fireEvent.click(display);
const displayMenu = await screen.findByRole("menu", { hidden: true });
expect(
within(displayMenu)
.getByRole("menuitemradio", { name: "URL", hidden: true })
.getAttribute("aria-checked"),
).toBe("true");
fireEvent.mouseDown(
within(displayMenu).getByRole("menuitemradio", {
name: "Mention",
hidden: true,
}),
);
expect(popoverTarget?.getAttribute("aria-expanded")).toBe("true");
});
it("offers display choices for a plain link on a host the provider serves", async () => {
const { container } = renderDetachedLinkView({
href: "https://github.com/docmost/docmost/pull/2475",
internal: false,
});
fireEvent.click(container.querySelector("a")!);
const menu = await screen.findByRole("dialog", { hidden: true });
expect(
within(menu).getByRole("button", {
name: "Display as: URL",
hidden: true,
}),
).toBeTruthy();
});
it("hides display choices for a GitHub-like path on a host GitHub does not serve", async () => {
const { container } = renderDetachedLinkView({
href: "https://gitea.example.com/team/app/pulls/7",
internal: false,
});
fireEvent.click(container.querySelector("a")!);
const menu = await screen.findByRole("dialog", { hidden: true });
expect(
within(menu).getByRole("button", { name: "Edit link", hidden: true }),
).toBeTruthy();
expect(
within(menu).queryByRole("button", {
name: "Display as: URL",
hidden: true,
}),
).toBeNull();
});
it("supports keyboard-generated clicks on link-menu actions", async () => {
const { container } = renderDetachedLinkView();
const link = container.querySelector("a");
expect(link).not.toBeNull();
fireEvent.click(link!);
const menu = await screen.findByRole("dialog", { hidden: true });
const edit = within(menu).getByRole("button", {
name: "Edit link",
hidden: true,
});
fireEvent.click(edit);
expect(await within(menu).findByText("Page or URL")).toBeTruthy();
});
it("closes the integrated link menu when Escape is pressed on the selector", async () => {
const { container } = renderDetachedLinkView();
const link = container.querySelector("a");
const target = container.querySelector('[aria-haspopup="dialog"]');
expect(link).not.toBeNull();
expect(target).not.toBeNull();
fireEvent.click(link!);
expect(target?.getAttribute("aria-expanded")).toBe("true");
const menu = await screen.findByRole("dialog", { hidden: true });
const display = within(menu).getByRole("button", {
name: "Display as: URL",
hidden: true,
});
fireEvent.keyDown(display, { key: "Escape" });
expect(target?.getAttribute("aria-expanded")).toBe("false");
});
});

View file

@ -1,4 +1,5 @@
import { MarkViewContent, MarkViewProps } from "@tiptap/react";
import { getMarkRange } from "@tiptap/core";
import { useNavigate, useLocation, useParams } from "react-router-dom";
import {
IconFileDescription,
@ -8,7 +9,7 @@ import {
IconPencil,
IconWorld,
} from "@tabler/icons-react";
import { useState, useCallback, useRef, useEffect } from "react";
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import { notifications } from "@mantine/notifications";
import {
Divider,
@ -33,8 +34,20 @@ import {
buildSharedPageUrl,
} from "@/features/page/page.utils.ts";
import { extractPageSlugId } from "@/lib";
import { sanitizeUrl, copyToClipboard, isEditorReady } from "@docmost/editor-ext";
import {
matchIntegrationLink,
sanitizeUrl,
copyToClipboard,
isEditorReady,
} from "@docmost/editor-ext";
import { normalizeUrl } from "@/lib/utils";
import { useInstalledIntegrations } from "@/features/integration/queries/integration-query";
import {
convertIntegrationDisplay,
getIntegrationCardAvailability,
type IntegrationDisplaySource,
} from "@/features/editor/components/integration-link/integration-display";
import { IntegrationDisplayMenu } from "@/features/editor/components/integration-link/integration-display-picker";
const parseInternalLink = (
href: string,
@ -80,6 +93,31 @@ export default function LinkView(props: MarkViewProps) {
const wrapperRef = useRef<HTMLSpanElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const isEditable = editor.isEditable;
const integrationMatch = useMemo(() => matchIntegrationLink(href), [href]);
const markIntegrationProvider =
typeof mark.attrs.integrationProvider === "string"
? mark.attrs.integrationProvider
: null;
const { data: installedIntegrations } = useInstalledIntegrations(
Boolean(markIntegrationProvider || integrationMatch) && isEditable,
);
// Only hosts an installed provider unfurls get "Display as".
const hostIntegrationProvider = useMemo(() => {
if (!installedIntegrations) return null;
const unfurlHosts = Object.fromEntries(
installedIntegrations
.filter((integration) => integration.unfurlHosts)
.map((integration) => [integration.type, integration.unfurlHosts]),
);
return matchIntegrationLink(href, unfurlHosts)?.provider ?? null;
}, [href, installedIntegrations]);
const detectedIntegrationProvider =
markIntegrationProvider ?? hostIntegrationProvider;
const integrationProvider = installedIntegrations?.some(
(integration) => integration.type === detectedIntegrationProvider,
)
? detectedIntegrationProvider
: null;
const {
isInternal,
slugId,
@ -124,12 +162,36 @@ export default function LinkView(props: MarkViewProps) {
const getLinkPos = useCallback((): number | null => {
if (!wrapperRef.current) return null;
try {
return editor.view.posAtDOM(wrapperRef.current, 0);
const pos = editor.view.posAtDOM(wrapperRef.current, 0);
return pos >= 0 && pos <= editor.state.doc.content.size ? pos : null;
} catch {
return null;
}
}, [editor]);
const getIntegrationSource = useCallback((): Extract<
IntegrationDisplaySource,
{ kind: "link" }
> | null => {
if (!integrationProvider) return null;
const pos = getLinkPos();
if (pos === null) return null;
const range = getMarkRange(editor.state.doc.resolve(pos), mark.type, {
href,
});
if (!range) return null;
return {
kind: "link",
from: range.from,
to: range.to,
url: href,
provider: integrationProvider,
};
}, [editor, getLinkPos, href, integrationProvider, mark.type]);
const handleUpdateLinkTitle = useCallback(
(newTitle: string) => {
if (!newTitle) return;
@ -225,7 +287,9 @@ export default function LinkView(props: MarkViewProps) {
const target = e.target as Node;
if (
wrapperRef.current?.contains(target) ||
dropdownRef.current?.contains(target)
dropdownRef.current?.contains(target) ||
(target instanceof Element &&
target.closest("[data-integration-display-menu-dropdown]"))
) {
return;
}
@ -233,6 +297,12 @@ export default function LinkView(props: MarkViewProps) {
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
if (
e.target instanceof Element &&
e.target.closest("[data-integration-display-menu-dropdown]")
) {
return;
}
setPopoverState("closed");
}
};
@ -366,6 +436,22 @@ export default function LinkView(props: MarkViewProps) {
setPopoverState("closed");
}, [editor]);
const handleIntegrationDisplay = useCallback(
(display: "card" | "mention" | "url") => {
const source = getIntegrationSource();
setPopoverState("closed");
if (!source || display === "url") {
editor.commands.focus(undefined, { scrollIntoView: false });
return;
}
if (convertIntegrationDisplay(editor, source, display)) {
editor.commands.focus(undefined, { scrollIntoView: false });
}
},
[editor, getIntegrationSource],
);
const internalHref = () => {
if (isShareRoute && slugId) {
return buildSharedPageUrl({ shareId, pageSlugId: slugId, pageTitle });
@ -440,7 +526,13 @@ export default function LinkView(props: MarkViewProps) {
return (
<Popover
opened={isPopoverVisible}
width={activeView === "edit" ? 320 : undefined}
width={
activeView === "edit"
? "min(320px, calc(100vw - 24px))"
: integrationProvider
? "min(340px, calc(100vw - 24px))"
: undefined
}
position="bottom"
withArrow
shadow="md"
@ -451,6 +543,7 @@ export default function LinkView(props: MarkViewProps) {
<span
ref={wrapperRef}
className={classes.linkWrapper}
data-active={isPopoverVisible || undefined}
onClick={handleClick}
>
<a
@ -468,6 +561,11 @@ export default function LinkView(props: MarkViewProps) {
<Popover.Dropdown
ref={dropdownRef}
p={activeView === "edit" ? "sm" : 6}
className={
activeView === "preview" && integrationProvider
? classes.integrationPopover
: undefined
}
onMouseDown={(e) => e.stopPropagation()}
>
{activeView === "edit" ? (
@ -560,7 +658,7 @@ export default function LinkView(props: MarkViewProps) {
)}
</>
) : (
<Group gap={4} wrap="nowrap">
<Group gap={2} wrap="nowrap" className={classes.previewRow}>
<Group
component="a"
//@ts-ignore
@ -569,13 +667,7 @@ export default function LinkView(props: MarkViewProps) {
rel={isInternal ? undefined : "noopener noreferrer"}
gap={6}
wrap="nowrap"
style={{
cursor: "pointer",
maxWidth: 250,
textDecoration: "none",
color: "inherit",
userSelect: "none",
}}
className={classes.previewDestination}
onClick={(e: React.MouseEvent) => {
e.preventDefault();
handleNavigate();
@ -591,15 +683,43 @@ export default function LinkView(props: MarkViewProps) {
</Text>
</Group>
<Divider orientation="vertical" />
<Divider
orientation="vertical"
className={classes.previewDivider}
/>
{isPopoverVisible &&
integrationProvider &&
(() => {
const source = getIntegrationSource();
const cardAvailability = source
? getIntegrationCardAvailability(editor, source)
: null;
return (
<IntegrationDisplayMenu
current="url"
label={t("Display as")}
canUseCard={cardAvailability?.canUseCard ?? false}
onChange={handleIntegrationDisplay}
/>
);
})()}
<Tooltip label={t("Edit link")} withArrow withinPortal={false}>
<ActionIcon
variant="subtle"
color="gray"
size={32}
className={classes.previewAction}
aria-label={t("Edit link")}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowSearch(false);
setPopoverState("edit");
}}
@ -612,9 +732,16 @@ export default function LinkView(props: MarkViewProps) {
<ActionIcon
variant="subtle"
color="gray"
size={32}
className={classes.previewAction}
aria-label={t("Copy link")}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleCopy(e);
}}
>
@ -626,9 +753,16 @@ export default function LinkView(props: MarkViewProps) {
<ActionIcon
variant="subtle"
color="gray"
size={32}
className={classes.previewAction}
aria-label={t("Remove link")}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleRemoveLink();
}}
>

View file

@ -3,6 +3,69 @@
display: inline;
}
.linkWrapper[data-active="true"] > a {
border-radius: 4px;
outline: 2px solid
light-dark(var(--mantine-color-blue-4), var(--mantine-color-blue-6));
outline-offset: 2px;
}
.integrationPopover {
overflow: hidden;
border-color: light-dark(
var(--mantine-color-gray-3),
var(--mantine-color-dark-4)
);
border-radius: 10px;
box-shadow: 0 5px 18px light-dark(rgba(31, 35, 40, 0.12), rgba(0, 0, 0, 0.36));
}
.previewRow {
min-width: 0;
}
.previewDestination {
min-width: 0;
max-width: min(250px, calc(100vw - 170px));
flex: 1 1 auto;
cursor: pointer;
text-decoration: none;
color: inherit;
user-select: none;
}
.previewDivider {
height: 22px;
margin: 0 3px;
}
.previewAction {
--ai-size: 32px !important;
flex: 0 0 32px;
border-radius: 6px;
-webkit-tap-highlight-color: transparent;
}
.previewAction:focus-visible {
outline: 2px solid var(--mantine-color-blue-6);
outline-offset: -2px;
}
@media (pointer: coarse) {
.previewDivider {
height: 26px;
margin-inline: 4px;
}
.previewAction {
--ai-size: 44px !important;
flex-basis: 44px;
border-radius: 8px;
}
}
.linkInput {
border: 1.5px solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));

View file

@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import { render } from "@testing-library/react";
import LaserPointer from "./laser-pointer";
type PointerInit = MouseEventInit & { pointerType?: string };
function dispatchPointer(
target: EventTarget,
type: "pointermove" | "pointerout",
init: PointerInit = {},
) {
// jsdom has no PointerEvent
const event = new MouseEvent(type, { bubbles: true, ...init });
Object.defineProperty(event, "pointerType", {
value: init.pointerType ?? "mouse",
});
target.dispatchEvent(event);
}
function renderLaserPointer(enabled = true) {
const view = render(<LaserPointer enabled={enabled} />);
return { ...view, dot: view.container.firstElementChild as HTMLElement };
}
describe("LaserPointer", () => {
it("follows the pointer", () => {
const { dot } = renderLaserPointer();
dispatchPointer(window, "pointermove", { clientX: 120, clientY: 80 });
expect(dot.style.transform).toBe("translate(120px, 80px)");
expect(dot.style.visibility).toBe("visible");
});
it("stays hidden while disabled", () => {
const { dot } = renderLaserPointer(false);
dispatchPointer(window, "pointermove", { clientX: 120, clientY: 80 });
expect(dot.hidden).toBe(true);
});
it("shows at the last pointer position once enabled", () => {
const { dot, rerender } = renderLaserPointer(false);
dispatchPointer(window, "pointermove", { clientX: 120, clientY: 80 });
rerender(<LaserPointer enabled />);
expect(dot.hidden).toBe(false);
expect(dot.style.transform).toBe("translate(120px, 80px)");
});
it("hides for touch input", () => {
const { dot } = renderLaserPointer();
dispatchPointer(window, "pointermove", { clientX: 120, clientY: 80 });
dispatchPointer(window, "pointermove", { pointerType: "touch" });
expect(dot.style.visibility).toBe("hidden");
});
it("hides when the pointer leaves the window", () => {
const { dot, container } = renderLaserPointer();
dispatchPointer(window, "pointermove", { clientX: 120, clientY: 80 });
dispatchPointer(container, "pointerout", { relatedTarget: null });
expect(dot.style.visibility).toBe("hidden");
});
it("hides when the pointer moves over an embed", () => {
const { dot, container } = renderLaserPointer();
const embed = container.appendChild(document.createElement("iframe"));
dispatchPointer(window, "pointermove", { clientX: 120, clientY: 80 });
dispatchPointer(container, "pointerout", { relatedTarget: embed });
expect(dot.style.visibility).toBe("hidden");
});
it("stays visible when the pointer moves onto another page element", () => {
const { dot, container } = renderLaserPointer();
const paragraph = container.appendChild(document.createElement("p"));
dispatchPointer(window, "pointermove", { clientX: 120, clientY: 80 });
dispatchPointer(container, "pointerout", { relatedTarget: paragraph });
expect(dot.style.visibility).toBe("visible");
});
});

View file

@ -0,0 +1,36 @@
import { useRef } from "react";
import { useWindowEvent } from "@mantine/hooks";
import classes from "./presentation-modal.module.css";
type LaserPointerProps = {
enabled: boolean;
};
export default function LaserPointer({ enabled }: LaserPointerProps) {
const dotRef = useRef<HTMLDivElement>(null);
useWindowEvent("pointermove", (event) => {
const dot = dotRef.current;
if (!dot) return;
dot.style.transform = `translate(${event.clientX}px, ${event.clientY}px)`;
dot.style.visibility = event.pointerType === "touch" ? "hidden" : "visible";
});
useWindowEvent("pointerout", (event) => {
// the page gets no pointer events outside the window or over an embed
const leftPage =
!event.relatedTarget || event.relatedTarget instanceof HTMLIFrameElement;
if (leftPage && dotRef.current) {
dotRef.current.style.visibility = "hidden";
}
});
return (
<div
ref={dotRef}
className={classes.laserPointer}
hidden={!enabled}
aria-hidden="true"
/>
);
}

View file

@ -0,0 +1,159 @@
.content {
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
overflow: hidden;
}
.body {
position: relative;
height: 100%;
display: flex;
flex-direction: column;
padding: 0;
}
.closeButton {
position: absolute;
top: 16px;
right: 16px;
z-index: 10;
}
.stage {
position: relative;
flex: 1;
min-height: 0;
}
.slideArea,
.measurementArea {
position: absolute;
inset: 0;
display: flex;
justify-content: center;
align-items: flex-start;
padding: 6vh 5vw 10vh;
scrollbar-gutter: stable both-edges;
}
.slideArea {
overflow-y: auto;
overflow-x: hidden;
outline: none;
}
.scrollable {
zoom: 0.85;
}
.measurementArea {
overflow: hidden;
visibility: hidden;
pointer-events: none;
}
.editorContainer {
width: 100%;
max-width: 1440px;
margin-block: auto;
}
.stage :global(.ProseMirror) {
font-size: 1.3rem;
line-height: 1.6;
}
.stage :global(.ProseMirror) h1 {
font-size: 3rem;
}
.stage :global(.ProseMirror) h2 {
font-size: 2.25rem;
}
.stage :global(.ProseMirror) h3 {
font-size: 1.75rem;
}
.stage :global(.ProseMirror .comment-mark) {
background: none;
border-bottom: none;
}
.titleSlide {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
max-width: 1440px;
margin-block: auto;
text-align: center;
}
.titleSlideText {
font-size: 3.5rem;
font-weight: 700;
overflow-wrap: anywhere;
}
.controls {
display: flex;
justify-content: center;
padding: 8px 0 24px;
}
.controlBar {
display: flex;
align-items: center;
gap: 4px;
padding: 6px;
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
border-radius: 999px;
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
box-shadow: var(--mantine-shadow-md);
}
.controlButton:disabled {
background-color: transparent;
}
.slideCounter {
min-width: max(72px, calc((var(--total-digits, 1) * 2 + 3) * 1ch + 16px));
padding-inline: 8px;
text-align: center;
font-size: var(--mantine-font-size-md);
font-variant-numeric: tabular-nums;
user-select: none;
}
.slideTotal {
color: light-dark(
var(--mantine-color-dimmed),
color-mix(in srgb, var(--mantine-color-dark-1), var(--mantine-color-dark-2))
);
}
.controlBar .controlDivider {
align-self: center;
height: 24px;
margin-inline: 4px;
}
/* the dot replaces every cursor, including ones slide content sets */
.content:has(.laserPointer:not([hidden])),
.content:has(.laserPointer:not([hidden])) * {
cursor: none !important;
}
.laserPointer {
position: fixed;
top: -7px;
left: -7px;
z-index: 20;
width: 14px;
height: 14px;
border-radius: 50%;
background-color: color-mix(in srgb, var(--mantine-color-blue-4) 65%, transparent);
box-shadow: 0 0 10px 4px color-mix(in srgb, var(--mantine-color-blue-4) 30%, transparent);
pointer-events: none;
visibility: hidden;
}

View file

@ -0,0 +1,546 @@
import "@/features/editor/styles/index.css";
import {
type CSSProperties,
type MouseEvent as ReactMouseEvent,
type RefObject,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import {
ActionIcon,
Box,
Button,
Center,
Divider,
Modal,
Title,
Tooltip,
VisuallyHidden,
} from "@mantine/core";
import {
IconAlertTriangle,
IconChevronLeft,
IconChevronRight,
IconMaximize,
IconMinimize,
IconPointer,
IconX,
} from "@tabler/icons-react";
import { useFullscreenDocument, useWindowEvent } from "@mantine/hooks";
import {
type Content,
type Editor,
EditorProvider,
type JSONContent,
useCurrentEditor,
} from "@tiptap/react";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { UniqueID } from "@docmost/editor-ext";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
import { ErrorBoundary } from "react-error-boundary";
import clsx from "clsx";
import { EmptyState } from "@/components/ui/empty-state.tsx";
import { mainExtensions } from "@/features/editor/extensions/extensions";
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
import { extractPageSlugId } from "@/lib";
import LaserPointer from "./laser-pointer";
import {
findSlideIndexForNode,
getContentBoxHeight,
getDeckKeyAction,
measureBlocks,
paginateBlocks,
sameSlideLayouts,
setEditorPageId,
shouldReturnFocusToSlide,
type SlideLayout,
} from "./presentation.utils";
import classes from "./presentation-modal.module.css";
type PresentationModalProps = {
pageId: string;
title: string;
content: Content;
opened: boolean;
onClose: () => void;
};
type PresentationDeckProps = Omit<PresentationModalProps, "opened">;
type DeckState = {
slides: SlideLayout[] | null;
nodes: JSONContent[];
index: number;
};
type NodesCache = {
doc: ProseMirrorNode;
nodes: JSONContent[];
};
const excludedExtensionNames = new Set([
"uniqueID",
"trailingNode",
"tableHeaderPin",
"tableReadonlySort",
]);
const presentationExtensions = [
...mainExtensions.filter((ext) => !excludedExtensionNames.has(ext.name)),
UniqueID.configure({
types: ["heading", "paragraph"],
updateDocument: false,
}),
];
export default function PresentationModal({
pageId,
title,
content,
opened,
onClose,
}: PresentationModalProps) {
const { t } = useTranslation();
const { pageSlug } = useParams();
const pageSlugId = extractPageSlugId(pageSlug);
useEffect(() => {
if (opened) {
onClose();
}
}, [pageSlugId]);
useEffect(() => {
if (!opened) return;
// keeps focus, clicks and find-in-page out of the live page behind the deck
const root = document.getElementById("root");
root?.setAttribute("inert", "");
return () => root?.removeAttribute("inert");
}, [opened]);
return (
<Modal.Root
opened={opened}
onClose={onClose}
fullScreen
closeOnEscape
transitionProps={{ duration: 150 }}
>
<Modal.Overlay />
<Modal.Content
className={classes.content}
aria-label={t("Presentation mode")}
>
<Modal.Body className={classes.body}>
<ErrorBoundary
fallback={
<Center h="100%">
<EmptyState
icon={IconAlertTriangle}
title={t("Failed to load presentation. An error occurred.")}
action={
<Button
variant="default"
size="sm"
mt="xs"
onClick={onClose}
>
{t("Close")}
</Button>
}
/>
</Center>
}
>
<PresentationDeck
pageId={pageId}
title={title}
content={content}
onClose={onClose}
/>
</ErrorBoundary>
</Modal.Body>
</Modal.Content>
</Modal.Root>
);
}
function PresentationDeck({
pageId,
title,
content,
onClose,
}: PresentationDeckProps) {
const { t } = useTranslation();
const [deck, setDeck] = useState<DeckState>({
slides: null,
nodes: [],
index: 0,
});
const stageRef = useRef<HTMLDivElement>(null);
const measurementAreaRef = useRef<HTMLDivElement>(null);
const slideAreaRef = useRef<HTMLDivElement>(null);
const lastLayoutRef = useRef<SlideLayout[] | null>(null);
const nodesCacheRef = useRef<NodesCache | null>(null);
const { toggle: toggleFullscreen, fullscreen } = useFullscreenDocument();
const canFullscreen = document.fullscreenEnabled;
const [laserPointerEnabled, setLaserPointerEnabled] = useState(false);
useEffect(
() => () => {
// closing the presentation must not leave the page in fullscreen
if (document.fullscreenElement) {
document.exitFullscreen().catch(() => undefined);
}
},
[],
);
const handleToggleFullscreen = () => {
toggleFullscreen().catch(() => undefined);
};
const slideCount = (deck.slides?.length ?? 0) + 1;
const contentSlide =
deck.index > 0 ? deck.slides?.[deck.index - 1] : undefined;
const slideKey = contentSlide ? contentSlide.nodeIndexes.join("-") : "title";
const slideDoc = useMemo<JSONContent | null>(
() =>
contentSlide
? {
type: "doc",
content: contentSlide.nodeIndexes.map((index) => deck.nodes[index]),
}
: null,
[contentSlide, deck.nodes],
);
const paginate = useCallback((editor: Editor) => {
const area = measurementAreaRef.current;
if (!area || editor.isDestroyed) return;
const slides = paginateBlocks(
measureBlocks(editor),
getContentBoxHeight(area),
);
if (sameSlideLayouts(lastLayoutRef.current, slides)) return;
lastLayoutRef.current = slides;
const doc = editor.state.doc;
if (nodesCacheRef.current?.doc !== doc) {
nodesCacheRef.current = { doc, nodes: doc.toJSON().content ?? [] };
}
const { nodes } = nodesCacheRef.current;
setDeck((current) => {
const anchor = current.slides?.[current.index - 1]?.nodeIndexes[0];
const index =
anchor === undefined ? 0 : findSlideIndexForNode(slides, anchor) + 1;
return { slides, nodes, index };
});
}, []);
const showSlide = (pick: (index: number, lastIndex: number) => number) => {
setDeck((current) => {
const lastIndex = current.slides?.length ?? 0;
const index = Math.min(
Math.max(pick(current.index, lastIndex), 0),
lastIndex,
);
return index === current.index ? current : { ...current, index };
});
};
const goToNext = () => showSlide((index) => index + 1);
const goToPrevious = () => showSlide((index) => index - 1);
const scrollSlide = (direction: 1 | -1, page: boolean) => {
const area = slideAreaRef.current;
if (!area) return false;
const previousTop = area.scrollTop;
// scrollBy is instant here, so scrollTop has already moved when it returns
area.scrollBy({ top: direction * area.clientHeight * (page ? 0.9 : 0.15) });
return area.scrollTop !== previousTop;
};
useWindowEvent("keydown", (event) => {
const action = getDeckKeyAction(event, stageRef.current);
if (!action) return;
event.preventDefault();
switch (action) {
case "next":
goToNext();
break;
case "previous":
goToPrevious();
break;
case "first":
showSlide(() => 0);
break;
case "last":
showSlide((_, lastIndex) => lastIndex);
break;
case "lineDown":
if (!scrollSlide(1, false)) goToNext();
break;
case "lineUp":
if (!scrollSlide(-1, false)) goToPrevious();
break;
case "pageDown":
if (!scrollSlide(1, true)) goToNext();
break;
case "pageUp":
if (!scrollSlide(-1, true)) goToPrevious();
break;
}
});
useLayoutEffect(() => {
const area = slideAreaRef.current;
area?.scrollTo({ top: 0 });
const active = document.activeElement;
if (
!active ||
active === document.body ||
(active as HTMLButtonElement).disabled
) {
area?.focus({ preventScroll: true });
}
}, [slideKey]);
useEffect(() => {
const stage = stageRef.current;
if (!stage) return;
// comment marks dispatch this to open the page's comment panel behind the modal
const stopCommentActivation = (event: Event) => event.stopPropagation();
stage.addEventListener("ACTIVE_COMMENT_EVENT", stopCommentActivation);
return () =>
stage.removeEventListener("ACTIVE_COMMENT_EVENT", stopCommentActivation);
}, []);
const returnFocusAfterClick = (event: ReactMouseEvent) => {
const area = slideAreaRef.current;
// detail is 0 when a key activated the control, which should keep focus
if (!area || event.detail === 0) return;
if (shouldReturnFocusToSlide(document.activeElement, area)) {
area.focus({ preventScroll: true });
}
};
const handleSlideMouseOut = (event: ReactMouseEvent) => {
// keys typed while an embed's iframe has focus never reach the deck
if (
event.target instanceof HTMLIFrameElement &&
document.activeElement === event.target
) {
slideAreaRef.current?.focus({ preventScroll: true });
}
};
return (
<TransclusionLookupProvider>
<Tooltip label={t("Exit presentation mode")} openDelay={250}>
<ActionIcon
className={classes.closeButton}
variant="subtle"
color="gray"
size="lg"
aria-label={t("Exit presentation mode")}
onClick={onClose}
>
<IconX size={22} />
</ActionIcon>
</Tooltip>
<div ref={stageRef} className={classes.stage}>
<div
ref={measurementAreaRef}
className={classes.measurementArea}
aria-hidden="true"
>
<EditorProvider
editorContainerProps={{ className: classes.editorContainer }}
editable={false}
immediatelyRender={true}
textDirection="auto"
extensions={presentationExtensions}
content={content}
onBeforeCreate={({ editor }) => setEditorPageId(editor, pageId)}
>
<MeasurementObserver
areaRef={measurementAreaRef}
onMeasure={paginate}
/>
</EditorProvider>
</div>
<Box
ref={slideAreaRef}
className={clsx(
classes.slideArea,
contentSlide?.scrollable && classes.scrollable,
)}
tabIndex={-1}
data-autofocus
onClick={returnFocusAfterClick}
onMouseOut={handleSlideMouseOut}
>
{slideDoc ? (
<EditorProvider
key={slideKey}
editorContainerProps={{ className: classes.editorContainer }}
editable={false}
immediatelyRender={true}
textDirection="auto"
extensions={presentationExtensions}
content={slideDoc}
editorProps={{
attributes: {
role: "document",
"aria-label": t("Page content"),
},
}}
onBeforeCreate={({ editor }) => setEditorPageId(editor, pageId)}
/>
) : (
<div className={classes.titleSlide}>
<Title order={1} className={classes.titleSlideText} dir="auto">
{title || t("Untitled")}
</Title>
</div>
)}
</Box>
</div>
<div className={classes.controls}>
<div className={classes.controlBar}>
<ActionIcon
className={classes.controlButton}
variant="subtle"
color="dark"
size="lg"
radius="xl"
aria-label={t("Previous slide")}
disabled={deck.index === 0}
onClick={goToPrevious}
>
<IconChevronLeft size={20} />
</ActionIcon>
<span
className={classes.slideCounter}
style={
{
"--total-digits": String(slideCount).length,
} as CSSProperties
}
aria-hidden="true"
>
<span>{deck.index + 1}</span>
<span className={classes.slideTotal}> / {slideCount}</span>
</span>
<ActionIcon
className={classes.controlButton}
variant="subtle"
color="dark"
size="lg"
radius="xl"
aria-label={t("Next slide")}
disabled={deck.index === slideCount - 1}
onClick={goToNext}
>
<IconChevronRight size={20} />
</ActionIcon>
<Divider
orientation="vertical"
aria-orientation="vertical"
className={classes.controlDivider}
/>
<Tooltip label={t("Laser pointer")} openDelay={250}>
<ActionIcon
className={classes.controlButton}
variant="subtle"
color={laserPointerEnabled ? "blue" : "dark"}
size="lg"
radius="xl"
aria-label={t("Laser pointer")}
aria-pressed={laserPointerEnabled}
onClick={() => setLaserPointerEnabled((enabled) => !enabled)}
>
<IconPointer size={20} />
</ActionIcon>
</Tooltip>
{canFullscreen && (
<Tooltip
label={fullscreen ? t("Exit fullscreen") : t("Enter fullscreen")}
openDelay={250}
>
<ActionIcon
className={classes.controlButton}
variant="subtle"
color="dark"
size="lg"
radius="xl"
aria-label={
fullscreen ? t("Exit fullscreen") : t("Enter fullscreen")
}
onClick={handleToggleFullscreen}
>
{fullscreen ? (
<IconMinimize size={20} />
) : (
<IconMaximize size={20} />
)}
</ActionIcon>
</Tooltip>
)}
</div>
<VisuallyHidden role="status">
{t("Slide {{current}} of {{total}}", {
current: deck.index + 1,
total: slideCount,
})}
</VisuallyHidden>
</div>
<LaserPointer enabled={laserPointerEnabled} />
</TransclusionLookupProvider>
);
}
function MeasurementObserver({
areaRef,
onMeasure,
}: {
areaRef: RefObject<HTMLDivElement | null>;
onMeasure: (editor: Editor) => void;
}) {
const { editor } = useCurrentEditor();
useEffect(() => {
const area = areaRef.current;
if (!editor || !area) return;
const observer = new ResizeObserver(() => onMeasure(editor));
observer.observe(area);
observer.observe(editor.view.dom);
return () => observer.disconnect();
}, [editor, areaRef, onMeasure]);
return null;
}

View file

@ -0,0 +1,423 @@
import { describe, expect, it } from "vitest";
import { Schema } from "@tiptap/pm/model";
import {
findSlideIndexForNode,
getContentBoxHeight,
getDeckKeyAction,
isBlankBlock,
type MeasuredBlock,
paginateBlocks,
sameSlideLayouts,
shouldReturnFocusToSlide,
} from "./presentation.utils";
const schema = new Schema({
nodes: {
doc: { content: "block+" },
paragraph: { group: "block", content: "inline*", toDOM: () => ["p", 0] },
image: { group: "block", atom: true, toDOM: () => ["img"] },
text: { group: "inline" },
hardBreak: { group: "inline", inline: true, toDOM: () => ["br"] },
mention: { group: "inline", inline: true, atom: true, toDOM: () => ["span"] },
},
});
let nextIndex = 0;
function block(height: number, overrides: Partial<MeasuredBlock> = {}): MeasuredBlock {
const index = overrides.index ?? nextIndex++;
const top = overrides.top ?? index * 1000;
return {
index,
type: "paragraph",
blank: false,
top,
bottom: top + height,
marginTop: 0,
marginBottom: 0,
...overrides,
};
}
function stacked(heights: number[], gap = 0, type = "paragraph"): MeasuredBlock[] {
let top = 0;
return heights.map((height, index) => {
const result = block(height, { index, top, type });
top += height + gap;
return result;
});
}
type BlockSpec = [type: string, height: number, headingLevel?: number];
function sequence(...specs: BlockSpec[]): MeasuredBlock[] {
return stacked(specs.map(([, height]) => height)).map((measured, index) => ({
...measured,
type: specs[index][0],
headingLevel: specs[index][2],
}));
}
const indexes = (slides: { nodeIndexes: number[] }[]) =>
slides.map((slide) => slide.nodeIndexes);
describe("isBlankBlock", () => {
const paragraph = (...content: ReturnType<typeof schema.text>[]) =>
schema.nodes.paragraph.create(null, content);
it("treats empty, whitespace, nbsp and hardBreak-only paragraphs as blank", () => {
expect(isBlankBlock(paragraph())).toBe(true);
expect(isBlankBlock(paragraph(schema.text(" ")))).toBe(true);
expect(isBlankBlock(paragraph(schema.text("\u00a0")))).toBe(true);
expect(isBlankBlock(paragraph(schema.nodes.hardBreak.create()))).toBe(true);
});
it("keeps paragraphs with text or inline atoms", () => {
expect(isBlankBlock(paragraph(schema.text("Hi")))).toBe(false);
expect(isBlankBlock(paragraph(schema.nodes.mention.create()))).toBe(false);
});
it("never treats non-textblocks as blank", () => {
expect(isBlankBlock(schema.nodes.image.create())).toBe(false);
});
});
describe("getContentBoxHeight", () => {
const area = (clientHeight: number, padding: { top?: string; bottom?: string } = {}) => {
const element = document.createElement("div");
element.style.paddingTop = padding.top ?? "";
element.style.paddingBottom = padding.bottom ?? "";
Object.defineProperty(element, "clientHeight", { value: clientHeight });
document.body.appendChild(element);
return element;
};
it("subtracts the vertical padding from the client height", () => {
expect(getContentBoxHeight(area(600, { top: "20px", bottom: "10px" }))).toBe(570);
});
it("treats padding that does not resolve to a number as zero", () => {
expect(getContentBoxHeight(area(600))).toBe(600);
});
});
describe("paginateBlocks", () => {
it("keeps blocks that fit on one slide", () => {
const slides = paginateBlocks(stacked([100, 100, 100], 10), 500);
expect(indexes(slides)).toEqual([[0, 1, 2]]);
expect(slides[0].scrollable).toBe(false);
});
it("starts a new slide when the next block would overflow", () => {
expect(indexes(paginateBlocks(stacked([300, 300, 300], 0), 650))).toEqual([[0, 1], [2]]);
});
it("counts the first block's top margin and the last block's bottom margin", () => {
const blocks = stacked([300, 300], 0).map((b) => ({ ...b, marginTop: 30, marginBottom: 30 }));
expect(indexes(paginateBlocks(blocks, 640))).toEqual([[0], [1]]);
expect(indexes(paginateBlocks(blocks, 660))).toEqual([[0, 1]]);
});
it("counts the gap between adjacent blocks", () => {
const blocks = stacked([300, 300, 300], 20);
expect(indexes(paginateBlocks(blocks, 930))).toEqual([[0, 1], [2]]);
expect(indexes(paginateBlocks(blocks, 940))).toEqual([[0, 1, 2]]);
});
it("uses the larger margin as the gap across a skipped blank block", () => {
for (const [marginBottom, marginTop] of [[10, 30], [30, 10]]) {
const blocks = [
block(200, { index: 0, top: 0, marginBottom }),
block(400, { index: 1, top: 210, blank: true }),
block(200, { index: 2, top: 640, marginTop }),
];
expect(indexes(paginateBlocks(blocks, 425))).toEqual([[0], [2]]);
expect(indexes(paginateBlocks(blocks, 435))).toEqual([[0, 2]]);
}
});
it("splits on dividers and drops them", () => {
const blocks = stacked([100, 10, 100], 0);
blocks[1].type = "horizontalRule";
expect(indexes(paginateBlocks(blocks, 1000))).toEqual([[0], [2]]);
});
it("starts a new slide at every H1", () => {
const blocks = stacked([50, 50, 50], 0);
blocks[1] = { ...blocks[1], type: "heading", headingLevel: 1 };
blocks[2] = { ...blocks[2], type: "heading", headingLevel: 2 };
expect(indexes(paginateBlocks(blocks, 1000))).toEqual([[0], [1, 2]]);
});
it("ends the slide after a table or base", () => {
const blocks = stacked([50, 50, 50, 50], 0);
blocks[1].type = "table";
blocks[2].type = "base";
expect(indexes(paginateBlocks(blocks, 1000))).toEqual([[0, 1], [2], [3]]);
});
it("skips blank blocks and never emits an empty slide", () => {
const blocks = stacked([10, 50, 50, 10], 0);
blocks[0].type = "horizontalRule";
blocks[1].blank = true;
blocks[2] = { ...blocks[2], type: "heading", headingLevel: 1 };
blocks[3].type = "horizontalRule";
expect(indexes(paginateBlocks(blocks, 1000))).toEqual([[2]]);
});
it("does not count the height of skipped blank blocks", () => {
const blocks = stacked([200, 400, 200], 0);
blocks[1].blank = true;
expect(indexes(paginateBlocks(blocks, 450))).toEqual([[0, 2]]);
});
describe("zero-height blocks", () => {
const divider: BlockSpec = ["horizontalRule", 10];
const pageBreak: BlockSpec = ["pageBreak", 0];
const heading: BlockSpec = ["heading", 50, 1];
const paragraph: BlockSpec = ["paragraph", 50];
const table: BlockSpec = ["table", 50];
const cases: [string, BlockSpec[], number[][]][] = [
["divider, page break, H1", [divider, pageBreak, heading], [[2]]],
["divider, page break, H1, paragraph", [divider, pageBreak, heading, paragraph], [[2, 3]]],
["table, page break, H1, paragraph", [table, pageBreak, heading, paragraph], [[0], [2, 3]]],
["page break, H1, paragraph", [pageBreak, heading, paragraph], [[1, 2]]],
["paragraph, table, page break", [paragraph, table, pageBreak], [[0, 1]]],
];
it.each(cases)("never gives a page break a slide of its own: %s", (_label, specs, expected) => {
expect(indexes(paginateBlocks(sequence(...specs), 1000))).toEqual(expected);
});
it("still splits on a zero-height divider", () => {
const slides = paginateBlocks(sequence(paragraph, ["horizontalRule", 0], paragraph), 1000);
expect(indexes(slides)).toEqual([[0], [2]]);
});
});
it("marks a block taller than the area as a scrollable slide of its own", () => {
const slides = paginateBlocks(stacked([100, 900, 100], 0), 500);
expect(indexes(slides)).toEqual([[0], [1], [2]]);
expect(slides.map((slide) => slide.scrollable)).toEqual([false, true, false]);
});
it("returns no slides for an empty or blank-only document", () => {
expect(paginateBlocks([], 500)).toEqual([]);
expect(paginateBlocks(stacked([0, 0]).map((b) => ({ ...b, blank: true })), 500)).toEqual([]);
});
});
describe("findSlideIndexForNode", () => {
const slides = [
{ nodeIndexes: [0, 1], scrollable: false },
{ nodeIndexes: [3, 4], scrollable: false },
{ nodeIndexes: [6], scrollable: false },
];
it("finds the slide containing the node", () => {
expect(findSlideIndexForNode(slides, 4)).toBe(1);
});
it("falls forward to the next slide for a skipped node", () => {
expect(findSlideIndexForNode(slides, 2)).toBe(1);
});
it("clamps to the last slide", () => {
expect(findSlideIndexForNode(slides, 9)).toBe(2);
});
it("returns -1 when there are no slides", () => {
expect(findSlideIndexForNode([], 3)).toBe(-1);
});
});
describe("sameSlideLayouts", () => {
it("compares node indexes and scrollable flags", () => {
const a = [{ nodeIndexes: [0, 1], scrollable: false }];
expect(sameSlideLayouts(a, [{ nodeIndexes: [0, 1], scrollable: false }])).toBe(true);
expect(sameSlideLayouts(a, [{ nodeIndexes: [0, 1], scrollable: true }])).toBe(false);
expect(sameSlideLayouts(a, [{ nodeIndexes: [0], scrollable: false }])).toBe(false);
expect(sameSlideLayouts(null, a)).toBe(false);
});
});
function keyEvent(key: string, target: Element = document.body, extra: Partial<KeyboardEvent> = {}) {
return { key, target, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false, defaultPrevented: false, ...extra };
}
function element(html: string, selector: string): Element {
const host = document.createElement("div");
host.innerHTML = html;
document.body.appendChild(host);
return host.querySelector(selector);
}
describe("getDeckKeyAction", () => {
it("maps navigation keys", () => {
expect(getDeckKeyAction(keyEvent("ArrowRight"), null)).toBe("next");
expect(getDeckKeyAction(keyEvent("Enter"), null)).toBe("next");
expect(getDeckKeyAction(keyEvent("ArrowLeft"), null)).toBe("previous");
expect(getDeckKeyAction(keyEvent("ArrowDown"), null)).toBe("lineDown");
expect(getDeckKeyAction(keyEvent("ArrowUp"), null)).toBe("lineUp");
expect(getDeckKeyAction(keyEvent("PageDown"), null)).toBe("pageDown");
expect(getDeckKeyAction(keyEvent(" "), null)).toBe("pageDown");
expect(getDeckKeyAction(keyEvent("PageUp"), null)).toBe("pageUp");
expect(getDeckKeyAction(keyEvent("Home"), null)).toBe("first");
expect(getDeckKeyAction(keyEvent("End"), null)).toBe("last");
expect(getDeckKeyAction(keyEvent("Escape"), null)).toBeNull();
});
it("ignores modified and already handled keys", () => {
expect(getDeckKeyAction(keyEvent("ArrowRight", document.body, { metaKey: true }), null)).toBeNull();
expect(getDeckKeyAction(keyEvent("f", document.body, { ctrlKey: true }), null)).toBeNull();
expect(getDeckKeyAction(keyEvent(" ", document.body, { shiftKey: true }), null)).toBeNull();
expect(getDeckKeyAction(keyEvent("ArrowRight", document.body, { defaultPrevented: true }), null)).toBeNull();
});
it("leaves typing targets alone", () => {
expect(getDeckKeyAction(keyEvent("ArrowRight", element("<input />", "input")), null)).toBeNull();
expect(getDeckKeyAction(keyEvent(" ", element('<div contenteditable="true">x</div>', "div")), null)).toBeNull();
});
it("lets Space and Enter activate focused buttons", () => {
const button = element("<button>Prev</button>", "button");
expect(getDeckKeyAction(keyEvent(" ", button), null)).toBeNull();
expect(getDeckKeyAction(keyEvent("Enter", button), null)).toBeNull();
expect(getDeckKeyAction(keyEvent("ArrowRight", button), null)).toBe("next");
});
it("lets Enter activate a focused link while Space still moves the slide", () => {
const link = element('<a href="/x">x</a>', "a");
expect(getDeckKeyAction(keyEvent("Enter", link), null)).toBeNull();
expect(getDeckKeyAction(keyEvent(" ", link), null)).toBe("pageDown");
});
it("keeps Enter reserved for every interactive target", () => {
const cases: [string, string][] = [
['<div role="link" tabindex="0">Docs</div>', "div"],
["<label>Name</label>", "label"],
['<div class="node-base"><div>cell</div></div>', ".node-base > div"],
];
for (const [html, selector] of cases) {
expect(getDeckKeyAction(keyEvent("Enter", element(html, selector)), null)).toBeNull();
}
});
it("exempts Space only for targets that Space activates", () => {
const activated: [string, string][] = [
["<details><summary>More</summary></details>", "summary"],
['<div role="button" tabindex="0">Copy</div>', "div"],
['<div role="tablist"><button role="tab">A</button></div>', '[role="tab"]'],
['<div role="checkbox" tabindex="0">Done</div>', "div"],
['<div role="menuitem" tabindex="0">Rename</div>', "div"],
["<video controls></video>", "video"],
["<audio controls></audio>", "audio"],
];
for (const [html, selector] of activated) {
expect(getDeckKeyAction(keyEvent(" ", element(html, selector)), null)).toBeNull();
}
const notActivated: [string, string][] = [
['<div role="link" tabindex="0">Docs</div>', "div"],
["<label>Name</label>", "label"],
];
for (const [html, selector] of notActivated) {
expect(getDeckKeyAction(keyEvent(" ", element(html, selector)), null)).toBe("pageDown");
}
});
it("leaves horizontal arrows to a focused tab list", () => {
const tab = element('<div role="tablist"><button role="tab">A</button></div>', '[role="tab"]');
expect(getDeckKeyAction(keyEvent("ArrowRight", tab), null)).toBeNull();
expect(getDeckKeyAction(keyEvent("PageDown", tab), null)).toBe("pageDown");
});
describe("overlays opened from slide content", () => {
const deckElement = () => element("<div></div>", "div");
it("ignores keys inside a dialog or menu that does not hold the deck", () => {
const dialog = element('<div role="dialog"><span>Filter</span></div>', "span");
const menu = element('<div role="menu"><span>Sort</span></div>', "span");
expect(getDeckKeyAction(keyEvent("ArrowRight", dialog), deckElement())).toBeNull();
expect(getDeckKeyAction(keyEvent(" ", dialog), deckElement())).toBeNull();
expect(getDeckKeyAction(keyEvent("ArrowRight", menu), deckElement())).toBeNull();
});
it("still navigates from inside the dialog that holds the deck", () => {
const slide = element('<div role="dialog"><div class="stage"><span>Slide</span></div></div>', "span");
expect(getDeckKeyAction(keyEvent("ArrowRight", slide), slide.closest(".stage"))).toBe("next");
});
it("navigates from a target outside any overlay when the deck element is known", () => {
expect(getDeckKeyAction(keyEvent("ArrowRight"), deckElement())).toBe("next");
});
it("ignores keys inside an overlay nested in the deck's own dialog", () => {
const html =
'<div role="dialog"><div class="stage"></div><div role="dialog"><span>Filter</span></div></div>';
const nested = element(html, 'div[role="dialog"] div[role="dialog"] span');
const stage = nested.parentElement.parentElement.querySelector(".stage");
expect(getDeckKeyAction(keyEvent("ArrowRight", nested), stage)).toBeNull();
});
it("skips the overlay check without a deck element", () => {
const dialog = element('<div role="dialog"><span>Filter</span></div>', "span");
expect(getDeckKeyAction(keyEvent("ArrowRight", dialog), null)).toBe("next");
});
});
});
describe("shouldReturnFocusToSlide", () => {
const slide = (html: string, selector: string) => {
const host = document.createElement("div");
host.innerHTML = `<div class="slide">${html}</div>`;
document.body.appendChild(host);
return { area: host.firstElementChild, active: host.querySelector(selector) };
};
it("returns focus from a control clicked inside the slide", () => {
const cases: [string, string][] = [
["<button>Copy</button>", "button"],
['<div role="tablist"><button role="tab"><span>Tab 2</span></button></div>', "button"],
];
for (const [html, selector] of cases) {
const { area, active } = slide(html, selector);
expect(shouldReturnFocusToSlide(active, area)).toBe(true);
}
});
it("leaves text entry inside the slide alone", () => {
const cases: [string, string][] = [
["<input />", "input"],
["<textarea></textarea>", "textarea"],
['<div contenteditable="true"><span>x</span></div>', "span"],
];
for (const [html, selector] of cases) {
const { area, active } = slide(html, selector);
expect(shouldReturnFocusToSlide(active, area)).toBe(false);
}
});
it("leaves a focused video or audio control alone", () => {
const cases: [string, string][] = [
["<video controls></video>", "video"],
["<audio controls></audio>", "audio"],
];
for (const [html, selector] of cases) {
const { area, active } = slide(html, selector);
expect(shouldReturnFocusToSlide(active, area)).toBe(false);
}
});
it("leaves focus inside an embedded base alone", () => {
const { area, active } = slide('<div class="node-base"><button>Filter</button></div>', "button");
expect(shouldReturnFocusToSlide(active, area)).toBe(false);
});
it("ignores focus outside the slide, on the slide itself, or nowhere", () => {
const { area } = slide("<p>Text</p>", "p");
const outside = element("<button>Outside</button>", "button");
expect(shouldReturnFocusToSlide(outside, area)).toBe(false);
expect(shouldReturnFocusToSlide(area, area)).toBe(false);
expect(shouldReturnFocusToSlide(null, area)).toBe(false);
});
});

View file

@ -0,0 +1,270 @@
import type { Editor } from "@tiptap/react";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
export type MeasuredBlock = {
index: number;
type: string;
headingLevel?: number;
blank: boolean;
top: number;
bottom: number;
marginTop: number;
marginBottom: number;
};
export type SlideLayout = {
nodeIndexes: number[];
scrollable: boolean;
};
export type DeckKeyAction =
| "next"
| "previous"
| "first"
| "last"
| "lineDown"
| "lineUp"
| "pageDown"
| "pageUp";
type DeckKeyEvent = Pick<
KeyboardEvent,
| "key"
| "altKey"
| "ctrlKey"
| "metaKey"
| "shiftKey"
| "target"
| "defaultPrevented"
>;
const deckKeyActions: Record<string, DeckKeyAction> = {
ArrowRight: "next",
Enter: "next",
ArrowLeft: "previous",
Home: "first",
End: "last",
ArrowDown: "lineDown",
ArrowUp: "lineUp",
PageDown: "pageDown",
" ": "pageDown",
PageUp: "pageUp",
};
const interactiveSelector = [
"a[href]",
"button",
"input",
"select",
"textarea",
"label",
"summary",
"video",
"audio",
"iframe",
'[role="button"]',
'[role="tab"]',
'[role="link"]',
'[role="checkbox"]',
'[role="menuitem"]',
'[contenteditable="true"]',
".node-base",
].join(", ");
const spaceActivatedSelector = [
"button",
"summary",
'input[type="checkbox"]',
'input[type="radio"]',
"video",
"audio",
'[role="button"]',
'[role="tab"]',
'[role="checkbox"]',
'[role="menuitem"]',
].join(", ");
const textEntrySelector = 'input, textarea, select, [contenteditable="true"]';
const overlaySelector = '[role="dialog"], [role="menu"]';
const isDivider = (block: MeasuredBlock) => block.type === "horizontalRule";
const isSectionHeading = (block: MeasuredBlock) =>
block.type === "heading" && block.headingLevel === 1;
const endsSection = (block: MeasuredBlock) =>
block.type === "table" || block.type === "base";
function closestFrom(target: EventTarget | null, selector: string) {
return target instanceof Element ? target.closest(selector) : null;
}
function gapBetween(previous: MeasuredBlock, next: MeasuredBlock) {
if (next.index === previous.index + 1) return next.top - previous.bottom;
return Math.max(previous.marginBottom, next.marginTop);
}
export function isBlankBlock(node: ProseMirrorNode): boolean {
if (!node.isTextblock) return false;
let blank = true;
node.forEach((child) => {
if (child.isText ? child.text?.trim() : child.type.name !== "hardBreak") {
blank = false;
}
});
return blank;
}
export function measureBlocks(editor: Editor): MeasuredBlock[] {
const blocks: MeasuredBlock[] = [];
editor.state.doc.forEach((node, offset, index) => {
const dom = editor.view.nodeDOM(offset);
const block: MeasuredBlock = {
index,
type: node.type.name,
headingLevel: node.type.name === "heading" ? node.attrs.level : undefined,
blank: true,
top: 0,
bottom: 0,
marginTop: 0,
marginBottom: 0,
};
if (dom instanceof HTMLElement && dom.offsetParent !== null) {
const style = window.getComputedStyle(dom);
block.blank = isBlankBlock(node);
block.top = dom.offsetTop;
block.bottom = dom.offsetTop + dom.offsetHeight;
block.marginTop = parseFloat(style.marginTop) || 0;
block.marginBottom = parseFloat(style.marginBottom) || 0;
}
blocks.push(block);
});
return blocks;
}
export function getContentBoxHeight(element: HTMLElement): number {
const style = window.getComputedStyle(element);
return (
element.clientHeight -
(parseFloat(style.paddingTop) || 0) -
(parseFloat(style.paddingBottom) || 0)
);
}
export function paginateBlocks(
blocks: MeasuredBlock[],
availableHeight: number,
): SlideLayout[] {
const slides: SlideLayout[] = [];
let current: MeasuredBlock[] = [];
let currentHeight = 0;
const closeSlide = () => {
if (!current.length) return;
slides.push({
nodeIndexes: current.map((block) => block.index),
scrollable: currentHeight > availableHeight,
});
current = [];
currentHeight = 0;
};
for (const block of blocks) {
if (block.blank) continue;
if (isDivider(block)) {
closeSlide();
continue;
}
// page breaks lay out with zero height and must never form a slide
if (block.bottom <= block.top) continue;
const blockHeight = block.bottom - block.top;
const previous = current[current.length - 1];
const extendedHeight = previous
? currentHeight -
previous.marginBottom +
gapBetween(previous, block) +
blockHeight +
block.marginBottom
: 0;
if (previous && !isSectionHeading(block) && extendedHeight <= availableHeight) {
current.push(block);
currentHeight = extendedHeight;
} else {
closeSlide();
current.push(block);
currentHeight = block.marginTop + blockHeight + block.marginBottom;
}
if (endsSection(block)) closeSlide();
}
closeSlide();
return slides;
}
export function findSlideIndexForNode(
slides: SlideLayout[],
nodeIndex: number,
): number {
const position = slides.findIndex(
(slide) => slide.nodeIndexes[slide.nodeIndexes.length - 1] >= nodeIndex,
);
return position === -1 ? slides.length - 1 : position;
}
export function sameSlideLayouts(
previous: SlideLayout[] | null,
next: SlideLayout[],
): boolean {
return (
previous !== null &&
previous.length === next.length &&
previous.every(
(slide, index) =>
slide.scrollable === next[index].scrollable &&
slide.nodeIndexes.join() === next[index].nodeIndexes.join(),
)
);
}
export function getDeckKeyAction(
event: DeckKeyEvent,
deckElement: Element | null,
): DeckKeyAction | null {
if (event.defaultPrevented) return null;
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return null;
const action = deckKeyActions[event.key];
if (!action || closestFrom(event.target, textEntrySelector)) return null;
if (event.key === "Enter" && closestFrom(event.target, interactiveSelector)) return null;
if (event.key === " " && closestFrom(event.target, spaceActivatedSelector)) return null;
const movesBetweenTabs = event.key === "ArrowLeft" || event.key === "ArrowRight";
if (movesBetweenTabs && closestFrom(event.target, '[role="tablist"]')) return null;
const overlay = closestFrom(event.target, overlaySelector);
if (overlay && deckElement && !overlay.contains(deckElement)) return null;
return action;
}
export function shouldReturnFocusToSlide(
active: Element | null,
slideArea: Element,
): boolean {
if (!active || active === slideArea || !slideArea.contains(active)) return false;
return !active.closest(`${textEntrySelector}, video, audio, .node-base`);
}
export function setEditorPageId(editor: Editor, pageId: string): void {
(editor.storage as unknown as { pageId?: string }).pageId = pageId;
}

View file

@ -1,5 +1,9 @@
import { ActionIcon, Collapse } from "@mantine/core";
import { IconChevronRight, IconFileDescription } from "@tabler/icons-react";
import {
IconChevronRight,
IconFileDescription,
IconPointFilled,
} from "@tabler/icons-react";
import { useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
@ -86,7 +90,9 @@ export default function SubpageItem({
/>
</ActionIcon>
) : (
<span className={classes.togglePlaceholder} />
<span className={classes.leafDot} aria-hidden>
<IconPointFilled size={8} />
</span>
)}
<Link to={pageUrl} className={classes.link} draggable={false}>

View file

@ -100,9 +100,14 @@
flex: none;
}
.togglePlaceholder {
.leafDot {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: rem(22px);
height: rem(22px);
color: var(--subpages-icon);
}
.chevron {

View file

@ -57,6 +57,8 @@ import {
Indent,
UniqueID,
SharedStorage,
IntegrationCard,
IntegrationMention,
Columns,
Column,
Status,
@ -99,6 +101,9 @@ import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-v
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
import PdfView from "@/features/editor/components/pdf/pdf-view.tsx";
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
import IntegrationCardView from "@/features/editor/components/integration-link/integration-card-view.tsx";
import IntegrationMentionView from "@/features/editor/components/integration-link/integration-mention-view.tsx";
import { IntegrationPasteMenuExtension } from "@/features/editor/extensions/integration-paste-menu";
import TabsView from "@/features/editor/components/tabs/tabs-view.tsx";
import TransclusionView from "@/features/editor/components/transclusion/transclusion-view.tsx";
import TransclusionReferenceView from "@/features/editor/components/transclusion/transclusion-reference-view.tsx";
@ -396,6 +401,13 @@ export const mainExtensions = [
Subpages.configure({
view: SubpagesView,
}),
IntegrationCard.configure({
view: IntegrationCardView,
}),
IntegrationMention.configure({
view: IntegrationMentionView,
}),
IntegrationPasteMenuExtension,
Status.configure({
view: StatusView,
}),

View file

@ -0,0 +1,47 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
export type IntegrationPasteMenuState = {
pos: number;
joinBefore: boolean;
joinAfter: boolean;
} | null;
export const integrationPasteMenuKey = new PluginKey<IntegrationPasteMenuState>(
"integrationPasteMenu",
);
// Position of a just-pasted integration node, where the "Paste as" menu anchors,
// and which sides of it hold the halves of the paragraph the paste split.
export const IntegrationPasteMenuExtension = Extension.create({
name: "integrationPasteMenu",
addProseMirrorPlugins() {
return [
new Plugin({
key: integrationPasteMenuKey,
state: {
init: (): IntegrationPasteMenuState => null,
apply(tr, prev): IntegrationPasteMenuState {
const meta = tr.getMeta(integrationPasteMenuKey);
if (meta !== undefined) return meta;
if (!prev) return null;
// Clicking or typing elsewhere dismisses.
if (tr.selectionSet) return null;
// Follow-up transactions (unique ids, trailing node) keep the anchor.
if (tr.docChanged) {
const pos = tr.mapping.map(prev.pos);
const node = tr.doc.nodeAt(pos);
const isIntegrationNode =
node &&
(node.type.name === "integrationCard" ||
node.type.name === "integrationMention");
return isIntegrationNode ? { ...prev, pos } : null;
}
return prev;
},
},
}),
];
},
});

View file

@ -48,6 +48,7 @@ export interface FullEditorProps {
slugId: string;
title: string;
content: string;
spaceId: string;
spaceSlug: string;
editable: boolean;
creator?: PageUser;
@ -60,6 +61,7 @@ export function FullEditor({
title,
slugId,
content,
spaceId,
spaceSlug,
editable,
creator,
@ -67,24 +69,24 @@ export function FullEditor({
canComment,
}: FullEditorProps) {
const [user] = useAtom(userAtom);
const fullPageWidth = user.settings?.preferences?.fullPageWidth;
const fullPageWidth = user?.settings?.preferences?.fullPageWidth;
const editorToolbarEnabled =
user.settings?.preferences?.editorToolbar ?? false;
user?.settings?.preferences?.editorToolbar ?? false;
const [currentPageEditMode, setCurrentPageEditMode] = useAtom(
currentPageEditModeAtom,
);
const userPageEditMode =
user.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const isEditMode = currentPageEditMode === PageEditMode.Edit;
// Apply the user's saved preference only once on initial load, not on every
// page navigation — so the mode sticks across navigations within a session.
useEffect(() => {
if (!defaultEditModeApplied) {
if (user && !defaultEditModeApplied) {
setCurrentPageEditMode(userPageEditMode as PageEditMode);
defaultEditModeApplied = true;
}
}, [userPageEditMode, setCurrentPageEditMode]);
}, [user, userPageEditMode, setCurrentPageEditMode]);
return (
<Container
@ -115,7 +117,11 @@ export function FullEditor({
content={content}
canComment={canComment}
/>
<EmptyPageGetStarted pageId={pageId} editable={editable} />
<EmptyPageGetStarted
pageId={pageId}
spaceId={spaceId}
editable={editable}
/>
</Container>
);
}

View file

@ -82,6 +82,8 @@ import { useEditorScroll } from "./hooks/use-editor-scroll";
import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu";
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
import { IntegrationPasteMenu } from "@/features/editor/components/integration-link/integration-paste-menu.tsx";
import { getInstalledIntegrations } from "@/features/integration/services/integration-service";
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
import { useTranslation } from "react-i18next";
import {
@ -119,6 +121,31 @@ export default function PageEditor({
const handleStateless = ({ payload }: onStatelessParameters) => {
try {
const message = JSON.parse(payload);
if (message?.type === "page.permissions") {
// applied locally, a refetch can still read the old access from the server cache
for (const key of [slugId, pageId]) {
queryClient.setQueryData<IPage>(
["pages", key],
(page) =>
page && {
...page,
permissions: { ...page.permissions, canEdit: false },
},
);
}
if (message.access === "none") {
queryClient
.getQueryCache()
.find({ queryKey: ["pages", slugId], exact: true })
?.setState({
status: "error",
fetchStatus: "idle",
data: undefined,
error: Object.assign(new Error("Forbidden"), { status: 403 }),
});
}
return;
}
if (message?.type !== "page.updated" || !message.updatedAt) return;
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
if (pageData) {
@ -363,6 +390,15 @@ function CollabPageEditor({
[pageId, editable, extensions],
);
useEffect(() => {
// Read by the paste handler with no observer, so it must not be garbage collected.
queryClient.prefetchQuery({
queryKey: ["installed-integrations"],
queryFn: getInstalledIntegrations,
gcTime: Infinity,
});
}, []);
useLayoutEffect(() => {
if (editor && !editor.isDestroyed) {
// @ts-ignore
@ -490,6 +526,7 @@ function CollabPageEditor({
<ExcalidrawMenu editor={editor} />
<DrawioMenu editor={editor} />
<ColumnsMenu editor={editor} />
<IntegrationPasteMenu editor={editor} />
</div>
)}
{editor && !editorIsEditable && (editable || canComment) && (

View file

@ -149,7 +149,8 @@ export function TitleEditor({
},
};
if (page.title !== titleEditor.getText()) return;
if (!titleEditor.isDestroyed && page.title !== titleEditor.getText())
return;
updatePageData(page);

View file

@ -0,0 +1,91 @@
import { Group, Text, Button, Box, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { IntegrationDefinition, UserConnection } from "../types/integration.types";
import { getIntegrationIcon } from "./integration-icons";
type ConnectionRowProps = {
definition: IntegrationDefinition;
connection?: UserConnection;
onConnect: (type: string) => void;
onDisconnect: (integrationId: string) => void;
disconnectingId?: string;
};
export default function ConnectionRow({
definition,
connection,
onConnect,
onDisconnect,
disconnectingId,
}: ConnectionRowProps) {
const { t } = useTranslation();
const connectedLabel =
connection?.providerDisplayName || connection?.providerUserId;
return (
<Box
py="sm"
px="xs"
style={{
borderBottom: "1px solid var(--mantine-color-default-border)",
}}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
{getIntegrationIcon(definition.type, 28)}
<Stack gap={2} style={{ minWidth: 0 }}>
<Text size="sm" fw={500}>
{definition.name}
</Text>
{connection?.invalidatedAt ? (
<Text size="xs" c="orange">
{t("Connection expired")}
</Text>
) : connection && connectedLabel ? (
<Text size="xs" c="dimmed" truncate>
{t("Connected as {{label}}", { label: connectedLabel })}
</Text>
) : (
<Text size="xs" c="dimmed" truncate>
{definition.description}
</Text>
)}
</Stack>
</Group>
<Group gap="sm" wrap="nowrap" style={{ flexShrink: 0 }}>
{connection ? (
<>
{connection.invalidatedAt && (
<Button
size="xs"
variant="light"
color="orange"
onClick={() => onConnect(definition.type)}
>
{t("Reconnect")}
</Button>
)}
<Button
size="xs"
variant="default"
onClick={() => onDisconnect(connection.integrationId)}
loading={disconnectingId === connection.integrationId}
>
{t("Disconnect")}
</Button>
</>
) : (
<Button
size="xs"
variant="light"
onClick={() => onConnect(definition.type)}
>
{t("Connect")}
</Button>
)}
</Group>
</Group>
</Box>
);
}

View file

@ -0,0 +1,34 @@
import { ReactNode } from "react";
import {
AzureDevOpsIcon,
FigmaIcon,
GithubIcon,
GitlabIcon,
GoogleDocsIcon,
JiraIcon,
LinearIcon,
SlackIcon,
} from "@/components/icons";
import { IconPuzzle } from "@tabler/icons-react";
const integrationIconMap: Record<string, (size: number) => ReactNode> = {
github: (size) => <GithubIcon size={size} />,
gitlab: (size) => <GitlabIcon size={size} />,
slack: (size) => <SlackIcon size={size} />,
linear: (size) => <LinearIcon size={size} />,
jira: (size) => <JiraIcon size={size} />,
figma: (size) => <FigmaIcon size={size} />,
google_docs: (size) => <GoogleDocsIcon size={size} />,
azure_devops: (size) => <AzureDevOpsIcon size={size} />,
};
export function getIntegrationIcon(
type: string,
size: number,
): ReactNode {
// The type can come from a document; ignore inherited keys like "constructor".
if (Object.prototype.hasOwnProperty.call(integrationIconMap, type)) {
return integrationIconMap[type](size);
}
return <IconPuzzle size={size} stroke={1.5} />;
}

View file

@ -0,0 +1,66 @@
import { Box, Group, Skeleton, Stack } from "@mantine/core";
const TITLE_WIDTHS = [64, 52, 60, 44, 58, 96, 56];
const DESCRIPTION_WIDTHS = [300, 250, 320, 180, 290, 270, 260];
type IntegrationListSkeletonProps = {
rows?: number;
withBadges?: boolean;
};
export default function IntegrationListSkeleton({
rows = 7,
withBadges = true,
}: IntegrationListSkeletonProps) {
return (
<Stack gap={0} aria-hidden="true">
{Array.from({ length: rows }, (_, index) => (
<Box
key={index}
py="sm"
px="xs"
style={{
borderBottom: "1px solid var(--mantine-color-default-border)",
}}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
<Skeleton height={28} circle style={{ flexShrink: 0 }} />
<Stack gap={2} style={{ minWidth: 0 }}>
<Group gap="xs" wrap="nowrap" h={20}>
<Skeleton
height={12}
width={TITLE_WIDTHS[index % TITLE_WIDTHS.length]}
radius="xs"
/>
{withBadges && (
<>
<Skeleton height={16} width={52} radius="xl" />
<Skeleton height={16} width={52} radius="xl" />
</>
)}
</Group>
<Group h={17}>
<Skeleton
height={10}
width={
DESCRIPTION_WIDTHS[index % DESCRIPTION_WIDTHS.length]
}
maw="100%"
radius="xs"
/>
</Group>
</Stack>
</Group>
<Skeleton
height={30}
width={64}
radius="sm"
style={{ flexShrink: 0 }}
/>
</Group>
</Box>
))}
</Stack>
);
}

View file

@ -0,0 +1,102 @@
import {
Group,
Text,
Badge,
Button,
Box,
Stack,
Tooltip,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import {
IntegrationDefinition,
Integration,
} from "../types/integration.types";
import { getIntegrationIcon } from "./integration-icons";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { Feature } from "@/ee/features";
import { isCloud } from "@/lib/config.ts";
// Keep in sync with apps/server/src/ee/licence/feature-registry.ts.
const MINIMUM_PLAN: Record<string, { selfHosted: string; cloud: string }> = {
[Feature.INTEGRATIONS]: { selfHosted: "Business", cloud: "Standard" },
[Feature.INTEGRATIONS_ADVANCED]: { selfHosted: "Enterprise", cloud: "Business" },
};
type IntegrationRowProps = {
definition: IntegrationDefinition;
installation?: Integration;
onInstall: (type: string) => void;
onUninstall: (integrationId: string) => void;
};
export default function IntegrationRow({
definition,
installation,
onInstall,
onUninstall,
}: IntegrationRowProps) {
const { t } = useTranslation();
const isInstalled = !!installation;
const hasAccess = useHasFeature(definition.requiresFeature ?? "");
const locked = !!definition.requiresFeature && !hasAccess;
const upgradeLabel = useUpgradeLabel();
const minimumPlan = MINIMUM_PLAN[definition.requiresFeature ?? ""];
return (
<Box
py="sm"
px="xs"
style={{
borderBottom: "1px solid var(--mantine-color-default-border)",
}}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
{getIntegrationIcon(definition.type, 28)}
<Stack gap={2} style={{ minWidth: 0 }}>
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={500}>
{definition.name}
</Text>
{locked && (
<Badge size="xs" variant="light" color="violet">
{minimumPlan
? t(isCloud() ? minimumPlan.cloud : minimumPlan.selfHosted)
: t("Paid")}
</Badge>
)}
</Group>
<Text size="xs" c="dimmed" truncate>
{definition.description}
</Text>
</Stack>
</Group>
<Group gap="sm" wrap="nowrap" style={{ flexShrink: 0 }}>
{isInstalled ? (
<Button
size="xs"
variant="default"
onClick={() => onUninstall(installation.id)}
>
{t("Uninstall")}
</Button>
) : (
<Tooltip label={upgradeLabel} disabled={!locked}>
<Button
size="xs"
variant="light"
disabled={locked}
onClick={() => onInstall(definition.type)}
>
{t("Install")}
</Button>
</Tooltip>
)}
</Group>
</Group>
</Box>
);
}

View file

@ -0,0 +1,271 @@
import { useEffect, useRef } from "react";
import { Text, Alert, Stack, Button, Group, List } from "@mantine/core";
import { modals } from "@mantine/modals";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import { notifications } from "@mantine/notifications";
import { getAppName } from "@/lib/config";
import SettingsTitle from "@/components/settings/settings-title";
import ConnectionRow from "../components/connection-row";
import IntegrationListSkeleton from "../components/integration-list-skeleton";
import {
useAvailableIntegrations,
useInstalledIntegrations,
useMyConnections,
useDisconnectIntegration,
} from "../queries/integration-query";
import * as integrationService from "../services/integration-service";
import { resolveConnectPrompt } from "../utils/connect-prompt";
import type { IntegrationDefinition } from "../types/integration.types";
export default function Connections() {
const { t } = useTranslation();
const { data: available, isLoading: loadingAvailable } =
useAvailableIntegrations();
const { data: installed, isLoading: loadingInstalled } =
useInstalledIntegrations();
const { data: myConnections, isLoading: loadingConnections } =
useMyConnections();
const disconnectMutation = useDisconnectIntegration();
const [searchParams, setSearchParams] = useSearchParams();
const connectType = searchParams.get("connect");
const prompted = useRef(false);
const mounted = useRef(true);
const isLoading = loadingAvailable || loadingInstalled || loadingConnections;
const handleConnect = async (type: string) => {
const integration = installed?.find((i) => i.type === type);
if (!integration) return;
try {
// Workspace-scoped providers otherwise return to the admin integrations page.
const result = await integrationService.getOAuthAuthorizeUrl({
integrationId: integration.id,
returnPath: "/settings/account/connections",
});
window.location.href = result.authorizationUrl;
} catch (error) {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to start OAuth connection"),
color: "red",
});
}
};
useEffect(() => {
if (isLoading) return;
const definition = resolveConnectPrompt(
connectType,
available,
installed,
myConnections,
);
if (!definition) {
if (connectType) {
modals.close("connect-prompt");
setSearchParams({}, { replace: true });
} else {
prompted.current = false;
}
return;
}
if (prompted.current) return;
prompted.current = true;
modals.open({
modalId: "connect-prompt",
title: t("Connect {{name}} to Docmost", { name: definition.name }),
centered: true,
// Mantine also calls onClose on unmount, and navigating then would bounce the user back here.
onClose: () => {
if (mounted.current) setSearchParams({}, { replace: true });
},
children: (
<ConnectPromptBody
definition={definition}
onConnect={() => {
modals.close("connect-prompt");
handleConnect(definition.type);
}}
onCancel={() => modals.close("connect-prompt")}
/>
),
});
}, [connectType, available, installed, myConnections, isLoading]);
// The modal lives on the app-root provider, so it must not outlive this page.
useEffect(
() => () => {
mounted.current = false;
modals.close("connect-prompt");
},
[],
);
const handleDisconnect = (integrationId: string) => {
const installation = installed?.find((i) => i.id === integrationId);
const name =
available?.find((d) => d.type === installation?.type)?.name ??
installation?.type ??
"";
modals.openConfirmModal({
title: t("Disconnect {{name}}", { name }),
centered: true,
children: (
<Text size="sm">
{t(
"This disconnects your {{name}} account. Links are not enriched for you until you reconnect.",
{ name },
)}
</Text>
),
labels: { confirm: t("Disconnect"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => disconnectMutation.mutate({ integrationId }),
});
};
// Only the row being disconnected shows a loader.
const disconnectingId = disconnectMutation.isPending
? disconnectMutation.variables?.integrationId
: undefined;
const error = new URLSearchParams(window.location.search).get("error");
return (
<>
<Helmet>
<title>
{t("Connections")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Connections")} />
<Text size="sm" c="dimmed" mb="md">
{t("Manage the apps you have connected to your account.")}
</Text>
{error === "oauth_failed" && (
<Alert color="red" mb="md">
{t("OAuth connection failed. Please try again.")}
</Alert>
)}
{error === "identity_mismatch" && (
<Alert color="red" mb="md">
{t(
"The account you signed in with does not use your Docmost email address. Sign in to the provider with the account that matches your Docmost email and try again.",
)}
</Alert>
)}
{error === "tenant_mismatch" && (
<Alert color="red" mb="md">
{t(
"The Slack account you signed in with is not in the Slack workspace connected to Docmost. Sign in to Slack with your account in that workspace and try again.",
)}
</Alert>
)}
{isLoading ? (
<IntegrationListSkeleton rows={3} withBadges={false} />
) : !available?.length ? (
<Text c="dimmed" size="sm">
{t("No integrations available.")}
</Text>
) : (
<Stack gap={0}>
{available
.filter((def) => {
if (!def.capabilities.includes("oauth")) return false;
return installed?.some((i) => i.type === def.type);
})
.map((def) => {
const connection = myConnections?.find(
(c) => c.type === def.type,
);
return (
<ConnectionRow
key={def.type}
definition={def}
connection={connection}
onConnect={handleConnect}
onDisconnect={handleDisconnect}
disconnectingId={disconnectingId}
/>
);
})}
</Stack>
)}
</>
);
}
type ConnectPromptBodyProps = {
definition: IntegrationDefinition;
onConnect: () => void;
onCancel: () => void;
};
function ConnectPromptBody({
definition,
onConnect,
onCancel,
}: ConnectPromptBodyProps) {
const { t } = useTranslation();
return (
<Stack gap="sm">
{definition.type === "slack" ? (
<>
<Text size="sm">
{t("Link your Slack account to your Docmost account to:")}
</Text>
<List size="sm" spacing="xs">
<List.Item>
{t("Search and open Docmost pages from Slack")}
</List.Item>
<List.Item>
{t("Create pages and save Slack messages to Docmost")}
</List.Item>
<List.Item>
{t("Ask AI questions about your workspace docs")}
</List.Item>
<List.Item>
{t("Get your Docmost notifications as Slack DMs")}
</List.Item>
<List.Item>
{t(
"See previews of Docmost links in Slack and Slack links in Docmost",
)}
</List.Item>
</List>
<Text size="sm" c="dimmed">
{t(
"You will sign in with Slack to confirm which Slack account is yours. It must use the same email as your Docmost account.",
)}
</Text>
</>
) : (
<Text size="sm">
{t(
"Connect your {{name}} account so links you paste show rich previews.",
{ name: definition.name },
)}
</Text>
)}
<Group justify="flex-end">
<Button variant="default" onClick={onCancel}>
{t("Cancel")}
</Button>
<Button onClick={onConnect}>{t("Connect")}</Button>
</Group>
</Stack>
);
}

View file

@ -0,0 +1,123 @@
import { Text, Alert, Stack } from "@mantine/core";
import { modals } from "@mantine/modals";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { useCallback } from "react";
import { getAppName } from "@/lib/config";
import SettingsTitle from "@/components/settings/settings-title";
import IntegrationRow from "../components/integration-row";
import IntegrationListSkeleton from "../components/integration-list-skeleton";
import {
useAvailableIntegrations,
useInstalledIntegrations,
useUninstallIntegration,
} from "../queries/integration-query";
import { getOAuthInstallUrl } from "../services/integration-service";
import { notifications } from "@mantine/notifications";
export default function Integrations() {
const { t } = useTranslation();
const { data: available, isLoading: loadingAvailable } =
useAvailableIntegrations();
const { data: installed, isLoading: loadingInstalled } =
useInstalledIntegrations();
const uninstallMutation = useUninstallIntegration();
const handleInstall = useCallback(
async (type: string) => {
try {
const { authorizationUrl } = await getOAuthInstallUrl({ type });
window.location.href = authorizationUrl;
} catch (err: any) {
notifications.show({
message:
err?.response?.data?.message ?? t("Failed to start installation"),
color: "red",
});
}
},
[t],
);
const handleUninstall = useCallback(
(integrationId: string) => {
const installation = installed?.find((i) => i.id === integrationId);
const name =
available?.find((d) => d.type === installation?.type)?.name ??
installation?.type ??
"";
modals.openConfirmModal({
title: t("Uninstall {{name}}", { name }),
centered: true,
children: (
<Text size="sm">
{t(
"This disables the {{name}} integration for the entire workspace. Members' connections are removed and links are not enriched.",
{ name },
)}
</Text>
),
labels: { confirm: t("Uninstall"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => uninstallMutation.mutate({ integrationId }),
});
},
[uninstallMutation, installed, available, t],
);
const isLoading = loadingAvailable || loadingInstalled;
const error = new URLSearchParams(window.location.search).get("error");
return (
<>
<Helmet>
<title>
{t("Integrations")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Integrations")} />
<Text size="sm" c="dimmed" mb="md">
{t("Manage workspace integrations.")}
</Text>
{error === "oauth_failed" && (
<Alert color="red" mb="md">
{t("OAuth connection failed. Please try again.")}
</Alert>
)}
{error === "tenant_in_use" && (
<Alert color="red" mb="md">
{t(
"This Slack workspace is already connected to another Docmost workspace. Disconnect it there first, or install Docmost into a different Slack workspace.",
)}
</Alert>
)}
{isLoading ? (
<IntegrationListSkeleton />
) : !available?.length ? (
<Text c="dimmed" size="sm">
{t("No integrations available.")}
</Text>
) : (
<Stack gap={0}>
{available.map((def) => {
const installation = installed?.find((i) => i.type === def.type);
return (
<IntegrationRow
key={def.type}
definition={def}
installation={installation}
onInstall={handleInstall}
onUninstall={handleUninstall}
/>
);
})}
</Stack>
)}
</>
);
}

View file

@ -0,0 +1,68 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications";
import * as integrationService from "../services/integration-service";
export function useAvailableIntegrations() {
return useQuery({
queryKey: ["available-integrations"],
queryFn: integrationService.getAvailableIntegrations,
});
}
export function useInstalledIntegrations(enabled = true) {
return useQuery({
queryKey: ["installed-integrations"],
queryFn: integrationService.getInstalledIntegrations,
enabled,
});
}
export function useUninstallIntegration() {
const qc = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: integrationService.uninstallIntegration,
onSuccess: () => {
notifications.show({
message: t("Integration uninstalled successfully"),
});
qc.invalidateQueries({ queryKey: ["installed-integrations"] });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to uninstall integration"),
color: "red",
});
},
});
}
export function useMyConnections() {
return useQuery({
queryKey: ["my-connections"],
queryFn: integrationService.getMyConnections,
});
}
export function useDisconnectIntegration() {
const qc = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: integrationService.disconnectIntegration,
onSuccess: () => {
notifications.show({ message: t("Integration disconnected") });
qc.invalidateQueries({ queryKey: ["my-connections"] });
// refetchOnMount is false, so invalidated inactive queries would stay stale.
qc.removeQueries({ queryKey: ["unfurl"] });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to disconnect integration"),
color: "red",
});
},
});
}

View file

@ -0,0 +1,70 @@
import api from "@/lib/api-client";
import {
IntegrationDefinition,
Integration,
UserConnection,
UnfurlResult,
UnfurlNeedsConnection,
} from "../types/integration.types";
export async function getAvailableIntegrations(): Promise<
IntegrationDefinition[]
> {
const req = await api.post<IntegrationDefinition[]>(
"/integrations/available",
);
return req.data;
}
export async function getInstalledIntegrations(): Promise<Integration[]> {
const req = await api.post<Integration[]>("/integrations");
return req.data;
}
export async function uninstallIntegration(data: {
integrationId: string;
}): Promise<void> {
await api.post("/integrations/uninstall", data);
}
export async function getMyConnections(): Promise<UserConnection[]> {
const req = await api.post<UserConnection[]>("/integrations/connections");
return req.data;
}
export async function getOAuthAuthorizeUrl(data: {
integrationId: string;
returnPath?: string;
}): Promise<{ authorizationUrl: string }> {
const req = await api.post<{ authorizationUrl: string }>(
"/integrations/oauth/authorize",
data,
);
return req.data;
}
// The integration row is only created when the OAuth callback succeeds.
export async function getOAuthInstallUrl(data: {
type: string;
}): Promise<{ authorizationUrl: string }> {
const req = await api.post<{ authorizationUrl: string }>(
"/integrations/oauth/install",
data,
);
return req.data;
}
export async function disconnectIntegration(data: {
integrationId: string;
}): Promise<void> {
await api.post("/integrations/oauth/disconnect", data);
}
export async function unfurlUrl(data: {
url: string;
}): Promise<UnfurlResult | UnfurlNeedsConnection | null> {
const req = await api.post<{
data: UnfurlResult | UnfurlNeedsConnection | null;
}>("/integrations/unfurl", data);
return req.data.data;
}

View file

@ -0,0 +1,62 @@
export type IntegrationCapability = "oauth" | "unfurl" | "actions";
export type OAuthConfig = {
authUrl: string;
tokenUrl: string;
scopes: string[];
connectionScope?: 'workspace' | 'user';
credentialsKey?: string;
};
export type IntegrationDefinition = {
type: string;
name: string;
description: string;
icon: string;
capabilities: IntegrationCapability[];
oauth?: OAuthConfig;
requiresFeature?: string;
};
export type Integration = {
id: string;
workspaceId: string;
type: string;
settings: Record<string, any> | null;
installedById: string | null;
createdAt: string;
updatedAt: string;
// Hostnames the provider unfurls; "*.example.com" means any subdomain.
unfurlHosts?: string[];
};
export type UserConnection = {
integrationId: string;
type: string;
providerUserId: string | null;
providerDisplayName: string | null;
connectedAt: string;
invalidatedAt: string | null;
};
export type UnfurlResult = {
title: string;
description?: string;
url: string;
provider: string;
providerIcon?: string;
status?: string;
statusColor?: string;
author?: string;
authorAvatarUrl?: string;
metadata?: Record<string, any>;
};
export type UnfurlNeedsConnection = {
needsConnection: true;
integrationId: string;
integrationType: string;
integrationName: string;
title: string;
description?: string;
};

View file

@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { resolveConnectPrompt } from "./connect-prompt";
const slack = { type: "slack", name: "Slack", capabilities: ["oauth", "unfurl"] } as any;
const github = { type: "github", name: "GitHub", capabilities: ["oauth", "unfurl"] } as any;
const installed = [{ id: "i-slack", type: "slack" }] as any;
describe("resolveConnectPrompt", () => {
it("returns nothing without a connect parameter", () => {
expect(resolveConnectPrompt(null, [slack], installed, [])).toBeNull();
});
it("returns nothing for a provider that is not installed", () => {
expect(resolveConnectPrompt("github", [slack, github], installed, [])).toBeNull();
});
it("returns nothing when the user already has a live connection", () => {
const live = [{ type: "slack", invalidatedAt: null }] as any;
expect(resolveConnectPrompt("slack", [slack], installed, live)).toBeNull();
});
it("prompts when the user is unlinked", () => {
expect(resolveConnectPrompt("slack", [slack], installed, [])).toBe(slack);
});
it("prompts when the existing connection is invalidated", () => {
const dead = [{ type: "slack", invalidatedAt: "2026-09-01T00:00:00Z" }] as any;
expect(resolveConnectPrompt("slack", [slack], installed, dead)).toBe(slack);
});
});

View file

@ -0,0 +1,22 @@
import type {
Integration,
IntegrationDefinition,
UserConnection,
} from "../types/integration.types";
// The provider a `?connect=<type>` deep link should prompt for, if any.
export function resolveConnectPrompt(
connectType: string | null,
available: IntegrationDefinition[] | undefined,
installed: Integration[] | undefined,
myConnections: UserConnection[] | undefined,
): IntegrationDefinition | null {
if (!connectType) return null;
const definition = available?.find((d) => d.type === connectType);
if (!definition || !definition.capabilities.includes("oauth")) return null;
if (!installed?.some((i) => i.type === connectType)) return null;
const live = myConnections?.find(
(c) => c.type === connectType && !c.invalidatedAt,
);
return live ? null : definition;
}

View file

@ -12,13 +12,15 @@ import {
IconMarkdown,
IconMessage,
IconPaperclip,
IconPresentation,
IconPrinter,
IconStar,
IconStarFilled,
IconTrash,
IconWifiOff,
} from "@tabler/icons-react";
import React, { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { Content } from "@tiptap/react";
import { useAsideTriggerProps } from "@/hooks/use-toggle-aside.tsx";
import { useAtom, useAtomValue } from "jotai";
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
@ -39,11 +41,13 @@ import { htmlToMarkdown } from "@docmost/editor-ext";
import {
pageEditorAtom,
yjsConnectionStatusAtom,
yjsSyncedAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
import { formattedDate } from "@/lib/time.ts";
import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx";
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
import PageAttachmentsModal from "@/features/attachments/components/page-attachments-modal.tsx";
import PresentationModal from "@/features/editor/components/presentation/presentation-modal.tsx";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
import { PageShareModal } from "@/ee/page-permission";
import {
@ -78,10 +82,17 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
[
[
"mod+F",
() => {
(keyboardEvent) => {
// keepMounted modals (page import) stay in the DOM while closed
const modalOpen = Array.from(
document.querySelectorAll('[aria-modal="true"]'),
).some((modal) => modal.getClientRects().length > 0);
if (modalOpen) return;
keyboardEvent.preventDefault();
const event = new CustomEvent("openFindDialogFromEditor", {});
document.dispatchEvent(event);
},
{ preventDefault: false },
],
[
"Escape",
@ -163,6 +174,10 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
attachmentsOpened,
{ open: openAttachmentsModal, close: closeAttachmentsModal },
] = useDisclosure(false);
const [
presentationOpened,
{ open: openPresentationModal, close: closePresentationModal },
] = useDisclosure(false);
const [pageEditor] = useAtom(pageEditorAtom);
const pageUpdatedAt = useTimeAgo(page?.updatedAt);
const favoriteIds = useFavoriteIds("page", page?.spaceId);
@ -204,6 +219,18 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
openDeleteModal({ onConfirm: () => handleDelete(page.id) });
};
const yjsSynced = useAtomValue(yjsSyncedAtom);
const [presentationContent, setPresentationContent] = useState<Content>(null);
const handleOpenPresentation = () => {
const liveContent =
yjsSynced && pageEditor && !pageEditor.isDestroyed
? pageEditor.getJSON()
: null;
setPresentationContent(liveContent ?? page?.content ?? null);
openPresentationModal();
};
const handleToggleFavorite = () => {
if (!page?.id) return;
const params = { type: "page" as const, pageId: page.id };
@ -308,6 +335,15 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
</Menu.Item>
)}
{!page?.isBase && (
<Menu.Item
leftSection={<IconPresentation size={16} />}
onClick={handleOpenPresentation}
>
{t("Presentation mode")}
</Menu.Item>
)}
{!readOnly && !page?.isBase && (
<PageVerificationMenuItem
pageId={page?.id}
@ -413,9 +449,18 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
<PageAttachmentsModal
pageId={page.id}
readOnly={readOnly}
open={attachmentsOpened}
onClose={closeAttachmentsModal}
/>
<PresentationModal
pageId={page.id}
title={page.title}
content={presentationContent}
opened={presentationOpened}
onClose={closePresentationModal}
/>
</>
);
}

View file

@ -27,6 +27,7 @@ const APP_ROUTE = {
SPACES: "/settings/spaces",
BILLING: "/settings/billing",
SECURITY: "/settings/security",
INTEGRATIONS: "/settings/integrations",
},
},
};

View file

@ -0,0 +1,110 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
claimChunkReload,
claimReloadForPreloadError,
isChunkLoadFailure,
} from "./chunk-load-recovery";
const prefetchState = vi.hoisted(() => ({ running: false }));
vi.mock("@/lib/lazy-prefetch.ts", () => ({
isPrefetching: () => prefetchState.running,
}));
const LOAD_FAILURE = new TypeError(
"Failed to fetch dynamically imported module: https://app.docmost.com/assets/groups-DHo4N9RM.js",
);
describe("claimChunkReload", () => {
beforeEach(() => {
sessionStorage.clear();
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-10-05T12:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("allows the first reload", () => {
expect(claimChunkReload()).toBe(true);
});
it("refuses a second reload inside the cooldown", () => {
claimChunkReload();
vi.advanceTimersByTime(5_000);
expect(claimChunkReload()).toBe(false);
});
it("allows another reload once the cooldown has passed", () => {
claimChunkReload();
vi.advanceTimersByTime(60_000);
expect(claimChunkReload()).toBe(true);
});
it("refuses to reload while offline without using up the cooldown", () => {
const onLine = vi.spyOn(navigator, "onLine", "get").mockReturnValue(false);
expect(claimChunkReload()).toBe(false);
onLine.mockReturnValue(true);
expect(claimChunkReload()).toBe(true);
});
it("refuses to reload when session storage is unavailable", () => {
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
throw new Error("SecurityError");
});
expect(claimChunkReload()).toBe(false);
});
});
describe("isChunkLoadFailure", () => {
it.each([
"Failed to fetch dynamically imported module: https://app.docmost.com/assets/groups-DHo4N9RM.js",
"error loading dynamically imported module: https://app.docmost.com/assets/groups-DHo4N9RM.js",
"Importing a module script failed.",
"Unable to preload CSS for /assets/groups-BJHsxMuK.css",
])("treats %s as a load failure", (message) => {
expect(isChunkLoadFailure(new TypeError(message))).toBe(true);
});
it("does not treat an error thrown while a module runs as a load failure", () => {
expect(
isChunkLoadFailure(
new ReferenceError("Cannot access 'Groups' before initialization"),
),
).toBe(false);
});
it("does not treat a missing payload as a load failure", () => {
expect(isChunkLoadFailure(undefined)).toBe(false);
});
});
describe("claimReloadForPreloadError", () => {
beforeEach(() => {
sessionStorage.clear();
prefetchState.running = false;
});
it("claims a reload for a chunk that failed to download", () => {
expect(claimReloadForPreloadError(LOAD_FAILURE)).toBe(true);
});
it("ignores failures during the background prefetch without using up the cooldown", () => {
prefetchState.running = true;
expect(claimReloadForPreloadError(LOAD_FAILURE)).toBe(false);
prefetchState.running = false;
expect(claimReloadForPreloadError(LOAD_FAILURE)).toBe(true);
});
it("ignores an error thrown while a module runs without using up the cooldown", () => {
expect(
claimReloadForPreloadError(
new ReferenceError("Cannot access 'Groups' before initialization"),
),
).toBe(false);
expect(claimReloadForPreloadError(LOAD_FAILURE)).toBe(true);
});
});

View file

@ -0,0 +1,49 @@
import { isPrefetching } from "@/lib/lazy-prefetch.ts";
const RELOAD_AT_KEY = "chunk-reload-at";
const RELOAD_COOLDOWN_MS = 60_000;
// how Chromium, Firefox and Safari word a module that failed to download, plus Vite's CSS preload failure
const LOAD_FAILURE_MESSAGES = [
"Failed to fetch dynamically imported module",
"error loading dynamically imported module",
"Importing a module script failed",
"Unable to preload CSS",
];
export function claimChunkReload(): boolean {
if (!navigator.onLine) {
return false;
}
try {
const lastReloadAt = Number(sessionStorage.getItem(RELOAD_AT_KEY));
if (Date.now() - lastReloadAt < RELOAD_COOLDOWN_MS) {
return false;
}
sessionStorage.setItem(RELOAD_AT_KEY, String(Date.now()));
return true;
} catch {
return false;
}
}
export function isChunkLoadFailure(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return LOAD_FAILURE_MESSAGES.some((text) => message.includes(text));
}
export function claimReloadForPreloadError(error: unknown): boolean {
if (isPrefetching() || !isChunkLoadFailure(error)) {
return false;
}
return claimChunkReload();
}
export function installChunkLoadRecovery() {
// a tab opened before a deploy asks for chunks that no longer exist; a reload picks up the new build
window.addEventListener("vite:preloadError", (event) => {
if (claimReloadForPreloadError(event.payload)) {
window.location.reload();
}
});
}

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