Compare commits

..

2 Commits

Author SHA1 Message Date
Philipinho ea27919079 3 levels 2025-10-15 12:22:55 +01:00
Philipinho 2b6ac81c61 table of contents node - WIP 2025-10-15 12:20:08 +01:00
395 changed files with 10089 additions and 23377 deletions
+2 -3
View File
@@ -1,6 +1,5 @@
node_modules node_modules
.git .git
.gitignore
dist dist
/data data
.env*
.nx
+1 -7
View File
@@ -46,10 +46,4 @@ DRAWIO_URL=
DISABLE_TELEMETRY=false DISABLE_TELEMETRY=false
# Enable debug logging in production (default: false) # Enable debug logging in production (default: false)
DEBUG_MODE=false DEBUG_MODE=false
# Log database queries
DEBUG_DB=false
# Log http requests
LOG_HTTP=false
+5 -7
View File
@@ -1,22 +1,19 @@
FROM node:22-slim AS base FROM node:22-alpine AS base
LABEL org.opencontainers.image.source="https://github.com/docmost/docmost" LABEL org.opencontainers.image.source="https://github.com/docmost/docmost"
RUN npm install -g pnpm@10.4.0
FROM base AS builder FROM base AS builder
WORKDIR /app WORKDIR /app
COPY . . COPY . .
RUN npm install -g pnpm@10.4.0
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
RUN pnpm build RUN pnpm build
FROM base AS installer FROM base AS installer
RUN apt-get update \ RUN apk add --no-cache curl bash
&& apt-get install -y --no-install-recommends curl bash \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
@@ -32,11 +29,12 @@ COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-e
# Copy root package files # Copy root package files
COPY --from=builder /app/package.json /app/package.json COPY --from=builder /app/package.json /app/package.json
COPY --from=builder /app/pnpm*.yaml /app/ COPY --from=builder /app/pnpm*.yaml /app/
COPY --from=builder /app/.npmrc /app/.npmrc
# Copy patches # Copy patches
COPY --from=builder /app/patches /app/patches COPY --from=builder /app/patches /app/patches
RUN npm install -g pnpm@10.4.0
RUN chown -R node:node /app RUN chown -R node:node /app
USER node USER node
+31 -30
View File
@@ -1,7 +1,7 @@
{ {
"name": "client", "name": "client",
"private": true, "private": true,
"version": "0.25.3", "version": "0.23.2",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
@@ -10,65 +10,66 @@
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\"" "format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\""
}, },
"dependencies": { "dependencies": {
"@casl/ability": "^6.7.2",
"@casl/react": "^4.0.0", "@casl/react": "^4.0.0",
"@docmost/editor-ext": "workspace:*", "@docmost/editor-ext": "workspace:*",
"@emoji-mart/data": "^1.2.1", "@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1", "@emoji-mart/react": "^1.1.1",
"@excalidraw/excalidraw": "0.18.0-3a5ef40", "@excalidraw/excalidraw": "0.18.0-864353b",
"@mantine/core": "^8.3.14", "@mantine/core": "^8.1.3",
"@mantine/dates": "^8.3.14", "@mantine/dates": "^8.3.2",
"@mantine/form": "^8.3.14", "@mantine/form": "^8.1.3",
"@mantine/hooks": "^8.3.14", "@mantine/hooks": "^8.1.3",
"@mantine/modals": "^8.3.14", "@mantine/modals": "^8.1.3",
"@mantine/notifications": "^8.3.14", "@mantine/notifications": "^8.1.3",
"@mantine/spotlight": "^8.3.14", "@mantine/spotlight": "^8.1.3",
"@tabler/icons-react": "^3.36.1", "@tabler/icons-react": "^3.34.0",
"@tanstack/react-query": "^5.90.17", "@tanstack/react-query": "^5.80.6",
"@tiptap/extension-character-count": "^2.10.3",
"alfaaz": "^1.1.0", "alfaaz": "^1.1.0",
"axios": "^1.13.5", "axios": "^1.9.0",
"blueimp-load-image": "^5.16.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"emoji-mart": "^5.6.0", "emoji-mart": "^5.6.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"highlightjs-sap-abap": "^0.3.0", "highlightjs-sap-abap": "^0.3.0",
"i18next": "^23.16.8", "i18next": "^23.14.0",
"i18next-http-backend": "^2.7.3", "i18next-http-backend": "^2.6.1",
"jotai": "^2.16.2", "jotai": "^2.12.5",
"jotai-optics": "^0.4.0", "jotai-optics": "^0.4.0",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"katex": "0.16.27", "katex": "0.16.22",
"lowlight": "^3.3.0", "lowlight": "^3.3.0",
"mantine-form-zod-resolver": "^1.3.0", "mantine-form-zod-resolver": "^1.3.0",
"mermaid": "^11.12.2", "mermaid": "^11.11.0",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"posthog-js": "1.345.5", "posthog-js": "^1.255.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-arborist": "3.4.0", "react-arborist": "3.4.0",
"react-clear-modal": "^2.0.17", "react-clear-modal": "^2.0.15",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-drawio": "^1.0.7", "react-drawio": "^1.0.1",
"react-error-boundary": "^4.1.2", "react-error-boundary": "^4.1.2",
"react-helmet-async": "^2.0.5", "react-helmet-async": "^2.0.5",
"react-i18next": "^15.0.1", "react-i18next": "^15.0.1",
"react-router-dom": "^7.12.0", "react-router-dom": "^7.0.1",
"semver": "^7.7.3", "semver": "^7.7.2",
"socket.io-client": "^4.8.3", "socket.io-client": "^4.8.1",
"tippy.js": "^6.3.7",
"tiptap-extension-global-drag-handle": "^0.1.18", "tiptap-extension-global-drag-handle": "^0.1.18",
"zod": "^3.25.76" "zod": "^3.25.56"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.16.0", "@eslint/js": "^9.16.0",
"@tanstack/eslint-plugin-query": "^5.62.1", "@tanstack/eslint-plugin-query": "^5.62.1",
"@types/blueimp-load-image": "^5.16.0",
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/katex": "^0.16.7", "@types/katex": "^0.16.7",
"@types/node": "22.19.1", "@types/node": "22.10.0",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^4.4.1",
"eslint": "^9.39.2", "eslint": "^9.15.0",
"eslint-plugin-react": "^7.37.2", "eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.16", "eslint-plugin-react-refresh": "^0.4.16",
@@ -80,6 +81,6 @@
"prettier": "^3.4.1", "prettier": "^3.4.1",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"typescript-eslint": "^8.17.0", "typescript-eslint": "^8.17.0",
"vite": "^7.2.4" "vite": "^6.3.5"
} }
} }
@@ -29,7 +29,6 @@
"Choose your preferred interface language.": "Wählen Sie Ihre bevorzugte Benutzersprache.", "Choose your preferred interface language.": "Wählen Sie Ihre bevorzugte Benutzersprache.",
"Choose your preferred page width.": "Wählen Sie Ihre bevorzugte Seitenbreite.", "Choose your preferred page width.": "Wählen Sie Ihre bevorzugte Seitenbreite.",
"Confirm": "Bestätigen", "Confirm": "Bestätigen",
"Copy as Markdown": "Als Markdown kopieren",
"Copy link": "Link kopieren", "Copy link": "Link kopieren",
"Create": "Erstellen", "Create": "Erstellen",
"Create group": "Gruppe erstellen", "Create group": "Gruppe erstellen",
@@ -41,9 +40,9 @@
"Date": "Datum", "Date": "Datum",
"Delete": "Löschen", "Delete": "Löschen",
"Delete group": "Gruppe löschen", "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.", "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? Dadurch werden ihre Unterseiten und die Seitengeschichte gelöscht. Diese Aktion ist unwiderruflich.",
"Description": "Beschreibung", "Description": "Beschreibung",
"Details": "Details", "Details": "Einzelheiten",
"e.g ACME": "z.B. ACME", "e.g ACME": "z.B. ACME",
"e.g ACME Inc": "z.B. ACME Inc.", "e.g ACME Inc": "z.B. ACME Inc.",
"e.g Developers": "z.B. Entwickler", "e.g Developers": "z.B. Entwickler",
@@ -66,7 +65,7 @@
"Enter your new preferred email": "Geben Sie Ihre neue bevorzugte E-Mail ein", "Enter your new preferred email": "Geben Sie Ihre neue bevorzugte E-Mail ein",
"Enter your password": "Geben Sie Ihr Passwort ein", "Enter your password": "Geben Sie Ihr Passwort ein",
"Error fetching page data.": "Fehler beim Abrufen der Seitendaten.", "Error fetching page data.": "Fehler beim Abrufen der Seitendaten.",
"Error loading page history.": "Fehler beim Laden des Seitenverlaufs.", "Error loading page history.": "Fehler beim Laden der Seitengeschichte.",
"Export": "Exportieren", "Export": "Exportieren",
"Failed to create page": "Erstellung der Seite fehlgeschlagen", "Failed to create page": "Erstellung der Seite fehlgeschlagen",
"Failed to delete page": "Löschen der Seite fehlgeschlagen", "Failed to delete page": "Löschen der Seite fehlgeschlagen",
@@ -114,7 +113,7 @@
"New page": "Neue Seite", "New page": "Neue Seite",
"New password": "Neues Passwort", "New password": "Neues Passwort",
"No group found": "Keine Gruppe gefunden", "No group found": "Keine Gruppe gefunden",
"No page history saved yet.": "Es wurde noch kein Seitenverlauf gespeichert.", "No page history saved yet.": "Es wurde noch keine Seitengeschichte gespeichert.",
"No pages yet": "Noch keine Seiten", "No pages yet": "Noch keine Seiten",
"No results found...": "Keine Ergebnisse gefunden...", "No results found...": "Keine Ergebnisse gefunden...",
"No user found": "Kein Benutzer gefunden", "No user found": "Kein Benutzer gefunden",
@@ -122,9 +121,7 @@
"Owner": "Besitzer", "Owner": "Besitzer",
"page": "Seite", "page": "Seite",
"Page deleted successfully": "Seite erfolgreich gelöscht", "Page deleted successfully": "Seite erfolgreich gelöscht",
"Page history": "Seitenverlauf", "Page history": "Seitengeschichte",
"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.", "Page import is in progress. Please do not close this tab.": "Der Seitenimport läuft. Bitte schließen Sie diesen Tab nicht.",
"Pages": "Seiten", "Pages": "Seiten",
"pages": "Seiten", "pages": "Seiten",
@@ -256,7 +253,6 @@
"Export failed:": "Export fehlgeschlagen:", "Export failed:": "Export fehlgeschlagen:",
"export error": "Exportfehler", "export error": "Exportfehler",
"Export page": "Seite exportieren", "Export page": "Seite exportieren",
"Export successful": "Export erfolgreich",
"Export space": "Bereich exportieren", "Export space": "Bereich exportieren",
"Export {{type}}": "Exportiere {{type}}", "Export {{type}}": "Exportiere {{type}}",
"File exceeds the {{limit}} attachment limit": "Datei überschreitet das Anhängelimit von {{limit}}", "File exceeds the {{limit}} attachment limit": "Datei überschreitet das Anhängelimit von {{limit}}",
@@ -332,8 +328,6 @@
"Upload any image from your device.": "Laden Sie ein beliebiges Bild von Ihrem Gerät hoch.", "Upload any image from your device.": "Laden Sie ein beliebiges Bild von Ihrem Gerät hoch.",
"Upload any video from your device.": "Laden Sie ein beliebiges Video von Ihrem Gerät hoch.", "Upload any video from your device.": "Laden Sie ein beliebiges Video von Ihrem Gerät hoch.",
"Upload any file from your device.": "Laden Sie eine beliebige Datei von Ihrem Gerät hoch.", "Upload any file from your device.": "Laden Sie eine beliebige Datei von Ihrem Gerät hoch.",
"Uploading {{name}}": "Lade {{name}} hoch",
"Uploading file": "Datei wird hochgeladen",
"Table": "Tabelle", "Table": "Tabelle",
"Insert a table.": "Tabelle einfügen.", "Insert a table.": "Tabelle einfügen.",
"Insert collapsible block.": "Einklappbaren Block einfügen.", "Insert collapsible block.": "Einklappbaren Block einfügen.",
@@ -355,11 +349,6 @@
"Insert current date": "Aktuelles Datum einfügen", "Insert current date": "Aktuelles Datum einfügen",
"Draw and sketch excalidraw diagrams": "Excalidraw-Diagramme zeichnen und skizzieren", "Draw and sketch excalidraw diagrams": "Excalidraw-Diagramme zeichnen und skizzieren",
"Multiple": "Mehrere", "Multiple": "Mehrere",
"Turn into": "In verwandeln",
"Text align": "Text ausrichten",
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
"Go to homepage": "Go to homepage",
"Pages you create will show up here.": "Pages you create will show up here.",
"Heading {{level}}": "Überschrift {{level}}", "Heading {{level}}": "Überschrift {{level}}",
"Toggle title": "Titel umschalten", "Toggle title": "Titel umschalten",
"Write anything. Enter \"/\" for commands": "Schreiben Sie irgendetwas. Geben Sie \"/\" für Befehle ein", "Write anything. Enter \"/\" for commands": "Schreiben Sie irgendetwas. Geben Sie \"/\" für Befehle ein",
@@ -412,21 +401,6 @@
"Share deleted successfully": "Freigabe erfolgreich gelöscht", "Share deleted successfully": "Freigabe erfolgreich gelöscht",
"Share not found": "Freigabe nicht gefunden", "Share not found": "Freigabe nicht gefunden",
"Failed to share page": "Fehler beim Teilen der Seite", "Failed to share page": "Fehler beim Teilen der Seite",
"Disable public sharing": "Öffentliches Teilen deaktivieren",
"Prevent members from sharing pages publicly.": "Verhindern Sie, dass Mitglieder Seiten öffentlich teilen.",
"Toggle public sharing": "Öffentliches Teilen umschalten",
"Toggle space public sharing": "Öffentliches Teilen im Bereich umschalten",
"Public sharing is disabled at the workspace level": "Öffentliches Teilen ist auf der Arbeitsbereichsebene deaktiviert",
"Prevent pages in this space from being shared publicly.": "Verhindern Sie, dass Seiten in diesem Bereich öffentlich geteilt werden.",
"Requires an enterprise license": "Erfordert eine Unternehmenslizenz",
"Enable public sharing": "Öffentliches Teilen aktivieren",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Sind Sie sicher, dass Sie das öffentliche Teilen aktivieren möchten? Mitglieder können Seiten öffentlich teilen.",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Sind Sie sicher, dass Sie das öffentliche Teilen deaktivieren möchten? Alle bestehenden Freigabelinks in diesem Arbeitsbereich werden gelöscht.",
"Are you sure you want to enable public sharing for this space?": "Sind Sie sicher, dass Sie das öffentliche Teilen für diesen Bereich aktivieren möchten?",
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Sind Sie sicher, dass Sie das öffentliche Teilen deaktivieren möchten? Alle bestehenden Freigabelinks in diesem Bereich werden gelöscht.",
"Public sharing is disabled": "Öffentliches Teilen ist deaktiviert",
"Public sharing has been disabled at the workspace level.": "Das öffentliche Teilen wurde auf der Arbeitsbereichsebene deaktiviert.",
"Public sharing has been disabled for this space.": "Das öffentliche Teilen wurde für diesen Bereich deaktiviert.",
"Copy page": "Seite kopieren", "Copy page": "Seite kopieren",
"Copy page to a different space.": "Seite in einen anderen Bereich kopieren.", "Copy page to a different space.": "Seite in einen anderen Bereich kopieren.",
"Page copied successfully": "Seite erfolgreich kopiert", "Page copied successfully": "Seite erfolgreich kopiert",
@@ -553,67 +527,5 @@
"Delete SSO provider": "SSO-Anbieter löschen", "Delete SSO provider": "SSO-Anbieter löschen",
"Are you sure you want to delete this SSO provider?": "Sind Sie sicher, dass Sie diesen SSO-Anbieter löschen möchten?", "Are you sure you want to delete this SSO provider?": "Sind Sie sicher, dass Sie diesen SSO-Anbieter löschen möchten?",
"Action": "Aktion", "Action": "Aktion",
"{{ssoProviderType}} configuration": "{{ssoProviderType}}-Konfiguration", "{{ssoProviderType}} configuration": "{{ssoProviderType}}-Konfiguration"
"Icon": "Icon",
"Upload image": "Bild hochladen",
"Remove image": "Bild entfernen",
"Failed to remove image": "Fehler beim Entfernen des Bildes",
"Image exceeds 10MB limit.": "Bild überschreitet das Limit von 10 MB.",
"Image removed successfully": "Bild erfolgreich entfernt",
"API key": "API-Schlüssel",
"API key created successfully": "API-Schlüssel erfolgreich erstellt",
"API keys": "API-Schlüssel",
"API management": "API-Verwaltung",
"Are you sure you want to revoke this API key": "Sind Sie sicher, dass Sie diesen API-Schlüssel widerrufen möchten?",
"Create API Key": "API-Schlüssel erstellen",
"Custom expiration date": "Benutzerdefiniertes Ablaufdatum",
"Enter a descriptive token name": "Geben Sie einen beschreibenden Token-Namen ein",
"Expiration": "Ablauf",
"Expired": "Abgelaufen",
"Expires": "Läuft ab",
"I've saved my API key": "Ich habe meinen API-Schlüssel gespeichert",
"Last use": "Zuletzt verwendet",
"No API keys found": "Keine API-Schlüssel gefunden",
"No expiration": "Kein Ablauf",
"Revoke API key": "API-Schlüssel widerrufen",
"Revoked successfully": "Erfolgreich widerrufen",
"Select expiration date": "Ablaufdatum wählen",
"This action cannot be undone. Any applications using this API key will stop working.": "Diese Aktion kann nicht rückgängig gemacht werden. Alle Anwendungen, die diesen API-Schlüssel verwenden, werden nicht mehr funktionieren.",
"Update API key": "API-Schlüssel aktualisieren",
"Manage API keys for all users in the workspace": "Verwalten Sie API-Schlüssel für alle Benutzer im Arbeitsbereich",
"AI settings": "KI-Einstellungen",
"AI search": "KI-Suche",
"AI Answer": "KI-Antwort",
"Ask AI": "KI fragen",
"AI is thinking...": "Die KI überlegt...",
"Ask a question...": "Fragen stellen...",
"AI Answers": "KI-Antworten",
"AI-powered search (AI Answers)": "KI-unterstützte Suche (KI-Antworten)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Die KI-Suche verwendet Vektor-Einbettungen, um semantische Suchfunktionen in Ihrem Arbeitsbereich bereitzustellen.",
"Toggle AI search": "KI-Suche umschalten",
"Generative AI (Ask AI)": "Generative KI (KI fragen)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Aktivieren Sie die KI-unterstützte Inhaltserstellung im Editor. Ermöglicht Benutzern das Erzeugen, Verbessern, Übersetzen und Transformieren von Text.",
"Toggle generative AI": "Generative KI umschalten",
"Sources": "Quellen",
"AI Answers not available for attachments": "KI-Antworten sind für Anhänge nicht verfügbar",
"No answer available": "Keine Antwort verfügbar",
"Background color": "Hintergrundfarbe",
"Highlight color": "Hervorhebungsfarbe",
"Remove color": "Farbe entfernen",
"Notifications": "Benachrichtigungen",
"No notifications": "Keine Benachrichtigungen",
"No unread notifications": "Keine ungelesenen Benachrichtigungen",
"All notifications": "Alle Benachrichtigungen",
"Unread only": "Nur ungelesen",
"Mark all as read": "Alle als gelesen markieren",
"Mark as read": "Als gelesen markieren",
"More options": "Weitere Optionen",
"mentioned you in a comment": "hat Sie in einem Kommentar erwähnt",
"commented on a page": "hat auf einer Seite kommentiert",
"resolved a comment": "hat einen Kommentar gelöst",
"mentioned you on a page": "hat Sie auf einer Seite erwähnt",
"Today": "Heute",
"Yesterday": "Gestern",
"This week": "Diese Woche",
"Older": "Älter"
} }
@@ -29,7 +29,6 @@
"Choose your preferred interface language.": "Choose your preferred interface language.", "Choose your preferred interface language.": "Choose your preferred interface language.",
"Choose your preferred page width.": "Choose your preferred page width.", "Choose your preferred page width.": "Choose your preferred page width.",
"Confirm": "Confirm", "Confirm": "Confirm",
"Copy as Markdown": "Copy as Markdown",
"Copy link": "Copy link", "Copy link": "Copy link",
"Create": "Create", "Create": "Create",
"Create group": "Create group", "Create group": "Create group",
@@ -123,8 +122,6 @@
"page": "page", "page": "page",
"Page deleted successfully": "Page deleted successfully", "Page deleted successfully": "Page deleted successfully",
"Page history": "Page history", "Page history": "Page history",
"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.", "Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.",
"Pages": "Pages", "Pages": "Pages",
"pages": "pages", "pages": "pages",
@@ -256,7 +253,6 @@
"Export failed:": "Export failed:", "Export failed:": "Export failed:",
"export error": "export error", "export error": "export error",
"Export page": "Export page", "Export page": "Export page",
"Export successful": "Export successful",
"Export space": "Export space", "Export space": "Export space",
"Export {{type}}": "Export {{type}}", "Export {{type}}": "Export {{type}}",
"File exceeds the {{limit}} attachment limit": "File exceeds the {{limit}} attachment limit", "File exceeds the {{limit}} attachment limit": "File exceeds the {{limit}} attachment limit",
@@ -332,8 +328,6 @@
"Upload any image from your device.": "Upload any image from your device.", "Upload any image from your device.": "Upload any image from your device.",
"Upload any video from your device.": "Upload any video from your device.", "Upload any video from your device.": "Upload any video from your device.",
"Upload any file from your device.": "Upload any file from your device.", "Upload any file from your device.": "Upload any file from your device.",
"Uploading {{name}}": "Uploading {{name}}",
"Uploading file": "Uploading file",
"Table": "Table", "Table": "Table",
"Insert a table.": "Insert a table.", "Insert a table.": "Insert a table.",
"Insert collapsible block.": "Insert collapsible block.", "Insert collapsible block.": "Insert collapsible block.",
@@ -355,11 +349,6 @@
"Insert current date": "Insert current date", "Insert current date": "Insert current date",
"Draw and sketch excalidraw diagrams": "Draw and sketch excalidraw diagrams", "Draw and sketch excalidraw diagrams": "Draw and sketch excalidraw diagrams",
"Multiple": "Multiple", "Multiple": "Multiple",
"Turn into": "Turn into",
"Text align": "Text align",
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
"Go to homepage": "Go to homepage",
"Pages you create will show up here.": "Pages you create will show up here.",
"Heading {{level}}": "Heading {{level}}", "Heading {{level}}": "Heading {{level}}",
"Toggle title": "Toggle title", "Toggle title": "Toggle title",
"Write anything. Enter \"/\" for commands": "Write anything. Enter \"/\" for commands", "Write anything. Enter \"/\" for commands": "Write anything. Enter \"/\" for commands",
@@ -412,21 +401,6 @@
"Share deleted successfully": "Share deleted successfully", "Share deleted successfully": "Share deleted successfully",
"Share not found": "Share not found", "Share not found": "Share not found",
"Failed to share page": "Failed to share page", "Failed to share page": "Failed to share page",
"Disable public sharing": "Disable public sharing",
"Prevent members from sharing pages publicly.": "Prevent members from sharing pages publicly.",
"Toggle public sharing": "Toggle public sharing",
"Toggle space public sharing": "Toggle space public sharing",
"Public sharing is disabled at the workspace level": "Public sharing is disabled at the workspace level",
"Prevent pages in this space from being shared publicly.": "Prevent pages in this space from being shared publicly.",
"Requires an enterprise license": "Requires an enterprise license",
"Enable public sharing": "Enable public sharing",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Are you sure you want to enable public sharing? Members will be able to share pages publicly.",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.",
"Are you sure you want to enable public sharing for this space?": "Are you sure you want to enable public sharing for this space?",
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.",
"Public sharing is disabled": "Public sharing is disabled",
"Public sharing has been disabled at the workspace level.": "Public sharing has been disabled at the workspace level.",
"Public sharing has been disabled for this space.": "Public sharing has been disabled for this space.",
"Copy page": "Copy page", "Copy page": "Copy page",
"Copy page to a different space.": "Copy page to a different space.", "Copy page to a different space.": "Copy page to a different space.",
"Page copied successfully": "Page copied successfully", "Page copied successfully": "Page copied successfully",
@@ -580,40 +554,5 @@
"Select expiration date": "Select expiration date", "Select expiration date": "Select expiration date",
"This action cannot be undone. Any applications using this API key will stop working.": "This action cannot be undone. Any applications using this API key will stop working.", "This action cannot be undone. Any applications using this API key will stop working.": "This action cannot be undone. Any applications using this API key will stop working.",
"Update API key": "Update API key", "Update API key": "Update API key",
"Manage API keys for all users in the workspace": "Manage API keys for all users in the workspace", "Manage API keys for all users in the workspace": "Manage API keys for all users in the workspace"
"AI settings": "AI settings",
"AI search": "AI search",
"AI Answer": "AI Answer",
"Ask AI": "Ask AI",
"AI is thinking...": "AI is thinking...",
"Ask a question...": "Ask a question...",
"AI Answers": "AI Answers",
"AI-powered search (AI Answers)": "AI-powered search (AI Answers)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.",
"Toggle AI search": "Toggle AI search",
"Generative AI (Ask AI)": "Generative AI (Ask AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.",
"Toggle generative AI": "Toggle generative AI",
"Sources": "Sources",
"AI Answers not available for attachments": "AI Answers not available for attachments",
"No answer available": "No answer available",
"Background color": "Background color",
"Highlight color": "Highlight color",
"Remove color": "Remove color",
"Notifications": "Notifications",
"No notifications": "No notifications",
"No unread notifications": "No unread notifications",
"All notifications": "All notifications",
"Unread only": "Unread only",
"Mark all as read": "Mark all as read",
"Mark as read": "Mark as read",
"More options": "More options",
"mentioned you in a comment": "mentioned you in a comment",
"commented on a page": "commented on a page",
"resolved a comment": "resolved a comment",
"mentioned you on a page": "mentioned you on a page",
"Today": "Today",
"Yesterday": "Yesterday",
"This week": "This week",
"Older": "Older"
} }
@@ -29,7 +29,6 @@
"Choose your preferred interface language.": "Elige tu idioma de interfaz preferido.", "Choose your preferred interface language.": "Elige tu idioma de interfaz preferido.",
"Choose your preferred page width.": "Elige el ancho de página que prefieras.", "Choose your preferred page width.": "Elige el ancho de página que prefieras.",
"Confirm": "Confirmar", "Confirm": "Confirmar",
"Copy as Markdown": "Copiar como Markdown",
"Copy link": "Copiar enlace", "Copy link": "Copiar enlace",
"Create": "Crear", "Create": "Crear",
"Create group": "Crear grupo", "Create group": "Crear grupo",
@@ -123,8 +122,6 @@
"page": "página", "page": "página",
"Page deleted successfully": "Página eliminada con éxito", "Page deleted successfully": "Página eliminada con éxito",
"Page history": "Historial de la página", "Page history": "Historial de la página",
"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.", "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.",
"Pages": "Páginas", "Pages": "Páginas",
"pages": "páginas", "pages": "páginas",
@@ -256,7 +253,6 @@
"Export failed:": "Exportación fallida:", "Export failed:": "Exportación fallida:",
"export error": "error de exportación", "export error": "error de exportación",
"Export page": "Exportar página", "Export page": "Exportar página",
"Export successful": "Exportación exitosa",
"Export space": "Exportar espacio", "Export space": "Exportar espacio",
"Export {{type}}": "Exportar {{type}}", "Export {{type}}": "Exportar {{type}}",
"File exceeds the {{limit}} attachment limit": "El archivo supera el límite de {{limit}} adjuntos", "File exceeds the {{limit}} attachment limit": "El archivo supera el límite de {{limit}} adjuntos",
@@ -332,8 +328,6 @@
"Upload any image from your device.": "Sube cualquier imagen desde tu dispositivo.", "Upload any image from your device.": "Sube cualquier imagen desde tu dispositivo.",
"Upload any video from your device.": "Sube cualquier video desde tu dispositivo.", "Upload any video from your device.": "Sube cualquier video desde tu dispositivo.",
"Upload any file from your device.": "Sube cualquier archivo desde tu dispositivo.", "Upload any file from your device.": "Sube cualquier archivo desde tu dispositivo.",
"Uploading {{name}}": "Subiendo {{name}}",
"Uploading file": "Subiendo archivo",
"Table": "Tabla", "Table": "Tabla",
"Insert a table.": "Insertar una tabla.", "Insert a table.": "Insertar una tabla.",
"Insert collapsible block.": "Insertar bloque desplegable.", "Insert collapsible block.": "Insertar bloque desplegable.",
@@ -355,11 +349,6 @@
"Insert current date": "Insertar fecha actual", "Insert current date": "Insertar fecha actual",
"Draw and sketch excalidraw diagrams": "Dibujar y esbozar diagramas de Excalidraw", "Draw and sketch excalidraw diagrams": "Dibujar y esbozar diagramas de Excalidraw",
"Multiple": "Múltiple", "Multiple": "Múltiple",
"Turn into": "Convertir en",
"Text align": "Alineación del texto",
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
"Go to homepage": "Go to homepage",
"Pages you create will show up here.": "Pages you create will show up here.",
"Heading {{level}}": "Encabezado {{level}}", "Heading {{level}}": "Encabezado {{level}}",
"Toggle title": "Alternar título", "Toggle title": "Alternar título",
"Write anything. Enter \"/\" for commands": "Escribe cualquier cosa. Ingresa \"/\" para comandos", "Write anything. Enter \"/\" for commands": "Escribe cualquier cosa. Ingresa \"/\" para comandos",
@@ -412,21 +401,6 @@
"Share deleted successfully": "Compartición eliminada con éxito", "Share deleted successfully": "Compartición eliminada con éxito",
"Share not found": "Compartición no encontrada", "Share not found": "Compartición no encontrada",
"Failed to share page": "Error al compartir la página", "Failed to share page": "Error al compartir la página",
"Disable public sharing": "Desactivar el uso compartido público",
"Prevent members from sharing pages publicly.": "Evitar que los miembros compartan páginas públicamente.",
"Toggle public sharing": "Alternar el uso compartido público",
"Toggle space public sharing": "Alternar el uso compartido público del espacio",
"Public sharing is disabled at the workspace level": "El uso compartido público está desactivado a nivel de espacio de trabajo",
"Prevent pages in this space from being shared publicly.": "Evitar que las páginas en este espacio se compartan públicamente.",
"Requires an enterprise license": "Requiere una licencia empresarial",
"Enable public sharing": "Activar el uso compartido público",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "¿Está seguro de que desea activar el uso compartido público? Los miembros podrán compartir páginas públicamente.",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "¿Está seguro de que desea desactivar el uso compartido público? Todos los enlaces compartidos existentes en este espacio de trabajo se eliminarán.",
"Are you sure you want to enable public sharing for this space?": "¿Está seguro de que desea activar el uso compartido público para este espacio?",
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "¿Está seguro de que desea desactivar el uso compartido público? Todos los enlaces compartidos existentes en este espacio se eliminarán.",
"Public sharing is disabled": "El uso compartido público está desactivado",
"Public sharing has been disabled at the workspace level.": "El uso compartido público se ha desactivado a nivel de espacio de trabajo.",
"Public sharing has been disabled for this space.": "El uso compartido público se ha desactivado para este espacio.",
"Copy page": "Copiar página", "Copy page": "Copiar página",
"Copy page to a different space.": "Copiar página en otro espacio", "Copy page to a different space.": "Copiar página en otro espacio",
"Page copied successfully": "Página copiada exitosamente", "Page copied successfully": "Página copiada exitosamente",
@@ -553,67 +527,5 @@
"Delete SSO provider": "Eliminar proveedor de SSO", "Delete SSO provider": "Eliminar proveedor de SSO",
"Are you sure you want to delete this SSO provider?": "¿Está seguro de que desea eliminar este proveedor de SSO?", "Are you sure you want to delete this SSO provider?": "¿Está seguro de que desea eliminar este proveedor de SSO?",
"Action": "Acción", "Action": "Acción",
"{{ssoProviderType}} configuration": "Configuración de {{ssoProviderType}}", "{{ssoProviderType}} configuration": "Configuración de {{ssoProviderType}}"
"Icon": "Icono",
"Upload image": "Subir imagen",
"Remove image": "Eliminar imagen",
"Failed to remove image": "No se ha podido eliminar la imagen",
"Image exceeds 10MB limit.": "La imagen excede del límite de 10 MB",
"Image removed successfully": "Imagen eliminada correctamente",
"API key": "Clave API",
"API key created successfully": "Clave API creada correctamente",
"API keys": "Claves API",
"API management": "Gestión de API",
"Are you sure you want to revoke this API key": "¿Está seguro de que desea revocar esta clave API? ",
"Create API Key": "Crear clave API",
"Custom expiration date": "Fecha de vencimiento personalizada",
"Enter a descriptive token name": "Introduce un nombre descriptivo del token",
"Expiration": "Vencimiento",
"Expired": "Vencido",
"Expires": "Vence",
"I've saved my API key": "He guardado mi clave API",
"Last use": "Último uso",
"No API keys found": "No se han encontrado claves API",
"No expiration": "Sin vencimiento",
"Revoke API key": "Revocar clave API",
"Revoked successfully": "Revocada correctamente",
"Select expiration date": "Seleccionar fecha de vencimiento",
"This action cannot be undone. Any applications using this API key will stop working.": "Esta acción no se puede deshacer. Las aplicaciones que utilicen esta clave API dejarán de funcionar.",
"Update API key": "Actualizar clave API",
"Manage API keys for all users in the workspace": "Gestionar claves API para todos los usuarios en el espacio de trabajo",
"AI settings": "Configuración de IA",
"AI search": "Búsqueda de IA",
"AI Answer": "Respuesta de IA",
"Ask AI": "Preguntar a IA",
"AI is thinking...": "IA está pensando...",
"Ask a question...": "Haz una pregunta...",
"AI Answers": "Respuestas de IA",
"AI-powered search (AI Answers)": "Búsqueda impulsada por IA (Respuestas de IA)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La búsqueda de IA utiliza incrustaciones vectoriales para proporcionar capacidades de búsqueda semántica en todo el contenido de su espacio de trabajo.",
"Toggle AI search": "Alternar búsqueda de IA",
"Generative AI (Ask AI)": "IA generativa (Preguntar a la IA)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Habilitar la generación de contenido impulsada por IA en el editor. Permite a los usuarios generar, mejorar, traducir y transformar texto.",
"Toggle generative AI": "Activar IA generativa",
"Sources": "Fuentes",
"AI Answers not available for attachments": "Respuestas de IA no disponibles para archivos adjuntos",
"No answer available": "No hay respuesta disponible",
"Background color": "Color de fondo",
"Highlight color": "Color de resaltado",
"Remove color": "Eliminar color",
"Notifications": "Notificaciones",
"No notifications": "Sin notificaciones",
"No unread notifications": "No hay notificaciones no leídas",
"All notifications": "Todas las notificaciones",
"Unread only": "Solo no leídas",
"Mark all as read": "Marcar todo como leído",
"Mark as read": "Marcar como leído",
"More options": "Más opciones",
"mentioned you in a comment": "te mencionó en un comentario",
"commented on a page": "comentó en una página",
"resolved a comment": "resolvió un comentario",
"mentioned you on a page": "te mencionó en una página",
"Today": "Hoy",
"Yesterday": "Ayer",
"This week": "Esta semana",
"Older": "Más antiguo"
} }
@@ -29,7 +29,6 @@
"Choose your preferred interface language.": "Choisissez votre langue d'interface préférée.", "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.", "Choose your preferred page width.": "Choisissez votre largeur de page préférée.",
"Confirm": "Confirmer", "Confirm": "Confirmer",
"Copy as Markdown": "Copier comme Markdown",
"Copy link": "Copier le lien", "Copy link": "Copier le lien",
"Create": "Créer", "Create": "Créer",
"Create group": "Créer groupe", "Create group": "Créer groupe",
@@ -123,8 +122,6 @@
"page": "page", "page": "page",
"Page deleted successfully": "Page supprimée avec succès", "Page deleted successfully": "Page supprimée avec succès",
"Page history": "Historique de la page", "Page history": "Historique de la page",
"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.", "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.",
"Pages": "Pages", "Pages": "Pages",
"pages": "pages", "pages": "pages",
@@ -256,7 +253,6 @@
"Export failed:": "Échec de l'exportation :", "Export failed:": "Échec de l'exportation :",
"export error": "exporter l'erreur", "export error": "exporter l'erreur",
"Export page": "Exporter la page", "Export page": "Exporter la page",
"Export successful": "Exportation réussie",
"Export space": "Exporter l'espace", "Export space": "Exporter l'espace",
"Export {{type}}": "Exporter {{type}}", "Export {{type}}": "Exporter {{type}}",
"File exceeds the {{limit}} attachment limit": "Le fichier dépasse la limite de {{limit}} pièces jointes", "File exceeds the {{limit}} attachment limit": "Le fichier dépasse la limite de {{limit}} pièces jointes",
@@ -332,8 +328,6 @@
"Upload any image from your device.": "Téléchargez n'importe quelle image depuis votre appareil.", "Upload any image from your device.": "Téléchargez n'importe quelle image depuis votre appareil.",
"Upload any video from your device.": "Téléchargez n'importe quelle vidéo depuis votre appareil.", "Upload any video from your device.": "Téléchargez n'importe quelle vidéo depuis votre appareil.",
"Upload any file from your device.": "Téléchargez n'importe quel fichier depuis votre appareil.", "Upload any file from your device.": "Téléchargez n'importe quel fichier depuis votre appareil.",
"Uploading {{name}}": "Téléchargement de {{name}}",
"Uploading file": "Téléchargement du fichier",
"Table": "Tableau", "Table": "Tableau",
"Insert a table.": "Insérez un tableau.", "Insert a table.": "Insérez un tableau.",
"Insert collapsible block.": "Insérer un bloc repliable.", "Insert collapsible block.": "Insérer un bloc repliable.",
@@ -355,11 +349,6 @@
"Insert current date": "Insérer la date actuelle", "Insert current date": "Insérer la date actuelle",
"Draw and sketch excalidraw diagrams": "Dessiner et esquisser des diagrammes Excalidraw", "Draw and sketch excalidraw diagrams": "Dessiner et esquisser des diagrammes Excalidraw",
"Multiple": "Multiple", "Multiple": "Multiple",
"Turn into": "Transformer en",
"Text align": "Alignement du texte",
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
"Go to homepage": "Go to homepage",
"Pages you create will show up here.": "Pages you create will show up here.",
"Heading {{level}}": "Titre {{level}}", "Heading {{level}}": "Titre {{level}}",
"Toggle title": "Basculer le titre", "Toggle title": "Basculer le titre",
"Write anything. Enter \"/\" for commands": "Écrivez n'importe quoi. Entrez \"/\" pour les commandes", "Write anything. Enter \"/\" for commands": "Écrivez n'importe quoi. Entrez \"/\" pour les commandes",
@@ -412,21 +401,6 @@
"Share deleted successfully": "Partage supprimé avec succès", "Share deleted successfully": "Partage supprimé avec succès",
"Share not found": "Partage non trouvé", "Share not found": "Partage non trouvé",
"Failed to share page": "Échec du partage de la page", "Failed to share page": "Échec du partage de la page",
"Disable public sharing": "Désactiver le partage public",
"Prevent members from sharing pages publicly.": "Empêcher les membres de partager des pages publiquement.",
"Toggle public sharing": "Basculer le partage public",
"Toggle space public sharing": "Basculer le partage public de l'espace",
"Public sharing is disabled at the workspace level": "Le partage public est désactivé au niveau de l'espace de travail",
"Prevent pages in this space from being shared publicly.": "Empêcher les pages de cet espace d'être partagées publiquement.",
"Requires an enterprise license": "Nécessite une licence d'entreprise",
"Enable public sharing": "Activer le partage public",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Êtes-vous sûr de vouloir activer le partage public ? Les membres pourront partager des pages publiquement.",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Êtes-vous sûr de vouloir désactiver le partage public ? Tous les liens partagés existants dans cet espace de travail seront supprimés.",
"Are you sure you want to enable public sharing for this space?": "Êtes-vous sûr de vouloir activer le partage public pour cet espace ?",
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Êtes-vous sûr de vouloir désactiver le partage public ? Tous les liens partagés existants dans cet espace seront supprimés.",
"Public sharing is disabled": "Le partage public est désactivé",
"Public sharing has been disabled at the workspace level.": "Le partage public a été désactivé au niveau de l'espace de travail.",
"Public sharing has been disabled for this space.": "Le partage public a été désactivé pour cet espace.",
"Copy page": "Copier la page", "Copy page": "Copier la page",
"Copy page to a different space.": "Copier la page dans un autre espace.", "Copy page to a different space.": "Copier la page dans un autre espace.",
"Page copied successfully": "Page copiée avec succès", "Page copied successfully": "Page copiée avec succès",
@@ -553,67 +527,5 @@
"Delete SSO provider": "Supprimer le fournisseur SSO", "Delete SSO provider": "Supprimer le fournisseur SSO",
"Are you sure you want to delete this SSO provider?": "Êtes-vous sûr de vouloir supprimer ce fournisseur SSO ?", "Are you sure you want to delete this SSO provider?": "Êtes-vous sûr de vouloir supprimer ce fournisseur SSO ?",
"Action": "Action", "Action": "Action",
"{{ssoProviderType}} configuration": "Configuration {{ssoProviderType}}", "{{ssoProviderType}} configuration": "Configuration {{ssoProviderType}}"
"Icon": "Icône",
"Upload image": "Téléverser une image",
"Remove image": "Supprimer l'image",
"Failed to remove image": "Échec de la suppression de l'image",
"Image exceeds 10MB limit.": "L'image dépasse la limite de 10 Mo.",
"Image removed successfully": "Image supprimée avec succès",
"API key": "Clé API",
"API key created successfully": "Clé API créée avec succès",
"API keys": "Clés API",
"API management": "Gestion des API",
"Are you sure you want to revoke this API key": "Êtes-vous sûr de vouloir révoquer cette clé API",
"Create API Key": "Créer une clé API",
"Custom expiration date": "Date d'expiration personnalisée",
"Enter a descriptive token name": "Entrez un nom descriptif pour le jeton",
"Expiration": "Expiration",
"Expired": "Expiré(e)",
"Expires": "Expire",
"I've saved my API key": "J'ai enregistré ma clé API",
"Last use": "Dernière utilisation",
"No API keys found": "Aucune clé API trouvée",
"No expiration": "Pas d'expiration",
"Revoke API key": "Révoquer la clé API",
"Revoked successfully": "Révoqué(e) avec succès",
"Select expiration date": "Sélectionnez la date d'expiration",
"This action cannot be undone. Any applications using this API key will stop working.": "Cette action ne peut pas être annulée. Toutes les applications utilisant cette clé API cesseront de fonctionner.",
"Update API key": "Mettre à jour la clé API",
"Manage API keys for all users in the workspace": "Gérer les clés API pour tous les utilisateurs dans l'espace de travail",
"AI settings": "Paramètres de l'IA",
"AI search": "Recherche IA",
"AI Answer": "Réponse IA",
"Ask AI": "Demander à l'IA",
"AI is thinking...": "L'IA réfléchit...",
"Ask a question...": "Posez une question...",
"AI Answers": "Réponses IA",
"AI-powered search (AI Answers)": "Recherche propulsée par IA (Réponses IA)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La recherche IA utilise des incorporations vectorielles pour fournir des capacités de recherche sémantique à travers le contenu de votre espace de travail.",
"Toggle AI search": "Basculer la recherche IA",
"Generative AI (Ask AI)": "IA générative (Demandez à l'IA)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Activer la génération de contenu assistée par IA dans l'éditeur. Permet aux utilisateurs de générer, améliorer, traduire et transformer du texte.",
"Toggle generative AI": "Activer/désactiver l'IA générative",
"Sources": "Sources",
"AI Answers not available for attachments": "Réponses IA non disponibles pour les pièces jointes",
"No answer available": "Pas de réponse disponible",
"Background color": "Couleur de fond",
"Highlight color": "Couleur de surbrillance",
"Remove color": "Supprimer la couleur",
"Notifications": "Notifications",
"No notifications": "Aucune notification",
"No unread notifications": "Aucune notification non lue",
"All notifications": "Toutes les notifications",
"Unread only": "Non lues uniquement",
"Mark all as read": "Tout marquer comme lu",
"Mark as read": "Marquer comme lu",
"More options": "Plus d'options",
"mentioned you in a comment": "vous a mentionné dans un commentaire",
"commented on a page": "a commenté une page",
"resolved a comment": "a résolu un commentaire",
"mentioned you on a page": "vous a mentionné sur une page",
"Today": "Aujourd'hui",
"Yesterday": "Hier",
"This week": "Cette semaine",
"Older": "Plus ancien"
} }
@@ -29,7 +29,6 @@
"Choose your preferred interface language.": "Scegli la lingua da utilizzare per l'interfaccia.", "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.", "Choose your preferred page width.": "Scegli la larghezza della pagina che preferisci.",
"Confirm": "Conferma", "Confirm": "Conferma",
"Copy as Markdown": "Copia come Markdown",
"Copy link": "Copia link", "Copy link": "Copia link",
"Create": "Crea", "Create": "Crea",
"Create group": "Crea gruppo", "Create group": "Crea gruppo",
@@ -123,8 +122,6 @@
"page": "pagina", "page": "pagina",
"Page deleted successfully": "Pagina eliminata con successo", "Page deleted successfully": "Pagina eliminata con successo",
"Page history": "Cronologia della pagina", "Page history": "Cronologia della pagina",
"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.", "Page import is in progress. Please do not close this tab.": "L'importazione della pagina è in corso. Si prega di non chiudere questa scheda.",
"Pages": "Pagine", "Pages": "Pagine",
"pages": "pagine", "pages": "pagine",
@@ -256,7 +253,6 @@
"Export failed:": "Esportazione fallita:", "Export failed:": "Esportazione fallita:",
"export error": "errore di esportazione", "export error": "errore di esportazione",
"Export page": "Esporta pagina", "Export page": "Esporta pagina",
"Export successful": "Esportazione riuscita",
"Export space": "Esporta spazio", "Export space": "Esporta spazio",
"Export {{type}}": "Esporta {{type}}", "Export {{type}}": "Esporta {{type}}",
"File exceeds the {{limit}} attachment limit": "Il file supera il limite per gli allegati di {{limit}}", "File exceeds the {{limit}} attachment limit": "Il file supera il limite per gli allegati di {{limit}}",
@@ -332,8 +328,6 @@
"Upload any image from your device.": "Carica un'immagine dal tuo dispositivo.", "Upload any image from your device.": "Carica un'immagine dal tuo dispositivo.",
"Upload any video from your device.": "Carica qualsiasi video dal tuo dispositivo.", "Upload any video from your device.": "Carica qualsiasi video dal tuo dispositivo.",
"Upload any file from your device.": "Carica qualsiasi file dal tuo dispositivo.", "Upload any file from your device.": "Carica qualsiasi file dal tuo dispositivo.",
"Uploading {{name}}": "Caricamento di {{name}}",
"Uploading file": "Caricamento file",
"Table": "Tabella", "Table": "Tabella",
"Insert a table.": "Inserisci una tabella.", "Insert a table.": "Inserisci una tabella.",
"Insert collapsible block.": "Inserisci blocco comprimibile.", "Insert collapsible block.": "Inserisci blocco comprimibile.",
@@ -355,11 +349,6 @@
"Insert current date": "Inserisci la data corrente", "Insert current date": "Inserisci la data corrente",
"Draw and sketch excalidraw diagrams": "Disegna e schizza diagrammi excalidraw", "Draw and sketch excalidraw diagrams": "Disegna e schizza diagrammi excalidraw",
"Multiple": "Multiplo", "Multiple": "Multiplo",
"Turn into": "Trasforma in",
"Text align": "Allinea testo",
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
"Go to homepage": "Go to homepage",
"Pages you create will show up here.": "Pages you create will show up here.",
"Heading {{level}}": "Intestazione {{level}}", "Heading {{level}}": "Intestazione {{level}}",
"Toggle title": "Attiva/disattiva titolo", "Toggle title": "Attiva/disattiva titolo",
"Write anything. Enter \"/\" for commands": "Scrivi qualcosa. Digita \"/\" per i comandi", "Write anything. Enter \"/\" for commands": "Scrivi qualcosa. Digita \"/\" per i comandi",
@@ -412,21 +401,6 @@
"Share deleted successfully": "Condivisione eliminata con successo", "Share deleted successfully": "Condivisione eliminata con successo",
"Share not found": "Condivisione non trovata", "Share not found": "Condivisione non trovata",
"Failed to share page": "Condivisione della pagina fallita", "Failed to share page": "Condivisione della pagina fallita",
"Disable public sharing": "Disabilita la condivisione pubblica",
"Prevent members from sharing pages publicly.": "Impedisci ai membri di condividere pubblicamente le pagine.",
"Toggle public sharing": "Attiva/disattiva la condivisione pubblica",
"Toggle space public sharing": "Attiva/disattiva la condivisione pubblica nello spazio",
"Public sharing is disabled at the workspace level": "La condivisione pubblica è disabilitata a livello di area di lavoro",
"Prevent pages in this space from being shared publicly.": "Impedisci che le pagine in questo spazio vengano condivise pubblicamente.",
"Requires an enterprise license": "Richiede una licenza enterprise",
"Enable public sharing": "Abilita la condivisione pubblica",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Sei sicuro di voler abilitare la condivisione pubblica? I membri potranno condividere le pagine pubblicamente.",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Sei sicuro di voler disabilitare la condivisione pubblica? Tutti i link condivisi esistenti in questa area di lavoro verranno eliminati.",
"Are you sure you want to enable public sharing for this space?": "Sei sicuro di voler abilitare la condivisione pubblica per questo spazio?",
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Sei sicuro di voler disabilitare la condivisione pubblica? Tutti i link condivisi esistenti in questo spazio verranno eliminati.",
"Public sharing is disabled": "La condivisione pubblica è disabilitata",
"Public sharing has been disabled at the workspace level.": "La condivisione pubblica è stata disabilitata a livello di area di lavoro.",
"Public sharing has been disabled for this space.": "La condivisione pubblica è stata disabilitata per questo spazio.",
"Copy page": "Copia pagina", "Copy page": "Copia pagina",
"Copy page to a different space.": "Copia pagina in un altro spazio.", "Copy page to a different space.": "Copia pagina in un altro spazio.",
"Page copied successfully": "Pagina copiata con successo", "Page copied successfully": "Pagina copiata con successo",
@@ -553,67 +527,5 @@
"Delete SSO provider": "Elimina provider SSO", "Delete SSO provider": "Elimina provider SSO",
"Are you sure you want to delete this SSO provider?": "Sei sicuro di voler eliminare questo provider SSO?", "Are you sure you want to delete this SSO provider?": "Sei sicuro di voler eliminare questo provider SSO?",
"Action": "Azione", "Action": "Azione",
"{{ssoProviderType}} configuration": "Configurazione {{ssoProviderType}}", "{{ssoProviderType}} configuration": "Configurazione {{ssoProviderType}}"
"Icon": "Icona",
"Upload image": "Carica immagine",
"Remove image": "Rimuovi immagine",
"Failed to remove image": "Rimozione immagine fallita",
"Image exceeds 10MB limit.": "L'immagine supera il limite di 10MB.",
"Image removed successfully": "Immagine rimossa con successo",
"API key": "Chiave API",
"API key created successfully": "Chiave API creata con successo",
"API keys": "Chiavi API",
"API management": "Gestione API",
"Are you sure you want to revoke this API key": "Sei sicuro di voler revocare questa chiave API",
"Create API Key": "Crea Chiave API",
"Custom expiration date": "Data di scadenza personalizzata",
"Enter a descriptive token name": "Inserisci un nome descrittivo del token",
"Expiration": "Scadenza",
"Expired": "Scaduto",
"Expires": "Scade",
"I've saved my API key": "Ho salvato la mia chiave API",
"Last use": "Ultimo utilizzo",
"No API keys found": "Nessuna chiave API trovata",
"No expiration": "Nessuna scadenza",
"Revoke API key": "Revoca chiave API",
"Revoked successfully": "Revocata con successo",
"Select expiration date": "Seleziona la data di scadenza",
"This action cannot be undone. Any applications using this API key will stop working.": "Questa azione non può essere annullata. Qualsiasi applicazione che utilizza questa chiave API smetterà di funzionare.",
"Update API key": "Aggiorna chiave API",
"Manage API keys for all users in the workspace": "Gestisci le chiavi API per tutti gli utenti nell'area di lavoro",
"AI settings": "Impostazioni AI",
"AI search": "Ricerca AI",
"AI Answer": "Risposta AI",
"Ask AI": "Chiedi all'AI",
"AI is thinking...": "L'AI sta pensando...",
"Ask a question...": "Fai una domanda...",
"AI Answers": "Risposte AI",
"AI-powered search (AI Answers)": "Ricerca con AI (Risposte AI)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La ricerca AI utilizza embeddings vettoriali per fornire capacità di ricerca semantica nel contenuto della tua area di lavoro.",
"Toggle AI search": "Attiva/disattiva ricerca AI",
"Generative AI (Ask AI)": "AI generativa (Chiedi AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Abilita la generazione di contenuti con AI nell'editor. Consente agli utenti di generare, migliorare, tradurre e trasformare il testo.",
"Toggle generative AI": "Attiva/Disattiva AI generativa",
"Sources": "Fonti",
"AI Answers not available for attachments": "Risposte AI non disponibili per gli allegati",
"No answer available": "Nessuna risposta disponibile",
"Background color": "Colore di sfondo",
"Highlight color": "Colore evidenziato",
"Remove color": "Rimuovi colore",
"Notifications": "Notifiche",
"No notifications": "Nessuna notifica",
"No unread notifications": "Nessuna notifica non letta",
"All notifications": "Tutte le notifiche",
"Unread only": "Solo non lette",
"Mark all as read": "Segna tutto come letto",
"Mark as read": "Segna come letto",
"More options": "Altre opzioni",
"mentioned you in a comment": "ti ha menzionato in un commento",
"commented on a page": "ha commentato una pagina",
"resolved a comment": "ha risolto un commento",
"mentioned you on a page": "ti ha menzionato in una pagina",
"Today": "Oggi",
"Yesterday": "Ieri",
"This week": "Questa settimana",
"Older": "Più vecchie"
} }
+139 -227
View File
@@ -13,23 +13,22 @@
"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.": "このバージョンを復元してもよろしいですか? バージョン管理されていない変更は失われます。",
"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": "編集可能",
"Can manage workspace": "ワークスペースを管理できます", "Can manage workspace": "ワークスペースを管理できます",
"Can manage workspace but cannot delete it": "ワークスペースを管理できますが削除はできません", "Can manage workspace but cannot delete it": "ワークスペースを管理できますが削除はできません",
"Can view": "閲覧可能", "Can view": "閲覧可能",
"Can view pages in space but not edit.": "スペース内のページを閲覧できますが編集はできません", "Can view pages in space but not edit.": "スペース内のページを閲覧できますが編集はできません",
"Cancel": "キャンセル", "Cancel": "キャンセル",
"Change email": "メールアドレスの変更", "Change email": "メールアドレスの変更",
"Change password": "パスワードの変更", "Change password": "パスワードの変更",
"Change photo": "画像の変更", "Change photo": "画像の変更",
"Choose a role": "ロールを選んでください", "Choose a role": "ロールを選んでください",
"Choose your preferred color scheme.": "お好みのカラースキームを選択してください", "Choose your preferred color scheme.": "お好みのカラースキームを選択してください",
"Choose your preferred interface language.": "お好みの言語を選択してください", "Choose your preferred interface language.": "お好みのインターフェース言語を選択してください",
"Choose your preferred page width.": "お好みのページ幅を選択してください", "Choose your preferred page width.": "左右の余白を縮小する場合はオンにしてください",
"Confirm": "確認", "Confirm": "確認",
"Copy as Markdown": "Markdownとしてコピー",
"Copy link": "リンクをコピー", "Copy link": "リンクをコピー",
"Create": "新規作成", "Create": "新規作成",
"Create group": "グループを作成", "Create group": "グループを作成",
@@ -41,24 +40,24 @@
"Date": "日付", "Date": "日付",
"Delete": "削除", "Delete": "削除",
"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": "説明",
"Details": "詳細", "Details": "詳細",
"e.g ACME": "例: 山田太郎", "e.g ACME": "例: 山田太郎",
"e.g ACME Inc": "例: 株式会社サンプル", "e.g ACME Inc": "例: 株式会社サンプル",
"e.g Developers": "例: エンジニア", "e.g Developers": "例: エンジニア",
"e.g Group for developers": "例: 開発チーム", "e.g Group for developers": "例: エンジニアグループ",
"e.g product": "例: product", "e.g product": "例: product",
"e.g Product Team": "例: プロダクトチーム", "e.g Product Team": "例: 製品チーム",
"e.g Sales": "例: 営業", "e.g Sales": "例: 営業",
"e.g Space for product team": "例: プロダクトチームスペース", "e.g Space for product team": "例: 製品チームスペース",
"e.g Space for sales team to collaborate": "例: 営業チーム用スペース", "e.g Space for sales team to collaborate": "例: 営業チーム連携用スペース",
"Edit": "編集", "Edit": "編集",
"Read": "閲覧", "Read": "読む",
"Edit group": "グループを編集", "Edit group": "グループを編集",
"Email": "メールアドレス", "Email": "メールアドレス",
"Enter a strong password": "強力なパスワードを入力してください", "Enter a strong password": "強力なパスワードを入力してください",
"Enter valid email addresses separated by comma or space max_50": "メールアドレスをカンマまたはスペース区切りで入力(最大50", "Enter valid email addresses separated by comma or space max_50": "有効なメールアドレスをカンマまたはスペース区切って入力してください(最大 50",
"enter valid emails addresses": "有効なメールアドレスを入力してください", "enter valid emails addresses": "有効なメールアドレスを入力してください",
"Enter your current password": "現在のパスワードを入力してください", "Enter your current password": "現在のパスワードを入力してください",
"enter your full name": "氏名を入力してください", "enter your full name": "氏名を入力してください",
@@ -82,18 +81,18 @@
"Group description": "グループ説明", "Group description": "グループ説明",
"Group name": "グループ名", "Group name": "グループ名",
"Groups": "グループ", "Groups": "グループ",
"Has full access to space settings and pages.": "スペース設定とページにフルアクセスできます", "Has full access to space settings and pages.": "スペース設定とページにフルアクセスできます",
"Home": "ホーム", "Home": "ホーム",
"Import pages": "ページをインポート", "Import pages": "ページをインポート",
"Import pages & space settings": "ページとスペース設定をインポート", "Import pages & space settings": "ページとスペース設定をインポート",
"Importing pages": "ページをインポートしています", "Importing pages": "ページをインポートしています",
"invalid invitation link": "無効な招待リンクす", "invalid invitation link": "招待リンクが間違っています",
"Invitation signup": "招待登録", "Invitation signup": "招待登録",
"Invite by email": "メールアドレスで招待する", "Invite by email": "メールアドレスで招待する",
"Invite members": "メンバーを招待する", "Invite members": "メンバーを招待する",
"Invite new members": "新しいメンバーを招待する", "Invite new members": "新しいメンバーを招待する",
"Invited members who are yet to accept their invitation will appear here.": "招待を承諾していないメンバーここに表示されます", "Invited members who are yet to accept their invitation will appear here.": "招待をまだ承諾していないメンバーここに表示されます",
"Invited members will be granted access to spaces the groups can access": "招待されたメンバーはグループがアクセスできるスペースにアクセスできます", "Invited members will be granted access to spaces the groups can access": "招待されたメンバーはグループがアクセスできるスペースにアクセス権が付与されます",
"Join the workspace": "ワークスペースに参加", "Join the workspace": "ワークスペースに参加",
"Language": "言語", "Language": "言語",
"Light": "ライト", "Light": "ライト",
@@ -114,22 +113,20 @@
"New page": "新規ページ", "New page": "新規ページ",
"New password": "新しいパスワード", "New password": "新しいパスワード",
"No group found": "グループが見つかりません", "No group found": "グループが見つかりません",
"No page history saved yet.": "ページ履歴がありません", "No page history saved yet.": "まだページ履歴が保存されていません",
"No pages yet": "ページがありません", "No pages yet": "ページがありません",
"No results found...": "結果が見つかりません", "No results found...": "結果が見つかりませんでした...",
"No user found": "ユーザーが見つかりません", "No user found": "ユーザがいません",
"Overview": "概要", "Overview": "概要",
"Owner": "所有者", "Owner": "所有者",
"page": "ページ", "page": "ページ",
"Page deleted successfully": "ページを削除しました", "Page deleted successfully": "ページが正常に削除されました",
"Page history": "ページ履歴", "Page history": "ページ履歴",
"Select version": "ージョンを選択", "Page import is in progress. Please do not close this tab.": "ージのインポートが進行中です。このタブを閉じないでください。",
"Highlight changes": "変更を強調表示",
"Page import is in progress. Please do not close this tab.": "ページをインポート中です。このタブを閉じないでください",
"Pages": "ページ", "Pages": "ページ",
"pages": "ページ", "pages": "ページ",
"Password": "パスワード", "Password": "パスワード",
"Password changed successfully": "パスワードを変更しました", "Password changed successfully": "パスワードが正常に変更されました",
"Pending": "保留中", "Pending": "保留中",
"Please confirm your action": "アクションを確認してください", "Please confirm your action": "アクションを確認してください",
"Preferences": "設定", "Preferences": "設定",
@@ -146,95 +143,95 @@
"Search for groups": "グループを検索", "Search for groups": "グループを検索",
"Search for users": "ユーザーを検索", "Search for users": "ユーザーを検索",
"Search for users and groups": "ユーザーとグループを検索", "Search for users and groups": "ユーザーとグループを検索",
"Search...": "検索", "Search...": "検索する語句を入力",
"Select language": "言語を選択", "Select language": "言語を選択",
"Select role": "ロールを選択", "Select role": "ロールを選択",
"Select role to assign to all invited members": "招待するメンバーに割り当てるロールを選択", "Select role to assign to all invited members": "招待されたすべてのメンバーに割り当てるロールを選択してください",
"Select theme": "テーマを選択", "Select theme": "テーマを選択",
"Send invitation": "招待を送る", "Send invitation": "招待を送る",
"Invitation sent": "招待送信ました", "Invitation sent": "招待送信されました",
"Settings": "設定", "Settings": "設定",
"Setup workspace": "ワークスペースを設定する", "Setup workspace": "ワークスペースを設定する",
"Sign In": "サインイン", "Sign In": "サインイン",
"Sign Up": "新規登録", "Sign Up": "アカウント登録",
"Slug": "スラッグ(URL識別子)", "Slug": "Slug (URL用文字列)",
"Space": "スペース", "Space": "スペース",
"Space description": "スペース説明", "Space description": "スペース説明",
"Space menu": "スペースメニュー", "Space menu": "スペースメニュー",
"Space name": "スペース名", "Space name": "スペース名",
"Space settings": "スペース設定", "Space settings": "スペース設定",
"Space slug": "スペースのスラッグ(URL識別子)", "Space slug": "スペースのSlug (URL用文字列)",
"Spaces": "スペース", "Spaces": "スペース",
"Spaces you belong to": "所属しているスペース", "Spaces you belong to": "所属しているスペース",
"No space found": "スペースが見つかりません", "No space found": "スペースが見つかりません",
"Search for spaces": "スペースを検索", "Search for spaces": "スペースを検索",
"Start typing to search...": "入力して検索", "Start typing to search...": "検索を開始するには入力してください...",
"Status": "ステータス", "Status": "ステータス",
"Successfully imported": "インポートしました", "Successfully imported": "インポートに成功しました",
"Successfully restored": "復元しました", "Successfully restored": "正常に復元されました",
"System settings": "システム設定", "System settings": "システム設定",
"Theme": "テーマ", "Theme": "テーマ",
"To change your email, you have to enter your password and new email.": "メールアドレスを変更するには、パスワードと新しいメールアドレスを入力してください", "To change your email, you have to enter your password and new email.": "メールアドレスを変更するには、パスワードと新しいメールアドレスを入力する必要があります。",
"Toggle full page width": "ページ幅を切り替え", "Toggle full page width": "ページ幅を切り替え",
"Unable to import pages. Please try again.": "ページをインポートできませんでした。もう一度お試しください", "Unable to import pages. Please try again.": "ページをインポートできません。もう一度お試しください",
"untitled": "無題", "untitled": "無題",
"Untitled": "無題", "Untitled": "無題",
"Updated successfully": "更新しました", "Updated successfully": "正常に更新されました",
"User": "ユーザー", "User": "ユーザー",
"Workspace": "ワークスペース", "Workspace": "ワークスペース",
"Workspace Name": "ワークスペース名", "Workspace Name": "ワークスペース名",
"Workspace settings": "ワークスペース設定", "Workspace settings": "ワークスペース設定",
"You can change your password here.": "パスワードを変更できます", "You can change your password here.": "パスワードを変更できます",
"Your Email": "メールアドレス", "Your Email": "メールアドレス",
"Your import is complete.": "インポートが完了しました", "Your import is complete.": "インポートが完了しました",
"Your name": "名前", "Your name": "名前",
"Your Name": "名前", "Your Name": "名前",
"Your password": "パスワード", "Your password": "パスワード",
"Your password must be a minimum of 8 characters.": "パスワードは8文字以上にしてください", "Your password must be a minimum of 8 characters.": "パスワードは最低 8 文字必要です。",
"Sidebar toggle": "サイドバー切り替え", "Sidebar toggle": "サイドバー切り替え",
"Comments": "コメント", "Comments": "コメント",
"404 page not found": "404 ページが見つかりません", "404 page not found": "404 ページが見つかりません",
"Sorry, we can't find the page you are looking for.": "お探しのページが見つかりません", "Sorry, we can't find the page you are looking for.": "お探しのページが見つかりません",
"Take me back to homepage": "ホームに戻る", "Take me back to homepage": "ホームに戻る",
"Forgot password": "パスワードを忘れた", "Forgot password": "パスワードを忘れた",
"Forgot your password?": "パスワードを忘れましたか?", "Forgot your password?": "パスワードを忘れましたか?",
"A password reset link has been sent to your email. Please check your inbox.": "パスワードリセット用のリンクをメールに送信ました。受信トレイを確認してください", "A password reset link has been sent to your email. Please check your inbox.": "パスワードリセットリンクがあなたのメールアドレスに送信されました。受信を確認してください",
"Send reset link": "リセットリンクを送", "Send reset link": "リセットリンクを送",
"Password reset": "パスワードリセット", "Password reset": "パスワードリセット",
"Your new password": "新しいパスワード", "Your new password": "新しいパスワード",
"Set password": "パスワードを設定", "Set password": "パスワードを設定",
"Write a comment": "コメントを書く", "Write a comment": "コメントを書く",
"Reply...": "返信...", "Reply...": "返信...",
"Error loading comments.": "コメントの読み込みに失敗しました", "Error loading comments.": "コメントの読み込み中にエラーが発生しました",
"No comments yet.": "コメントがありません", "No comments yet.": "コメントがありません",
"Edit comment": "コメントを編集する", "Edit comment": "コメントを編集する",
"Delete comment": "コメントを削除する", "Delete comment": "コメントを削除する",
"Are you sure you want to delete this comment?": "このコメントを削除してもよろしいですか?", "Are you sure you want to delete this comment?": "このコメントを削除してもよろしいですか?",
"Comment created successfully": "コメント作成ました", "Comment created successfully": "コメント作成されました",
"Error creating comment": "コメントの作成に失敗しました", "Error creating comment": "コメントの作成中にエラーが発生しました",
"Comment updated successfully": "コメント更新ました", "Comment updated successfully": "コメント更新されました",
"Failed to update comment": "コメントの更新に失敗しました", "Failed to update comment": "コメントの更新に失敗しました",
"Comment deleted successfully": "コメント削除ました", "Comment deleted successfully": "コメント削除されました",
"Failed to delete comment": "コメントの削除に失敗しました", "Failed to delete comment": "コメントの削除に失敗しました",
"Comment resolved successfully": "コメント解決ました", "Comment resolved successfully": "コメント解決されました",
"Comment re-opened successfully": "コメント再開ました", "Comment re-opened successfully": "コメント再開されました",
"Comment unresolved successfully": "コメントを未解決に戻しました", "Comment unresolved successfully": "コメントが再解決されました",
"Failed to resolve comment": "コメントの解決に失敗しました", "Failed to resolve comment": "コメントの解決に失敗しました",
"Resolve comment": "コメントを解決", "Resolve comment": "コメントを解決",
"Unresolve comment": "コメントを解決に戻す", "Unresolve comment": "コメントを解決",
"Resolve Comment Thread": "コメントスレッドを解決", "Resolve Comment Thread": "コメントスレッドを解決",
"Unresolve Comment Thread": "コメントスレッドを解決に戻す", "Unresolve Comment Thread": "コメントスレッドを解決",
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "このコメントスレッドを解決しますか完了としてマークされます", "Are you sure you want to resolve this comment thread? This will mark it as completed.": "このコメントスレッドを解決しますか? これにより完了としてマークされます",
"Are you sure you want to unresolve this comment thread?": "このコメントスレッドを解決に戻しますか?", "Are you sure you want to unresolve this comment thread?": "このコメントスレッドを解決しますか?",
"Resolved": "解決済", "Resolved": "解決済",
"No active comments.": "アクティブなコメントはありません", "No active comments.": "アクティブなコメントはありません",
"No resolved comments.": "解決済みのコメントはありません", "No resolved comments.": "解決されたコメントはありません",
"Revoke invitation": "招待を取り消す", "Revoke invitation": "招待を取り消す",
"Revoke": "取り消す", "Revoke": "取り消す",
"Don't": "取り消さない", "Don't": "取り消さない",
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "この招待を取り消してもよろしいですかユーザはワークスペースに参加できなくなります", "Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "この招待を取り消してもよろしいですか? ユーザはワークスペースに参加できなくなります",
"Resend invitation": "招待を再度送る", "Resend invitation": "招待を再度送る",
"Anyone with this link can join this workspace.": "このリンクをっている人は誰でもワークスペースに参加できます", "Anyone with this link can join this workspace.": "このリンクをっている人は誰でもこのワークスペースに参加できます",
"Invite link": "招待リンク", "Invite link": "招待リンク",
"Copy": "コピー", "Copy": "コピー",
"Copy to space": "スペースにコピー", "Copy to space": "スペースにコピー",
@@ -242,13 +239,13 @@
"Duplicate": "複製", "Duplicate": "複製",
"Select a user": "ユーザを選択", "Select a user": "ユーザを選択",
"Select a group": "グループを選択", "Select a group": "グループを選択",
"Export all pages and attachments in this space.": "このスペースのすべてのページと添付ファイルをエクスポートします", "Export all pages and attachments in this space.": "このスペースのすべてのページと添付ファイルをエクスポートします",
"Delete space": "スペースを削除", "Delete space": "スペースを削除",
"Are you sure you want to delete this space?": "このスペースを削除してもよろしいですか?", "Are you sure you want to delete this space?": "このスペースを削除してもよろしいですか?",
"Delete this space with all its pages and data.": "このスペースすべてのページデータを削除します", "Delete this space with all its pages and data.": "このスペースおよびスペース内のすべてのページデータを削除します",
"All pages, comments, attachments and permissions in this space will be deleted irreversibly.": "スペース内のすべてのページ、コメント、添付ファイル、権限完全に削除されます", "All pages, comments, attachments and permissions in this space will be deleted irreversibly.": "このスペース内のすべてのページ、コメント、添付ファイル、および権限完全に削除されます",
"Confirm space name": "スペース名を確認する", "Confirm space name": "スペース名を確認する",
"Type the space name <b>{{spaceName}}</b> to confirm your action.": "確認のためスペース名 <b>{{spaceName}}</b> を入力してください", "Type the space name <b>{{spaceName}}</b> to confirm your action.": "アクションを確認するためスペース名 <b>{{spaceName}}</b> を入力してください",
"Format": "フォーマット", "Format": "フォーマット",
"Include subpages": "サブページを含める", "Include subpages": "サブページを含める",
"Include attachments": "添付ファイルを含める", "Include attachments": "添付ファイルを含める",
@@ -256,7 +253,6 @@
"Export failed:": "エクスポートに失敗しました:", "Export failed:": "エクスポートに失敗しました:",
"export error": "エクスポートエラー", "export error": "エクスポートエラー",
"Export page": "エクスポートページ", "Export page": "エクスポートページ",
"Export successful": "エクスポート成功",
"Export space": "エクスポートスペース", "Export space": "エクスポートスペース",
"Export {{type}}": "{{type}}をエクスポート", "Export {{type}}": "{{type}}をエクスポート",
"File exceeds the {{limit}} attachment limit": "ファイルが{{limit}}の添付制限を超えています", "File exceeds the {{limit}} attachment limit": "ファイルが{{limit}}の添付制限を超えています",
@@ -277,12 +273,12 @@
"Success": "成功", "Success": "成功",
"Warning": "警告", "Warning": "警告",
"Danger": "危険", "Danger": "危険",
"Mermaid diagram error:": "Mermaid ダイアグラムエラー:", "Mermaid diagram error:": "Mermaid コードエラー",
"Invalid Mermaid diagram": "無効な Mermaid ダイアグラムです", "Invalid Mermaid diagram": "無効な Mermaid コードです",
"Double-click to edit Draw.io diagram": "ダブルクリックして Draw.io 図を編集", "Double-click to edit Draw.io diagram": "ダブルクリックしてDraw.io図を編集",
"Exit": "終了", "Exit": "終了",
"Save & Exit": "保存して終了", "Save & Exit": "保存して終了",
"Double-click to edit Excalidraw diagram": "ダブルクリックして Excalidraw 図を編集", "Double-click to edit Excalidraw diagram": "ダブルクリックしてExcalidraw図を編集",
"Paste link": "リンクを貼り付け", "Paste link": "リンクを貼り付け",
"Edit link": "リンクを編集", "Edit link": "リンクを編集",
"Remove link": "リンクを削除", "Remove link": "リンクを削除",
@@ -319,24 +315,22 @@
"Bullet List": "箇条書きリスト", "Bullet List": "箇条書きリスト",
"Numbered List": "番号付きリスト", "Numbered List": "番号付きリスト",
"Blockquote": "引用", "Blockquote": "引用",
"Just start typing with plain text.": "プレーンテキストを入力します", "Just start typing with plain text.": "すぐに文章を書き始められます",
"Track tasks with a to-do list.": "Todo リストでタスクを管理します", "Track tasks with a to-do list.": "Todoリストでタスクを追跡します",
"Big section heading.": "大見出し", "Big section heading.": "大きいフォントのセクション見出しです。",
"Medium section heading.": "中見出し", "Medium section heading.": "中くらいのフォントのセクション見出しです。",
"Small section heading.": "小見出し", "Small section heading.": "小さいフォントのセクション見出しです。",
"Create a simple bullet list.": "箇条書きリストを作成します", "Create a simple bullet list.": "シンプルな箇条書きリストを作成します",
"Create a list with numbering.": "番号付きリストを作成します", "Create a list with numbering.": "番号付きリストを作成します",
"Create block quote.": "引用ブロックを作成します", "Create block quote.": "引用を作成します",
"Insert code snippet.": "コードスニペットを入します", "Insert code snippet.": "コードスニペットを入します",
"Insert horizontal rule divider": "区切り線を挿入します", "Insert horizontal rule divider": "水平線を挿入します",
"Upload any image from your device.": "デバイスから画像をアップロードします", "Upload any image from your device.": "画像をアップロードします",
"Upload any video from your device.": "デバイスから動画をアップロードします", "Upload any video from your device.": "動画をアップロードします",
"Upload any file from your device.": "デバイスからファイルをアップロードします", "Upload any file from your device.": "ファイルをアップロードします",
"Uploading {{name}}": "{{name}} をアップロード中",
"Uploading file": "ファイルをアップロード中",
"Table": "テーブル", "Table": "テーブル",
"Insert a table.": "テーブルを挿入します", "Insert a table.": "を挿入します",
"Insert collapsible block.": "折りたたみブロックを挿入します", "Insert collapsible block.": "折りたたみ可能なブロックを挿入します",
"Video": "動画", "Video": "動画",
"Divider": "区切り線", "Divider": "区切り線",
"Quote": "引用", "Quote": "引用",
@@ -344,22 +338,17 @@
"File attachment": "ファイル添付", "File attachment": "ファイル添付",
"Toggle block": "ブロックを切り替える", "Toggle block": "ブロックを切り替える",
"Callout": "コールアウト", "Callout": "コールアウト",
"Insert callout notice.": "コールアウトを挿入します", "Insert callout notice.": "コールアウトブロックを挿入します",
"Math inline": "インライン数式", "Math inline": "インライン数式",
"Insert inline math equation.": "インライン数式を挿入します", "Insert inline math equation.": "インライン数式を挿入します",
"Math block": "数式ブロック", "Math block": "数式ブロック",
"Insert math equation": "数式を挿入します", "Insert math equation": "数式を挿入します",
"Mermaid diagram": "Mermaid ダイアグラム", "Mermaid diagram": "Mermaidコード",
"Insert mermaid diagram": "Mermaid ダイアグラムを挿入します", "Insert mermaid diagram": "Mermaidコードを記述して図を挿入します",
"Insert and design Drawio diagrams": "Draw.io 図を挿入・編集します", "Insert and design Drawio diagrams": "Drawio図を挿入してデザインします",
"Insert current date": "現在の日付を挿入します", "Insert current date": "今日の日付を挿入します",
"Draw and sketch excalidraw diagrams": "Excalidraw 図を挿入します", "Draw and sketch excalidraw diagrams": "Excalidraw図を埋め込みます",
"Multiple": "複数", "Multiple": "複数",
"Turn into": "変換する",
"Text align": "テキストの配置",
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
"Go to homepage": "Go to homepage",
"Pages you create will show up here.": "Pages you create will show up here.",
"Heading {{level}}": "見出し {{level}}", "Heading {{level}}": "見出し {{level}}",
"Toggle title": "タイトルの表示/非表示を切り替える", "Toggle title": "タイトルの表示/非表示を切り替える",
"Write anything. Enter \"/\" for commands": "文字を入力するか、「/」でコマンドを呼び出します", "Write anything. Enter \"/\" for commands": "文字を入力するか、「/」でコマンドを呼び出します",
@@ -368,29 +357,29 @@
"Yesterday, {{time}}": "昨日、{{time}}", "Yesterday, {{time}}": "昨日、{{time}}",
"Space created successfully": "スペースを作成しました", "Space created successfully": "スペースを作成しました",
"Space updated successfully": "スペースを更新しました", "Space updated successfully": "スペースを更新しました",
"Space deleted successfully": "スペース削除ました", "Space deleted successfully": "スペース削除されました",
"Members added successfully": "メンバーを追加しました", "Members added successfully": "メンバーを追加しました",
"Member removed successfully": "メンバー削除ました", "Member removed successfully": "メンバー削除されました",
"Member role updated successfully": "メンバーのロールを更新しました", "Member role updated successfully": "メンバーのロールを更新しました",
"Created by: <b>{{creatorName}}</b>": "作成者: <b>{{creatorName}}</b>", "Created by: <b>{{creatorName}}</b>": "作成者: <b>{{creatorName}}</b>",
"Created at: {{time}}": "作成日: {{time}}", "Created at: {{time}}": "作成しました:{{time}}",
"Edited by {{name}} {{time}}": "最終編集: {{name}} {{time}}", "Edited by {{name}} {{time}}": "最終編集: {{name}} {{time}}",
"Word count: {{wordCount}}": "単語数: {{wordCount}}", "Word count: {{wordCount}}": "ワード数: {{wordCount}}",
"Character count: {{characterCount}}": "文字数: {{characterCount}}", "Character count: {{characterCount}}": "文字数: {{characterCount}}",
"New update": "新規更新", "New update": "新規更新",
"{{latestVersion}} is available": "{{latestVersion}}利用可能です", "{{latestVersion}} is available": "{{latestVersion}}利用可能です",
"Default page edit mode": "デフォルトのページ編集モード", "Default page edit mode": "デフォルトのページ編集モード",
"Choose your preferred page edit mode. Avoid accidental edits.": "お好みのページ編集モードを選択してください(誤編集を防止します", "Choose your preferred page edit mode. Avoid accidental edits.": "希望のページ編集モードを選択してください。誤って編集を防ます",
"Reading": "読み取り", "Reading": "読み取り",
"Delete member": "メンバーを削除する", "Delete member": "メンバーを削除する",
"Member deleted successfully": "メンバー削除ました", "Member deleted successfully": "メンバー削除されました",
"Are you sure you want to delete this workspace member? This action is irreversible.": "このメンバーを削除してもよろしいですか?この操作は取り消せません", "Are you sure you want to delete this workspace member? This action is irreversible.": "ワークスペースメンバーを削除してもよろしいですか?この操作は元に戻せません",
"Move": "移動", "Move": "移動",
"Move page": "ページを移動", "Move page": "ページを移動",
"Move page to a different space.": "ページを別のスペースに移動します", "Move page to a different space.": "ページを別のスペースに移動します",
"Real-time editor connection lost. Retrying...": "リアルタイム編集の接続が切断されました。再接続中...", "Real-time editor connection lost. Retrying...": "リアルタイムエディターの接続が失われました。再試行しています…",
"Table of contents": "目次", "Table of contents": "目次",
"Add headings (H1, H2, H3) to generate a table of contents.": "見出し(H1、H2、H3)を追加すると目次生成されます", "Add headings (H1, H2, H3) to generate a table of contents.": "見出し(H1、H2、H3)を追加して目次生成ます",
"Share": "共有", "Share": "共有",
"Public sharing": "公開共有", "Public sharing": "公開共有",
"Shared by": "共有者", "Shared by": "共有者",
@@ -409,28 +398,13 @@
"Delete share": "共有を削除", "Delete share": "共有を削除",
"Are you sure you want to delete this shared link?": "この共有リンクを削除してもよろしいですか?", "Are you sure you want to delete this shared link?": "この共有リンクを削除してもよろしいですか?",
"Publicly shared pages from spaces you are a member of will appear here": "メンバーであるスペースからの公開ページがここに表示されます", "Publicly shared pages from spaces you are a member of will appear here": "メンバーであるスペースからの公開ページがここに表示されます",
"Share deleted successfully": "共有を削除しました", "Share deleted successfully": "共有が正常に削除されました",
"Share not found": "共有が見つかりません", "Share not found": "共有が見つかりません",
"Failed to share page": "ページの共有に失敗しました", "Failed to share page": "ページの共有に失敗しました",
"Disable public sharing": "公開共有を無効にする",
"Prevent members from sharing pages publicly.": "メンバーがページを公開で共有するのを防ぐ。",
"Toggle public sharing": "公開共有を切り替える",
"Toggle space public sharing": "スペースの公開共有を切り替える",
"Public sharing is disabled at the workspace level": "ワークスペースレベルで公開共有が無効になっています",
"Prevent pages in this space from being shared publicly.": "このスペース内のページが公開で共有されるのを防ぐ。",
"Requires an enterprise license": "エンタープライズライセンスが必要です",
"Enable public sharing": "公開共有を有効にする",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "本当に公開共有を有効にしますか?メンバーはページを公開で共有できるようになります。",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "本当に公開共有を無効にしますか?このワークスペース内のすべての既存の共有リンクが削除されます。",
"Are you sure you want to enable public sharing for this space?": "本当にこのスペースの公開共有を有効にしますか?",
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "本当に公開共有を無効にしますか?このスペースのすべての既存の共有リンクが削除されます。",
"Public sharing is disabled": "公開共有が無効になっています",
"Public sharing has been disabled at the workspace level.": "ワークスペースレベルで公開共有が無効になりました。",
"Public sharing has been disabled for this space.": "このスペースで公開共有が無効になりました。",
"Copy page": "ページをコピー", "Copy page": "ページをコピー",
"Copy page to a different space.": "ページを別のスペースにコピーします", "Copy page to a different space.": "ページを別のスペースにコピーします",
"Page copied successfully": "ページコピーしました", "Page copied successfully": "ページコピーに成功しました",
"Page duplicated successfully": "ページを複製しました", "Page duplicated successfully": "ページが正常に複製されました",
"Find": "検索", "Find": "検索",
"Not found": "見つかりません", "Not found": "見つかりません",
"Previous Match (Shift+Enter)": "前の一致 (Shift+Enter)", "Previous Match (Shift+Enter)": "前の一致 (Shift+Enter)",
@@ -445,26 +419,26 @@
"Error": "エラー", "Error": "エラー",
"Failed to disable MFA": "MFAの無効化に失敗しました", "Failed to disable MFA": "MFAの無効化に失敗しました",
"Disable two-factor authentication": "二要素認証を無効化", "Disable two-factor authentication": "二要素認証を無効化",
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "二要素認証を無効すると、アカウントのセキュリティが低下します。サインインにはパスワードのみが必要になります", "Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "二要素認証を無効すると、アカウントのセキュリティが低下します。サインインにはパスワードのみが必要になります",
"Please enter your password to disable two-factor authentication:": "二要素認証を無効するにはパスワードを入力してください", "Please enter your password to disable two-factor authentication:": "二要素認証を無効するにはパスワードを入力してください:",
"Two-factor authentication has been enabled": "二要素認証有効にました", "Two-factor authentication has been enabled": "二要素認証有効になりました",
"Two-factor authentication has been disabled": "二要素認証無効にました", "Two-factor authentication has been disabled": "二要素認証無効になりました",
"2-step verification": "2段階認", "2-step verification": "2段階認",
"Protect your account with an additional verification layer when signing in.": "サインイン時に追加の認証でアカウントを保護します", "Protect your account with an additional verification layer when signing in.": "サインイン時に追加の認証レイヤーでアカウントを保護します",
"Two-factor authentication is active on your account.": "二要素認証が有効です", "Two-factor authentication is active on your account.": "二要素認証がアカウントで有効です",
"Add 2FA method": "2FAメソッドを追加", "Add 2FA method": "2FAメソッドを追加",
"Backup codes": "バックアップコード", "Backup codes": "バックアップコード",
"Disable": "無効にする", "Disable": "無効にする",
"Invalid verification code": "無効な認証コード", "Invalid verification code": "無効な認証コード",
"New backup codes have been generated": "新しいバックアップコード生成ました", "New backup codes have been generated": "新しいバックアップコード生成されました",
"Failed to regenerate backup codes": "バックアップコードの再生成に失敗しました", "Failed to regenerate backup codes": "バックアップコードの再生成に失敗しました",
"About backup codes": "バックアップコードについて", "About backup codes": "バックアップコードについて",
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "認証アプリアクセスできない場合、バックアップコードでアカウントにアクセスできます。各コードは1回のみ使用可能です", "Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "バックアップコードは、認証アプリへのアクセスを失った場合にアカウントにアクセスするために使用できます。各コードは一度しか使用できません。",
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "新しいバックアップコードはいつでも再生成できます。既存のコードはすべて無効になります", "You can regenerate new backup codes at any time. This will invalidate all existing codes.": "いつでも新しいバックアップコード再生成できます。これにより、既存のすべてのコードが無効になります",
"Confirm password": "パスワードを確認", "Confirm password": "パスワードを確認",
"Generate new backup codes": "新しいバックアップコードを生成", "Generate new backup codes": "新しいバックアップコードを生成",
"Save your new backup codes": "新しいバックアップコードを保存", "Save your new backup codes": "新しいバックアップコードを保存",
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "これらのコードを安全な場所に保存してください。古いバックアップコードは無効になりました", "Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "これらのコードを安全な場所に保存してください。古いバックアップコードは無効です。",
"Your new backup codes": "新しいバックアップコード", "Your new backup codes": "新しいバックアップコード",
"I've saved my backup codes": "バックアップコードを保存しました", "I've saved my backup codes": "バックアップコードを保存しました",
"Failed to setup MFA": "MFAの設定に失敗しました", "Failed to setup MFA": "MFAの設定に失敗しました",
@@ -475,51 +449,51 @@
"Enter this code manually in your authenticator app:": "このコードを認証アプリに手動で入力してください:", "Enter this code manually in your authenticator app:": "このコードを認証アプリに手動で入力してください:",
"2. Enter the 6-digit code from your authenticator": "2. 認証アプリからの6桁のコードを入力してください", "2. Enter the 6-digit code from your authenticator": "2. 認証アプリからの6桁のコードを入力してください",
"Verify and enable": "確認と有効化", "Verify and enable": "確認と有効化",
"Failed to generate QR code. Please try again.": "QRコードの生成に失敗しました。もう一度お試しください", "Failed to generate QR code. Please try again.": "QRコードの生成に失敗しました。再試行してください",
"Backup": "バックアップ", "Backup": "バックアップ",
"Save codes": "コードを保存", "Save codes": "コードを保存",
"Save your backup codes": "バックアップコードを保存", "Save your backup codes": "バックアップコードを保存",
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "認証アプリアクセスできない場合、これらのコードでアカウントにアクセスできます。各コードは1回のみ使用可能です", "These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "これらのコードは、認証アプリへのアクセスを失った場合にアカウントにアクセスするために使用できます。各コードは一度しか使用できません。",
"Print": "印刷", "Print": "印刷",
"Two-factor authentication has been set up. Please log in again.": "二要素認証設定ました。再度ログインしてください", "Two-factor authentication has been set up. Please log in again.": "二要素認証設定されました。再度ログインしてください",
"Two-Factor authentication required": "二要素認証が必要です", "Two-Factor authentication required": "二要素認証が必要です",
"Your workspace requires two-factor authentication for all users": "このワークスペースではすべてのユーザーに二要素認証が必要です", "Your workspace requires two-factor authentication for all users": "ワークスペースではすべてのユーザーに二要素認証が必要です",
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "ワークスペースアクセスるには二要素認証を設定してください。アカウントのセキュリティが強化されます", "To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "ワークスペースへのアクセスを続けるには二要素認証を設定する必要があります。これにより、アカウントに追加のセキュリティ層が追加されます",
"Set up two-factor authentication": "二要素認証を設定", "Set up two-factor authentication": "二要素認証を設定",
"Cancel and logout": "キャンセルしてログアウト", "Cancel and logout": "キャンセルしてログアウト",
"Your workspace requires two-factor authentication. Please set it up to continue.": "このワークスペースでは二要素認証が必要です。続行するには設定してください", "Your workspace requires two-factor authentication. Please set it up to continue.": "ワークスペースでは二要素認証が必要です。続行するには設定してください",
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "認証アプリからの確認コードアカウントのセキュリティが強化されます", "This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "これにより、認証アプリからの確認コードが必要となり、アカウントに追加のセキュリティ層が追加されます",
"Password is required": "パスワードが必要です", "Password is required": "パスワードが必要です",
"Password must be at least 8 characters": "パスワードは8文字以上必要です", "Password must be at least 8 characters": "パスワードは8文字以上必要です",
"Please enter a 6-digit code": "6桁のコードを入力してください", "Please enter a 6-digit code": "6桁のコードを入力してください",
"Code must be exactly 6 digits": "コードは6桁で入力してください", "Code must be exactly 6 digits": "コードは正確に6桁である必要があります",
"Enter the 6-digit code found in your authenticator app": "認証アプリに表示された6桁のコードを入力してください", "Enter the 6-digit code found in your authenticator app": "認証アプリに表示された6桁のコードを入力してください",
"Need help authenticating?": "認証に関するヘルプが必要ですか?", "Need help authenticating?": "認証に関するヘルプが必要ですか?",
"MFA QR Code": "MFA QRコード", "MFA QR Code": "MFA QRコード",
"Account created successfully. Please log in to set up two-factor authentication.": "アカウントを作成しました。二要素認証を設定するためにログインしてください", "Account created successfully. Please log in to set up two-factor authentication.": "アカウントが正常に作成されました。二要素認証を設定するためにログインしてください",
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "パスワードリセットしました。新しいパスワードでログインし二要素認証を完了してください", "Password reset successful. Please log in with your new password and complete two-factor authentication.": "パスワードリセットが成功しました。新しいパスワードでログインし二要素認証を完了してください",
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "パスワードリセットしました。新しいパスワードでログインして二要素認証を設定してください", "Password reset successful. Please log in with your new password to set up two-factor authentication.": "パスワードリセットが成功しました。二要素認証を設定するために新しいパスワードでログインしてください",
"Password reset was successful. Please log in with your new password.": "パスワードリセットしました。新しいパスワードでログインしてください", "Password reset was successful. Please log in with your new password.": "パスワードリセットが成功しました。新しいパスワードでログインしてください",
"Two-factor authentication": "二要素認証", "Two-factor authentication": "二要素認証",
"Use authenticator app instead": "代わりに認証アプリを使用", "Use authenticator app instead": "代わりに認証アプリを使用",
"Verify backup code": "バックアップコードを確認", "Verify backup code": "バックアップコードを確認",
"Use backup code": "バックアップコードを使用", "Use backup code": "バックアップコードを使用",
"Enter one of your backup codes": "バックアップコードのいずれかを入力してください", "Enter one of your backup codes": "バックアップコードのいずれかを入力してください",
"Backup code": "バックアップコード", "Backup code": "バックアップコード",
"Enter one of your backup codes. Each backup code can only be used once.": "バックアップコードを入力してください。各コードは1回のみ使用可能です", "Enter one of your backup codes. Each backup code can only be used once.": "バックアップコードのいずれかを入力してください。各バックアップコードは一度しか使用できません。",
"Verify": "確認", "Verify": "確認",
"Trash": "ごみ箱", "Trash": "ごみ箱",
"Pages in trash will be permanently deleted after 30 days.": "ごみ箱内のページは30日後に完全に削除されます", "Pages in trash will be permanently deleted after 30 days.": "ごみ箱内のページは30日後に完全に削除されます",
"Deleted": "削除", "Deleted": "削除",
"No pages in trash": "ごみ箱にページがありません", "No pages in trash": "ごみ箱にページがありません",
"Permanently delete page?": "ページを完全に削除しますか?", "Permanently delete page?": "ページを完全に削除しますか?",
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "{{title}}を完全に削除しますかこの操作は取り消せません", "Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "{{title}}を完全に削除しますか? この操作は元に戻せません",
"Restore '{{title}}' and its sub-pages?": "{{title}}とそのサブページを復元しますか?", "Restore '{{title}}' and its sub-pages?": "{{title}}とそのサブページを復元しますか?",
"Move to trash": "ごみ箱に移動", "Move to trash": "ごみ箱に移動",
"Move this page to trash?": "このページをごみ箱に移動しますか?", "Move this page to trash?": "このページをごみ箱に移動しますか?",
"Restore page": "ページを復元", "Restore page": "ページを復元",
"Page moved to trash": "ページごみ箱に移動ました", "Page moved to trash": "ページごみ箱に移動されました",
"Page restored successfully": "ページを復元しました", "Page restored successfully": "ページが正常に復元されました",
"Deleted by": "削除者", "Deleted by": "削除者",
"Deleted at": "削除日時", "Deleted at": "削除日時",
"Preview": "プレビュー", "Preview": "プレビュー",
@@ -537,10 +511,10 @@
"Enterprise": "エンタープライズ", "Enterprise": "エンタープライズ",
"Download attachment": "添付ファイルをダウンロード", "Download attachment": "添付ファイルをダウンロード",
"Allowed email domains": "許可されたメールドメイン", "Allowed email domains": "許可されたメールドメイン",
"Only users with email addresses from these domains can signup via SSO.": "これらのドメインのメールアドレスを持つユーザーのみSSO経由で登録できます", "Only users with email addresses from these domains can signup via SSO.": "これらのドメインからのメールアドレスを持つユーザーのみSSOで登録できます",
"Enter valid domain names separated by comma or space": "コンマまたはスペースで区切って有効なドメイン名を入力してください", "Enter valid domain names separated by comma or space": "コンマまたはスペースで区切って有効なドメイン名を入力してください",
"Enforce two-factor authentication": "二要素認証を強制する", "Enforce two-factor authentication": "二要素認証を強制する",
"Once enforced, all members must enable two-factor authentication to access the workspace.": "有効にすると、すべてのメンバーが二要素認証を設定しないとワークスペースにアクセスできなくなります", "Once enforced, all members must enable two-factor authentication to access the workspace.": "一度強制されると、すべてのメンバーはワークスペースにアクセスするために二要素認証を有効にする必要があります",
"Toggle MFA enforcement": "MFAの強制を切り替える", "Toggle MFA enforcement": "MFAの強制を切り替える",
"Display name": "表示名", "Display name": "表示名",
"Allow signup": "登録を許可する", "Allow signup": "登録を許可する",
@@ -553,67 +527,5 @@
"Delete SSO provider": "SSOプロバイダーを削除する", "Delete SSO provider": "SSOプロバイダーを削除する",
"Are you sure you want to delete this SSO provider?": "このSSOプロバイダーを削除してもよろしいですか?", "Are you sure you want to delete this SSO provider?": "このSSOプロバイダーを削除してもよろしいですか?",
"Action": "アクション", "Action": "アクション",
"{{ssoProviderType}} configuration": "{{ssoProviderType}}の構成", "{{ssoProviderType}} configuration": "{{ssoProviderType}}の構成"
"Icon": "アイコン",
"Upload image": "画像をアップロード",
"Remove image": "画像を削除",
"Failed to remove image": "画像の削除に失敗しました",
"Image exceeds 10MB limit.": "画像が10MBの制限を超えています",
"Image removed successfully": "画像を削除しました",
"API key": "APIキー",
"API key created successfully": "APIキーを作成しました",
"API keys": "APIキー",
"API management": "API管理",
"Are you sure you want to revoke this API key": "このAPIキーを無効にしてもよろしいですか",
"Create API Key": "APIキーを作成",
"Custom expiration date": "カスタム有効期限",
"Enter a descriptive token name": "説明的なトークン名を入力してください",
"Expiration": "有効期限",
"Expired": "期限切れ",
"Expires": "期限が切れます",
"I've saved my API key": "APIキーを保存しました",
"Last use": "最終使用",
"No API keys found": "APIキーが見つかりません",
"No expiration": "期限なし",
"Revoke API key": "APIキーを無効にする",
"Revoked successfully": "無効にしました",
"Select expiration date": "有効期限を選択してください",
"This action cannot be undone. Any applications using this API key will stop working.": "この操作は取り消せません。このAPIキーを使用しているアプリケーションは動作しなくなります",
"Update API key": "APIキーを更新",
"Manage API keys for all users in the workspace": "ワークスペース内のすべてのユーザーのAPIキーを管理",
"AI settings": "AI設定",
"AI search": "AI検索",
"AI Answer": "AI回答",
"Ask AI": "AIに質問する",
"AI is thinking...": "AIが考え中...",
"Ask a question...": "質問を入力...",
"AI Answers": "AI回答",
"AI-powered search (AI Answers)": "AI搭載検索 (AI回答)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI検索はベクター埋め込みを使用してワークスペース全体の意味検索を実現します",
"Toggle AI search": "AI検索を切り替え",
"Generative AI (Ask AI)": "生成AI (Ask AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "エディターでAIを活用したコンテンツ生成を有効にします。ユーザーがテキストの生成、改善、翻訳、および変換を行うことができます。",
"Toggle generative AI": "生成AIを切り替える",
"Sources": "ソース",
"AI Answers not available for attachments": "添付ファイルにはAI回答を利用できません",
"No answer available": "回答がありません",
"Background color": "背景色",
"Highlight color": "ハイライト色",
"Remove color": "色を削除",
"Notifications": "通知",
"No notifications": "通知なし",
"No unread notifications": "未読の通知はありません",
"All notifications": "すべての通知",
"Unread only": "未読のみ",
"Mark all as read": "すべてを既読にする",
"Mark as read": "既読にする",
"More options": "その他のオプション",
"mentioned you in a comment": "コメントであなたに言及しました",
"commented on a page": "ページにコメントしました",
"resolved a comment": "コメントを解決しました",
"mentioned you on a page": "ページ上であなたに言及しました",
"Today": "今日",
"Yesterday": "昨日",
"This week": "今週",
"Older": "以前のもの"
} }
@@ -29,7 +29,6 @@
"Choose your preferred interface language.": "선호하는 인터페이스 언어를 선택하세요.", "Choose your preferred interface language.": "선호하는 인터페이스 언어를 선택하세요.",
"Choose your preferred page width.": "선호하는 페이지 너비를 선택하세요.", "Choose your preferred page width.": "선호하는 페이지 너비를 선택하세요.",
"Confirm": "확인", "Confirm": "확인",
"Copy as Markdown": "Markdown으로 복사",
"Copy link": "링크 복사", "Copy link": "링크 복사",
"Create": "생성", "Create": "생성",
"Create group": "팀 생성", "Create group": "팀 생성",
@@ -123,8 +122,6 @@
"page": "페이지", "page": "페이지",
"Page deleted successfully": "페이지 삭제 완료", "Page deleted successfully": "페이지 삭제 완료",
"Page history": "페이지 기록", "Page history": "페이지 기록",
"Select version": "버전 선택",
"Highlight changes": "변경 사항 강조",
"Page import is in progress. Please do not close this tab.": "페이지 가져오기가 진행 중입니다. 이 탭을 닫지 마세요.", "Page import is in progress. Please do not close this tab.": "페이지 가져오기가 진행 중입니다. 이 탭을 닫지 마세요.",
"Pages": "페이지", "Pages": "페이지",
"pages": "페이지", "pages": "페이지",
@@ -256,7 +253,6 @@
"Export failed:": "내보내기 실패:", "Export failed:": "내보내기 실패:",
"export error": "내보내기 오류", "export error": "내보내기 오류",
"Export page": "페이지 내보내기", "Export page": "페이지 내보내기",
"Export successful": "내보내기 성공",
"Export space": "Space 내보내기", "Export space": "Space 내보내기",
"Export {{type}}": "{{type}} 내보내기", "Export {{type}}": "{{type}} 내보내기",
"File exceeds the {{limit}} attachment limit": "첨부 파일 크기 제한 {{limit}}을 초과했습니다", "File exceeds the {{limit}} attachment limit": "첨부 파일 크기 제한 {{limit}}을 초과했습니다",
@@ -332,8 +328,6 @@
"Upload any image from your device.": "기기에서 이미지를 업로드하세요.", "Upload any image from your device.": "기기에서 이미지를 업로드하세요.",
"Upload any video from your device.": "기기에서 비디오를 업로드하세요.", "Upload any video from your device.": "기기에서 비디오를 업로드하세요.",
"Upload any file from your device.": "기기에서 파일을 업로드하세요.", "Upload any file from your device.": "기기에서 파일을 업로드하세요.",
"Uploading {{name}}": "{{name}} 업로드 중",
"Uploading file": "파일 업로드 중",
"Table": "테이블", "Table": "테이블",
"Insert a table.": "테이블 삽입.", "Insert a table.": "테이블 삽입.",
"Insert collapsible block.": "접을 수 있는 블록 삽입.", "Insert collapsible block.": "접을 수 있는 블록 삽입.",
@@ -355,11 +349,6 @@
"Insert current date": "현재 날짜 삽입", "Insert current date": "현재 날짜 삽입",
"Draw and sketch excalidraw diagrams": "Excalidraw diagram 그리기 및 스케치", "Draw and sketch excalidraw diagrams": "Excalidraw diagram 그리기 및 스케치",
"Multiple": "복제", "Multiple": "복제",
"Turn into": "변경하기",
"Text align": "텍스트 정렬",
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
"Go to homepage": "Go to homepage",
"Pages you create will show up here.": "Pages you create will show up here.",
"Heading {{level}}": "제목 {{level}}", "Heading {{level}}": "제목 {{level}}",
"Toggle title": "제목 토글", "Toggle title": "제목 토글",
"Write anything. Enter \"/\" for commands": "아무거나 입력하세요. 명령어를 사용하려면 \"/\"를 입력하세요", "Write anything. Enter \"/\" for commands": "아무거나 입력하세요. 명령어를 사용하려면 \"/\"를 입력하세요",
@@ -412,21 +401,6 @@
"Share deleted successfully": "공유가 성공적으로 삭제되었습니다", "Share deleted successfully": "공유가 성공적으로 삭제되었습니다",
"Share not found": "공유를 찾을 수 없습니다", "Share not found": "공유를 찾을 수 없습니다",
"Failed to share page": "페이지 공유에 실패했습니다", "Failed to share page": "페이지 공유에 실패했습니다",
"Disable public sharing": "공유 비활성화",
"Prevent members from sharing pages publicly.": "멤버들이 페이지를 공개적으로 공유하지 못하도록 방지하십시오.",
"Toggle public sharing": "공유 전환",
"Toggle space public sharing": "공간 공유 전환",
"Public sharing is disabled at the workspace level": "워크스페이스 수준에서 공유가 비활성화되었습니다.",
"Prevent pages in this space from being shared publicly.": "이 공간의 페이지가 공개적으로 공유되지 않도록 방지하십시오.",
"Requires an enterprise license": "기업 라이센스가 필요합니다.",
"Enable public sharing": "공유 활성화",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "공유를 활성화하시겠습니까? 멤버들이 페이지를 공개적으로 공유할 수 있게 됩니다.",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "정말로 공유를 비활성화하시겠습니까? 이 워크스페이스의 모든 기존 공유 링크가 삭제됩니다.",
"Are you sure you want to enable public sharing for this space?": "이 공간의 공유를 활성화하시겠습니까?",
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "정말로 공유를 비활성화하시겠습니까? 이 공간의 모든 기존 공유 링크가 삭제됩니다.",
"Public sharing is disabled": "공유가 비활성화되었습니다.",
"Public sharing has been disabled at the workspace level.": "워크스페이스 수준에서 공유가 비활성화되었습니다.",
"Public sharing has been disabled for this space.": "이 공간의 공유가 비활성화되었습니다.",
"Copy page": "페이지 복사하기", "Copy page": "페이지 복사하기",
"Copy page to a different space.": "다른 공간으로 페이지 복사하기.", "Copy page to a different space.": "다른 공간으로 페이지 복사하기.",
"Page copied successfully": "페이지가 성공적으로 복사되었습니다", "Page copied successfully": "페이지가 성공적으로 복사되었습니다",
@@ -553,67 +527,5 @@
"Delete SSO provider": "SSO 제공자 삭제", "Delete SSO provider": "SSO 제공자 삭제",
"Are you sure you want to delete this SSO provider?": "이 SSO 제공자를 삭제하시겠습니까?", "Are you sure you want to delete this SSO provider?": "이 SSO 제공자를 삭제하시겠습니까?",
"Action": "작업", "Action": "작업",
"{{ssoProviderType}} configuration": "{{ssoProviderType}} 구성", "{{ssoProviderType}} configuration": "{{ssoProviderType}} 구성"
"Icon": "아이콘",
"Upload image": "이미지 업로드",
"Remove image": "이미지 제거",
"Failed to remove image": "이미지 제거 실패",
"Image exceeds 10MB limit.": "이미지가 10MB 용량 제한을 초과합니다.",
"Image removed successfully": "이미지가 성공적으로 제거되었습니다",
"API key": "API 키",
"API key created successfully": "API 키 생성 완료",
"API keys": "API 키",
"API management": "API 관리",
"Are you sure you want to revoke this API key": "이 API 키를 취소하시겠습니까?",
"Create API Key": "API 키 생성",
"Custom expiration date": "사용자 정의 만료일",
"Enter a descriptive token name": "토큰 이름을 입력하세요",
"Expiration": "만료",
"Expired": "만료됨",
"Expires": "만료일",
"I've saved my API key": "API 키를 저장했습니다",
"Last use": "최근 사용",
"No API keys found": "API 키를 찾을 수 없습니다",
"No expiration": "유효기간 없음",
"Revoke API key": "API 키 취소",
"Revoked successfully": "성공적으로 취소되었습니다",
"Select expiration date": "만료일 선택",
"This action cannot be undone. Any applications using this API key will stop working.": "이 작업은 되돌릴 수 없습니다. 이 API 키를 사용하는 모든 응용 프로그램이 작동을 멈출 것입니다.",
"Update API key": "API 키 갱신",
"Manage API keys for all users in the workspace": "워크스페이스 내 모든 사용자의 API 키 관리",
"AI settings": "AI 설정",
"AI search": "AI 검색",
"AI Answer": "AI 답변",
"Ask AI": "AI에게 묻기",
"AI is thinking...": "AI가 생각 중입니다...",
"Ask a question...": "질문하세요...",
"AI Answers": "AI 답변",
"AI-powered search (AI Answers)": "AI 구동 검색 (AI 답변)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI 검색은 벡터 임베딩을 사용하여 작업공간 콘텐츠에 대한 의미 검색 기능을 제공합니다.",
"Toggle AI search": "AI 검색 전환",
"Generative AI (Ask AI)": "생성 AI (Ask AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "편집기에서 AI 구동 콘텐츠 생성을 활성화합니다. 사용자가 텍스트를 생성, 개선, 번역 및 변환할 수 있습니다.",
"Toggle generative AI": "생성 AI 토글",
"Sources": "출처",
"AI Answers not available for attachments": "첨부 파일에 대해 AI 답변을 사용할 수 없습니다",
"No answer available": "답변을 제공할 수 없습니다",
"Background color": "배경 색",
"Highlight color": "강조 색",
"Remove color": "색 제거",
"Notifications": "알림",
"No notifications": "알림 없음",
"No unread notifications": "읽지 않은 알림 없음",
"All notifications": "모든 알림",
"Unread only": "읽지 않음만",
"Mark all as read": "모두 읽음으로 표시",
"Mark as read": "읽음으로 표시",
"More options": "추가 옵션",
"mentioned you in a comment": "댓글에서 당신을 언급했습니다",
"commented on a page": "페이지에 댓글을 달았습니다",
"resolved a comment": "댓글을 해결했습니다",
"mentioned you on a page": "페이지에서 당신을 언급했습니다",
"Today": "오늘",
"Yesterday": "어제",
"This week": "이번 주",
"Older": "이전"
} }
@@ -29,13 +29,12 @@
"Choose your preferred interface language.": "Kies uw gewenste interfacetaal.", "Choose your preferred interface language.": "Kies uw gewenste interfacetaal.",
"Choose your preferred page width.": "Kies uw gewenste paginabreedte.", "Choose your preferred page width.": "Kies uw gewenste paginabreedte.",
"Confirm": "Bevestig", "Confirm": "Bevestig",
"Copy as Markdown": "Kopiëren als Markdown",
"Copy link": "Link kopiëren", "Copy link": "Link kopiëren",
"Create": "Aanmaken", "Create": "Aanmaken",
"Create group": "Groep aanmaken", "Create group": "Groep aanmaken",
"Create page": "Pagina aanmaken", "Create page": "Pagina aanmaken",
"Create space": "Ruimte aanmaken", "Create space": "Ruimte aanmaken",
"Create workspace": "Werkruimte aanmaken", "Create workspace": "Wwerkruimte aanmaken",
"Current password": "Huidig wachtwoord", "Current password": "Huidig wachtwoord",
"Dark": "Donker", "Dark": "Donker",
"Date": "Datum", "Date": "Datum",
@@ -92,7 +91,7 @@
"Invite by email": "Uitnodigen via e-mail", "Invite by email": "Uitnodigen via e-mail",
"Invite members": "Leden uitnodigen", "Invite members": "Leden uitnodigen",
"Invite new members": "Nieuwe leden uitnodigen", "Invite new members": "Nieuwe leden uitnodigen",
"Invited members who are yet to accept their invitation will appear here.": "Uitgenodigde leden die hun uitnodiging nog moeten accepteren zullen hier worden getoond.", "Invited members who are yet to accept their invitation will appear here.": "Uigenodigde leden die hun uitnodiging nog moeten accepteren zullen hier worden getoond.",
"Invited members will be granted access to spaces the groups can access": "Uitgenodigde leden wordt toegang gegeven tot ruimtes de groepen toegang toe heeft", "Invited members will be granted access to spaces the groups can access": "Uitgenodigde leden wordt toegang gegeven tot ruimtes de groepen toegang toe heeft",
"Join the workspace": "Word lid van de werkruimte", "Join the workspace": "Word lid van de werkruimte",
"Language": "Taal", "Language": "Taal",
@@ -123,8 +122,6 @@
"page": "pagina", "page": "pagina",
"Page deleted successfully": "Pagina succesvol verwijderd", "Page deleted successfully": "Pagina succesvol verwijderd",
"Page history": "Pagina geschiedenis", "Page history": "Pagina geschiedenis",
"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.", "Page import is in progress. Please do not close this tab.": "Importeren van pagina's is bezig. Sluit dit tabblad niet.",
"Pages": "Pagina's", "Pages": "Pagina's",
"pages": "pagina's", "pages": "pagina's",
@@ -256,7 +253,6 @@
"Export failed:": "Exporteren mislukt:", "Export failed:": "Exporteren mislukt:",
"export error": "Exporteer fout", "export error": "Exporteer fout",
"Export page": "Exporteer pagina", "Export page": "Exporteer pagina",
"Export successful": "Export succesvol",
"Export space": "Exporteer ruimte", "Export space": "Exporteer ruimte",
"Export {{type}}": "Exporteer {{type}}", "Export {{type}}": "Exporteer {{type}}",
"File exceeds the {{limit}} attachment limit": "Bestand overschrijdt de bijlagelimiet van {{limit}}", "File exceeds the {{limit}} attachment limit": "Bestand overschrijdt de bijlagelimiet van {{limit}}",
@@ -332,8 +328,6 @@
"Upload any image from your device.": "Upload een afbeelding vanaf uw apparaat.", "Upload any image from your device.": "Upload een afbeelding vanaf uw apparaat.",
"Upload any video from your device.": "Upload een video vanaf uw apparaat.", "Upload any video from your device.": "Upload een video vanaf uw apparaat.",
"Upload any file from your device.": "Upload een bestand vanaf uw apparaat.", "Upload any file from your device.": "Upload een bestand vanaf uw apparaat.",
"Uploading {{name}}": "Uploaden {{name}}",
"Uploading file": "Bestand uploaden",
"Table": "Tabel", "Table": "Tabel",
"Insert a table.": "Voeg een tabel in.", "Insert a table.": "Voeg een tabel in.",
"Insert collapsible block.": "Inklapbaar blok invoegen.", "Insert collapsible block.": "Inklapbaar blok invoegen.",
@@ -355,11 +349,6 @@
"Insert current date": "Huidige datum invoeren", "Insert current date": "Huidige datum invoeren",
"Draw and sketch excalidraw diagrams": "Teken en schets excalidraw diagrammen", "Draw and sketch excalidraw diagrams": "Teken en schets excalidraw diagrammen",
"Multiple": "Meerdere", "Multiple": "Meerdere",
"Turn into": "Omzetten naar",
"Text align": "Tekstuitlijning",
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
"Go to homepage": "Go to homepage",
"Pages you create will show up here.": "Pages you create will show up here.",
"Heading {{level}}": "Kop {{level}}", "Heading {{level}}": "Kop {{level}}",
"Toggle title": "Schakel titel in/uit", "Toggle title": "Schakel titel in/uit",
"Write anything. Enter \"/\" for commands": "Schrijf iets. Voer \"/\" in voor commando's", "Write anything. Enter \"/\" for commands": "Schrijf iets. Voer \"/\" in voor commando's",
@@ -412,21 +401,6 @@
"Share deleted successfully": "Delen succesvol verwijderd", "Share deleted successfully": "Delen succesvol verwijderd",
"Share not found": "Delen niet gevonden", "Share not found": "Delen niet gevonden",
"Failed to share page": "Pagina delen mislukt", "Failed to share page": "Pagina delen mislukt",
"Disable public sharing": "Openbaar delen uitschakelen",
"Prevent members from sharing pages publicly.": "Voorkom dat leden pagina's openbaar delen.",
"Toggle public sharing": "Wissel openbaar delen",
"Toggle space public sharing": "Wissel openbaar delen van ruimte",
"Public sharing is disabled at the workspace level": "Openbaar delen is uitgeschakeld op werkruimteniveau",
"Prevent pages in this space from being shared publicly.": "Voorkom dat pagina's in deze ruimte openbaar worden gedeeld.",
"Requires an enterprise license": "Vereist een bedrijfslicentie",
"Enable public sharing": "Openbaar delen inschakelen",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Weet je zeker dat je openbaar delen wilt inschakelen? Leden kunnen pagina's openbaar delen.",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Weet je zeker dat je openbaar delen wilt uitschakelen? Alle bestaande gedeelde links in deze werkruimte zullen worden verwijderd.",
"Are you sure you want to enable public sharing for this space?": "Weet je zeker dat je openbaar delen voor deze ruimte wilt inschakelen?",
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Weet je zeker dat je openbaar delen wilt uitschakelen? Alle bestaande gedeelde links in deze ruimte zullen worden verwijderd.",
"Public sharing is disabled": "Openbaar delen is uitgeschakeld",
"Public sharing has been disabled at the workspace level.": "Openbaar delen is uitgeschakeld op werkruimteniveau.",
"Public sharing has been disabled for this space.": "Openbaar delen is uitgeschakeld voor deze ruimte.",
"Copy page": "Pagina kopiëren", "Copy page": "Pagina kopiëren",
"Copy page to a different space.": "Kopieer pagina naar een andere ruimte.", "Copy page to a different space.": "Kopieer pagina naar een andere ruimte.",
"Page copied successfully": "Pagina succesvol gekopieerd", "Page copied successfully": "Pagina succesvol gekopieerd",
@@ -553,67 +527,5 @@
"Delete SSO provider": "Verwijder SSO-provider", "Delete SSO provider": "Verwijder SSO-provider",
"Are you sure you want to delete this SSO provider?": "Weet u zeker dat u deze SSO-provider wilt verwijderen?", "Are you sure you want to delete this SSO provider?": "Weet u zeker dat u deze SSO-provider wilt verwijderen?",
"Action": "Actie", "Action": "Actie",
"{{ssoProviderType}} configuration": "{{ssoProviderType}} configuratie", "{{ssoProviderType}} configuration": "{{ssoProviderType}} configuratie"
"Icon": "Icoon",
"Upload image": "Afbeelding uploaden",
"Remove image": "Afbeelding verwijderen",
"Failed to remove image": "Afbeelding verwijderen mislukt",
"Image exceeds 10MB limit.": "Afbeelding overschrijdt de limiet van 10MB.",
"Image removed successfully": "Afbeelding succesvol verwijderd",
"API key": "API-sleutel",
"API key created successfully": "API-sleutel succesvol aangemaakt",
"API keys": "API-sleutels",
"API management": "API-beheer",
"Are you sure you want to revoke this API key": "Weet u zeker dat u deze API-sleutel wilt intrekken",
"Create API Key": "API-sleutel aanmaken",
"Custom expiration date": "Aangepaste vervaldatum",
"Enter a descriptive token name": "Voer een beschrijvende tokennaam in",
"Expiration": "Vervaldatum",
"Expired": "Verlopen",
"Expires": "Verloopt",
"I've saved my API key": "Ik heb mijn API-sleutel opgeslagen",
"Last use": "Laatst gebruikt",
"No API keys found": "Geen API-sleutels gevonden",
"No expiration": "Geen vervaldatum",
"Revoke API key": "API-sleutel intrekken",
"Revoked successfully": "Succesvol ingetrokken",
"Select expiration date": "Selecteer vervaldatum",
"This action cannot be undone. Any applications using this API key will stop working.": "Deze actie kan niet ongedaan worden gemaakt. Alle toepassingen die deze API-sleutel gebruiken, zullen niet meer werken.",
"Update API key": "API-sleutel bijwerken",
"Manage API keys for all users in the workspace": "Beheer API-sleutels voor alle gebruikers in de werkruimte",
"AI settings": "AI-instellingen",
"AI search": "AI-zoekopdracht",
"AI Answer": "AI Antwoord",
"Ask AI": "Vraag AI",
"AI is thinking...": "AI is aan het nadenken...",
"Ask a question...": "Stel een vraag...",
"AI Answers": "AI Antwoorden",
"AI-powered search (AI Answers)": "AI-gestuurde zoekopdracht (AI Antwoorden)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI-zoekopdracht maakt gebruik van vectorembeddings om semantische zoekmogelijkheden te bieden in uw werkruimte-inhoud.",
"Toggle AI search": "Schakel AI-zoekopdracht in/uit",
"Generative AI (Ask AI)": "Generatieve AI (Vraag het AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Schakel AI-gestuurde inhoudsgeneratie in de editor in. Hiermee kunnen gebruikers tekst genereren, verbeteren, vertalen en transformeren.",
"Toggle generative AI": "Generatieve AI schakelen",
"Sources": "Bronnen",
"AI Answers not available for attachments": "AI Antwoorden niet beschikbaar voor bijlagen",
"No answer available": "Geen antwoord beschikbaar",
"Background color": "Achtergrondkleur",
"Highlight color": "Markeerkleur",
"Remove color": "Kleur verwijderen",
"Notifications": "Meldingen",
"No notifications": "Geen meldingen",
"No unread notifications": "Geen ongelezen meldingen",
"All notifications": "Alle meldingen",
"Unread only": "Alleen ongelezen",
"Mark all as read": "Markeer alles als gelezen",
"Mark as read": "Markeer als gelezen",
"More options": "Meer opties",
"mentioned you in a comment": "noemde je in een reactie",
"commented on a page": "reageerde op een pagina",
"resolved a comment": "heeft een opmerking opgelost",
"mentioned you on a page": "noemde je op een pagina",
"Today": "Vandaag",
"Yesterday": "Gisteren",
"This week": "Deze week",
"Older": "Ouder"
} }
@@ -29,7 +29,6 @@
"Choose your preferred interface language.": "Escolha o idioma da interface.", "Choose your preferred interface language.": "Escolha o idioma da interface.",
"Choose your preferred page width.": "Escolha a largura preferida da página.", "Choose your preferred page width.": "Escolha a largura preferida da página.",
"Confirm": "Confirmar", "Confirm": "Confirmar",
"Copy as Markdown": "Copiar como Markdown",
"Copy link": "Copiar link", "Copy link": "Copiar link",
"Create": "Criar", "Create": "Criar",
"Create group": "Criar grupo", "Create group": "Criar grupo",
@@ -123,8 +122,6 @@
"page": "página", "page": "página",
"Page deleted successfully": "Página excluída com sucesso", "Page deleted successfully": "Página excluída com sucesso",
"Page history": "Histórico da página", "Page history": "Histórico da página",
"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.", "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.",
"Pages": "Páginas", "Pages": "Páginas",
"pages": "páginas", "pages": "páginas",
@@ -256,7 +253,6 @@
"Export failed:": "Falha ao exportar:", "Export failed:": "Falha ao exportar:",
"export error": "erro de exportação", "export error": "erro de exportação",
"Export page": "Exportar página", "Export page": "Exportar página",
"Export successful": "Exportação bem-sucedida",
"Export space": "Exportar espaço", "Export space": "Exportar espaço",
"Export {{type}}": "Exportar para {{type}}", "Export {{type}}": "Exportar para {{type}}",
"File exceeds the {{limit}} attachment limit": "O arquivo excede o limite de anexos {{limit}}", "File exceeds the {{limit}} attachment limit": "O arquivo excede o limite de anexos {{limit}}",
@@ -332,8 +328,6 @@
"Upload any image from your device.": "Envie qualquer imagem do seu dispositivo.", "Upload any image from your device.": "Envie qualquer imagem do seu dispositivo.",
"Upload any video from your device.": "Envie qualquer vídeo do seu dispositivo.", "Upload any video from your device.": "Envie qualquer vídeo do seu dispositivo.",
"Upload any file from your device.": "Envie qualquer arquivo do seu dispositivo.", "Upload any file from your device.": "Envie qualquer arquivo do seu dispositivo.",
"Uploading {{name}}": "Enviando {{name}}",
"Uploading file": "Enviando arquivo",
"Table": "Tabela", "Table": "Tabela",
"Insert a table.": "Insira uma tabela.", "Insert a table.": "Insira uma tabela.",
"Insert collapsible block.": "Insira um bloco colapsável.", "Insert collapsible block.": "Insira um bloco colapsável.",
@@ -355,11 +349,6 @@
"Insert current date": "Insira a data atual", "Insert current date": "Insira a data atual",
"Draw and sketch excalidraw diagrams": "Desenhe e esboce diagramas Excalidraw", "Draw and sketch excalidraw diagrams": "Desenhe e esboce diagramas Excalidraw",
"Multiple": "Múltiplo", "Multiple": "Múltiplo",
"Turn into": "Transformar em",
"Text align": "Alinhar texto",
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
"Go to homepage": "Go to homepage",
"Pages you create will show up here.": "Pages you create will show up here.",
"Heading {{level}}": "Título {{level}}", "Heading {{level}}": "Título {{level}}",
"Toggle title": "Alternar título", "Toggle title": "Alternar título",
"Write anything. Enter \"/\" for commands": "Escreva qualquer coisa. Digite \"/\" para comandos", "Write anything. Enter \"/\" for commands": "Escreva qualquer coisa. Digite \"/\" para comandos",
@@ -412,21 +401,6 @@
"Share deleted successfully": "Compartilhamento excluído com sucesso", "Share deleted successfully": "Compartilhamento excluído com sucesso",
"Share not found": "Compartilhamento não encontrado", "Share not found": "Compartilhamento não encontrado",
"Failed to share page": "Falha ao compartilhar página", "Failed to share page": "Falha ao compartilhar página",
"Disable public sharing": "Desativar compartilhamento público",
"Prevent members from sharing pages publicly.": "Impedir que os membros compartilhem páginas publicamente.",
"Toggle public sharing": "Alternar compartilhamento público",
"Toggle space public sharing": "Alternar compartilhamento público do espaço",
"Public sharing is disabled at the workspace level": "O compartilhamento público está desativado no nível do espaço de trabalho",
"Prevent pages in this space from being shared publicly.": "Impedir que as páginas neste espaço sejam compartilhadas publicamente.",
"Requires an enterprise license": "Requer uma licença empresarial",
"Enable public sharing": "Ativar compartilhamento público",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Tem certeza de que deseja ativar o compartilhamento público? Os membros poderão compartilhar páginas publicamente.",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Tem certeza de que deseja desativar o compartilhamento público? Todos os links compartilhados existentes neste espaço de trabalho serão excluídos.",
"Are you sure you want to enable public sharing for this space?": "Tem certeza de que deseja ativar o compartilhamento público para este espaço?",
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Tem certeza de que deseja desativar o compartilhamento público? Todos os links compartilhados existentes neste espaço serão excluídos.",
"Public sharing is disabled": "Compartilhamento público está desativado",
"Public sharing has been disabled at the workspace level.": "O compartilhamento público foi desativado no nível do espaço de trabalho.",
"Public sharing has been disabled for this space.": "O compartilhamento público foi desativado para este espaço.",
"Copy page": "Copiar página", "Copy page": "Copiar página",
"Copy page to a different space.": "Copiar página para um espaço diferente.", "Copy page to a different space.": "Copiar página para um espaço diferente.",
"Page copied successfully": "Página copiada com sucesso", "Page copied successfully": "Página copiada com sucesso",
@@ -553,67 +527,5 @@
"Delete SSO provider": "Excluir provedor de SSO", "Delete SSO provider": "Excluir provedor de SSO",
"Are you sure you want to delete this SSO provider?": "Tem certeza de que deseja excluir este provedor de SSO?", "Are you sure you want to delete this SSO provider?": "Tem certeza de que deseja excluir este provedor de SSO?",
"Action": "Ação", "Action": "Ação",
"{{ssoProviderType}} configuration": "Configuração de {{ssoProviderType}}", "{{ssoProviderType}} configuration": "Configuração de {{ssoProviderType}}"
"Icon": "Ícone",
"Upload image": "Fazer upload da imagem",
"Remove image": "Remover imagem",
"Failed to remove image": "Falha ao remover imagem",
"Image exceeds 10MB limit.": "A imagem excede o limite de 10MB.",
"Image removed successfully": "Imagem removida com sucesso",
"API key": "Chave API",
"API key created successfully": "Chave API criada com sucesso",
"API keys": "Chaves API",
"API management": "Gestão de API",
"Are you sure you want to revoke this API key": "Tem certeza de que deseja revogar esta chave API",
"Create API Key": "Criar Chave API",
"Custom expiration date": "Data de expiração personalizada",
"Enter a descriptive token name": "Insira um nome descritivo para o token",
"Expiration": "Expiração",
"Expired": "Expirado",
"Expires": "Expira",
"I've saved my API key": "Salvei minha chave API",
"Last use": "Último uso",
"No API keys found": "Nenhuma chave API encontrada",
"No expiration": "Sem expiração",
"Revoke API key": "Revogar chave API",
"Revoked successfully": "Revogada com sucesso",
"Select expiration date": "Selecionar data de expiração",
"This action cannot be undone. Any applications using this API key will stop working.": "Esta ação não pode ser desfeita. Qualquer aplicação usando esta chave API deixará de funcionar.",
"Update API key": "Atualizar chave API",
"Manage API keys for all users in the workspace": "Gerenciar chaves API para todos os usuários no espaço de trabalho",
"AI settings": "Configurações de IA",
"AI search": "Pesquisa IA",
"AI Answer": "Resposta de IA",
"Ask AI": "Pergunte à IA",
"AI is thinking...": "IA está pensando...",
"Ask a question...": "Faça uma pergunta...",
"AI Answers": "Respostas de IA",
"AI-powered search (AI Answers)": "Pesquisa com IA (Respostas de IA)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "A pesquisa IA usa vetores de incorporação para fornecer capacidades de pesquisa semântica em todo o conteúdo do seu espaço de trabalho.",
"Toggle AI search": "Alternar pesquisa de IA",
"Generative AI (Ask AI)": "IA generativa (Perguntar à IA)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Habilitar geração de conteúdo com IA no editor. Permite aos usuários gerar, melhorar, traduzir e transformar texto.",
"Toggle generative AI": "Alternar IA generativa",
"Sources": "Fontes",
"AI Answers not available for attachments": "Respostas de IA não disponíveis para anexos",
"No answer available": "Nenhuma resposta disponível",
"Background color": "Cor de fundo",
"Highlight color": "Cor de destaque",
"Remove color": "Remover cor",
"Notifications": "Notificações",
"No notifications": "Sem notificações",
"No unread notifications": "Sem notificações não lidas",
"All notifications": "Todas as notificações",
"Unread only": "Somente não lidas",
"Mark all as read": "Marcar todas como lidas",
"Mark as read": "Marcar como lida",
"More options": "Mais opções",
"mentioned you in a comment": "mencionou você em um comentário",
"commented on a page": "comentou em uma página",
"resolved a comment": "resolveu um comentário",
"mentioned you on a page": "mencionou você em uma página",
"Today": "Hoje",
"Yesterday": "Ontem",
"This week": "Esta semana",
"Older": "Mais antigo"
} }
@@ -29,7 +29,6 @@
"Choose your preferred interface language.": "Выберите предпочитаемый язык интерфейса.", "Choose your preferred interface language.": "Выберите предпочитаемый язык интерфейса.",
"Choose your preferred page width.": "Выберите предпочитаемую ширину страницы.", "Choose your preferred page width.": "Выберите предпочитаемую ширину страницы.",
"Confirm": "Подтвердить", "Confirm": "Подтвердить",
"Copy as Markdown": "Копировать как Markdown",
"Copy link": "Копировать ссылку", "Copy link": "Копировать ссылку",
"Create": "Создать", "Create": "Создать",
"Create group": "Создать группу", "Create group": "Создать группу",
@@ -123,8 +122,6 @@
"page": "страница", "page": "страница",
"Page deleted successfully": "Страница успешно удалена", "Page deleted successfully": "Страница успешно удалена",
"Page history": "История страницы", "Page history": "История страницы",
"Select version": "Выбрать версию",
"Highlight changes": "Выделить изменения",
"Page import is in progress. Please do not close this tab.": "Импорт страницы в процессе. Пожалуйста, не закрывайте эту вкладку.", "Page import is in progress. Please do not close this tab.": "Импорт страницы в процессе. Пожалуйста, не закрывайте эту вкладку.",
"Pages": "Страницы", "Pages": "Страницы",
"pages": "страницы", "pages": "страницы",
@@ -256,7 +253,6 @@
"Export failed:": "Экспортирование не удалось:", "Export failed:": "Экспортирование не удалось:",
"export error": "ошибка экспорта", "export error": "ошибка экспорта",
"Export page": "Экспорт страницы", "Export page": "Экспорт страницы",
"Export successful": "Экспорт выполнен успешно",
"Export space": "Экспорт пространства", "Export space": "Экспорт пространства",
"Export {{type}}": "Экспорт {{type}}", "Export {{type}}": "Экспорт {{type}}",
"File exceeds the {{limit}} attachment limit": "Файл превышает лимит вложений {{limit}}", "File exceeds the {{limit}} attachment limit": "Файл превышает лимит вложений {{limit}}",
@@ -332,8 +328,6 @@
"Upload any image from your device.": "Загрузить любое изображение с вашего устройства.", "Upload any image from your device.": "Загрузить любое изображение с вашего устройства.",
"Upload any video from your device.": "Загрузить любое видео с вашего устройства.", "Upload any video from your device.": "Загрузить любое видео с вашего устройства.",
"Upload any file from your device.": "Загрузить любой файл с вашего устройства.", "Upload any file from your device.": "Загрузить любой файл с вашего устройства.",
"Uploading {{name}}": "Загрузка {{name}}",
"Uploading file": "Загрузка файла",
"Table": "Таблица", "Table": "Таблица",
"Insert a table.": "Вставить таблицу.", "Insert a table.": "Вставить таблицу.",
"Insert collapsible block.": "Вставить сворачиваемый блок.", "Insert collapsible block.": "Вставить сворачиваемый блок.",
@@ -355,11 +349,6 @@
"Insert current date": "Вставить текущую дату", "Insert current date": "Вставить текущую дату",
"Draw and sketch excalidraw diagrams": "Вставить и рисовать диаграммы Excalidraw", "Draw and sketch excalidraw diagrams": "Вставить и рисовать диаграммы Excalidraw",
"Multiple": "Несколько", "Multiple": "Несколько",
"Turn into": "Преобразовать в",
"Text align": "Выравнивание текста",
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
"Go to homepage": "Go to homepage",
"Pages you create will show up here.": "Pages you create will show up here.",
"Heading {{level}}": "Заголовок {{level}}", "Heading {{level}}": "Заголовок {{level}}",
"Toggle title": "Переключить заголовок", "Toggle title": "Переключить заголовок",
"Write anything. Enter \"/\" for commands": "Начните писать. Введите \"/\" для списка команд", "Write anything. Enter \"/\" for commands": "Начните писать. Введите \"/\" для списка команд",
@@ -412,21 +401,6 @@
"Share deleted successfully": "Общий доступ успешно удален", "Share deleted successfully": "Общий доступ успешно удален",
"Share not found": "Общий доступ не найден", "Share not found": "Общий доступ не найден",
"Failed to share page": "Не удалось поделиться страницей", "Failed to share page": "Не удалось поделиться страницей",
"Disable public sharing": "Отключить общий доступ",
"Prevent members from sharing pages publicly.": "Запретить участникам делиться страницами публично.",
"Toggle public sharing": "Переключить общий доступ",
"Toggle space public sharing": "Переключить общий доступ для пространства",
"Public sharing is disabled at the workspace level": "Общий доступ отключен на уровне рабочего пространства",
"Prevent pages in this space from being shared publicly.": "Запретить делиться страницами в этом пространстве публично.",
"Requires an enterprise license": "Требуется корпоративная лицензия",
"Enable public sharing": "Включить общий доступ",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Вы уверены, что хотите включить общий доступ? Участники смогут делиться страницами публично.",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Вы уверены, что хотите отключить общий доступ? Все существующие ссылки в этом рабочем пространстве будут удалены.",
"Are you sure you want to enable public sharing for this space?": "Вы уверены, что хотите включить общий доступ для этого пространства?",
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Вы уверены, что хотите отключить общий доступ? Все существующие ссылки в этом пространстве будут удалены.",
"Public sharing is disabled": "Общий доступ отключен",
"Public sharing has been disabled at the workspace level.": "Общий доступ был отключен на уровне рабочего пространства.",
"Public sharing has been disabled for this space.": "Общий доступ был отключен для этого пространства.",
"Copy page": "Копировать страницу", "Copy page": "Копировать страницу",
"Copy page to a different space.": "Копировать страницу в другое пространство.", "Copy page to a different space.": "Копировать страницу в другое пространство.",
"Page copied successfully": "Страница успешно скопирована", "Page copied successfully": "Страница успешно скопирована",
@@ -524,10 +498,10 @@
"Deleted at": "Удалено в", "Deleted at": "Удалено в",
"Preview": "Предпросмотр", "Preview": "Предпросмотр",
"Subpages": "Подстраницы", "Subpages": "Подстраницы",
"Failed to load subpages": "Не удалось загрузить под страницы", "Failed to load subpages": "Не удалось загрузить подстраницы",
"No subpages": "Нет подстраниц", "No subpages": "Нет подстраниц",
"Subpages (Child pages)": "Подстраницы (вложенные страницы)", "Subpages (Child pages)": "Подстраницы (вложенные страницы)",
"List all subpages of the current page": "Показать все под страницы", "List all subpages of the current page": "Показать все подстраницы текущей страницы",
"Attachments": "Вложения", "Attachments": "Вложения",
"All spaces": "Все пространства", "All spaces": "Все пространства",
"Unknown": "Неизвестно", "Unknown": "Неизвестно",
@@ -553,67 +527,5 @@
"Delete SSO provider": "Удалить поставщика SSO", "Delete SSO provider": "Удалить поставщика SSO",
"Are you sure you want to delete this SSO provider?": "Вы уверены, что хотите удалить этого поставщика SSO?", "Are you sure you want to delete this SSO provider?": "Вы уверены, что хотите удалить этого поставщика SSO?",
"Action": "Действие", "Action": "Действие",
"{{ssoProviderType}} configuration": "Настройка {{ssoProviderType}}", "{{ssoProviderType}} configuration": "Настройка {{ssoProviderType}}"
"Icon": "Иконка",
"Upload image": "Загрузить изображение",
"Remove image": "Удалить изображение",
"Failed to remove image": "Не удалось удалить изображение",
"Image exceeds 10MB limit.": "Изображение превышает предел 10MB.",
"Image removed successfully": "Изображение успешно удалено",
"API key": "API ключ",
"API key created successfully": "API ключ успешно создан",
"API keys": "API ключи",
"API management": "Управление API",
"Are you sure you want to revoke this API key": "Вы уверены, что хотите отозвать этот API ключ",
"Create API Key": "Создать API ключ",
"Custom expiration date": "Пользовательская дата срока действия",
"Enter a descriptive token name": "Введите понятное имя токена",
"Expiration": "Срок действия",
"Expired": "Истек",
"Expires": "Истекает",
"I've saved my API key": "Я сохранил мой API ключ",
"Last use": "Последнее использование",
"No API keys found": "API ключи не найдены",
"No expiration": "Не истекает",
"Revoke API key": "Отозвать API ключ",
"Revoked successfully": "Отозван успешно",
"Select expiration date": "Выберете срок действия",
"This action cannot be undone. Any applications using this API key will stop working.": "Это действие необратимо. Любые приложения, использующие этот API ключ, перестанут работать.",
"Update API key": "Обновить API ключ",
"Manage API keys for all users in the workspace": "Управлять API ключами для всех пользователей в рабочей области",
"AI settings": "Настройки ИИ",
"AI search": "Поиск ИИ",
"AI Answer": "Ответ ИИ",
"Ask AI": "Спросить ИИ",
"AI is thinking...": "ИИ обрабатывает запрос...",
"Ask a question...": "Задайте вопрос...",
"AI Answers": "Ответы ИИ",
"AI-powered search (AI Answers)": "Поиск на базе ИИ (Ответы ИИ)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Поиск ИИ использует векторные встраивания для обеспечения семантического поиска по содержимому вашего рабочего пространства.",
"Toggle AI search": "Переключить поиск ИИ",
"Generative AI (Ask AI)": "Генеративный ИИ (Спросить ИИ)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Включите создание контента на базе ИИ в редакторе. Позволяет пользователям генерировать, улучшать, переводить и преобразовывать текст.",
"Toggle generative AI": "Переключить генеративный ИИ",
"Sources": "Источники",
"AI Answers not available for attachments": "Ответы ИИ недоступны для вложений",
"No answer available": "Ответ недоступен",
"Background color": "Цвет фона",
"Highlight color": "Цвет выделения",
"Remove color": "Удалить цвет",
"Notifications": "Уведомления",
"No notifications": "Нет уведомлений",
"No unread notifications": "Нет непрочитанных уведомлений",
"All notifications": "Все уведомления",
"Unread only": "Только непрочитанные",
"Mark all as read": "Отметить все как прочитанные",
"Mark as read": "Отметить как прочитанное",
"More options": "Больше возможностей",
"mentioned you in a comment": "упомянул вас в комментарии",
"commented on a page": "прокомментировал на странице",
"resolved a comment": "разрешил комментарий",
"mentioned you on a page": "упомянул вас на странице",
"Today": "Сегодня",
"Yesterday": "Вчера",
"This week": "На этой неделе",
"Older": "Старше"
} }
@@ -29,7 +29,6 @@
"Choose your preferred interface language.": "Оберіть бажану мову інтерфейсу.", "Choose your preferred interface language.": "Оберіть бажану мову інтерфейсу.",
"Choose your preferred page width.": "Оберіть бажану ширину сторінки.", "Choose your preferred page width.": "Оберіть бажану ширину сторінки.",
"Confirm": "Підтвердити", "Confirm": "Підтвердити",
"Copy as Markdown": "Скопіювати як Markdown",
"Copy link": "Копіювати посилання", "Copy link": "Копіювати посилання",
"Create": "Створити", "Create": "Створити",
"Create group": "Створити групу", "Create group": "Створити групу",
@@ -123,8 +122,6 @@
"page": "сторінка", "page": "сторінка",
"Page deleted successfully": "Сторінку успішно видалено", "Page deleted successfully": "Сторінку успішно видалено",
"Page history": "Історія сторінки", "Page history": "Історія сторінки",
"Select version": "Вибрати версію",
"Highlight changes": "Підсвітити зміни",
"Page import is in progress. Please do not close this tab.": "Імпорт сторінки в процесі. Будь ласка, не закривайте цю вкладку.", "Page import is in progress. Please do not close this tab.": "Імпорт сторінки в процесі. Будь ласка, не закривайте цю вкладку.",
"Pages": "Сторінки", "Pages": "Сторінки",
"pages": "сторінки", "pages": "сторінки",
@@ -256,7 +253,6 @@
"Export failed:": "Експортування не вдалося:", "Export failed:": "Експортування не вдалося:",
"export error": "помилка експорту", "export error": "помилка експорту",
"Export page": "Експорт сторінки", "Export page": "Експорт сторінки",
"Export successful": "Експорт виконано успішно",
"Export space": "Експорт простору", "Export space": "Експорт простору",
"Export {{type}}": "Експорт {{type}}", "Export {{type}}": "Експорт {{type}}",
"File exceeds the {{limit}} attachment limit": "Файл перевищує ліміт вкладень {{limit}}", "File exceeds the {{limit}} attachment limit": "Файл перевищує ліміт вкладень {{limit}}",
@@ -332,8 +328,6 @@
"Upload any image from your device.": "Завантажити будь-яке зображення з вашого пристрою.", "Upload any image from your device.": "Завантажити будь-яке зображення з вашого пристрою.",
"Upload any video from your device.": "Завантажити будь-яке відео з вашого пристрою.", "Upload any video from your device.": "Завантажити будь-яке відео з вашого пристрою.",
"Upload any file from your device.": "Завантажити будь-який файл з вашого пристрою.", "Upload any file from your device.": "Завантажити будь-який файл з вашого пристрою.",
"Uploading {{name}}": "Завантаження {{name}}",
"Uploading file": "Завантаження файлу",
"Table": "Таблиця", "Table": "Таблиця",
"Insert a table.": "Вставити таблицю.", "Insert a table.": "Вставити таблицю.",
"Insert collapsible block.": "Вставити блок, що згортається.", "Insert collapsible block.": "Вставити блок, що згортається.",
@@ -355,11 +349,6 @@
"Insert current date": "Вставити поточну дату", "Insert current date": "Вставити поточну дату",
"Draw and sketch excalidraw diagrams": "Вставити та малювати діаграми Excalidraw", "Draw and sketch excalidraw diagrams": "Вставити та малювати діаграми Excalidraw",
"Multiple": "Декілька", "Multiple": "Декілька",
"Turn into": "Перетворити",
"Text align": "Вирівнювання тексту",
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
"Go to homepage": "Go to homepage",
"Pages you create will show up here.": "Pages you create will show up here.",
"Heading {{level}}": "Заголовок {{level}}", "Heading {{level}}": "Заголовок {{level}}",
"Toggle title": "Перемкнути заголовок", "Toggle title": "Перемкнути заголовок",
"Write anything. Enter \"/\" for commands": "Почніть писати. Введіть \"/\" для списку команд", "Write anything. Enter \"/\" for commands": "Почніть писати. Введіть \"/\" для списку команд",
@@ -412,21 +401,6 @@
"Share deleted successfully": "Спільний доступ успішно видалено", "Share deleted successfully": "Спільний доступ успішно видалено",
"Share not found": "Спільний доступ не знайдено", "Share not found": "Спільний доступ не знайдено",
"Failed to share page": "Не вдалося поділитися сторінкою", "Failed to share page": "Не вдалося поділитися сторінкою",
"Disable public sharing": "Вимкнути публічний доступ",
"Prevent members from sharing pages publicly.": "Перешкодити учасникам публічно ділитися сторінками.",
"Toggle public sharing": "Перемикання публічного доступу",
"Toggle space public sharing": "Перемикання публічного доступу до просторів",
"Public sharing is disabled at the workspace level": "Публічний доступ вимкнуто на рівні робочого простору",
"Prevent pages in this space from being shared publicly.": "Перешкодити публічному поширенню сторінок у цьому просторі.",
"Requires an enterprise license": "Потребує корпоративної ліцензії",
"Enable public sharing": "Увімкнути публічний доступ",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Ви впевнені, що хочете увімкнути публічний доступ? Учасники зможуть публічно ділитися сторінками.",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Ви впевнені, що хочете вимкнути публічний доступ? Усі існуючі посилання для спільного доступу в цьому робочому просторі будуть видалені.",
"Are you sure you want to enable public sharing for this space?": "Ви впевнені, що хочете увімкнути публічний доступ для цього простору?",
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Ви впевнені, що хочете вимкнути публічний доступ? Усі існуючі посилання для спільного доступу в цьому просторі будуть видалені.",
"Public sharing is disabled": "Публічний доступ вимкнуто",
"Public sharing has been disabled at the workspace level.": "Публічний доступ було вимкнено на рівні робочого простору.",
"Public sharing has been disabled for this space.": "Публічний доступ було вимкнено для цього простору.",
"Copy page": "Копіювати сторінки", "Copy page": "Копіювати сторінки",
"Copy page to a different space.": "Скопіювати сторінку в інший простір.", "Copy page to a different space.": "Скопіювати сторінку в інший простір.",
"Page copied successfully": "Сторінку успішно скопійовано", "Page copied successfully": "Сторінку успішно скопійовано",
@@ -553,67 +527,5 @@
"Delete SSO provider": "Видалити постачальника SSO", "Delete SSO provider": "Видалити постачальника SSO",
"Are you sure you want to delete this SSO provider?": "Ви впевнені, що хочете видалити цього постачальника SSO?", "Are you sure you want to delete this SSO provider?": "Ви впевнені, що хочете видалити цього постачальника SSO?",
"Action": "Дія", "Action": "Дія",
"{{ssoProviderType}} configuration": "Конфігурація {{ssoProviderType}}", "{{ssoProviderType}} configuration": "Конфігурація {{ssoProviderType}}"
"Icon": "Іконка",
"Upload image": "Завантажити зображення",
"Remove image": "Видалити зображення",
"Failed to remove image": "Не вдалося видалити зображення",
"Image exceeds 10MB limit.": "Зображення має займати менше, ніж 10 МБ.",
"Image removed successfully": "Зображення видалено",
"API key": "Ключ API",
"API key created successfully": "Ключ API успішно створено",
"API keys": "Ключі API",
"API management": "Управління API",
"Are you sure you want to revoke this API key": "Ви впевнені, що хочете відкликати цей ключ API",
"Create API Key": "Створити ключ API",
"Custom expiration date": "Користувацька дата закінчення",
"Enter a descriptive token name": "Введіть описову назву токена",
"Expiration": "Термін дії",
"Expired": "Закінчився",
"Expires": "Закінчується",
"I've saved my API key": "Я зберіг свій ключ API",
"Last use": "Останнє використання",
"No API keys found": "Ключі API не знайдено",
"No expiration": "Без терміну дії",
"Revoke API key": "Відкликати ключ API",
"Revoked successfully": "Успішно відкликано",
"Select expiration date": "Виберіть дату закінчення",
"This action cannot be undone. Any applications using this API key will stop working.": "Цю дію не можна скасувати. Будь-які додатки, що використовують цей ключ API, перестануть працювати.",
"Update API key": "Оновити ключ API",
"Manage API keys for all users in the workspace": "Керувати ключами API для всіх користувачів у робочій області",
"AI settings": "Налаштування ШІ",
"AI search": "Пошук з ШІ",
"AI Answer": "Відповідь ШІ",
"Ask AI": "Запитати ШІ",
"AI is thinking...": "ШІ думає...",
"Ask a question...": "Задайте питання...",
"AI Answers": "Відповіді ШІ",
"AI-powered search (AI Answers)": "Пошук на базі ШІ (Відповіді ШІ)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Пошук з ШІ використовує векторні вбудовування для надання можливостей семантичного пошуку у вашому робочому вмісті.",
"Toggle AI search": "Переключити пошук з ШІ",
"Generative AI (Ask AI)": "Генеративний ШІ (Запитати ШІ)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Увімкнути генерацію контенту за допомогою ШІ в редакторі. Дозволяє користувачам генерувати, покращувати, перекладати та трансформувати текст.",
"Toggle generative AI": "Переключити генеративний ШІ",
"Sources": "Джерела",
"AI Answers not available for attachments": "Відповіді ШІ недоступні для вкладень",
"No answer available": "Відповідь недоступна",
"Background color": "Колір фону",
"Highlight color": "Колір підсвічування",
"Remove color": "Видалити колір",
"Notifications": "Сповіщення",
"No notifications": "Немає сповіщень",
"No unread notifications": "Немає непрочитаних сповіщень",
"All notifications": "Усі сповіщення",
"Unread only": "Тільки непрочитані",
"Mark all as read": "Позначити все як прочитане",
"Mark as read": "Позначити як прочитане",
"More options": "Більше опцій",
"mentioned you in a comment": "згадали вас у коментарі",
"commented on a page": "прокоментували на сторінці",
"resolved a comment": "вирішили коментар",
"mentioned you on a page": "згадали вас на сторінці",
"Today": "Сьогодні",
"Yesterday": "Вчора",
"This week": "Цього тижня",
"Older": "Старіші"
} }
@@ -29,7 +29,6 @@
"Choose your preferred interface language.": "选择您喜欢的界面语言。", "Choose your preferred interface language.": "选择您喜欢的界面语言。",
"Choose your preferred page width.": "选择您喜欢的页面宽度。", "Choose your preferred page width.": "选择您喜欢的页面宽度。",
"Confirm": "确认", "Confirm": "确认",
"Copy as Markdown": "复制为Markdown",
"Copy link": "复制链接", "Copy link": "复制链接",
"Create": "创建", "Create": "创建",
"Create group": "创建群组", "Create group": "创建群组",
@@ -123,8 +122,6 @@
"page": "个页面", "page": "个页面",
"Page deleted successfully": "页面已成功删除", "Page deleted successfully": "页面已成功删除",
"Page history": "页面历史", "Page history": "页面历史",
"Select version": "选择版本",
"Highlight changes": "突出显示更改",
"Page import is in progress. Please do not close this tab.": "页面导入正在进行中。请不要关闭此标签页。", "Page import is in progress. Please do not close this tab.": "页面导入正在进行中。请不要关闭此标签页。",
"Pages": "页面", "Pages": "页面",
"pages": "个页面", "pages": "个页面",
@@ -256,7 +253,6 @@
"Export failed:": "导出失败:", "Export failed:": "导出失败:",
"export error": "导出出错", "export error": "导出出错",
"Export page": "导出页面", "Export page": "导出页面",
"Export successful": "导出成功",
"Export space": "导出空间", "Export space": "导出空间",
"Export {{type}}": "导出为 {{type}}", "Export {{type}}": "导出为 {{type}}",
"File exceeds the {{limit}} attachment limit": "文件超出了 {{limit}} 类型附件限制", "File exceeds the {{limit}} attachment limit": "文件超出了 {{limit}} 类型附件限制",
@@ -332,8 +328,6 @@
"Upload any image from your device.": "从设备上传任何图像", "Upload any image from your device.": "从设备上传任何图像",
"Upload any video from your device.": "从设备上传任何视频", "Upload any video from your device.": "从设备上传任何视频",
"Upload any file from your device.": "从设备上传任何文件", "Upload any file from your device.": "从设备上传任何文件",
"Uploading {{name}}": "正在上传{{name}}",
"Uploading file": "正在上传文件",
"Table": "表格", "Table": "表格",
"Insert a table.": "插入一个表格", "Insert a table.": "插入一个表格",
"Insert collapsible block.": "插入一个折叠块", "Insert collapsible block.": "插入一个折叠块",
@@ -355,11 +349,6 @@
"Insert current date": "插入当前日期", "Insert current date": "插入当前日期",
"Draw and sketch excalidraw diagrams": "绘制 Excalidraw 图表", "Draw and sketch excalidraw diagrams": "绘制 Excalidraw 图表",
"Multiple": "多个", "Multiple": "多个",
"Turn into": "变成",
"Text align": "文本对齐",
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
"Go to homepage": "Go to homepage",
"Pages you create will show up here.": "Pages you create will show up here.",
"Heading {{level}}": "{{level}} 级标题", "Heading {{level}}": "{{level}} 级标题",
"Toggle title": "切换标题", "Toggle title": "切换标题",
"Write anything. Enter \"/\" for commands": "开始编写内容,输入 \"/\" 以使用指令", "Write anything. Enter \"/\" for commands": "开始编写内容,输入 \"/\" 以使用指令",
@@ -412,21 +401,6 @@
"Share deleted successfully": "分享已成功删除", "Share deleted successfully": "分享已成功删除",
"Share not found": "未找到分享", "Share not found": "未找到分享",
"Failed to share page": "页面分享失败", "Failed to share page": "页面分享失败",
"Disable public sharing": "禁用公开分享",
"Prevent members from sharing pages publicly.": "阻止成员公开分享页面。",
"Toggle public sharing": "切换公开分享",
"Toggle space public sharing": "切换空间公开分享",
"Public sharing is disabled at the workspace level": "公开分享在工作区级别被禁用",
"Prevent pages in this space from being shared publicly.": "阻止此空间中的页面被公开分享。",
"Requires an enterprise license": "需要企业许可证",
"Enable public sharing": "启用公开分享",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "您确定要启用公开分享吗?成员将能够公开分享页面。",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "您确定要禁用公开分享吗?此工作区中的所有现有共享链接都将被删除。",
"Are you sure you want to enable public sharing for this space?": "您确定要为此空间启用公开分享吗?",
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "您确定要禁用公开分享吗?此空间中的所有现有共享链接都将被删除。",
"Public sharing is disabled": "公开分享已被禁用",
"Public sharing has been disabled at the workspace level.": "公开分享已在工作区级别被禁用。",
"Public sharing has been disabled for this space.": "此空间的公开分享已被禁用。",
"Copy page": "复制页面", "Copy page": "复制页面",
"Copy page to a different space.": "将页面复制到不同的空间。", "Copy page to a different space.": "将页面复制到不同的空间。",
"Page copied successfully": "页面复制成功", "Page copied successfully": "页面复制成功",
@@ -553,67 +527,5 @@
"Delete SSO provider": "删除SSO提供商", "Delete SSO provider": "删除SSO提供商",
"Are you sure you want to delete this SSO provider?": "您确定要删除此SSO提供商吗?", "Are you sure you want to delete this SSO provider?": "您确定要删除此SSO提供商吗?",
"Action": "操作", "Action": "操作",
"{{ssoProviderType}} configuration": "{{ssoProviderType}} 配置", "{{ssoProviderType}} configuration": "{{ssoProviderType}} 配置"
"Icon": "图标",
"Upload image": "上传图片",
"Remove image": "删除图片",
"Failed to remove image": "无法删除图片",
"Image exceeds 10MB limit.": "图片超过10MB限制。",
"Image removed successfully": "图片删除成功",
"API key": "API密钥",
"API key created successfully": "API密钥创建成功",
"API keys": "API密钥",
"API management": "API管理",
"Are you sure you want to revoke this API key": "确定要撤销此API密钥吗",
"Create API Key": "创建API密钥",
"Custom expiration date": "自定义到期日期",
"Enter a descriptive token name": "输入描述性令牌名称",
"Expiration": "到期",
"Expired": "已过期",
"Expires": "到期",
"I've saved my API key": "我已保存我的API密钥",
"Last use": "上次使用",
"No API keys found": "找不到API密钥",
"No expiration": "无到期",
"Revoke API key": "撤销API密钥",
"Revoked successfully": "撤销成功",
"Select expiration date": "选择到期日期",
"This action cannot be undone. Any applications using this API key will stop working.": "此操作无法撤销。使用此API密钥的任何应用程序将停止工作。",
"Update API key": "更新API密钥",
"Manage API keys for all users in the workspace": "管理工作空间中所有用户的API密钥",
"AI settings": "AI设置",
"AI search": "AI搜索",
"AI Answer": "AI回答",
"Ask AI": "询问AI",
"AI is thinking...": "AI正在思考...",
"Ask a question...": "提问...",
"AI Answers": "AI答案",
"AI-powered search (AI Answers)": "AI驱动的搜索 (AI答案)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI搜索使用向量嵌入提供跨工作空间内容的语义搜索功能。",
"Toggle AI search": "切换AI搜索",
"Generative AI (Ask AI)": "生成型AI (询问AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "在编辑器中启用AI驱动的内容生成。允许用户生成、改进、翻译和转换文本。",
"Toggle generative AI": "切换生成型AI",
"Sources": "来源",
"AI Answers not available for attachments": "AI答案不适用于附件",
"No answer available": "无可用答案",
"Background color": "背景颜色",
"Highlight color": "突出显示颜色",
"Remove color": "移除颜色",
"Notifications": "通知",
"No notifications": "没有通知",
"No unread notifications": "没有未读通知",
"All notifications": "所有通知",
"Unread only": "仅未读",
"Mark all as read": "标记所有为已读",
"Mark as read": "标记为已读",
"More options": "更多选项",
"mentioned you in a comment": "在评论中提到你",
"commented on a page": "在页面上评论",
"resolved a comment": "解决了一个评论",
"mentioned you on a page": "在页面上提到你",
"Today": "今天",
"Yesterday": "昨天",
"This week": "本周",
"Older": "较早"
} }
+8 -3
View File
@@ -14,6 +14,7 @@ import AccountPreferences from "@/pages/settings/account/account-preferences.tsx
import SpaceHome from "@/pages/space/space-home.tsx"; import SpaceHome from "@/pages/space/space-home.tsx";
import PageRedirect from "@/pages/page/page-redirect.tsx"; import PageRedirect from "@/pages/page/page-redirect.tsx";
import Layout from "@/components/layouts/global/layout.tsx"; import Layout from "@/components/layouts/global/layout.tsx";
import { ErrorBoundary } from "react-error-boundary";
import InviteSignup from "@/pages/auth/invite-signup.tsx"; import InviteSignup from "@/pages/auth/invite-signup.tsx";
import ForgotPassword from "@/pages/auth/forgot-password.tsx"; import ForgotPassword from "@/pages/auth/forgot-password.tsx";
import PasswordReset from "./pages/auth/password-reset"; import PasswordReset from "./pages/auth/password-reset";
@@ -36,7 +37,6 @@ import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page";
import SpaceTrash from "@/pages/space/space-trash.tsx"; import SpaceTrash from "@/pages/space/space-trash.tsx";
import UserApiKeys from "@/ee/api-key/pages/user-api-keys"; import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys"; import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
export default function App() { export default function App() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -83,7 +83,13 @@ export default function App() {
<Route path={"/s/:spaceSlug/trash"} element={<SpaceTrash />} /> <Route path={"/s/:spaceSlug/trash"} element={<SpaceTrash />} />
<Route <Route
path={"/s/:spaceSlug/p/:pageSlug"} path={"/s/:spaceSlug/p/:pageSlug"}
element={<Page />} element={
<ErrorBoundary
fallback={<>{t("Failed to load page. An error occurred.")}</>}
>
<Page />
</ErrorBoundary>
}
/> />
<Route path={"/settings"}> <Route path={"/settings"}>
@@ -101,7 +107,6 @@ export default function App() {
<Route path={"spaces"} element={<Spaces />} /> <Route path={"spaces"} element={<Spaces />} />
<Route path={"sharing"} element={<Shares />} /> <Route path={"sharing"} element={<Shares />} />
<Route path={"security"} element={<Security />} /> <Route path={"security"} element={<Security />} />
<Route path={"ai"} element={<AiSettings />} />
{!isCloud() && <Route path={"license"} element={<License />} />} {!isCloud() && <Route path={"license"} element={<License />} />}
{isCloud() && <Route path={"billing"} element={<Billing />} />} {isCloud() && <Route path={"billing"} element={<Billing />} />}
</Route> </Route>
@@ -1,33 +0,0 @@
// Source: https://github.com/mantinedev/mantine/blob/master/packages/@mantine/core/src/components/CopyButton/CopyButton.tsx - MIT
// modified to use the polyfilled clipboard api
import React from "react";
import { useClipboard } from "@/hooks/use-clipboard";
import { useProps } from "@mantine/core";
interface CopyButtonProps {
/** Children callback, provides current status and copy function as an argument */
children: (payload: { copied: boolean; copy: () => void }) => React.ReactNode;
/** Value that is copied to the clipboard when the button is clicked */
value: string;
/** Copied status timeout in ms @default `1000` */
timeout?: number;
}
const defaultProps = {
timeout: 1000,
} satisfies Partial<CopyButtonProps>;
export function CopyButton(props: CopyButtonProps) {
const { children, timeout, value, ...others } = useProps(
"CopyButton",
defaultProps,
props,
);
const clipboard = useClipboard({ timeout });
const copy = () => clipboard.copy(value);
return <>{children({ copy, copied: clipboard.copied, ...others })}</>;
}
CopyButton.displayName = "@mantine/core/CopyButton";
+1 -2
View File
@@ -1,5 +1,4 @@
import { ActionIcon, Tooltip } from "@mantine/core"; import { ActionIcon, CopyButton, Tooltip } from "@mantine/core";
import { CopyButton } from "@/components/common/copy-button";
import { IconCheck, IconCopy } from "@tabler/icons-react"; import { IconCheck, IconCopy } from "@tabler/icons-react";
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -30,11 +30,9 @@ export default function ExportModal({
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown); const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
const [includeChildren, setIncludeChildren] = useState<boolean>(false); const [includeChildren, setIncludeChildren] = useState<boolean>(false);
const [includeAttachments, setIncludeAttachments] = useState<boolean>(false); const [includeAttachments, setIncludeAttachments] = useState<boolean>(false);
const [isExporting, setIsExporting] = useState<boolean>(false);
const { t } = useTranslation(); const { t } = useTranslation();
const handleExport = async () => { const handleExport = async () => {
setIsExporting(true);
try { try {
if (type === "page") { if (type === "page") {
await exportPage({ await exportPage({
@@ -47,9 +45,6 @@ export default function ExportModal({
if (type === "space") { if (type === "space") {
await exportSpace({ spaceId: id, format, includeAttachments }); await exportSpace({ spaceId: id, format, includeAttachments });
} }
notifications.show({
message: t("Export successful"),
});
onClose(); onClose();
} catch (err) { } catch (err) {
notifications.show({ notifications.show({
@@ -57,8 +52,6 @@ export default function ExportModal({
color: "red", color: "red",
}); });
console.error("export error", err); console.error("export error", err);
} finally {
setIsExporting(false);
} }
}; };
@@ -143,7 +136,7 @@ export default function ExportModal({
<Button onClick={onClose} variant="default"> <Button onClick={onClose} variant="default">
{t("Cancel")} {t("Cancel")}
</Button> </Button>
<Button onClick={handleExport} loading={isExporting}>{t("Export")}</Button> <Button onClick={handleExport}>{t("Export")}</Button>
</Group> </Group>
</Modal.Body> </Modal.Body>
</Modal.Content> </Modal.Content>
@@ -2,17 +2,17 @@ import { Button, Group } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export interface PagePaginationProps { export interface PagePaginationProps {
currentPage: number;
hasPrevPage: boolean; hasPrevPage: boolean;
hasNextPage: boolean; hasNextPage: boolean;
onPrev: () => void; onPageChange: (newPage: number) => void;
onNext: () => void;
} }
export default function Paginate({ export default function Paginate({
currentPage,
hasPrevPage, hasPrevPage,
hasNextPage, hasNextPage,
onPrev, onPageChange,
onNext,
}: PagePaginationProps) { }: PagePaginationProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -25,7 +25,7 @@ export default function Paginate({
<Button <Button
variant="default" variant="default"
size="compact-sm" size="compact-sm"
onClick={onPrev} onClick={() => onPageChange(currentPage - 1)}
disabled={!hasPrevPage} disabled={!hasPrevPage}
> >
{t("Prev")} {t("Prev")}
@@ -34,7 +34,7 @@ export default function Paginate({
<Button <Button
variant="default" variant="default"
size="compact-sm" size="compact-sm"
onClick={onNext} onClick={() => onPageChange(currentPage + 1)}
disabled={!hasNextPage} disabled={!hasNextPage}
> >
{t("Next")} {t("Next")}
@@ -5,28 +5,26 @@ import {
Badge, Badge,
Table, Table,
ActionIcon, ActionIcon,
} from "@mantine/core"; } from '@mantine/core';
import { Link } from "react-router-dom"; import {Link} from 'react-router-dom';
import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx"; import PageListSkeleton from '@/components/ui/page-list-skeleton.tsx';
import { buildPageUrl } from "@/features/page/page.utils.ts"; import { buildPageUrl } from '@/features/page/page.utils.ts';
import { formattedDate } from "@/lib/time.ts"; import { formattedDate } from '@/lib/time.ts';
import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts"; import { useRecentChangesQuery } from '@/features/page/queries/page-query.ts';
import { IconFileDescription, IconFiles } from "@tabler/icons-react"; import { IconFileDescription } from '@tabler/icons-react';
import { EmptyState } from "@/components/ui/empty-state.tsx"; import { getSpaceUrl } from '@/lib/config.ts';
import { getSpaceUrl } from "@/lib/config.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getInitialsColor } from "@/lib/get-initials-color.ts";
interface Props { interface Props {
spaceId?: string; spaceId?: string;
} }
export default function RecentChanges({ spaceId }: Props) { export default function RecentChanges({spaceId}: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: pages, isLoading, isError } = useRecentChangesQuery(spaceId); const {data: pages, isLoading, isError} = useRecentChangesQuery(spaceId);
if (isLoading) { if (isLoading) {
return <PageListSkeleton />; return <PageListSkeleton/>;
} }
if (isError) { if (isError) {
@@ -46,8 +44,8 @@ export default function RecentChanges({ spaceId }: Props) {
> >
<Group wrap="nowrap"> <Group wrap="nowrap">
{page.icon || ( {page.icon || (
<ActionIcon variant="transparent" color="gray" size={18}> <ActionIcon variant='transparent' color='gray' size={18}>
<IconFileDescription size={18} /> <IconFileDescription size={18}/>
</ActionIcon> </ActionIcon>
)} )}
@@ -60,23 +58,18 @@ export default function RecentChanges({ spaceId }: Props) {
{!spaceId && ( {!spaceId && (
<Table.Td> <Table.Td>
<Badge <Badge
color={getInitialsColor(page?.space.name)} color="blue"
variant="light" variant="light"
component={Link} component={Link}
to={getSpaceUrl(page?.space.slug)} to={getSpaceUrl(page?.space.slug)}
style={{ cursor: "pointer" }} style={{cursor: 'pointer'}}
> >
{page?.space.name} {page?.space.name}
</Badge> </Badge>
</Table.Td> </Table.Td>
)} )}
<Table.Td> <Table.Td>
<Text <Text c="dimmed" style={{whiteSpace: 'nowrap'}} size="xs" fw={500}>
c="dimmed"
style={{ whiteSpace: "nowrap" }}
size="xs"
fw={500}
>
{formattedDate(page.updatedAt)} {formattedDate(page.updatedAt)}
</Text> </Text>
</Table.Td> </Table.Td>
@@ -86,10 +79,8 @@ export default function RecentChanges({ spaceId }: Props) {
</Table> </Table>
</Table.ScrollContainer> </Table.ScrollContainer>
) : ( ) : (
<EmptyState <Text size="md" ta="center">
icon={IconFiles} {t("No pages yet")}
title={t("No pages yet")} </Text>
description={t("Pages you create will show up here.")}
/>
); );
} }
@@ -22,7 +22,6 @@ import {
searchSpotlight, searchSpotlight,
shareSearchSpotlight, shareSearchSpotlight,
} from "@/features/search/constants.ts"; } from "@/features/search/constants.ts";
import { NotificationPopover } from "@/features/notification/components/notification-popover.tsx";
const links = [{ link: APP_ROUTE.HOME, label: "Home" }]; const links = [{ link: APP_ROUTE.HOME, label: "Home" }];
@@ -98,7 +97,6 @@ export function AppHeader() {
</div> </div>
<Group px={"xl"} wrap="nowrap"> <Group px={"xl"} wrap="nowrap">
<NotificationPopover />
{isCloud() && isTrial && trialDaysLeft !== 0 && ( {isCloud() && isTrial && trialDaysLeft !== 0 && (
<Badge <Badge
variant="light" variant="light"
@@ -13,7 +13,7 @@ import { getShares } from "@/features/share/services/share-service.ts";
import { getApiKeys } from "@/ee/api-key"; import { getApiKeys } from "@/ee/api-key";
export const prefetchWorkspaceMembers = () => { export const prefetchWorkspaceMembers = () => {
const params: QueryParams = { limit: 100, query: "" }; const params = { limit: 100, page: 1, query: "" } as QueryParams;
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ["workspaceMembers", params], queryKey: ["workspaceMembers", params],
queryFn: () => getWorkspaceMembers(params), queryFn: () => getWorkspaceMembers(params),
@@ -22,15 +22,15 @@ export const prefetchWorkspaceMembers = () => {
export const prefetchSpaces = () => { export const prefetchSpaces = () => {
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ["spaces", {}], queryKey: ["spaces", { page: 1 }],
queryFn: () => getSpaces({}), queryFn: () => getSpaces({ page: 1 }),
}); });
}; };
export const prefetchGroups = () => { export const prefetchGroups = () => {
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ["groups", {}], queryKey: ["groups", { page: 1 }],
queryFn: () => getGroups({}), queryFn: () => getGroups({ page: 1 }),
}); });
}; };
@@ -62,21 +62,21 @@ export const prefetchSsoProviders = () => {
export const prefetchShares = () => { export const prefetchShares = () => {
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ["share-list", {}], queryKey: ["share-list", { page: 1 }],
queryFn: () => getShares({}), queryFn: () => getShares({ page: 1, limit: 100 }),
}); });
}; };
export const prefetchApiKeys = () => { export const prefetchApiKeys = () => {
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ["api-key-list", {}], queryKey: ["api-key-list", { page: 1 }],
queryFn: () => getApiKeys({}), queryFn: () => getApiKeys({ page: 1 }),
}); });
}; };
export const prefetchApiKeyManagement = () => { export const prefetchApiKeyManagement = () => {
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ["api-key-list", { adminView: true }], queryKey: ["api-key-list", { page: 1 }],
queryFn: () => getApiKeys({ adminView: true }), queryFn: () => getApiKeys({ page: 1, adminView: true }),
}); });
}; };
@@ -12,14 +12,13 @@ import {
IconLock, IconLock,
IconKey, IconKey,
IconWorld, IconWorld,
IconSparkles,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { Link, useLocation } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
import classes from "./settings.module.css"; import classes from "./settings.module.css";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { isCloud } from "@/lib/config.ts"; import { isCloud } from "@/lib/config.ts";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import { useAtom } from "jotai"; import { useAtom } from "jotai/index";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { import {
prefetchApiKeyManagement, prefetchApiKeyManagement,
@@ -110,12 +109,6 @@ const groupedData: DataGroup[] = [
isAdmin: true, isAdmin: true,
showDisabledInNonEE: true, showDisabledInNonEE: true,
}, },
{
label: "AI settings",
icon: IconSparkles,
path: "/settings/ai",
isAdmin: true,
},
], ],
}, },
{ {
@@ -1,49 +0,0 @@
import { useRef, useState, ReactNode } from "react";
import { Text, TextProps, Tooltip } from "@mantine/core";
type AutoTooltipTextProps = TextProps & {
children: ReactNode;
tooltipLabel?: string;
tooltipProps?: Omit<
React.ComponentProps<typeof Tooltip>,
"children" | "label"
>;
};
export function AutoTooltipText({
children,
tooltipLabel,
tooltipProps,
...textProps
}: AutoTooltipTextProps) {
const textRef = useRef<HTMLParagraphElement>(null);
const [isTruncated, setIsTruncated] = useState(false);
const handleMouseEnter = () => {
const element = textRef.current;
if (element) {
setIsTruncated(element.scrollWidth > element.clientWidth);
}
};
const label = tooltipLabel ?? (typeof children === "string" ? children : "");
return (
<Tooltip
label={label}
disabled={!isTruncated || !label}
multiline
withArrow
{...tooltipProps}
>
<Text
ref={textRef}
truncate
onMouseEnter={handleMouseEnter}
{...textProps}
>
{children}
</Text>
</Tooltip>
);
}
@@ -1,8 +0,0 @@
.root {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
}
@@ -1,30 +0,0 @@
import { Stack, Text } from "@mantine/core";
import { type TablerIcon } from "@tabler/icons-react";
import { ReactNode } from "react";
import classes from "./empty-state.module.css";
type EmptyStateProps = {
icon: TablerIcon;
title: string;
description?: string;
action?: ReactNode;
};
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
return (
<div className={classes.root}>
<Stack align="center" gap="xs">
<Icon size={40} stroke={1.5} color="var(--mantine-color-dimmed)" />
<Text size="lg" fw={500}>
{title}
</Text>
{description && (
<Text size="sm" c="dimmed" maw={350}>
{description}
</Text>
)}
{action}
</Stack>
</div>
);
}
@@ -1,113 +0,0 @@
import React, { useMemo } from "react";
import { Paper, Text, Group, Stack, Loader, Box } from "@mantine/core";
import { IconSparkles, IconFileText } from "@tabler/icons-react";
import { Link } from "react-router-dom";
import { IAiSearchResponse } from "../services/ai-search-service.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { markdownToHtml } from "@docmost/editor-ext";
import DOMPurify from "dompurify";
import { useTranslation } from "react-i18next";
interface AiSearchResultProps {
result?: IAiSearchResponse;
isLoading?: boolean;
streamingAnswer?: string;
streamingSources?: any[];
}
export function AiSearchResult({
result,
isLoading,
streamingAnswer = "",
streamingSources = [],
}: AiSearchResultProps) {
const { t } = useTranslation();
// Use streaming data if available, otherwise fall back to result
const answer = streamingAnswer || result?.answer || "";
const sources =
streamingSources.length > 0 ? streamingSources : result?.sources || [];
// Deduplicate sources by pageId, keeping the one with highest similarity
const deduplicatedSources = useMemo(() => {
if (!sources || sources.length === 0) return [];
const pageMap = new Map();
sources.forEach((source) => {
const existing = pageMap.get(source.pageId);
if (!existing || source.similarity > existing.similarity) {
pageMap.set(source.pageId, source);
}
});
return Array.from(pageMap.values());
}, [sources]);
if (isLoading && !answer) {
return (
<Paper p="md" radius="md" withBorder>
<Group>
<Loader size="sm" />
<Text size="sm">{t("AI is thinking...")}</Text>
</Group>
</Paper>
);
}
if (!answer && !isLoading) {
return null;
}
return (
<Stack gap="md" p="md">
<Paper p="md" radius="md" withBorder>
<Group gap="xs" mb="sm">
<IconSparkles size={20} color="var(--mantine-color-blue-6)" />
<Text fw={600} size="sm">
{t("AI Answer")}
</Text>
{isLoading && <Loader size="xs" />}
</Group>
<div
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(markdownToHtml(answer) as string),
}}
/>
</Paper>
{deduplicatedSources.length > 0 && (
<Stack gap="xs">
<Text size="xs" fw={600} c="dimmed">
{t("Sources")}
</Text>
{deduplicatedSources.map((source) => (
<Box
key={source.pageId}
component={Link}
to={buildPageUrl(source.spaceSlug, source.slugId, source.title)}
style={{
textDecoration: "none",
color: "inherit",
display: "block",
}}
>
<Paper
p="xs"
radius="sm"
withBorder
style={{ cursor: "pointer" }}
>
<Group gap="xs">
<IconFileText size={16} />
<Text size="sm" truncate>
{source.title}
</Text>
</Group>
</Paper>
</Box>
))}
</Stack>
)}
</Stack>
);
}
@@ -1,61 +0,0 @@
.aiMenu {
display: flex;
flex-direction: column;
width: 100%;
max-width: 600px;
min-height: 2.25rem;
}
.aiInput {
width: 100%;
& input {
height: 44px;
border-radius: 22px;
padding-left: 20px;
padding-right: 40px;
border: 1px solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
font-size: var(--mantine-font-size-sm);
&:focus {
border-color: light-dark(
var(--mantine-color-gray-4),
var(--mantine-color-dark-3)
);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
}
}
.menuItemSelected {
background-color: var(--mantine-color-gray-1);
@mixin dark {
background-color: var(--mantine-color-dark-5);
}
}
.resultPreview {
background-color: light-dark(
var(--mantine-color-white),
var(--mantine-color-dark-6)
);
border: 1px solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.resultPreviewWrapper {
font-size: var(--mantine-font-size-md);
line-height: 1.6;
padding: var(--mantine-spacing-md);
*:first-child {
margin-top: 0;
}
*:last-child {
margin-bottom: 0;
}
}
@@ -1,325 +0,0 @@
import { Editor } from "@tiptap/react";
import { ActionIcon, TextInput, Tooltip } from "@mantine/core";
import { useDebouncedCallback, useMediaQuery } from "@mantine/hooks";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useAtom } from "jotai";
import { IconArrowUp } from "@tabler/icons-react";
import { showAiMenuAtom } from "@/features/editor/atoms/editor-atoms.ts";
import { useAiGenerateStreamMutation } from "@/ee/ai/queries/ai-query.ts";
import { AiAction } from "@/ee/ai/types/ai.types.ts";
import { CommandItem, commandItems, CommandSet } from "./command-items.ts";
import { CommandSelector } from "./command-selector.tsx";
import { ResultPreview } from "./result-preview.tsx";
import classes from "./ai-menu.module.css";
import { marked } from "marked";
import { DOMSerializer } from "@tiptap/pm/model";
import { htmlToMarkdown } from "@docmost/editor-ext";
import { useLocation } from "react-router-dom";
interface EditorAiMenuProps {
editor: Editor | null;
}
const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
const aiGenerateStreamMutation = useAiGenerateStreamMutation();
const location = useLocation();
const isSmBreakpoint = useMediaQuery("(max-width: 48em)");
const [showAiMenu, setShowAiMenu] = useAtom(showAiMenuAtom);
const containerRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const [prompt, setPrompt] = useState("");
const [output, setOutput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [activeCommandSet, setActiveCommandSet] = useState<CommandSet>("main");
const [lastAction, setLastAction] = useState<CommandItem | null>(null);
const [menuPlacement, setMenuPlacement] = useState<{
top: number;
left: number;
width: number;
}>({
top: 0,
left: 0,
width: 0,
});
const currentItems = useMemo(() => {
return commandItems[activeCommandSet].filter((item) => {
return item.name.toLowerCase().includes(prompt.toLowerCase());
});
}, [prompt, output, activeCommandSet]);
const updateMenuPlacement = useCallback(() => {
if (!editor || !showAiMenu) return;
const { view } = editor;
const { to } = editor.state.selection;
const editorRect = view.dom.getBoundingClientRect();
const cursorCoords = view.coordsAtPos(to);
const topOffset = 8;
const editorPadding = isSmBreakpoint ? 16 : 48;
setMenuPlacement({
top: cursorCoords.bottom + topOffset + window.scrollY,
left: editorRect.left + editorPadding + window.scrollX,
width: editorRect.width - editorPadding * 2,
});
}, [editor, showAiMenu, isSmBreakpoint]);
const resetMenu = useCallback(() => {
setPrompt("");
setOutput("");
setActiveCommandSet("main");
setLastAction(null);
aiGenerateStreamMutation.reset();
}, [aiGenerateStreamMutation.reset]);
const debouncedUpdateMenuPlacement = useDebouncedCallback(
updateMenuPlacement,
60,
);
const handleGenerate = useCallback(
(item?: CommandItem) => {
if (!editor || isLoading) return;
let command: CommandItem | null = item || null;
if (!command) {
if (!prompt) return;
command = {
id: "custom",
name: "Custom",
action: AiAction.CUSTOM,
prompt,
};
}
const { from, to } = editor.state.selection;
const slice = editor.state.doc.slice(from, to);
const serializer = DOMSerializer.fromSchema(editor.schema);
const fragment = serializer.serializeFragment(slice.content);
const wrapper = document.createElement("div");
wrapper.appendChild(fragment);
const content = htmlToMarkdown(wrapper.innerHTML);
setOutput("");
setIsLoading(true);
aiGenerateStreamMutation.mutate({
action: command.action,
prompt: command.prompt,
content,
onChunk: (chunk) => {
setOutput((output) => output + chunk.content);
},
onComplete: () => {
setIsLoading(false);
setActiveCommandSet("result");
},
onError: () => {
setIsLoading(false);
resetMenu();
},
});
setLastAction(command);
},
[
editor,
prompt,
isLoading,
aiGenerateStreamMutation.mutateAsync,
resetMenu,
],
);
const handleCommand = useCallback(
(item?: CommandItem) => {
setPrompt("");
if (!item) {
return handleGenerate();
}
if (item.id === "back") {
return setActiveCommandSet("main");
}
if (item.id === "result-replace") {
const chain = editor.chain().focus();
if (lastAction.action === AiAction.CONTINUE_WRITING) {
chain.setTextSelection(editor.state.selection.to);
}
const html = (marked.parse(output) as string).trim();
// Strip <p> wrapper for single-paragraph output to preserve inline context
const content =
html.startsWith("<p>") &&
html.endsWith("</p>") &&
html.lastIndexOf("<p>") === 0
? html.slice(3, -4)
: html;
chain.insertContent(content).run();
return setShowAiMenu(false);
}
if (item.id === "result-insert-below") {
editor
.chain()
.focus()
.setTextSelection(editor.state.selection.to)
.insertContent(marked.parse(output))
.run();
return setShowAiMenu(false);
}
if (item.id === "result-copy") {
navigator.clipboard.writeText(output);
return setShowAiMenu(false);
}
if (item.id === "result-discard") {
setOutput("");
return resetMenu();
}
if (item.id === "result-try-again" && lastAction) {
return handleGenerate(lastAction);
}
if (item.subCommandSet) {
return setActiveCommandSet(item.subCommandSet);
}
return handleGenerate(item);
},
[editor, output, lastAction, handleGenerate, resetMenu],
);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
const totalItems = currentItems.length;
const cycleSize = totalItems + 1;
if (event.key === "Escape") {
return setShowAiMenu(false);
}
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
return setSelectedIndex((selectedIndex) => {
const direction = event.key === "ArrowDown" ? 1 : -1;
const newIndex = selectedIndex + direction;
if (newIndex < -1) return cycleSize - 1;
if (newIndex >= cycleSize) return 0;
return newIndex;
});
}
if (event.key === "Enter") {
event.preventDefault();
return handleCommand(currentItems[selectedIndex]);
}
},
[currentItems, selectedIndex],
);
useEffect(() => {
if (!editor) return;
const handleClose = () => setShowAiMenu(false);
const observer = new ResizeObserver(() => {
debouncedUpdateMenuPlacement();
});
updateMenuPlacement();
editor.on("focus", handleClose);
editor.on("blur", handleClose);
window.addEventListener("resize", debouncedUpdateMenuPlacement);
window.addEventListener("scroll", debouncedUpdateMenuPlacement, true);
observer.observe(editor.view.dom);
return () => {
editor.off("focus", handleClose);
editor.off("blur", handleClose);
window.removeEventListener("resize", debouncedUpdateMenuPlacement);
window.removeEventListener("scroll", debouncedUpdateMenuPlacement, true);
observer.disconnect();
};
}, [editor, updateMenuPlacement, debouncedUpdateMenuPlacement]);
useEffect(() => {
setShowAiMenu(false);
}, [location]);
useEffect(() => {
if (showAiMenu) {
resetMenu();
}
}, [showAiMenu, resetMenu]);
useEffect(() => {
// Focus input when menu opens or command set changes
requestAnimationFrame(() => {
inputRef.current?.focus({ preventScroll: true });
});
}, [showAiMenu, isLoading, currentItems]);
useEffect(() => {
if (!currentItems.length) {
setSelectedIndex(-1);
}
setSelectedIndex(prompt || activeCommandSet !== "main" ? 0 : -1);
}, [prompt, activeCommandSet, currentItems]);
if (!showAiMenu) return null;
return createPortal(
<div
style={{
zIndex: 200,
position: "absolute",
top: menuPlacement.top,
left: menuPlacement.left,
width: menuPlacement.width,
pointerEvents: "none",
}}
>
<div
className={classes.aiMenu}
style={{ pointerEvents: "auto" }}
tabIndex={0}
ref={containerRef}
>
<ResultPreview output={output} isLoading={isLoading} />
<CommandSelector
selectedIndex={selectedIndex}
isLoading={isLoading}
output={output}
currentItems={currentItems}
handleCommand={handleCommand}
>
<TextInput
ref={inputRef}
className={classes.aiInput}
placeholder="Ask AI..."
data-autofocus
value={prompt}
disabled={isLoading}
onChange={(e) => setPrompt(e.currentTarget.value)}
rightSection={
<ActionIcon
disabled={!prompt || isLoading}
variant="filled"
color="blue"
radius="xl"
size="sm"
onClick={() => handleGenerate()}
>
<IconArrowUp size={14} stroke={2.5} />
</ActionIcon>
}
onKeyDown={handleKeyDown}
/>
</CommandSelector>
</div>
</div>,
document.body,
);
};
export { EditorAiMenu };
@@ -1,219 +0,0 @@
import { AiAction } from "@/ee/ai/types/ai.types.ts";
import {
IconSparkles,
IconArrowsMaximize,
IconArrowsMinimize,
IconWriting,
IconHelp,
IconList,
IconMoodSmile,
IconLanguage,
IconTrash,
IconRefresh,
IconChevronLeft,
IconCheck,
IconArrowDownLeft,
IconCopy,
IconTextPlus,
IconAlignJustified,
} from "@tabler/icons-react";
interface CommandItem {
name: string;
id: string;
icon?: typeof IconSparkles;
action?: AiAction;
prompt?: string;
subCommandSet?: CommandSet;
}
type CommandSet = "main" | "tone" | "translate" | "result";
const mainItems: CommandItem[] = [
{
id: "improve-writing",
name: "Improve writing",
icon: IconSparkles,
action: AiAction.IMPROVE_WRITING,
},
{
id: "fix-spelling-grammar",
name: "Fix spelling & grammar",
icon: IconCheck,
action: AiAction.FIX_SPELLING_GRAMMAR,
},
{
id: "make-longer",
name: "Make longer",
icon: IconTextPlus,
action: AiAction.MAKE_LONGER,
},
{
id: "make-shorter",
name: "Make shorter",
icon: IconAlignJustified,
action: AiAction.MAKE_SHORTER,
},
{
id: "continue-writing",
name: "Continue writing",
icon: IconWriting,
action: AiAction.CONTINUE_WRITING,
},
{
id: "explain",
name: "Explain",
icon: IconHelp,
action: AiAction.EXPLAIN,
},
{
id: "summarize",
name: "Summarize",
icon: IconList,
action: AiAction.SUMMARIZE,
},
{
id: "change-tone",
name: "Change tone",
icon: IconMoodSmile,
subCommandSet: "tone",
},
{
id: "translate",
name: "Translate",
icon: IconLanguage,
subCommandSet: "translate",
},
];
const toneItems: CommandItem[] = [
{
id: "back",
name: "Back",
icon: IconChevronLeft,
},
{
id: "tone-professional",
name: "Professional",
icon: IconMoodSmile,
action: AiAction.CHANGE_TONE,
prompt: "Professional",
},
{
id: "tone-casual",
name: "Casual",
icon: IconMoodSmile,
action: AiAction.CHANGE_TONE,
prompt: "Casual",
},
{
id: "tone-friendly",
name: "Friendly",
icon: IconMoodSmile,
action: AiAction.CHANGE_TONE,
prompt: "Friendly",
},
];
const translateItems: CommandItem[] = [
{
id: "back",
name: "Back",
icon: IconChevronLeft,
},
{
id: "translate-english",
name: "English",
icon: IconLanguage,
action: AiAction.TRANSLATE,
prompt: "English",
},
{
id: "translate-spanish",
name: "Spanish",
icon: IconLanguage,
action: AiAction.TRANSLATE,
prompt: "Spanish",
},
{
id: "translate-german",
name: "German",
icon: IconLanguage,
action: AiAction.TRANSLATE,
prompt: "German",
},
{
id: "translate-french",
name: "French",
icon: IconLanguage,
action: AiAction.TRANSLATE,
prompt: "French",
},
{
id: "translate-dutch",
name: "Dutch",
icon: IconLanguage,
action: AiAction.TRANSLATE,
prompt: "Dutch",
},
{
id: "translate-portuguese",
name: "Portuguese",
icon: IconLanguage,
action: AiAction.TRANSLATE,
prompt: "Portuguese",
},
{
id: "translate-italian",
name: "Italian",
icon: IconLanguage,
action: AiAction.TRANSLATE,
prompt: "Italian",
},
{
id: "translate-japanese",
name: "Japanese",
icon: IconLanguage,
action: AiAction.TRANSLATE,
prompt: "Japanese",
},
{
id: "translate-korean",
name: "Korean",
icon: IconLanguage,
action: AiAction.TRANSLATE,
prompt: "Korean",
},
{
id: "translate-swedish",
name: "Swedish",
icon: IconLanguage,
action: AiAction.TRANSLATE,
prompt: "Swedish",
},
{
id: "translate-chinese",
name: "Chinese (Simplified)",
icon: IconLanguage,
action: AiAction.TRANSLATE,
prompt: "Simplified Chinese",
},
];
const resultItems: CommandItem[] = [
{ id: "result-replace", name: "Replace", icon: IconCheck },
{ id: "result-insert-below", name: "Insert below", icon: IconArrowDownLeft },
{ id: "result-copy", name: "Copy", icon: IconCopy },
{ id: "result-discard", name: "Discard", icon: IconTrash },
{
id: "result-try-again",
name: "Try again",
icon: IconRefresh,
},
];
const commandItems: Record<CommandSet, CommandItem[]> = {
main: mainItems,
tone: toneItems,
translate: translateItems,
result: resultItems,
};
export type { CommandItem, CommandSet };
export { commandItems };
@@ -1,72 +0,0 @@
import { Loader, Menu, ScrollArea } from "@mantine/core";
import { IconChevronRight } from "@tabler/icons-react";
import { ReactNode } from "react";
import { CommandItem } from "./command-items.ts";
import classes from "./ai-menu.module.css";
interface CommandSelectorProps {
selectedIndex: number;
isLoading: boolean;
output: string;
currentItems: CommandItem[];
children: ReactNode;
handleCommand(item: CommandItem): void;
}
const CommandSelector = ({
selectedIndex,
children,
isLoading,
output,
currentItems,
handleCommand,
}: CommandSelectorProps) => {
return (
<Menu
opened={!isLoading && currentItems.length > 0}
middlewares={{ flip: false }}
position="bottom-start"
offset={4}
width={250}
trapFocus={false}
shadow="lg"
>
<Menu.Target>{children}</Menu.Target>
<Menu.Dropdown>
<ScrollArea.Autosize type="scroll" scrollbarSize={5} mah={300}>
{currentItems.map((item, index) => {
const isSelected = selectedIndex === index;
const showLoader =
isLoading && output === "" && !item.subCommandSet;
return (
<Menu.Item
key={item.id}
className={isSelected ? classes.menuItemSelected : undefined}
leftSection={
showLoader ? (
<Loader size={14} />
) : item.icon ? (
<item.icon size={16} />
) : undefined
}
rightSection={
item.subCommandSet ? (
<IconChevronRight size={14} />
) : undefined
}
onClick={() => handleCommand(item)}
disabled={isLoading}
>
{item.name}
</Menu.Item>
);
})}
</ScrollArea.Autosize>
</Menu.Dropdown>
</Menu>
);
};
export { CommandSelector };
@@ -1,32 +0,0 @@
import { Loader, Paper, ScrollArea } from "@mantine/core";
import DOMPurify from "dompurify";
import { marked } from "marked";
import { memo } from "react";
import classes from "./ai-menu.module.css";
interface ResultPreviewProps {
output: string;
isLoading: boolean;
}
const ResultPreview = memo(({ output, isLoading }: ResultPreviewProps) => {
if (!output && !isLoading) return;
const parsedOutput = `${marked.parse(output)}`;
return (
<Paper mb={4} shadow="lg" radius="md" className={classes.resultPreview}>
<ScrollArea.Autosize mah={300} type="scroll" scrollbarSize={5}>
<div className={classes.resultPreviewWrapper}>
{parsedOutput && (
<div
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(parsedOutput) }}
/>
)}
{isLoading && <Loader size={12} ml="xs" display="inline-block" />}
</div>
</ScrollArea.Autosize>
</Paper>
);
});
export { ResultPreview };
@@ -1,69 +0,0 @@
import { Group, Text, Switch, MantineSize, Title } from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import { isCloud } from "@/lib/config.ts";
import useLicense from "@/ee/hooks/use-license.tsx";
export default function EnableAiSearch() {
const { t } = useTranslation();
return (
<>
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("AI-powered search (AI Answers)")}</Text>
<Text size="sm" c="dimmed">
{t(
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.",
)}
</Text>
</div>
<AiSearchToggle />
</Group>
</>
);
}
interface AiSearchToggleProps {
size?: MantineSize;
label?: string;
}
export function AiSearchToggle({ size, label }: AiSearchToggleProps) {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.settings?.ai?.search);
const { hasLicenseKey } = useLicense();
const hasAccess = isCloud() || (!isCloud() && hasLicenseKey);
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
const updatedWorkspace = await updateWorkspace({ aiSearch: value });
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<Switch
size={size}
label={label}
labelPosition="left"
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle AI search")}
/>
);
}
@@ -1,48 +0,0 @@
import { Group, Text, Switch } from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx";
export default function EnableGenerativeAi() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.settings?.ai?.generative);
const hasAccess = useIsCloudEE();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
const updatedWorkspace = await updateWorkspace({ generativeAi: value });
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Generative AI (Ask AI)")}</Text>
<Text size="sm" c="dimmed">
{t(
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.",
)}
</Text>
</div>
<Switch
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
/>
</Group>
);
}
@@ -1,46 +0,0 @@
import { useMutation, UseMutationResult } from "@tanstack/react-query";
import { useState, useCallback } from "react";
import { aiAnswers, IAiSearchResponse } from "@/ee/ai/services/ai-search-service.ts";
import { IPageSearchParams } from "@/features/search/types/search.types.ts";
// @ts-ignore
interface UseAiSearchResult extends UseMutationResult<IAiSearchResponse, Error, IPageSearchParams> {
streamingAnswer: string;
streamingSources: any[];
clearStreaming: () => void;
}
export function useAiSearch(): UseAiSearchResult {
const [streamingAnswer, setStreamingAnswer] = useState("");
const [streamingSources, setStreamingSources] = useState<any[]>([]);
const clearStreaming = useCallback(() => {
setStreamingAnswer("");
setStreamingSources([]);
}, []);
const mutation = useMutation({
mutationFn: async (params: IPageSearchParams & { contentType?: string }) => {
setStreamingAnswer("");
setStreamingSources([]);
const { contentType, ...apiParams } = params;
return await aiAnswers(apiParams, (chunk) => {
if (chunk.content) {
setStreamingAnswer((prev) => prev + chunk.content);
}
if (chunk.sources) {
setStreamingSources(chunk.sources);
}
});
},
});
return {
...mutation,
streamingAnswer,
streamingSources,
clearStreaming,
};
}
-61
View File
@@ -1,61 +0,0 @@
import { useState, useCallback, useRef } from "react";
import { useAiGenerateStreamMutation } from "@/ee/ai/queries/ai-query.ts";
import { AiGenerateDto } from "@/ee/ai/types/ai.types.ts";
export function useAiStream() {
const [content, setContent] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
const mutation = useAiGenerateStreamMutation();
const startStream = useCallback(
async (data: AiGenerateDto) => {
setContent("");
setIsStreaming(true);
try {
const controller = await mutation.mutateAsync({
...data,
onChunk: (chunk) => {
setContent((prev) => prev + chunk.content);
},
onError: (error) => {
console.error("AI stream error:", error);
setIsStreaming(false);
},
onComplete: () => {
setIsStreaming(false);
},
});
abortControllerRef.current = controller;
} catch (error) {
console.error("Failed to start stream:", error);
setIsStreaming(false);
}
},
[mutation]
);
const stopStream = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
setIsStreaming(false);
}
}, []);
const resetContent = useCallback(() => {
setContent("");
}, []);
return {
content,
isStreaming,
startStream,
stopStream,
resetContent,
isLoading: mutation.isPending,
error: mutation.error,
};
}
@@ -1,49 +0,0 @@
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import React from "react";
import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next";
import EnableAiSearch from "@/ee/ai/components/enable-ai-search.tsx";
import EnableGenerativeAi from "@/ee/ai/components/enable-generative-ai.tsx";
import { Alert, Stack } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx";
import { isCloud } from "@/lib/config.ts";
export default function AiSettings() {
const { t } = useTranslation();
const { isAdmin } = useUserRole();
const hasAccess = useIsCloudEE();
if (!isAdmin) {
return null;
}
return (
<>
<Helmet>
<title>AI - {getAppName()}</title>
</Helmet>
<SettingsTitle title={t("AI settings")} />
{!hasAccess && (
<Alert
icon={<IconInfoCircle />}
title={t("Enterprise feature")}
color="blue"
mb="lg"
>
{t(
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
)}
</Alert>
)}
<Stack gap="md">
{!isCloud() && <EnableAiSearch />}
<EnableGenerativeAi />
</Stack>
</>
);
}
-44
View File
@@ -1,44 +0,0 @@
import {
useMutation,
UseMutationResult,
useQuery,
UseQueryResult,
} from "@tanstack/react-query";
import {
generateAiContent,
generateAiContentStream,
} from "@/ee/ai/services/ai-service.ts";
import {
AiConfigResponse,
AiContentResponse,
AiGenerateDto,
AiStreamChunk,
AiStreamError,
} from "@/ee/ai/types/ai.types.ts";
export function useAiGenerateMutation(): UseMutationResult<
AiContentResponse,
Error,
AiGenerateDto
> {
return useMutation({
mutationFn: (data: AiGenerateDto) => generateAiContent(data),
});
}
interface StreamCallbacks {
onChunk: (chunk: AiStreamChunk) => void;
onError?: (error: AiStreamError) => void;
onComplete?: () => void;
}
export function useAiGenerateStreamMutation(): UseMutationResult<
AbortController,
Error,
AiGenerateDto & StreamCallbacks
> {
return useMutation({
mutationFn: ({ onChunk, onError, onComplete, ...data }) =>
generateAiContentStream(data, onChunk, onError, onComplete),
});
}
@@ -1,83 +0,0 @@
import api from "@/lib/api-client.ts";
import { IPageSearchParams } from "@/features/search/types/search.types.ts";
export interface IAiSearchResponse {
answer: string;
sources?: Array<{
pageId: string;
title: string;
slugId: string;
spaceSlug: string;
similarity: number;
distance: number;
chunkIndex: number;
excerpt: string;
}>;
}
export async function aiAnswers(
params: IPageSearchParams,
onChunk?: (chunk: { content?: string; sources?: any[] }) => void,
): Promise<IAiSearchResponse> {
const response = await fetch("/api/ai/answers", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify(params),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let answer = "";
let sources: any[] = [];
let buffer = "";
if (reader) {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
// Keep the last incomplete line in the buffer
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") break;
try {
const parsed = JSON.parse(data);
if (parsed.error) {
throw new Error(parsed.error);
}
if (parsed.content) {
answer += parsed.content;
onChunk?.({ content: parsed.content });
}
if (parsed.sources) {
sources = parsed.sources;
onChunk?.({ sources: parsed.sources });
}
} catch (e) {
if (e instanceof Error) {
throw e;
}
// Skip invalid JSON
}
}
}
}
}
return { answer, sources };
}
@@ -1,92 +0,0 @@
import api from "@/lib/api-client.ts";
import {
AiGenerateDto,
AiContentResponse,
AiStreamChunk,
AiStreamError,
} from "@/ee/ai/types/ai.types.ts";
export async function generateAiContent(
data: AiGenerateDto,
): Promise<AiContentResponse> {
const req = await api.post<AiContentResponse>("/ai/generate", data);
return req.data;
}
export async function generateAiContentStream(
data: AiGenerateDto,
onChunk: (chunk: AiStreamChunk) => void,
onError?: (error: AiStreamError) => void,
onComplete?: () => void,
): Promise<AbortController> {
const abortController = new AbortController();
try {
const response = await fetch("/api/ai/generate/stream", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
signal: abortController.signal,
credentials: "include", // This ensures cookies are sent, matching axios withCredentials
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body?.getReader();
const decoder = new TextDecoder();
if (!reader) {
throw new Error("Response body is not readable");
}
const processStream = async () => {
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") {
onComplete?.();
return;
}
try {
const parsed = JSON.parse(data);
if (parsed.error) {
onError?.(parsed);
} else {
onChunk(parsed);
}
} catch (e) {
// Skip invalid JSON
}
}
}
}
} catch (error) {
if (error.name !== "AbortError") {
onError?.({ error: error.message });
}
} finally {
reader.releaseLock();
}
};
processStream();
} catch (error) {
onError?.({ error: error.message });
}
return abortController;
}
-41
View File
@@ -1,41 +0,0 @@
export enum AiAction {
IMPROVE_WRITING = "improve_writing",
FIX_SPELLING_GRAMMAR = "fix_spelling_grammar",
MAKE_SHORTER = "make_shorter",
MAKE_LONGER = "make_longer",
SIMPLIFY = "simplify",
CHANGE_TONE = "change_tone",
SUMMARIZE = "summarize",
EXPLAIN = "explain",
CONTINUE_WRITING = "continue_writing",
TRANSLATE = "translate",
CUSTOM = "custom",
}
export interface AiGenerateDto {
action?: AiAction;
content: string;
prompt?: string;
}
export interface AiContentResponse {
content: string;
usage?: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
};
}
export interface AiConfigResponse {
configured: boolean;
availableActions: AiAction[];
}
export interface AiStreamChunk {
content: string;
}
export interface AiStreamError {
error: string;
}
@@ -10,19 +10,19 @@ import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-moda
import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal"; import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal"; import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
import Paginate from "@/components/common/paginate"; import Paginate from "@/components/common/paginate";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate"; import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts"; import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
import { IApiKey } from "@/ee/api-key"; import { IApiKey } from "@/ee/api-key";
export default function UserApiKeys() { export default function UserApiKeys() {
const { t } = useTranslation(); const { t } = useTranslation();
const { cursor, goNext, goPrev } = useCursorPaginate(); const { page, setPage } = usePaginateAndSearch();
const [createModalOpened, setCreateModalOpened] = useState(false); const [createModalOpened, setCreateModalOpened] = useState(false);
const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null); const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null);
const [updateModalOpened, setUpdateModalOpened] = useState(false); const [updateModalOpened, setUpdateModalOpened] = useState(false);
const [revokeModalOpened, setRevokeModalOpened] = useState(false); const [revokeModalOpened, setRevokeModalOpened] = useState(false);
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null); const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
const { data, isLoading } = useGetApiKeysQuery({ cursor }); const { data, isLoading } = useGetApiKeysQuery({ page });
const handleCreateSuccess = (response: IApiKey) => { const handleCreateSuccess = (response: IApiKey) => {
setCreatedApiKey(response); setCreatedApiKey(response);
@@ -65,10 +65,10 @@ export default function UserApiKeys() {
{data?.items.length > 0 && ( {data?.items.length > 0 && (
<Paginate <Paginate
hasPrevPage={data?.meta?.hasPrevPage} currentPage={page}
hasNextPage={data?.meta?.hasNextPage} hasPrevPage={data?.meta.hasPrevPage}
onNext={() => goNext(data?.meta?.nextCursor)} hasNextPage={data?.meta.hasNextPage}
onPrev={goPrev} onPageChange={setPage}
/> />
)} )}
@@ -10,20 +10,20 @@ import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-moda
import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal"; import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal"; import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
import Paginate from "@/components/common/paginate"; import Paginate from "@/components/common/paginate";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate"; import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts"; import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
import { IApiKey } from "@/ee/api-key"; import { IApiKey } from "@/ee/api-key";
import useUserRole from '@/hooks/use-user-role.tsx'; import useUserRole from '@/hooks/use-user-role.tsx';
export default function WorkspaceApiKeys() { export default function WorkspaceApiKeys() {
const { t } = useTranslation(); const { t } = useTranslation();
const { cursor, goNext, goPrev } = useCursorPaginate(); const { page, setPage } = usePaginateAndSearch();
const [createModalOpened, setCreateModalOpened] = useState(false); const [createModalOpened, setCreateModalOpened] = useState(false);
const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null); const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null);
const [updateModalOpened, setUpdateModalOpened] = useState(false); const [updateModalOpened, setUpdateModalOpened] = useState(false);
const [revokeModalOpened, setRevokeModalOpened] = useState(false); const [revokeModalOpened, setRevokeModalOpened] = useState(false);
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null); const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
const { data, isLoading } = useGetApiKeysQuery({ cursor, adminView: true }); const { data, isLoading } = useGetApiKeysQuery({ page, adminView: true });
const { isAdmin } = useUserRole(); const { isAdmin } = useUserRole();
if (!isAdmin) { if (!isAdmin) {
@@ -76,10 +76,10 @@ export default function WorkspaceApiKeys() {
{data?.items.length > 0 && ( {data?.items.length > 0 && (
<Paginate <Paginate
hasPrevPage={data?.meta?.hasPrevPage} currentPage={page}
hasNextPage={data?.meta?.hasNextPage} hasPrevPage={data?.meta.hasPrevPage}
onNext={() => goNext(data?.meta?.nextCursor)} hasNextPage={data?.meta.hasNextPage}
onPrev={goPrev} onPageChange={setPage}
/> />
)} )}
@@ -1,12 +0,0 @@
import { isCloud } from "@/lib/config";
import useLicense from "@/ee/hooks/use-license";
import usePlan from "@/ee/hooks/use-plan";
const useEnterpriseAccess = () => {
const { hasLicenseKey } = useLicense();
const { isBusiness } = usePlan();
return (isCloud() && isBusiness) || (!isCloud() && hasLicenseKey);
};
export default useEnterpriseAccess;
@@ -11,7 +11,7 @@ export default function OssDetails() {
withTableBorder withTableBorder
> >
<Table.Caption> <Table.Caption>
To unlock enterprise features like AI, SSO, MFA, Resolve comments, contact sales@docmost.com. To unlock enterprise features like SSO, MFA, Resolve comments, contact sales@docmost.com.
</Table.Caption> </Table.Caption>
<Table.Tbody> <Table.Tbody>
<Table.Tr> <Table.Tr>
@@ -8,10 +8,10 @@ import {
Group, Group,
List, List,
Code, Code,
CopyButton,
Alert, Alert,
PasswordInput, PasswordInput,
} from "@mantine/core"; } from "@mantine/core";
import { CopyButton } from "@/components/common/copy-button";
import { import {
IconRefresh, IconRefresh,
IconCopy, IconCopy,
@@ -11,6 +11,7 @@ import {
PinInput, PinInput,
Alert, Alert,
List, List,
CopyButton,
ActionIcon, ActionIcon,
Tooltip, Tooltip,
Paper, Paper,
@@ -19,7 +20,6 @@ import {
Collapse, Collapse,
UnstyledButton, UnstyledButton,
} from "@mantine/core"; } from "@mantine/core";
import { CopyButton } from "@/components/common/copy-button";
import { import {
IconQrcode, IconQrcode,
IconShieldCheck, IconShieldCheck,
@@ -1,88 +0,0 @@
import { Group, Text, Switch, Tooltip } from "@mantine/core";
import { modals } from "@mantine/modals";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx";
export default function DisablePublicSharing() {
const { t } = useTranslation();
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Disable public sharing")}</Text>
<Text size="sm" c="dimmed">
{t("Prevent members from sharing pages publicly.")}
</Text>
</div>
<DisablePublicSharingToggle />
</Group>
);
}
function DisablePublicSharingToggle() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(
workspace?.settings?.sharing?.disabled === true,
);
const hasAccess = useEnterpriseAccess();
const applyChange = async (value: boolean) => {
try {
const updatedWorkspace = await updateWorkspace({
disablePublicSharing: value,
});
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
modals.openConfirmModal({
title: value ? t("Disable public sharing") : t("Enable public sharing"),
children: (
<Text size="sm">
{value
? t(
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.",
)
: t(
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.",
)}
</Text>
),
centered: true,
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
confirmProps: value ? { color: "red" } : {},
onConfirm: () => applyChange(value),
});
};
return (
<Tooltip
label={t("Requires an enterprise license")}
disabled={hasAccess}
refProp="rootRef"
>
<Switch
checked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle public sharing")}
/>
</Tooltip>
);
}
@@ -10,18 +10,23 @@ export default function EnforceMfa() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Group justify="space-between" wrap="nowrap" gap="xl"> <>
<div> <Title order={4} my="sm">
<Text size="md">{t("Enforce two-factor authentication")}</Text> MFA
<Text size="sm" c="dimmed"> </Title>
{t( <Group justify="space-between" wrap="nowrap" gap="xl">
"Once enforced, all members must enable two-factor authentication to access the workspace.", <div>
)} <Text size="md">{t("Enforce two-factor authentication")}</Text>
</Text> <Text size="sm" c="dimmed">
</div> {t(
"Once enforced, all members must enable two-factor authentication to access the workspace.",
)}
</Text>
</div>
<EnforceMfaToggle /> <EnforceMfaToggle />
</Group> </Group>
</>
); );
} }
@@ -1,84 +0,0 @@
import { Group, Text, Switch, Tooltip } from "@mantine/core";
import { modals } from "@mantine/modals";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { ISpace } from "@/features/space/types/space.types.ts";
import { useUpdateSpaceMutation } from "@/features/space/queries/space-query.ts";
type SpacePublicSharingToggleProps = {
space: ISpace;
};
export default function SpacePublicSharingToggle({
space,
}: SpacePublicSharingToggleProps) {
const { t } = useTranslation();
const [workspace] = useAtom(workspaceAtom);
const workspaceDisabled = workspace?.settings?.sharing?.disabled === true;
const [checked, setChecked] = useState(
space.settings?.sharing?.disabled === true,
);
const updateSpaceMutation = useUpdateSpaceMutation();
const applyChange = async (value: boolean) => {
try {
await updateSpaceMutation.mutateAsync({
spaceId: space.id,
disablePublicSharing: value,
});
setChecked(value);
} catch {
// error handled by mutation
}
};
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
modals.openConfirmModal({
title: value ? t("Disable public sharing") : t("Enable public sharing"),
children: (
<Text size="sm">
{value
? t(
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.",
)
: t(
"Are you sure you want to enable public sharing for this space?",
)}
</Text>
),
centered: true,
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
confirmProps: value ? { color: "red" } : {},
onConfirm: () => applyChange(value),
});
};
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Disable public sharing")}</Text>
<Text size="sm" c="dimmed">
{workspaceDisabled
? t("Public sharing is disabled at the workspace level")
: t("Prevent pages in this space from being shared publicly.")}
</Text>
</div>
<Tooltip
label={t("Public sharing is disabled at the workspace level")}
disabled={!workspaceDisabled}
refProp="rootRef"
>
<Switch
checked={checked}
onChange={handleChange}
disabled={workspaceDisabled}
aria-label={t("Toggle space public sharing")}
/>
</Tooltip>
</Group>
);
}
@@ -43,7 +43,7 @@ export default function SsoProviderList() {
return null; return null;
} }
if (data?.items.length === 0) { if (data?.length === 0) {
return <Text c="dimmed">{t("No SSO providers found.")}</Text>; return <Text c="dimmed">{t("No SSO providers found.")}</Text>;
} }
@@ -81,7 +81,7 @@ export default function SsoProviderList() {
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
{data?.items {data
.sort((a, b) => { .sort((a, b) => {
const enabledDiff = Number(b.isEnabled) - Number(a.isEnabled); const enabledDiff = Number(b.isEnabled) - Number(a.isEnabled);
if (enabledDiff !== 0) return enabledDiff; if (enabledDiff !== 0) return enabledDiff;
@@ -104,11 +104,7 @@ export default function SsoProviderList() {
</Group> </Group>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Badge <Badge color={"gray"} variant="light" style={{ whiteSpace: "nowrap" }}>
color={"gray"}
variant="light"
style={{ whiteSpace: "nowrap" }}
>
{provider.type.toUpperCase()} {provider.type.toUpperCase()}
</Badge> </Badge>
</Table.Td> </Table.Td>
@@ -138,41 +134,41 @@ export default function SsoProviderList() {
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Group gap="xs" wrap="nowrap"> <Group gap="xs" wrap="nowrap">
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color="gray" color="gray"
onClick={() => handleEdit(provider)} onClick={() => handleEdit(provider)}
> >
<IconPencil size={16} /> <IconPencil size={16} />
</ActionIcon> </ActionIcon>
<Menu <Menu
transitionProps={{ transition: "pop" }} transitionProps={{ transition: "pop" }}
withArrow withArrow
position="bottom-end" position="bottom-end"
withinPortal withinPortal
> >
<Menu.Target> <Menu.Target>
<ActionIcon variant="subtle" color="gray"> <ActionIcon variant="subtle" color="gray">
<IconDots size={16} /> <IconDots size={16} />
</ActionIcon> </ActionIcon>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Item <Menu.Item
onClick={() => handleEdit(provider)} onClick={() => handleEdit(provider)}
leftSection={<IconPencil size={16} />} leftSection={<IconPencil size={16} />}
> >
{t("Edit")} {t("Edit")}
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item
onClick={() => openDeleteModal(provider.id)} onClick={() => openDeleteModal(provider.id)}
leftSection={<IconTrash size={16} />} leftSection={<IconTrash size={16} />}
color="red" color="red"
disabled={provider.type === SSO_PROVIDER.GOOGLE} disabled={provider.type === SSO_PROVIDER.GOOGLE}
> >
{t("Delete")} {t("Delete")}
</Menu.Item> </Menu.Item>
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
</Group> </Group>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
+10 -26
View File
@@ -9,16 +9,15 @@ import CreateSsoProvider from "@/ee/security/components/create-sso-provider.tsx"
import EnforceSso from "@/ee/security/components/enforce-sso.tsx"; import EnforceSso from "@/ee/security/components/enforce-sso.tsx";
import AllowedDomains from "@/ee/security/components/allowed-domains.tsx"; import AllowedDomains from "@/ee/security/components/allowed-domains.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useLicense from "@/ee/hooks/use-license.tsx";
import usePlan from "@/ee/hooks/use-plan.tsx";
import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx"; import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx";
import DisablePublicSharing from "@/ee/security/components/disable-public-sharing.tsx";
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx";
export default function Security() { export default function Security() {
const { t } = useTranslation(); const { t } = useTranslation();
const { isAdmin } = useUserRole(); const { isAdmin } = useUserRole();
const hasEnterpriseAccess = useEnterpriseAccess(); const { hasLicenseKey } = useLicense();
const isCloudEE = useIsCloudEE(); const { isBusiness } = usePlan();
if (!isAdmin) { if (!isAdmin) {
return null; return null;
@@ -31,41 +30,26 @@ export default function Security() {
</Helmet> </Helmet>
<SettingsTitle title={t("Security")} /> <SettingsTitle title={t("Security")} />
<EnforceMfa /> <AllowedDomains />
<Divider my="lg" /> <Divider my="lg" />
{(!isCloud() || hasEnterpriseAccess) && ( <EnforceMfa />
<>
<DisablePublicSharing /> <Divider my="lg" />
<Divider my="lg" />
</>
)}
<Title order={4} my="lg"> <Title order={4} my="lg">
Single sign-on (SSO) Single sign-on (SSO)
</Title> </Title>
{hasEnterpriseAccess && ( {(isCloud() && isBusiness) || (!isCloud() && hasLicenseKey) ? (
<> <>
<EnforceSso /> <EnforceSso />
<Divider my="lg" /> <Divider my="lg" />
</>
)}
{isCloudEE && (
<>
<AllowedDomains />
<Divider my="lg" />
</>
)}
{hasEnterpriseAccess && (
<>
<CreateSsoProvider /> <CreateSsoProvider />
<Divider size={0} my="lg" /> <Divider size={0} my="lg" />
</> </>
)} ) : null}
<SsoProviderList /> <SsoProviderList />
</> </>
@@ -13,9 +13,8 @@ import {
} from "@/ee/security/services/security-service.ts"; } from "@/ee/security/services/security-service.ts";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { IAuthProvider } from "@/ee/security/types/security.types.ts"; import { IAuthProvider } from "@/ee/security/types/security.types.ts";
import { IPagination } from "@/lib/types.ts";
export function useGetSsoProviders(): UseQueryResult<IPagination<IAuthProvider>, Error> { export function useGetSsoProviders(): UseQueryResult<IAuthProvider[], Error> {
return useQuery({ return useQuery({
queryKey: ["sso-providers"], queryKey: ["sso-providers"],
queryFn: () => getSsoProviders(), queryFn: () => getSsoProviders(),
@@ -1,6 +1,5 @@
import api from "@/lib/api-client.ts"; import api from "@/lib/api-client.ts";
import { IAuthProvider } from "@/ee/security/types/security.types.ts"; import { IAuthProvider } from "@/ee/security/types/security.types.ts";
import { IPagination } from "@/lib/types.ts";
export async function getSsoProviderById(data: { export async function getSsoProviderById(data: {
providerId: string; providerId: string;
@@ -9,8 +8,8 @@ export async function getSsoProviderById(data: {
return req.data; return req.data;
} }
export async function getSsoProviders(): Promise<IPagination<IAuthProvider>> { export async function getSsoProviders(): Promise<IAuthProvider[]> {
const req = await api.post<IPagination<IAuthProvider>>("/sso/providers"); const req = await api.post<IAuthProvider[]>("/sso/providers");
return req.data; return req.data;
} }
@@ -1,62 +1,20 @@
import api from "@/lib/api-client"; import api from "@/lib/api-client";
import loadImage from "blueimp-load-image";
import { import {
AvatarIconType, AvatarIconType,
IAttachment, IAttachment,
} from "@/features/attachments/types/attachment.types.ts"; } from "@/features/attachments/types/attachment.types.ts";
async function compressAndResizeIcon(
file: File,
type: AvatarIconType,
): Promise<File> {
const isPng = file.type === "image/png";
const { image: canvas } = await loadImage(file, {
maxWidth: 300,
maxHeight: 300,
canvas: true,
orientation: true,
imageSmoothingQuality: "high",
});
if (type === AvatarIconType.AVATAR || !isPng) {
const ctx = (canvas as HTMLCanvasElement).getContext("2d")!;
ctx.globalCompositeOperation = "destination-over";
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.globalCompositeOperation = "source-over";
}
const outputType = isPng ? "image/png" : "image/jpeg";
return new Promise<File>((resolve, reject) => {
(canvas as HTMLCanvasElement).toBlob(
(blob) => {
if (!blob) {
reject(new Error("Failed to compress image"));
return;
}
resolve(new File([blob], file.name, { type: outputType }));
},
outputType,
isPng ? undefined : 0.85,
);
});
}
export async function uploadIcon( export async function uploadIcon(
file: File, file: File,
type: AvatarIconType, type: AvatarIconType,
spaceId?: string, spaceId?: string,
): Promise<IAttachment> { ): Promise<IAttachment> {
const processed = await compressAndResizeIcon(file, type);
const formData = new FormData(); const formData = new FormData();
formData.append("type", type); formData.append("type", type);
if (spaceId) { if (spaceId) {
formData.append("spaceId", spaceId); formData.append("spaceId", spaceId);
} }
formData.append("image", processed); formData.append("image", file);
return await api.post("/attachments/upload-image", formData, { return await api.post("/attachments/upload-image", formData, {
headers: { headers: {
@@ -31,7 +31,6 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
const [currentUser] = useAtom(currentUserAtom); const [currentUser] = useAtom(currentUserAtom);
const [, setAsideState] = useAtom(asideStateAtom); const [, setAsideState] = useAtom(asideStateAtom);
const useClickOutsideRef = useClickOutside(() => { const useClickOutsideRef = useClickOutside(() => {
if (document.querySelector("#mention")) return;
handleDialogClose(); handleDialogClose();
}); });
const createCommentMutation = useCreateCommentMutation(); const createCommentMutation = useCreateCommentMutation();
@@ -106,7 +105,6 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
position={{ bottom: 500, right: 50 }} position={{ bottom: 500, right: 50 }}
withCloseButton withCloseButton
withBorder withBorder
data-comment-dialog
> >
<Stack gap={2}> <Stack gap={2}>
<Group> <Group>
@@ -1,15 +1,14 @@
import { EditorContent, ReactNodeViewRenderer, useEditor } from "@tiptap/react"; import { EditorContent, useEditor } from "@tiptap/react";
import { Placeholder } from "@tiptap/extension-placeholder"; import { Placeholder } from "@tiptap/extension-placeholder";
import { Underline } from "@tiptap/extension-underline";
import { Link } from "@tiptap/extension-link";
import { StarterKit } from "@tiptap/starter-kit"; import { StarterKit } from "@tiptap/starter-kit";
import { Mention, LinkExtension } from "@docmost/editor-ext";
import classes from "./comment.module.css"; import classes from "./comment.module.css";
import { useFocusWithin } from "@mantine/hooks"; import { useFocusWithin } from "@mantine/hooks";
import clsx from "clsx"; import clsx from "clsx";
import { forwardRef, useEffect, useImperativeHandle } from "react"; import { forwardRef, useEffect, useImperativeHandle } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import EmojiCommand from "@/features/editor/extensions/emoji-command"; import EmojiCommand from "@/features/editor/extensions/emoji-command";
import mentionRenderItems from "@/features/editor/components/mention/mention-suggestion";
import MentionView from "@/features/editor/components/mention/mention-view";
interface CommentEditorProps { interface CommentEditorProps {
defaultContent?: any; defaultContent?: any;
@@ -40,29 +39,13 @@ const CommentEditor = forwardRef(
StarterKit.configure({ StarterKit.configure({
gapcursor: false, gapcursor: false,
dropcursor: false, dropcursor: false,
link: false,
}), }),
Placeholder.configure({ Placeholder.configure({
placeholder: placeholder || t("Reply..."), placeholder: placeholder || t("Reply..."),
}), }),
LinkExtension, Underline,
Link,
EmojiCommand, EmojiCommand,
Mention.configure({
suggestion: {
allowSpaces: true,
items: () => [],
// @ts-ignore
render: mentionRenderItems,
},
HTMLAttributes: {
class: "mention",
},
}).extend({
addNodeView() {
this.editor.isInitialized = true;
return ReactNodeViewRenderer(MentionView);
},
}),
], ],
editorProps: { editorProps: {
handleDOMEvents: { handleDOMEvents: {
@@ -77,8 +60,7 @@ const CommentEditor = forwardRef(
].includes(event.key) ].includes(event.key)
) { ) {
const emojiCommand = document.querySelector("#emoji-command"); const emojiCommand = document.querySelector("#emoji-command");
const mentionPopup = document.querySelector("#mention"); if (emojiCommand) {
if (emojiCommand || mentionPopup) {
return true; return true;
} }
} }
@@ -102,14 +84,9 @@ const CommentEditor = forwardRef(
autofocus: (autofocus && "end") || false, autofocus: (autofocus && "end") || false,
}); });
// Sync content from props for read-only editors (e.g. when updated via
// websocket on another browser). Skip for editable editors to avoid
// resetting the cursor position on every keystroke.
useEffect(() => { useEffect(() => {
if (!editable && commentEditor && defaultContent) { commentEditor.commands.setContent(defaultContent);
commentEditor.commands.setContent(defaultContent); }, [defaultContent]);
}
}, [defaultContent, editable, commentEditor]);
useEffect(() => { useEffect(() => {
setTimeout(() => { setTimeout(() => {
@@ -126,11 +103,7 @@ const CommentEditor = forwardRef(
})); }));
return ( return (
<div <div ref={focusRef} className={classes.commentEditor}>
ref={focusRef}
className={classes.commentEditor}
data-editable={editable || undefined}
>
<EditorContent <EditorContent
editor={commentEditor} editor={commentEditor}
className={clsx(classes.ProseMirror, { [classes.focused]: focused })} className={clsx(classes.ProseMirror, { [classes.focused]: focused })}
@@ -1,5 +1,5 @@
import { Group, Text, Box, Badge } from "@mantine/core"; import { Group, Text, Box, Badge } from "@mantine/core";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useState } from "react";
import classes from "./comment.module.css"; import classes from "./comment.module.css";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { timeAgo } from "@/lib/time"; import { timeAgo } from "@/lib/time";
@@ -40,7 +40,6 @@ function CommentListItem({
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const editor = useAtomValue(pageEditorAtom); const editor = useAtomValue(pageEditorAtom);
const [content, setContent] = useState<string>(comment.content); const [content, setContent] = useState<string>(comment.content);
const editContentRef = useRef<any>(null);
const updateCommentMutation = useUpdateCommentMutation(); const updateCommentMutation = useUpdateCommentMutation();
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId); const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
const resolveCommentMutation = useResolveCommentMutation(); const resolveCommentMutation = useResolveCommentMutation();
@@ -57,13 +56,9 @@ function CommentListItem({
setIsLoading(true); setIsLoading(true);
const commentToUpdate = { const commentToUpdate = {
commentId: comment.id, commentId: comment.id,
content: JSON.stringify(editContentRef.current ?? content), content: JSON.stringify(content),
}; };
await updateCommentMutation.mutateAsync(commentToUpdate); await updateCommentMutation.mutateAsync(commentToUpdate);
if (editContentRef.current) {
setContent(editContentRef.current);
editContentRef.current = null;
}
setIsEditing(false); setIsEditing(false);
emit({ emit({
@@ -133,7 +128,6 @@ function CommentListItem({
setIsEditing(true); setIsEditing(true);
} }
function cancelEdit() { function cancelEdit() {
editContentRef.current = null;
setIsEditing(false); setIsEditing(false);
} }
@@ -200,7 +194,7 @@ function CommentListItem({
<CommentEditor <CommentEditor
defaultContent={content} defaultContent={content}
editable={true} editable={true}
onUpdate={(newContent: any) => { editContentRef.current = newContent; }} onUpdate={(newContent: any) => setContent(newContent)}
onSave={handleUpdateComment} onSave={handleUpdateComment}
autofocus={true} autofocus={true}
/> />
@@ -32,14 +32,11 @@
max-width: 100%; max-width: 100%;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
max-height: 20vh;
padding-left: 6px; padding-left: 6px;
padding-right: 6px; padding-right: 6px;
margin-top: 10px; margin-top: 10px;
margin-bottom: 2px; margin-bottom: 2px;
}
&[data-editable] .ProseMirror :global(.ProseMirror){
max-height: 50vh;
overflow: hidden auto; overflow: hidden auto;
} }
@@ -8,5 +8,3 @@ export const titleEditorAtom = atom<Editor | null>(null);
export const readOnlyEditorAtom = atom<Editor | null>(null); export const readOnlyEditorAtom = atom<Editor | null>(null);
export const yjsConnectionStatusAtom = atom<string>(""); export const yjsConnectionStatusAtom = atom<string>("");
export const showAiMenuAtom = atom(false);
@@ -1,13 +1,11 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Group, Text, Paper, ActionIcon, Loader } from "@mantine/core"; import { Group, Text, Paper, ActionIcon } from "@mantine/core";
import { getFileUrl } from "@/lib/config.ts"; import { getFileUrl } from "@/lib/config.ts";
import { IconDownload, IconPaperclip } from "@tabler/icons-react"; import { IconDownload, IconPaperclip } from "@tabler/icons-react";
import { useHover } from "@mantine/hooks"; import { useHover } from "@mantine/hooks";
import { formatBytes } from "@/lib"; import { formatBytes } from "@/lib";
import { useTranslation } from "react-i18next";
export default function AttachmentView(props: NodeViewProps) { export default function AttachmentView(props: NodeViewProps) {
const { t } = useTranslation();
const { node, selected } = props; const { node, selected } = props;
const { url, name, size } = node.attrs; const { url, name, size } = node.attrs;
const { hovered, ref } = useHover(); const { hovered, ref } = useHover();
@@ -22,28 +20,26 @@ export default function AttachmentView(props: NodeViewProps) {
wrap="nowrap" wrap="nowrap"
h={25} h={25}
> >
<Group wrap="nowrap" gap="sm" style={{ minWidth: 0, flex: 1 }}> <Group justify="space-between" wrap="nowrap">
{url ? ( <IconPaperclip size={20} />
<IconPaperclip size={20} style={{ flexShrink: 0 }} />
) : (
<Loader size={20} style={{ flexShrink: 0 }} />
)}
<Text component="span" size="md" truncate="end" style={{ minWidth: 0 }}> <Text component="span" size="md" truncate="end">
{url ? name : t("Uploading {{name}}", { name })} {name}
</Text> </Text>
<Text component="span" size="sm" c="dimmed" style={{ flexShrink: 0 }}> <Text component="span" size="sm" c="dimmed" inline>
{formatBytes(size)} {formatBytes(size)}
</Text> </Text>
</Group> </Group>
{url && (selected || hovered) && ( {selected || hovered ? (
<a href={getFileUrl(url)} target="_blank"> <a href={getFileUrl(url)} target="_blank">
<ActionIcon variant="default" aria-label="download file"> <ActionIcon variant="default" aria-label="download file">
<IconDownload size={18} /> <IconDownload size={18} />
</ActionIcon> </ActionIcon>
</a> </a>
) : (
""
)} )}
</Group> </Group>
</Paper> </Paper>
@@ -1,53 +1,11 @@
.bubbleMenu { .bubbleMenu {
display: flex; display: flex;
flex-wrap: nowrap;
overflow-x: auto;
max-width: 100vw;
width: fit-content; width: fit-content;
box-shadow: 0 4px 12px light-dark(#cfcfcf, #0f0f0f); border-radius: 2px;
border-radius: 6px;
border: 1px solid border: 1px solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-gray-8)); light-dark(var(--mantine-color-gray-3), var(--mantine-color-gray-8));
background-color: light-dark(
var(--mantine-color-white),
var(--mantine-color-dark-6)
);
> * {
flex-shrink: 0;
}
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
.active { .active {
color: light-dark(var(--mantine-color-blue-8), var(--mantine-color-gray-5)); color: light-dark(var(--mantine-color-blue-8), var(--mantine-color-gray-5));
} }
} }
.buttonRoot {
height: 34px;
padding-left: rem(8);
padding-right: rem(4);
border: none;
border-radius: 6px;
}
.buttonSeparator {
border-right: 1px solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)) !important;
}
.divider {
width: 1px;
height: 16px;
align-self: center;
margin: 0 4px;
background-color: light-dark(
var(--mantine-color-gray-3),
var(--mantine-color-dark-3)
);
}
@@ -1,6 +1,10 @@
import { BubbleMenu, BubbleMenuProps } from "@tiptap/react/menus"; import {
import { isNodeSelection, useEditorState } from "@tiptap/react"; BubbleMenu,
import type { Editor } from "@tiptap/react"; BubbleMenuProps,
isNodeSelection,
useEditor,
useEditorState,
} from "@tiptap/react";
import { FC, useEffect, useRef, useState } from "react"; import { FC, useEffect, useRef, useState } from "react";
import { import {
IconBold, IconBold,
@@ -9,11 +13,10 @@ import {
IconStrikethrough, IconStrikethrough,
IconUnderline, IconUnderline,
IconMessage, IconMessage,
IconSparkles,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import clsx from "clsx"; import clsx from "clsx";
import classes from "./bubble-menu.module.css"; import classes from "./bubble-menu.module.css";
import { ActionIcon, Button, rem, Tooltip } from "@mantine/core"; import { ActionIcon, rem, Tooltip } from "@mantine/core";
import { ColorSelector } from "./color-selector"; import { ColorSelector } from "./color-selector";
import { NodeSelector } from "./node-selector"; import { NodeSelector } from "./node-selector";
import { TextAlignmentSelector } from "./text-alignment-selector"; import { TextAlignmentSelector } from "./text-alignment-selector";
@@ -21,13 +24,11 @@ import {
draftCommentIdAtom, draftCommentIdAtom,
showCommentPopupAtom, showCommentPopupAtom,
} from "@/features/comment/atoms/comment-atom"; } from "@/features/comment/atoms/comment-atom";
import { useAtom, useAtomValue } from "jotai"; import { useAtom } from "jotai";
import { v7 as uuid7 } from "uuid"; import { v7 as uuid7 } from "uuid";
import { isCellSelection, isTextSelected } from "@docmost/editor-ext"; import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx"; import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { showAiMenuAtom } from "@/features/editor/atoms/editor-atoms";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
export interface BubbleMenuItem { export interface BubbleMenuItem {
name: string; name: string;
@@ -37,27 +38,19 @@ export interface BubbleMenuItem {
} }
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & { type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
editor: Editor | null; editor: ReturnType<typeof useEditor>;
}; };
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => { export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [showAiMenu, setShowAiMenu] = useAtom(showAiMenuAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom); const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const workspace = useAtomValue(workspaceAtom);
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
const [, setDraftCommentId] = useAtom(draftCommentIdAtom); const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
const showCommentPopupRef = useRef(showCommentPopup); const showCommentPopupRef = useRef(showCommentPopup);
const showAiMenuRef = useRef(showAiMenu);
useEffect(() => { useEffect(() => {
showCommentPopupRef.current = showCommentPopup; showCommentPopupRef.current = showCommentPopup;
}, [showCommentPopup]); }, [showCommentPopup]);
useEffect(() => {
showAiMenuRef.current = showAiMenu;
}, [showAiMenu]);
const editorState = useEditorState({ const editorState = useEditorState({
editor: props.editor, editor: props.editor,
selector: (ctx) => { selector: (ctx) => {
@@ -134,63 +127,45 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
empty || empty ||
isNodeSelection(selection) || isNodeSelection(selection) ||
isCellSelection(selection) || isCellSelection(selection) ||
showAiMenuRef.current ||
showCommentPopupRef?.current showCommentPopupRef?.current
) { ) {
return false; return false;
} }
return isTextSelected(editor); return isTextSelected(editor);
}, },
options: { tippyOptions: {
placement: "top", moveTransition: "transform 0.15s ease-out",
offset: 8, onCreate: (instance) => {
instance.popper.firstChild?.addEventListener("blur", (event) => {
event.preventDefault();
event.stopImmediatePropagation();
});
},
onHide: () => { onHide: () => {
setIsNodeSelectorOpen(false); setIsNodeSelectorOpen(false);
setIsTextAlignmentOpen(false); setIsTextAlignmentOpen(false);
setIsLinkSelectorOpen(false);
setIsColorSelectorOpen(false); setIsColorSelectorOpen(false);
setIsLinkSelectorOpen(false);
}, },
}, },
}; };
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false); const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
const [isTextAlignmentSelectorOpen, setIsTextAlignmentOpen] = useState(false); const [isTextAlignmentSelectorOpen, setIsTextAlignmentOpen] = useState(false);
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false); const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
// Hide the bubble menu immediately when AI menu is shown
if (showAiMenu) return;
return ( return (
<BubbleMenu <BubbleMenu {...bubbleMenuProps}>
{...bubbleMenuProps}
style={{ zIndex: 200, position: "relative" }}
>
<div className={classes.bubbleMenu}> <div className={classes.bubbleMenu}>
{isGenerativeAiEnabled && (
<>
<Button
variant="default"
className={clsx(classes.buttonRoot)}
radius="0"
leftSection={<IconSparkles size={16} />}
onClick={() => {
setShowAiMenu(true);
}}
>
{t("Ask AI")}
</Button>
<div className={classes.divider} />
</>
)}
<NodeSelector <NodeSelector
editor={props.editor} editor={props.editor}
isOpen={isNodeSelectorOpen} isOpen={isNodeSelectorOpen}
setIsOpen={() => { setIsOpen={() => {
setIsNodeSelectorOpen(!isNodeSelectorOpen); setIsNodeSelectorOpen(!isNodeSelectorOpen);
setIsTextAlignmentOpen(false); setIsTextAlignmentOpen(false);
setIsLinkSelectorOpen(false);
setIsColorSelectorOpen(false); setIsColorSelectorOpen(false);
setIsLinkSelectorOpen(false);
}} }}
/> />
@@ -200,8 +175,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
setIsOpen={() => { setIsOpen={() => {
setIsTextAlignmentOpen(!isTextAlignmentSelectorOpen); setIsTextAlignmentOpen(!isTextAlignmentSelectorOpen);
setIsNodeSelectorOpen(false); setIsNodeSelectorOpen(false);
setIsLinkSelectorOpen(false);
setIsColorSelectorOpen(false); setIsColorSelectorOpen(false);
setIsLinkSelectorOpen(false);
}} }}
/> />
@@ -246,18 +221,16 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
}} }}
/> />
<Tooltip label={t(commentItem.name)} withArrow withinPortal={false}> <ActionIcon
<ActionIcon variant="default"
variant="default" size="lg"
size="lg" radius="0"
radius="6px" aria-label={t(commentItem.name)}
aria-label={t(commentItem.name)} style={{ border: "none" }}
style={{ border: "none" }} onClick={commentItem.command}
onClick={commentItem.command} >
> <IconMessage size={16} stroke={2} />
<IconMessage size={16} stroke={2} /> </ActionIcon>
</ActionIcon>
</Tooltip>
</div> </div>
</BubbleMenu> </BubbleMenu>
); );
@@ -1,21 +1,17 @@
import React, { Dispatch, FC, SetStateAction } from "react"; import { Dispatch, FC, SetStateAction } from "react";
import { IconCheck, IconChevronDown } from "@tabler/icons-react"; import { IconCheck, IconPalette } from "@tabler/icons-react";
import { import {
ActionIcon,
Button, Button,
Popover, Popover,
rem, rem,
ScrollArea, ScrollArea,
Text, Text,
Tooltip, Tooltip,
SimpleGrid,
Box,
Stack,
} from "@mantine/core"; } from "@mantine/core";
import type { Editor } from "@tiptap/react"; import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react"; import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import clsx from "clsx";
import classes from "./bubble-menu.module.css";
export interface BubbleColorMenuItem { export interface BubbleColorMenuItem {
name: string; name: string;
@@ -65,12 +61,9 @@ const TEXT_COLORS: BubbleColorMenuItem[] = [
name: "Gray", name: "Gray",
color: "#A8A29E", color: "#A8A29E",
}, },
{
name: "Brown",
color: "#92400E",
},
]; ];
// TODO: handle dark mode
const HIGHLIGHT_COLORS: BubbleColorMenuItem[] = [ const HIGHLIGHT_COLORS: BubbleColorMenuItem[] = [
{ {
name: "Default", name: "Default",
@@ -78,39 +71,35 @@ const HIGHLIGHT_COLORS: BubbleColorMenuItem[] = [
}, },
{ {
name: "Blue", name: "Blue",
color: "#98d8f2", color: "#c1ecf9",
}, },
{ {
name: "Green", name: "Green",
color: "#7edb6c", color: "#acf79f",
}, },
{ {
name: "Purple", name: "Purple",
color: "#e0d6ed", color: "#f6f3f8",
}, },
{ {
name: "Red", name: "Red",
color: "#ffc6c2", color: "#fdebeb",
}, },
{ {
name: "Yellow", name: "Yellow",
color: "#faf594", color: "#fbf4a2",
}, },
{ {
name: "Orange", name: "Orange",
color: "#f5c8a9", color: "#faebdd",
}, },
{ {
name: "Pink", name: "Pink",
color: "#f5cfe0", color: "#faf1f5",
}, },
{ {
name: "Gray", name: "Gray",
color: "#dfdfd7", color: "#f1f1ef",
},
{
name: "Brown",
color: "#d7c4b7",
}, },
]; ];
@@ -123,21 +112,17 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
const editorState = useEditorState({ const editorState = useEditorState({
editor, editor,
selector: (ctx) => { selector: ctx => {
if (!ctx.editor) { if (!ctx.editor) {
return null; return null;
} }
const activeColors: Record<string, boolean> = {}; const activeColors: Record<string, boolean> = {};
TEXT_COLORS.forEach(({ color }) => { TEXT_COLORS.forEach(({ color }) => {
activeColors[`text_${color}`] = ctx.editor.isActive("textStyle", { activeColors[`text_${color}`] = ctx.editor.isActive("textStyle", { color });
color,
});
}); });
HIGHLIGHT_COLORS.forEach(({ color }) => { HIGHLIGHT_COLORS.forEach(({ color }) => {
activeColors[`highlight_${color}`] = ctx.editor.isActive("highlight", { activeColors[`highlight_${color}`] = ctx.editor.isActive("highlight", { color });
color,
});
}); });
return activeColors; return activeColors;
@@ -148,148 +133,67 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
return null; return null;
} }
const activeColorItem = TEXT_COLORS.find( const activeColorItem = TEXT_COLORS.find(({ color }) =>
({ color }) => editorState[`text_${color}`], editorState[`text_${color}`]
); );
const activeHighlightItem = HIGHLIGHT_COLORS.find( const activeHighlightItem = HIGHLIGHT_COLORS.find(({ color }) =>
({ color }) => editorState[`highlight_${color}`], editorState[`highlight_${color}`]
); );
return ( return (
<Popover width={220} opened={isOpen} withArrow> <Popover width={200} opened={isOpen} withArrow>
<Popover.Target> <Popover.Target>
<Tooltip label={t("Text color")} withArrow> <Tooltip label={t("Text color")} withArrow>
<Button <ActionIcon
variant="default" variant="default"
size="lg"
radius="0" radius="0"
rightSection={<IconChevronDown size={16} />}
onClick={() => setIsOpen(!isOpen)}
data-text-color={activeColorItem?.color || ""}
data-highlight-color={activeHighlightItem?.color || ""}
className={clsx(["color-selector-trigger", classes.buttonRoot])}
style={{ style={{
fontWeight: 500, border: "none",
fontSize: rem(16), color: activeColorItem?.color,
}} }}
onClick={() => setIsOpen(!isOpen)}
> >
A <IconPalette size={16} stroke={2} />
</Button> </ActionIcon>
</Tooltip> </Tooltip>
</Popover.Target> </Popover.Target>
<Popover.Dropdown> <Popover.Dropdown>
{/* make mah responsive */}
<ScrollArea.Autosize type="scroll" mah="400"> <ScrollArea.Autosize type="scroll" mah="400">
<Stack gap="md"> <Text span c="dimmed" tt="uppercase" inherit>
<Box> {t("Color")}
<Text size="sm" fw={600} mb="xs"> </Text>
{t("Text color")}
</Text>
<SimpleGrid cols={5} spacing="xs">
{TEXT_COLORS.map(({ name, color }, index) => (
<Tooltip key={index} label={t(name)} withArrow>
<Box
onClick={() => {
if (name === "Default") {
editor.commands.unsetColor();
} else {
editor
.chain()
.focus()
.setColor(color || "")
.run();
}
setIsOpen(false);
}}
style={{
width: rem(28),
height: rem(28),
borderRadius: rem(6),
border: editorState[`text_${color}`]
? "2px solid var(--mantine-color-gray-8)"
: "1px solid var(--mantine-color-gray-4)",
cursor: "pointer",
position: "relative",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: rem(16),
fontWeight: 600,
color: color || "var(--mantine-color-gray-8)",
}}
>
A
</Box>
</Tooltip>
))}
</SimpleGrid>
</Box>
<Box> <Button.Group orientation="vertical">
<Text size="sm" fw={600} mb="xs"> {TEXT_COLORS.map(({ name, color }, index) => (
{t("Highlight color")} <Button
</Text> key={index}
<SimpleGrid cols={5} spacing="xs"> variant="default"
{HIGHLIGHT_COLORS.map(({ name, color }, index) => ( leftSection={<span style={{ color }}>A</span>}
<Tooltip key={index} label={t(name)} withArrow> justify="left"
<Box fullWidth
onClick={() => { rightSection={
if (name === "Default") { editorState[`text_${color}`] && (
editor.commands.unsetHighlight(); <IconCheck style={{ width: rem(16) }} />
} else { )
editor }
.chain() onClick={() => {
.focus() if (name === "Default") {
.toggleMark("highlight", { editor.commands.unsetColor();
color: color || "", } else {
colorName: name.toLowerCase() || "", editor.chain().focus().setColor(color || "").run();
}) }
.run(); setIsOpen(false);
} }}
setIsOpen(false); style={{ border: "none" }}
}} >
style={{ {t(name)}
width: rem(28), </Button>
height: rem(28), ))}
borderRadius: rem(4), </Button.Group>
backgroundColor: color || "var(--mantine-color-gray-2)",
border: "1px solid var(--mantine-color-gray-4)",
cursor: "pointer",
position: "relative",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: rem(16),
fontWeight: 600,
color: "var(--mantine-color-gray-8)",
}}
>
{editorState[`highlight_${color}`] ? (
<IconCheck
size={16}
color="var(--mantine-color-green-7)"
/>
) : (
"A"
)}
</Box>
</Tooltip>
))}
</SimpleGrid>
</Box>
<Button
variant="default"
fullWidth
onClick={() => {
editor.commands.unsetColor();
editor.commands.unsetHighlight();
setIsOpen(false);
}}
>
{t("Remove color")}
</Button>
</Stack>
</ScrollArea.Autosize> </ScrollArea.Autosize>
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </Popover>
@@ -1,7 +1,6 @@
import React, { Dispatch, FC, SetStateAction } from "react"; import React, { Dispatch, FC, SetStateAction } from "react";
import { import {
IconBlockquote, IconBlockquote,
IconCaretRightFilled,
IconCheck, IconCheck,
IconCheckbox, IconCheckbox,
IconChevronDown, IconChevronDown,
@@ -9,16 +8,14 @@ import {
IconH1, IconH1,
IconH2, IconH2,
IconH3, IconH3,
IconInfoCircle,
IconList, IconList,
IconListNumbers, IconListNumbers,
IconTypography, IconTypography,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { Popover, Button, ScrollArea, Tooltip } from "@mantine/core"; import { Popover, Button, ScrollArea } from "@mantine/core";
import type { Editor } from "@tiptap/react"; import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react"; import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import classes from "./bubble-menu.module.css";
interface NodeSelectorProps { interface NodeSelectorProps {
editor: Editor | null; editor: Editor | null;
@@ -57,8 +54,6 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
isTaskItem: ctx.editor.isActive("taskItem"), isTaskItem: ctx.editor.isActive("taskItem"),
isBlockquote: ctx.editor.isActive("blockquote"), isBlockquote: ctx.editor.isActive("blockquote"),
isCodeBlock: ctx.editor.isActive("codeBlock"), isCodeBlock: ctx.editor.isActive("codeBlock"),
isCallout: ctx.editor.isActive("callout"),
isDetails: ctx.editor.isActive("details"),
}; };
}, },
}); });
@@ -128,18 +123,6 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
command: () => editor.chain().focus().toggleCodeBlock().run(), command: () => editor.chain().focus().toggleCodeBlock().run(),
isActive: () => editorState?.isCodeBlock, isActive: () => editorState?.isCodeBlock,
}, },
{
name: "Callout",
icon: IconInfoCircle,
command: () => editor.chain().focus().toggleCallout().run(),
isActive: () => editorState?.isCallout,
},
{
name: "Toggle block",
icon: IconCaretRightFilled,
command: () => editor.chain().focus().setDetails().run(),
isActive: () => editorState?.isDetails,
},
]; ];
const activeItem = items.filter((item) => item.isActive()).pop() ?? { const activeItem = items.filter((item) => item.isActive()).pop() ?? {
@@ -149,18 +132,15 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
return ( return (
<Popover opened={isOpen} withArrow> <Popover opened={isOpen} withArrow>
<Popover.Target> <Popover.Target>
<Tooltip label={t("Turn into")} withArrow withinPortal={false} disabled={isOpen}> <Button
<Button variant="default"
className={classes.buttonRoot} style={{ border: "none", height: "34px" }}
variant="default" radius="0"
style={{ border: "none", height: "34px" }} rightSection={<IconChevronDown size={16} />}
radius="0" onClick={() => setIsOpen(!isOpen)}
rightSection={<IconChevronDown size={16} />} >
onClick={() => setIsOpen(!isOpen)} {t(activeItem?.name)}
> </Button>
{t(activeItem?.name)}
</Button>
</Tooltip>
</Popover.Target> </Popover.Target>
<Popover.Dropdown> <Popover.Dropdown>
@@ -7,7 +7,7 @@ import {
IconCheck, IconCheck,
IconChevronDown, IconChevronDown,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { Popover, Button, ScrollArea, Tooltip, rem } from "@mantine/core"; import { Popover, Button, ScrollArea, rem } from "@mantine/core";
import type { Editor } from "@tiptap/react"; import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react"; import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -84,18 +84,16 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
return ( return (
<Popover opened={isOpen} withArrow> <Popover opened={isOpen} withArrow>
<Popover.Target> <Popover.Target>
<Tooltip label={t("Text align")} withArrow withinPortal={false} disabled={isOpen}> <Button
<Button variant="default"
variant="default" style={{ border: "none", height: "34px" }}
style={{ border: "none", height: "34px" }} px="5"
px="5" radius="0"
radius="0" rightSection={<IconChevronDown size={16} />}
rightSection={<IconChevronDown size={16} />} onClick={() => setIsOpen(!isOpen)}
onClick={() => setIsOpen(!isOpen)} >
> <activeItem.icon style={{ width: rem(16) }} stroke={2} />
<activeItem.icon style={{ width: rem(16) }} stroke={2} /> </Button>
</Button>
</Tooltip>
</Popover.Target> </Popover.Target>
<Popover.Dropdown> <Popover.Dropdown>
@@ -1,5 +1,9 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import {
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { Node as PMNode } from "prosemirror-model"; import { Node as PMNode } from "prosemirror-model";
import { import {
@@ -49,26 +53,17 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
}, },
}); });
const getReferencedVirtualElement = useCallback(() => { const getReferenceClientRect = useCallback(() => {
if (!editor) return;
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "callout"; const predicate = (node: PMNode) => node.type.name === "callout";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
if (parent) { if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement; const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
const domRect = dom.getBoundingClientRect(); return dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
} }
const domRect = posToDOMRect(editor.view, selection.from, selection.to); return posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}, [editor]); }, [editor]);
const setCalloutType = useCallback( const setCalloutType = useCallback(
@@ -117,12 +112,14 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
editor={editor} editor={editor}
pluginKey={`callout-menu`} pluginKey={`callout-menu`}
updateDelay={0} updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement} tippyOptions={{
options={{ getReferenceClientRect,
offset: [0, 10],
placement: "bottom", placement: "bottom",
// offset: 233, // // offset: [0, 10], zIndex: 99,
// zIndex: 99, popperOptions: {
flip: false, modifiers: [{ name: "flip", enabled: false }],
},
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
@@ -1,6 +1,5 @@
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react"; import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { ActionIcon, Group, Select, Tooltip } from "@mantine/core"; import { ActionIcon, CopyButton, Group, Select, Tooltip } from "@mantine/core";
import { CopyButton } from "@/components/common/copy-button";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { IconCheck, IconCopy } from "@tabler/icons-react"; import { IconCheck, IconCopy } from "@tabler/icons-react";
import classes from "./code-block.module.css"; import classes from "./code-block.module.css";
@@ -91,7 +90,6 @@ export default function CodeBlockView(props: NodeViewProps) {
node.textContent.length > 0 node.textContent.length > 0
} }
> >
{/* @ts-ignore */}
<NodeViewContent as="code" className={`language-${language}`} /> <NodeViewContent as="code" className={`language-${language}`} />
</pre> </pre>
@@ -5,7 +5,6 @@ import { v4 as uuidv4 } from "uuid";
import classes from "./code-block.module.css"; import classes from "./code-block.module.css";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useComputedColorScheme } from "@mantine/core"; import { useComputedColorScheme } from "@mantine/core";
import DOMPurify from "dompurify";
interface MermaidViewProps { interface MermaidViewProps {
props: NodeViewProps; props: NodeViewProps;
@@ -38,7 +37,7 @@ export default function MermaidView({ props }: MermaidViewProps) {
.catch((err) => { .catch((err) => {
if (props.editor.isEditable) { if (props.editor.isEditable) {
setPreview( setPreview(
`<div class="${classes.error}">${t("Mermaid diagram error:")} ${DOMPurify.sanitize(err)}</div>`, `<div class="${classes.error}">${t("Mermaid diagram error:")} ${err}</div>`,
); );
} else { } else {
setPreview( setPreview(
@@ -1,12 +1,13 @@
import type { EditorView } from "@tiptap/pm/view";
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx"; import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx"; import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
import { uploadAttachmentAction } from "../attachment/upload-attachment-action"; import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts"; import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
import { Slice } from "@tiptap/pm/model";
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts"; import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
import { Editor } from "@tiptap/core";
export const handlePaste = ( export const handlePaste = (
editor: Editor, view: EditorView,
event: ClipboardEvent, event: ClipboardEvent,
pageId: string, pageId: string,
creatorId?: string, creatorId?: string,
@@ -17,7 +18,7 @@ export const handlePaste = (
// we have to do this validation here to allow the default link extension to takeover if needs be // we have to do this validation here to allow the default link extension to takeover if needs be
event.preventDefault(); event.preventDefault();
const url = clipboardData.trim(); const url = clipboardData.trim();
const { from: pos, empty } = editor.state.selection; const { from: pos, empty } = view.state.selection;
const match = INTERNAL_LINK_REGEX.exec(url); const match = INTERNAL_LINK_REGEX.exec(url);
const currentPageMatch = INTERNAL_LINK_REGEX.exec(window.location.href); const currentPageMatch = INTERNAL_LINK_REGEX.exec(window.location.href);
@@ -33,27 +34,17 @@ export const handlePaste = (
return false; return false;
} }
const anchorId = match[6] ? match[6].split("#")[0] : undefined; createMentionAction(url, view, pos, creatorId);
const urlWithoutAnchor = anchorId
? url.substring(0, url.indexOf("#"))
: url;
createMentionAction(
urlWithoutAnchor,
editor.view,
pos,
creatorId,
anchorId,
);
return true; return true;
} }
if (event.clipboardData?.files.length) { if (event.clipboardData?.files.length) {
event.preventDefault(); event.preventDefault();
for (const file of event.clipboardData.files) { for (const file of event.clipboardData.files) {
const pos = editor.state.selection.from; const pos = view.state.selection.from;
uploadImageAction(file, editor, pos, pageId); uploadImageAction(file, view, pos, pageId);
uploadVideoAction(file, editor, pos, pageId); uploadVideoAction(file, view, pos, pageId);
uploadAttachmentAction(file, editor, pos, pageId); uploadAttachmentAction(file, view, pos, pageId);
} }
return true; return true;
} }
@@ -61,7 +52,7 @@ export const handlePaste = (
}; };
export const handleFileDrop = ( export const handleFileDrop = (
editor: Editor, view: EditorView,
event: DragEvent, event: DragEvent,
moved: boolean, moved: boolean,
pageId: string, pageId: string,
@@ -70,14 +61,14 @@ export const handleFileDrop = (
event.preventDefault(); event.preventDefault();
for (const file of event.dataTransfer.files) { for (const file of event.dataTransfer.files) {
const coordinates = editor.view.posAtCoords({ const coordinates = view.posAtCoords({
left: event.clientX, left: event.clientX,
top: event.clientY, top: event.clientY,
}); });
uploadImageAction(file, editor, coordinates?.pos ?? 0 - 1, pageId); uploadImageAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
uploadVideoAction(file, editor, coordinates?.pos ?? 0 - 1, pageId); uploadVideoAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
uploadAttachmentAction(file, editor, coordinates?.pos ?? 0 - 1, pageId); uploadAttachmentAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
} }
return true; return true;
} }
@@ -1,6 +1,11 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import {
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import { useCallback } from "react"; import { useCallback } from "react";
import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model"; import { Node as PMNode } from "prosemirror-model";
import { import {
EditorMenuProps, EditorMenuProps,
@@ -35,26 +40,17 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
}, },
}); });
const getReferencedVirtualElement = useCallback(() => { const getReferenceClientRect = useCallback(() => {
if (!editor) return;
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "drawio"; const predicate = (node: PMNode) => node.type.name === "drawio";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
if (parent) { if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement; const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
const domRect = dom.getBoundingClientRect(); return dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
} }
const domRect = posToDOMRect(editor.view, selection.from, selection.to); return posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}, [editor]); }, [editor]);
const onWidthChange = useCallback( const onWidthChange = useCallback(
@@ -69,11 +65,15 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
editor={editor} editor={editor}
pluginKey={`drawio-menu`} pluginKey={`drawio-menu`}
updateDelay={0} updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement} tippyOptions={{
options={{ getReferenceClientRect,
placement: "top", offset: [0, 8],
offset: 8, zIndex: 99,
flip: false, popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
@@ -66,7 +66,6 @@ export default function DrawioView(props: NodeViewProps) {
const fileName = "diagram.drawio.svg"; const fileName = "diagram.drawio.svg";
const drawioSVGFile = await svgStringToFile(svgString, fileName); const drawioSVGFile = await svgStringToFile(svgString, fileName);
//@ts-ignore
const pageId = editor.storage?.pageId; const pageId = editor.storage?.pageId;
let attachment: IAttachment = null; let attachment: IAttachment = null;
@@ -88,7 +87,7 @@ export default function DrawioView(props: NodeViewProps) {
}; };
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper>
<Modal.Root opened={opened} onClose={close} fullScreen> <Modal.Root opened={opened} onClose={close} fullScreen>
<Modal.Overlay /> <Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}> <Modal.Content style={{ overflow: "hidden" }}>
@@ -85,7 +85,7 @@ export default function EmbedView(props: NodeViewProps) {
} }
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper>
{embedUrl ? ( {embedUrl ? (
<ResizableWrapper <ResizableWrapper
initialHeight={nodeHeight || 480} initialHeight={nodeHeight || 480}
@@ -1,41 +1,16 @@
import { ReactRenderer, useEditor } from "@tiptap/react"; import { ReactRenderer, useEditor } from "@tiptap/react";
import EmojiList from "./emoji-list"; import EmojiList from "./emoji-list";
import tippy from "tippy.js";
import { init } from "emoji-mart"; import { init } from "emoji-mart";
import {
autoUpdate,
computePosition,
flip,
offset,
shift,
} from "@floating-ui/dom";
const renderEmojiItems = () => { const renderEmojiItems = () => {
let component: ReactRenderer | null = null; let component: ReactRenderer | null = null;
let popup: HTMLDivElement | null = null; let popup: any | null = null;
let cleanup: (() => void) | null = null;
let getReferenceClientRect: (() => DOMRect) | null = null;
const destroy = () => {
if (cleanup) {
cleanup();
cleanup = null;
}
if (popup) {
popup.remove();
popup = null;
}
if (component) {
component.destroy();
component = null;
}
};
return { return {
onBeforeStart: (props: { onBeforeStart: (props: {
editor: ReturnType<typeof useEditor>; editor: ReturnType<typeof useEditor>;
clientRect: () => DOMRect; clientRect: DOMRect;
}) => { }) => {
init({ init({
data: async () => (await import("@emoji-mart/data")).default, data: async () => (await import("@emoji-mart/data")).default,
@@ -50,61 +25,51 @@ const renderEmojiItems = () => {
return; return;
} }
getReferenceClientRect = props.clientRect; // @ts-ignore
popup = document.createElement("div"); popup = tippy("body", {
popup.style.zIndex = "9999"; getReferenceClientRect: props.clientRect,
popup.style.position = "absolute"; appendTo: () => document.body,
popup.style.top = "0"; content: component.element,
popup.style.left = "0"; showOnCreate: true,
popup.appendChild(component.element); interactive: true,
document.body.appendChild(popup); trigger: "manual",
placement: "bottom",
const virtualElement = {
getBoundingClientRect: () => {
return getReferenceClientRect
? getReferenceClientRect()
: new DOMRect(0, 0, 0, 0);
},
};
cleanup = autoUpdate(virtualElement, popup, () => {
if (!popup) return;
computePosition(virtualElement, popup, {
placement: "bottom-start",
middleware: [offset(10), flip(), shift()],
}).then(({ x, y }) => {
if (!popup) return;
Object.assign(popup.style, {
transform: `translate(${x}px, ${y}px)`,
});
});
}); });
}, },
onStart: (props: { onStart: (props: {
editor: ReturnType<typeof useEditor>; editor: ReturnType<typeof useEditor>;
clientRect: () => DOMRect; clientRect: DOMRect;
}) => { }) => {
component?.updateProps({ ...props, isLoading: false }); component?.updateProps({...props, isLoading: false});
if (props.clientRect) { if (!props.clientRect) {
getReferenceClientRect = props.clientRect; return;
} }
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
}, },
onUpdate: (props: { onUpdate: (props: {
editor: ReturnType<typeof useEditor>; editor: ReturnType<typeof useEditor>;
clientRect: () => DOMRect; clientRect: DOMRect;
}) => { }) => {
component?.updateProps(props); component?.updateProps(props);
if (props.clientRect) { if (!props.clientRect) {
getReferenceClientRect = props.clientRect; return;
} }
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
}, },
onKeyDown: (props: { event: KeyboardEvent }) => { onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") { if (props.event.key === "Escape") {
destroy(); popup?.[0].hide();
component?.destroy()
return true; return true;
} }
@@ -113,7 +78,13 @@ const renderEmojiItems = () => {
return component?.ref?.onKeyDown(props); return component?.ref?.onKeyDown(props);
}, },
onExit: () => { onExit: () => {
destroy(); if (popup && !popup[0]?.state.isDestroyed) {
popup[0]?.destroy();
}
if (component) {
component?.destroy();
}
}, },
}; };
}; };
@@ -1,6 +1,11 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import {
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import { useCallback } from "react"; import { useCallback } from "react";
import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model"; import { Node as PMNode } from "prosemirror-model";
import { import {
EditorMenuProps, EditorMenuProps,
@@ -37,26 +42,17 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
}, },
}); });
const getReferencedVirtualElement = useCallback(() => { const getReferenceClientRect = useCallback(() => {
if (!editor) return;
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "excalidraw"; const predicate = (node: PMNode) => node.type.name === "excalidraw";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
if (parent) { if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement; const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
const domRect = dom.getBoundingClientRect(); return dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
} }
const domRect = posToDOMRect(editor.view, selection.from, selection.to); return posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}, [editor]); }, [editor]);
const onWidthChange = useCallback( const onWidthChange = useCallback(
@@ -69,13 +65,17 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
return ( return (
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
pluginKey={`excalidraw-menu`} pluginKey={`excalidraw-menu}`}
updateDelay={0} updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement} tippyOptions={{
options={{ getReferenceClientRect,
placement: "top", offset: [0, 8],
offset: 8, zIndex: 99,
flip: false, popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
@@ -98,7 +98,6 @@ export default function ExcalidrawView(props: NodeViewProps) {
const fileName = "diagram.excalidraw.svg"; const fileName = "diagram.excalidraw.svg";
const excalidrawSvgFile = await svgStringToFile(svgString, fileName); const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
// @ts-ignore
const pageId = editor.storage?.pageId; const pageId = editor.storage?.pageId;
let attachment: IAttachment = null; let attachment: IAttachment = null;
@@ -119,7 +118,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
}; };
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper>
<ReactClearModal <ReactClearModal
style={{ style={{
backgroundColor: "rgba(0, 0, 0, 0.5)", backgroundColor: "rgba(0, 0, 0, 0.5)",
@@ -1,6 +1,11 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import {
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model"; import { Node as PMNode } from "prosemirror-model";
import { import {
EditorMenuProps, EditorMenuProps,
@@ -17,6 +22,16 @@ import { useTranslation } from "react-i18next";
export function ImageMenu({ editor }: EditorMenuProps) { export function ImageMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive("image");
},
[editor],
);
const editorState = useEditorState({ const editorState = useEditorState({
editor, editor,
@@ -37,37 +52,17 @@ export function ImageMenu({ editor }: EditorMenuProps) {
}, },
}); });
const shouldShow = useCallback( const getReferenceClientRect = useCallback(() => {
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive("image") && editor.getAttributes("image").src;
},
[editor],
);
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "image"; const predicate = (node: PMNode) => node.type.name === "image";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
if (parent) { if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement; const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
const domRect = dom.getBoundingClientRect(); return dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
} }
const domRect = posToDOMRect(editor.view, selection.from, selection.to); return posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}, [editor]); }, [editor]);
const alignImageLeft = useCallback(() => { const alignImageLeft = useCallback(() => {
@@ -110,11 +105,15 @@ export function ImageMenu({ editor }: EditorMenuProps) {
editor={editor} editor={editor}
pluginKey={`image-menu`} pluginKey={`image-menu`}
updateDelay={0} updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement} tippyOptions={{
options={{ getReferenceClientRect,
placement: "top", offset: [0, 8],
offset: 8, zIndex: 99,
flip: false, popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
@@ -1,27 +0,0 @@
.imageWrapper {
display: flex;
justify-content: center;
align-items: center;
border-radius: 8px;
overflow: hidden;
animation: pulse 1.2s ease-in-out infinite;
@mixin light {
background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%);
background-size: 400% 400%;
}
@mixin dark {
background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%);
background-size: 400% 400%;
}
@keyframes pulse {
0% {
background-position: 0% 0%;
}
100% {
background-position: -135% 0%;
}
}
}
@@ -1,70 +1,30 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Group, Image, Loader, Text } from "@mantine/core";
import { useMemo } from "react"; import { useMemo } from "react";
import { Image } from "@mantine/core";
import { getFileUrl } from "@/lib/config.ts"; import { getFileUrl } from "@/lib/config.ts";
import clsx from "clsx"; import clsx from "clsx";
import classes from "./image-view.module.css";
import { useTranslation } from "react-i18next";
export default function ImageView(props: NodeViewProps) { export default function ImageView(props: NodeViewProps) {
const { t } = useTranslation(); const { node, selected } = props;
const { editor, node, selected } = props; const { src, width, align, title } = node.attrs;
const { src, width, align, title, aspectRatio, placeholder } = node.attrs;
const alignClass = useMemo(() => { const alignClass = useMemo(() => {
if (align === "left") return "alignLeft"; if (align === "left") return "alignLeft";
if (align === "right") return "alignRight"; if (align === "right") return "alignRight";
if (align === "center") return "alignCenter"; if (align === "center") return "alignCenter";
return "alignCenter"; return "alignCenter";
}, [align]); }, [align]);
const previewSrc = useMemo(() => {
editor.storage.shared.imagePreviews =
editor.storage.shared.imagePreviews || {};
if (placeholder?.id) {
return editor.storage.shared.imagePreviews[placeholder.id];
}
return null;
}, [placeholder, editor]);
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper>
<div <Image
className={clsx( radius="md"
selected && "ProseMirror-selectednode", fit="contain"
classes.imageWrapper, w={width}
alignClass, src={getFileUrl(src)}
)} alt={title}
style={{ className={clsx(selected ? "ProseMirror-selectednode" : "", alignClass)}
aspectRatio: aspectRatio ? aspectRatio : src ? undefined : "16 / 9", />
width,
}}
>
{src && (
<Image radius="md" fit="contain" src={getFileUrl(src)} alt={title} />
)}
{!src && previewSrc && (
<Group pos="relative" h="100%" w="100%">
<Image
radius="md"
fit="contain"
src={previewSrc}
alt={placeholder?.name}
/>
<Loader size={20} pos="absolute" bottom={6} right={6} />
</Group>
)}
{!src && !previewSrc && (
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md">
<Loader size={20} style={{ flexShrink: 0 }} />
<Text component="span" size="sm" truncate="end">
{placeholder?.name
? t("Uploading {{name}}", { name: placeholder.name })
: t("Uploading file")}
</Text>
</Group>
)}
</div>
</NodeViewWrapper> </NodeViewWrapper>
); );
} }
@@ -9,7 +9,6 @@ export type LinkFn = (
view: EditorView, view: EditorView,
pos: number, pos: number,
creatorId: string, creatorId: string,
anchorId?: string,
) => void; ) => void;
export interface InternalLinkOptions { export interface InternalLinkOptions {
@@ -19,7 +18,7 @@ export interface InternalLinkOptions {
export const handleInternalLink = export const handleInternalLink =
({ validateFn, onResolveLink }: InternalLinkOptions): LinkFn => ({ validateFn, onResolveLink }: InternalLinkOptions): LinkFn =>
async (url: string, view, pos, creatorId, anchorId) => { async (url: string, view, pos, creatorId) => {
const validated = validateFn(url, view); const validated = validateFn(url, view);
if (!validated) return; if (!validated) return;
@@ -36,7 +35,6 @@ export const handleInternalLink =
entityId: page.id, entityId: page.id,
slugId: page.slugId, slugId: page.slugId,
creatorId: creatorId, creatorId: creatorId,
anchorId: anchorId,
}); });
if (!node) return; if (!node) return;
@@ -1,10 +1,9 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { BubbleMenu as BaseBubbleMenu, useEditorState } from "@tiptap/react";
import React, { useCallback, useState } from "react"; import React, { useCallback, useState } from "react";
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts"; import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx"; import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
import { LinkPreviewPanel } from "@/features/editor/components/link/link-preview.tsx"; import { LinkPreviewPanel } from "@/features/editor/components/link/link-preview.tsx";
import { Card } from "@mantine/core"; import { Card } from "@mantine/core";
import { useEditorState } from "@tiptap/react";
export function LinkMenu({ editor, appendTo }: EditorMenuProps) { export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
const [showEdit, setShowEdit] = useState(false); const [showEdit, setShowEdit] = useState(false);
@@ -60,15 +59,18 @@ export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
return ( return (
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
pluginKey={`link-menu`} pluginKey={`link-menu}`}
updateDelay={0} updateDelay={0}
options={{ tippyOptions={{
onHide: () => { appendTo: () => {
return appendTo?.current;
},
onHidden: () => {
setShowEdit(false); setShowEdit(false);
}, },
placement: "bottom", placement: "bottom",
offset: 5, offset: [0, 5],
// zIndex: 101, zIndex: 101,
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
@@ -10,7 +10,6 @@ import React, {
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query.ts"; import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query.ts";
import { import {
ActionIcon, ActionIcon,
Divider,
Group, Group,
Paper, Paper,
ScrollArea, ScrollArea,
@@ -52,7 +51,6 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
const tree = useMemo(() => new SimpleTree<SpaceTreeNode>(data), [data]); const tree = useMemo(() => new SimpleTree<SpaceTreeNode>(data), [data]);
const createPageMutation = useCreatePageMutation(); const createPageMutation = useCreatePageMutation();
const emit = useQueryEmit(); const emit = useQueryEmit();
const isInCommentContext = props.isInCommentContext ?? false;
const { data: suggestion, isLoading } = useSearchSuggestionsQuery({ const { data: suggestion, isLoading } = useSearchSuggestionsQuery({
query: props.query, query: props.query,
@@ -60,7 +58,6 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
includePages: true, includePages: true,
spaceId: space.id, spaceId: space.id,
limit: 10, limit: 10,
preload: true,
}); });
const createPageItem = (label: string) : MentionSuggestionItem => { const createPageItem = (label: string) : MentionSuggestionItem => {
@@ -105,13 +102,10 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
})), })),
); );
} }
if (!isInCommentContext && props.query) { items.push(createPageItem(props.query));
items.push(createPageItem(props.query));
}
setRenderItems(items); setRenderItems(items);
// update editor storage // update editor storage
//@ts-ignore
props.editor.storage.mentionItems = items; props.editor.storage.mentionItems = items;
} }
}, [suggestion, isLoading]); }, [suggestion, isLoading]);
@@ -169,7 +163,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
const enterHandler = () => { const enterHandler = () => {
if (!renderItems.length) return; if (!renderItems.length) return;
if (renderItems[selectedIndex]?.entityType !== "header") { if (renderItems[selectedIndex].entityType !== "header") {
selectItem(selectedIndex); selectItem(selectedIndex);
} }
}; };
@@ -209,7 +203,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
parentPageId: page.id || null, parentPageId: page.id || null,
title: title title: title
}; };
let createdPage: IPage; let createdPage: IPage;
try { try {
createdPage = await createPageMutation.mutateAsync(payload); createdPage = await createPageMutation.mutateAsync(payload);
@@ -255,51 +249,35 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
} }
} }
// if no results and enter what to do?
useEffect(() => { useEffect(() => {
viewportRef.current viewportRef.current
?.querySelector(`[data-item-index="${selectedIndex}"]`) ?.querySelector(`[data-item-index="${selectedIndex}"]`)
?.scrollIntoView({ block: "nearest" }); ?.scrollIntoView({ block: "nearest" });
}, [selectedIndex]); }, [selectedIndex]);
const popupWidth = isInCommentContext ? 280 : 320;
if (renderItems.length === 0) { if (renderItems.length === 0) {
return ( return (
<Paper id="mention" shadow="md" py="xs" withBorder radius="md"> <Paper shadow="md" p="xs" withBorder>
<Text c="dimmed" size="sm" px="sm"> { t("No results") }
{ t("No results") }
</Text>
</Paper> </Paper>
); );
} }
const hasUsers = renderItems.some((item) => item.entityType === "user");
const hasPages = renderItems.some((item) => item.entityType === "page" && item.id !== null);
const createPageItemData = renderItems.find((item) => item.entityType === "page" && item.id === null);
return ( return (
<Paper id="mention" shadow="md" withBorder radius="md" py={6}> <Paper id="mention" shadow="md" p="xs" withBorder>
<ScrollArea.Autosize <ScrollArea.Autosize
viewportRef={viewportRef} viewportRef={viewportRef}
mah={350} mah={350}
w={popupWidth} w={320}
scrollbarSize={6} scrollbarSize={8}
> >
{renderItems?.map((item, index) => { {renderItems?.map((item, index) => {
if (item.entityType === "header") { if (item.entityType === "header") {
const isFirst = index === 0;
return ( return (
<div key={`${item.label}-${index}`}> <div key={`${item.label}-${index}`}>
{!isFirst && <Divider my={6} />} <Text c="dimmed" mb={4} tt="uppercase">
<Text
c="dimmed"
size="xs"
fw={500}
px="sm"
pt={isFirst ? 2 : 4}
pb={4}
tt="uppercase"
>
{item.label} {item.label}
</Text> </Text>
</div> </div>
@@ -313,9 +291,8 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
className={clsx(classes.menuBtn, { className={clsx(classes.menuBtn, {
[classes.selectedItem]: index === selectedIndex, [classes.selectedItem]: index === selectedIndex,
})} })}
px="sm"
> >
<Group gap="sm"> <Group>
<CustomAvatar <CustomAvatar
size={"sm"} size={"sm"}
avatarUrl={item.avatarUrl} avatarUrl={item.avatarUrl}
@@ -330,7 +307,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
</Group> </Group>
</UnstyledButton> </UnstyledButton>
); );
} else if (item.entityType === "page" && item.id !== null) { } else if (item.entityType === "page") {
return ( return (
<UnstyledButton <UnstyledButton
data-item-index={index} data-item-index={index}
@@ -339,24 +316,28 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
className={clsx(classes.menuBtn, { className={clsx(classes.menuBtn, {
[classes.selectedItem]: index === selectedIndex, [classes.selectedItem]: index === selectedIndex,
})} })}
px="sm"
> >
<Group gap="sm" wrap="nowrap"> <Group>
<ActionIcon <ActionIcon
variant="subtle" variant="default"
component="div" component="div"
aria-label={item.label} aria-label={item.label}
color="gray"
size="sm"
> >
{item.icon || ( {item.icon || (
<IconFileDescription size={18} stroke={1.5} /> <ActionIcon
component="span"
variant="transparent"
color="gray"
size={18}
>
{ (item.id) ? <IconFileDescription size={18} /> : <IconPlus size={18} /> }
</ActionIcon>
)} )}
</ActionIcon> </ActionIcon>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1 }}>
<Text size="sm" fw={500} truncate> <Text size="sm" fw={500}>
{item.label} { (item.id) ? item.label : t("Create page") + ': ' + item.label }
</Text> </Text>
</div> </div>
</Group> </Group>
@@ -366,37 +347,6 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
return null; return null;
} }
})} })}
{createPageItemData && !isInCommentContext && (
<>
{(hasUsers || hasPages) && <Divider my={6} />}
<UnstyledButton
data-item-index={renderItems.indexOf(createPageItemData)}
onClick={() => selectItem(renderItems.indexOf(createPageItemData))}
className={clsx(classes.menuBtn, {
[classes.selectedItem]: renderItems.indexOf(createPageItemData) === selectedIndex,
})}
px="sm"
>
<Group gap="sm" wrap="nowrap">
<ActionIcon
variant="subtle"
component="div"
color="gray"
size="sm"
>
<IconPlus size={16} stroke={1.5} />
</ActionIcon>
<div style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{t("Create page")}: {createPageItemData.label}
</Text>
</div>
</Group>
</UnstyledButton>
</>
)}
</ScrollArea.Autosize> </ScrollArea.Autosize>
</Paper> </Paper>
); );
@@ -1,11 +1,5 @@
import { ReactRenderer, useEditor } from "@tiptap/react"; import { ReactRenderer, useEditor } from "@tiptap/react";
import { import tippy from "tippy.js";
autoUpdate,
computePosition,
flip,
offset,
shift,
} from "@floating-ui/dom";
import MentionList from "@/features/editor/components/mention/mention-list.tsx"; import MentionList from "@/features/editor/components/mention/mention-list.tsx";
function getWhitespaceCount(query: string) { function getWhitespaceCount(query: string) {
@@ -15,32 +9,16 @@ function getWhitespaceCount(query: string) {
const mentionRenderItems = () => { const mentionRenderItems = () => {
let component: ReactRenderer | null = null; let component: ReactRenderer | null = null;
let activeClientRect: (() => DOMRect) | null = null; let popup: any | null = null;
let updatePositionCleanup: (() => void) | null = null;
let outsideClickHandler: ((e: MouseEvent) => void) | null = null;
const destroy = () => {
if (outsideClickHandler) {
document.removeEventListener("pointerdown", outsideClickHandler);
outsideClickHandler = null;
}
updatePositionCleanup?.();
updatePositionCleanup = null;
component?.destroy();
if (component?.element?.parentNode) {
component.element.parentNode.removeChild(component.element);
}
component = null;
};
return { return {
onStart: (props: { onStart: (props: {
editor: ReturnType<typeof useEditor>; editor: ReturnType<typeof useEditor>;
clientRect: () => DOMRect; clientRect: DOMRect;
query: string; query: string;
}) => { }) => {
// query must not start with a whitespace // query must not start with a whitespace
if (props.query.charAt(0) === " ") { if (props.query.charAt(0) === ' '){
return; return;
} }
@@ -50,14 +28,8 @@ const mentionRenderItems = () => {
return; return;
} }
const editorDom = props.editor?.view?.dom;
const asideEl = editorDom?.closest(".mantine-AppShell-aside");
const dialogEl = editorDom?.closest("[data-comment-dialog]");
const isInCommentContext = !!(asideEl || dialogEl);
// const isInCommentContext = !!asideEl;
component = new ReactRenderer(MentionList, { component = new ReactRenderer(MentionList, {
props: { ...props, isInCommentContext }, props,
editor: props.editor, editor: props.editor,
}); });
@@ -65,107 +37,75 @@ const mentionRenderItems = () => {
return; return;
} }
activeClientRect = props.clientRect; // @ts-ignore
popup = tippy("body", {
const { element } = component; getReferenceClientRect: props.clientRect,
document.body.appendChild(element); appendTo: () => document.body,
content: component.element,
outsideClickHandler = (e: MouseEvent) => { showOnCreate: true,
const target = e.target as Node; interactive: true,
if (element && !element.contains(target)) { trigger: "manual",
destroy(); placement: "bottom-start",
} });
};
document.addEventListener("pointerdown", outsideClickHandler);
const shiftMiddleware = asideEl
? shift({ boundary: asideEl, crossAxis: true, padding: 8 })
: shift();
updatePositionCleanup = autoUpdate(
{
getBoundingClientRect: () =>
activeClientRect ? activeClientRect() : new DOMRect(),
},
element,
() => {
if (!component?.element) return;
computePosition(
{
getBoundingClientRect: () => {
return activeClientRect ? activeClientRect() : new DOMRect();
},
},
element,
{
placement: "bottom-start",
middleware: [offset(4), flip(), shiftMiddleware],
},
).then(({ x, y }) => {
Object.assign(element.style, {
left: `${x}px`,
top: `${y}px`,
position: "absolute",
zIndex: "9999",
});
});
},
);
}, },
onUpdate: (props: { onUpdate: (props: {
editor: ReturnType<typeof useEditor>; editor: ReturnType<typeof useEditor>;
clientRect: () => DOMRect; clientRect: DOMRect;
query: string; query: string;
}) => { }) => {
// query must not start with a whitespace // query must not start with a whitespace
if (props.query.charAt(0) === " ") { if (props.query.charAt(0) === ' '){
destroy(); component?.destroy();
return; return;
} }
// only update component if popup is not destroyed // only update component if popup is not destroyed
if (component) { if (!popup?.[0].state.isDestroyed) {
component.updateProps(props); component?.updateProps(props);
} }
if (!props || !props.clientRect) { if (!props || !props.clientRect) {
return; return;
} }
activeClientRect = props.clientRect;
const whitespaceCount = getWhitespaceCount(props.query); const whitespaceCount = getWhitespaceCount(props.query);
// destroy component if space is greater 3 without a match // destroy component if space is greater 3 without a match
if ( if (
whitespaceCount > 4 && whitespaceCount > 3 &&
//@ts-ignore props.editor.storage.mentionItems.length === 0
props.editor.storage.mentionItems.length === 1
) { ) {
destroy(); popup?.[0]?.destroy();
return; component?.destroy();
}
// fallback exit
if (whitespaceCount > 7) {
destroy();
return; return;
} }
popup &&
!popup?.[0].state.isDestroyed &&
popup?.[0].setProps({
getReferenceClientRect: props.clientRect,
});
}, },
onKeyDown: (props: { event: KeyboardEvent }) => { onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") { if (props.event.key)
destroy(); if (
return true; props.event.key === "Escape" ||
} (props.event.key === "Enter" && !popup?.[0].state.isShown)
) {
if (props.event.key === "Enter" && !component) { popup?.[0].destroy();
destroy(); component?.destroy();
return false; return false;
} }
return (component?.ref as any)?.onKeyDown(props); return (component?.ref as any)?.onKeyDown(props);
}, },
onExit: () => { onExit: () => {
destroy(); if (popup && !popup?.[0].state.isDestroyed) {
popup[0].destroy();
}
if (component) {
component.destroy();
}
}, },
}; };
}; };
@@ -1,21 +1,19 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { ActionIcon, Anchor, Text } from "@mantine/core"; import { ActionIcon, Anchor, Text } from "@mantine/core";
import { IconFileDescription } from "@tabler/icons-react"; import { IconFileDescription } from "@tabler/icons-react";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom"; import { Link, useLocation, useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts"; import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { import {
buildPageUrl, buildPageUrl,
buildSharedPageUrl, buildSharedPageUrl,
} from "@/features/page/page.utils.ts"; } from "@/features/page/page.utils.ts";
import { extractPageSlugId } from "@/lib";
import classes from "./mention.module.css"; import classes from "./mention.module.css";
export default function MentionView(props: NodeViewProps) { export default function MentionView(props: NodeViewProps) {
const { node } = props; const { node } = props;
const { label, entityType, entityId, slugId, anchorId } = node.attrs; const { label, entityType, entityId, slugId } = node.attrs;
const { spaceSlug, pageSlug } = useParams(); const { spaceSlug } = useParams();
const { shareId } = useParams(); const { shareId } = useParams();
const navigate = useNavigate();
const { const {
data: page, data: page,
isLoading, isLoading,
@@ -25,29 +23,14 @@ export default function MentionView(props: NodeViewProps) {
const location = useLocation(); const location = useLocation();
const isShareRoute = location.pathname.startsWith("/share"); const isShareRoute = location.pathname.startsWith("/share");
const currentPageSlugId = extractPageSlugId(pageSlug);
const isSamePage = currentPageSlugId === slugId;
const handleClick = (e: React.MouseEvent) => {
if (isSamePage && anchorId) {
e.preventDefault();
const element = document.querySelector(`[id="${anchorId}"]`);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "start" });
navigate(`#${anchorId}`, { replace: true });
}
}
};
const shareSlugUrl = buildSharedPageUrl({ const shareSlugUrl = buildSharedPageUrl({
shareId, shareId,
pageSlugId: slugId, pageSlugId: slugId,
pageTitle: label, pageTitle: label,
anchorId,
}); });
return ( return (
<NodeViewWrapper style={{ display: "inline" }} data-drag-handle> <NodeViewWrapper style={{ display: "inline" }}>
{entityType === "user" && ( {entityType === "user" && (
<Text className={classes.userMention} component="span"> <Text className={classes.userMention} component="span">
@{label} @{label}
@@ -59,9 +42,8 @@ export default function MentionView(props: NodeViewProps) {
component={Link} component={Link}
fw={500} fw={500}
to={ to={
isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label, anchorId) isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label)
} }
onClick={handleClick}
underline="never" underline="never"
className={classes.pageMentionLink} className={classes.pageMentionLink}
> >
@@ -31,14 +31,14 @@
.menuBtn { .menuBtn {
width: 100%; width: 100%;
padding: 6px 4px; padding: 4px;
margin-bottom: 1px; margin-bottom: 2px;
color: var(--mantine-color-text); color: var(--mantine-color-text);
border-radius: var(--mantine-radius-sm); border-radius: var(--mantine-radius-sm);
&:hover { &:hover {
@mixin light { @mixin light {
background: var(--mantine-color-gray-1); background: var(--mantine-color-gray-2);
} }
@mixin dark { @mixin dark {
@@ -49,7 +49,7 @@
.selectedItem { .selectedItem {
@mixin light { @mixin light {
background: var(--mantine-color-gray-1); background: var(--mantine-color-gray-2);
} }
@mixin dark { @mixin dark {
@@ -7,7 +7,6 @@ export interface MentionListProps {
range: Range; range: Range;
text: string; text: string;
editor: Editor; editor: Editor;
isInCommentContext?: boolean;
} }
export type MentionSuggestionItem = export type MentionSuggestionItem =
@@ -73,8 +73,6 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
if (!editor) return; if (!editor) return;
const { results, resultIndex } = editor.storage.searchAndReplace; const { results, resultIndex } = editor.storage.searchAndReplace;
//TODO: check type error
//@ts-ignore
const position: Range = results[resultIndex]; const position: Range = results[resultIndex];
if (!position) return; if (!position) return;
@@ -20,6 +20,7 @@ import {
IconCalendar, IconCalendar,
IconAppWindow, IconAppWindow,
IconSitemap, IconSitemap,
IconAlignLeft2,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { import {
CommandProps, CommandProps,
@@ -153,6 +154,14 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }: CommandProps) => command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setHorizontalRule().run(), editor.chain().focus().deleteRange(range).setHorizontalRule().run(),
}, },
{
title: "Table of contents",
description: "Insert table of contents",
searchTerms: ["toc"],
icon: IconAlignLeft2,
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).insertTableOfContents().run(),
},
{ {
title: "Image", title: "Image",
description: "Upload any image from your device.", description: "Upload any image from your device.",
@@ -161,7 +170,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }) => { command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run(); editor.chain().focus().deleteRange(range).run();
// @ts-ignore
const pageId = editor.storage?.pageId; const pageId = editor.storage?.pageId;
if (!pageId) return; if (!pageId) return;
@@ -170,18 +178,13 @@ const CommandGroups: SlashMenuGroupedItemsType = {
input.type = "file"; input.type = "file";
input.accept = "image/*"; input.accept = "image/*";
input.multiple = true; input.multiple = true;
input.style.display = "none";
document.body.appendChild(input);
input.onchange = async () => { input.onchange = async () => {
if (input.files?.length) { if (input.files?.length) {
for (const file of input.files) { for (const file of input.files) {
const pos = editor.view.state.selection.from; const pos = editor.view.state.selection.from;
uploadImageAction(file, editor.view, pos, pageId);
uploadImageAction(file, editor, pos, pageId);
} }
} }
input.remove();
}; };
input.click(); input.click();
}, },
@@ -194,7 +197,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }) => { command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run(); editor.chain().focus().deleteRange(range).run();
// @ts-ignore
const pageId = editor.storage?.pageId; const pageId = editor.storage?.pageId;
if (!pageId) return; if (!pageId) return;
@@ -202,19 +204,12 @@ const CommandGroups: SlashMenuGroupedItemsType = {
const input = document.createElement("input"); const input = document.createElement("input");
input.type = "file"; input.type = "file";
input.accept = "video/*"; input.accept = "video/*";
input.multiple = true;
input.style.display = "none";
document.body.appendChild(input);
input.onchange = async () => { input.onchange = async () => {
if (input.files?.length) { if (input.files?.length) {
for (const file of input.files) { const file = input.files[0];
const pos = editor.view.state.selection.from; const pos = editor.view.state.selection.from;
uploadVideoAction(file, editor.view, pos, pageId);
uploadVideoAction(file, editor, pos, pageId);
}
} }
input.remove();
}; };
input.click(); input.click();
}, },
@@ -227,7 +222,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }) => { command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run(); editor.chain().focus().deleteRange(range).run();
// @ts-ignore
const pageId = editor.storage?.pageId; const pageId = editor.storage?.pageId;
if (!pageId) return; if (!pageId) return;
@@ -235,19 +229,12 @@ const CommandGroups: SlashMenuGroupedItemsType = {
const input = document.createElement("input"); const input = document.createElement("input");
input.type = "file"; input.type = "file";
input.accept = ""; input.accept = "";
input.multiple = true;
input.style.display = "none";
document.body.appendChild(input);
input.onchange = async () => { input.onchange = async () => {
if (input.files?.length) { if (input.files?.length) {
for (const file of input.files) { const file = input.files[0];
const pos = editor.view.state.selection.from; const pos = editor.view.state.selection.from;
uploadAttachmentAction(file, editor.view, pos, pageId, true);
uploadAttachmentAction(file, editor, pos, pageId, true);
}
} }
input.remove();
}; };
input.click(); input.click();
}, },
@@ -1,35 +1,10 @@
import { ReactRenderer, useEditor } from "@tiptap/react"; import { ReactRenderer, useEditor } from "@tiptap/react";
import CommandList from "@/features/editor/components/slash-menu/command-list"; import CommandList from "@/features/editor/components/slash-menu/command-list";
import { import tippy from "tippy.js";
autoUpdate,
computePosition,
flip,
offset,
shift,
} from "@floating-ui/dom";
const renderItems = () => { const renderItems = () => {
let component: ReactRenderer | null = null; let component: ReactRenderer | null = null;
let popup: HTMLElement | null = null; let popup: any | null = null;
let cleanup: (() => void) | null = null;
let getReferenceClientRect: (() => DOMRect) | null = null;
const updatePosition = () => {
if (!popup || !getReferenceClientRect) return;
// @ts-ignore
const rect = getReferenceClientRect();
computePosition({ getBoundingClientRect: () => rect }, popup, {
placement: "bottom-start",
middleware: [offset(0), flip(), shift()],
}).then(({ x, y }) => {
if (popup) {
popup.style.left = `${x}px`;
popup.style.top = `${y}px`;
}
});
};
return { return {
onStart: (props: { onStart: (props: {
@@ -46,29 +21,15 @@ const renderItems = () => {
} }
// @ts-ignore // @ts-ignore
getReferenceClientRect = props.clientRect; popup = tippy("body", {
getReferenceClientRect: props.clientRect,
popup = document.createElement("div"); appendTo: () => document.body,
popup.style.zIndex = "9999"; content: component.element,
popup.style.position = "absolute"; showOnCreate: true,
popup.style.top = "0"; interactive: true,
popup.style.left = "0"; trigger: "manual",
placement: "bottom-start",
document.body.appendChild(popup); });
popup.appendChild(component.element);
cleanup = autoUpdate(
// @ts-ignore
{
getBoundingClientRect: () => {
return getReferenceClientRect
? getReferenceClientRect()
: new DOMRect();
},
},
popup,
updatePosition
);
}, },
onUpdate: (props: { onUpdate: (props: {
editor: ReturnType<typeof useEditor>; editor: ReturnType<typeof useEditor>;
@@ -80,15 +41,14 @@ const renderItems = () => {
return; return;
} }
// @ts-ignore popup &&
getReferenceClientRect = props.clientRect; popup[0].setProps({
updatePosition(); getReferenceClientRect: props.clientRect,
});
}, },
onKeyDown: (props: { event: KeyboardEvent }) => { onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") { if (props.event.key === "Escape") {
if (popup) { popup?.[0].hide();
popup.style.display = "none";
}
return true; return true;
} }
@@ -97,19 +57,12 @@ const renderItems = () => {
return component?.ref?.onKeyDown(props); return component?.ref?.onKeyDown(props);
}, },
onExit: () => { onExit: () => {
if (cleanup) { if (popup && !popup[0].state.isDestroyed) {
cleanup(); popup[0].destroy();
cleanup = null;
}
if (popup) {
popup.remove();
popup = null;
} }
if (component) { if (component) {
component.destroy(); component.destroy();
component = null;
} }
}, },
}; };
@@ -1,11 +1,15 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import {
import { posToDOMRect, findParentNode } from "@tiptap/react"; BubbleMenu as BaseBubbleMenu,
posToDOMRect,
findParentNode,
} from "@tiptap/react";
import { Node as PMNode } from "@tiptap/pm/model"; import { Node as PMNode } from "@tiptap/pm/model";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { ActionIcon, Tooltip } from "@mantine/core"; import { ActionIcon, Tooltip } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react"; import { IconTrash } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Editor } from "@tiptap/core"; import { Editor } from "@tiptap/core";
import { sticky } from "tippy.js";
interface SubpagesMenuProps { interface SubpagesMenuProps {
editor: Editor; editor: Editor;
@@ -29,7 +33,7 @@ export const SubpagesMenu = React.memo(
return editor.isActive("subpages"); return editor.isActive("subpages");
}, },
[editor] [editor],
); );
const getReferenceClientRect = useCallback(() => { const getReferenceClientRect = useCallback(() => {
@@ -58,8 +62,18 @@ export const SubpagesMenu = React.memo(
return ( return (
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
pluginKey={`subpages-menu`} pluginKey={`subpages-menu}`}
updateDelay={0} updateDelay={0}
tippyOptions={{
getReferenceClientRect,
offset: [0, 8],
zIndex: 99,
popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
<Tooltip position="top" label={t("Delete")}> <Tooltip position="top" label={t("Delete")}>
@@ -75,7 +89,7 @@ export const SubpagesMenu = React.memo(
</Tooltip> </Tooltip>
</BaseBubbleMenu> </BaseBubbleMenu>
); );
} },
); );
export default SubpagesMenu; export default SubpagesMenu;
@@ -19,7 +19,6 @@ export default function SubpagesView(props: NodeViewProps) {
const { spaceSlug, shareId } = useParams(); const { spaceSlug, shareId } = useParams();
const { t } = useTranslation(); const { t } = useTranslation();
//@ts-ignore
const currentPageId = editor.storage.pageId; const currentPageId = editor.storage.pageId;
// Get subpages from shared tree if we're in a shared context // Get subpages from shared tree if we're in a shared context
@@ -53,7 +52,7 @@ export default function SubpagesView(props: NodeViewProps) {
if (error && !shareId) { if (error && !shareId) {
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper>
<Text c="dimmed" size="md" py="md"> <Text c="dimmed" size="md" py="md">
{t("Failed to load subpages")} {t("Failed to load subpages")}
</Text> </Text>
@@ -63,7 +62,7 @@ export default function SubpagesView(props: NodeViewProps) {
if (subpages.length === 0) { if (subpages.length === 0) {
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper>
<div className={classes.container}> <div className={classes.container}>
<Text c="dimmed" size="md" py="md"> <Text c="dimmed" size="md" py="md">
{t("No subpages")} {t("No subpages")}
@@ -74,7 +73,7 @@ export default function SubpagesView(props: NodeViewProps) {
} }
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper>
<div className={classes.container}> <div className={classes.container}>
<Stack gap={5}> <Stack gap={5}>
{subpages.map((page) => ( {subpages.map((page) => (
@@ -0,0 +1,61 @@
.header {
}
.container {
}
.emptyState {
}
.link {
outline: none;
cursor: pointer;
display: block;
width: 100%;
text-align: start;
word-wrap: break-word;
background-color: transparent;
color: var(--mantine-color-text);
font-size: var(--mantine-font-size-sm);
line-height: var(--mantine-line-height-sm);
padding: 6px;
border-top-right-radius: var(--mantine-radius-sm);
border-bottom-right-radius: var(--mantine-radius-sm);
border: none;
@mixin hover {
background-color: light-dark(
var(--mantine-color-gray-2),
var(--mantine-color-dark-6)
);
}
@media (max-width: $mantine-breakpoint-sm) {
& {
border: none !important;
padding-left: 0px;
}
}
}
.linkActive {
font-weight: 500;
border-left-color: light-dark(
var(--mantine-color-grey-5),
var(--mantine-color-grey-3)
);
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
&,
&:hover {
background-color: light-dark(
var(--mantine-color-gray-3),
var(--mantine-color-dark-5)
) !important;
}
}
.leftBorder {
border-left: 1px solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
@@ -0,0 +1,55 @@
import { Editor as CoreEditor } from "@tiptap/core";
import { TableOfContentsStorage } from "@tiptap/extension-table-of-contents";
import { NodeViewWrapper, useEditorState } from "@tiptap/react";
import { memo } from "react";
import { clsx } from "clsx";
import classes from "./table-of-contents-nodeview.module.css";
export type TableOfContentsProps = {
editor: CoreEditor;
onItemClick?: () => void;
};
export const TableOfContentsNodeview = memo(
({ editor, onItemClick }: TableOfContentsProps) => {
const content = useEditorState({
editor,
selector: (ctx) =>
(ctx.editor.storage.tableOfContents as TableOfContentsStorage)?.content,
});
return (
<NodeViewWrapper>
<div contentEditable={false}>
<div className={classes.header}>Table of contents</div>
{content.length > 0 ? (
<div className={classes.container}>
{content
.filter((item) => item.level <= 3)
.map((item) => (
<a
key={item.id}
href={`#${item.id}`}
style={{ marginLeft: `${1 * item.level - 1}rem` }}
onClick={onItemClick}
className={clsx(
classes.link,
item.isActive && classes.linkActive,
)}
>
{item.itemIndex}. {item.textContent}
</a>
))}
</div>
) : (
<div className={classes.emptyState}>
Start adding headlines to your document
</div>
)}
</div>
</NodeViewWrapper>
);
},
);
TableOfContentsNodeview.displayName = "TableOfContentsNodeview";
@@ -1,4 +1,6 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
@@ -15,7 +17,6 @@ import {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TableBackgroundColor } from "./table-background-color"; import { TableBackgroundColor } from "./table-background-color";
import { TableTextAlignment } from "./table-text-alignment"; import { TableTextAlignment } from "./table-text-alignment";
import { BubbleMenu } from "@tiptap/react/menus";
export const TableCellMenu = React.memo( export const TableCellMenu = React.memo(
({ editor, appendTo }: EditorMenuProps): JSX.Element => { ({ editor, appendTo }: EditorMenuProps): JSX.Element => {
@@ -28,7 +29,7 @@ export const TableCellMenu = React.memo(
return isCellSelection(state.selection); return isCellSelection(state.selection);
}, },
[editor] [editor],
); );
const mergeCells = useCallback(() => { const mergeCells = useCallback(() => {
@@ -52,27 +53,23 @@ export const TableCellMenu = React.memo(
}, [editor]); }, [editor]);
return ( return (
<BubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
pluginKey="table-cell-menu" pluginKey="table-cell-menu"
updateDelay={0} updateDelay={0}
appendTo={() => { tippyOptions={{
return appendTo?.current; appendTo: () => {
}} return appendTo?.current;
ref={(element) => {
element.style.zIndex = "99";
}}
options={{
offset: {
mainAxis: 15,
}, },
offset: [0, 15],
zIndex: 99,
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
<ActionIcon.Group> <ActionIcon.Group>
<TableBackgroundColor editor={editor} /> <TableBackgroundColor editor={editor} />
<TableTextAlignment editor={editor} /> <TableTextAlignment editor={editor} />
<Tooltip position="top" label={t("Merge cells")}> <Tooltip position="top" label={t("Merge cells")}>
<ActionIcon <ActionIcon
onClick={mergeCells} onClick={mergeCells}
@@ -128,9 +125,9 @@ export const TableCellMenu = React.memo(
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</ActionIcon.Group> </ActionIcon.Group>
</BubbleMenu> </BaseBubbleMenu>
); );
} },
); );
export default TableCellMenu; export default TableCellMenu;
@@ -1,6 +1,11 @@
import { posToDOMRect, findParentNode } from "@tiptap/react"; import {
BubbleMenu as BaseBubbleMenu,
posToDOMRect,
findParentNode,
} from "@tiptap/react";
import { Node as PMNode } from "@tiptap/pm/model"; import { Node as PMNode } from "@tiptap/pm/model";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
@@ -12,12 +17,9 @@ import {
IconColumnRemove, IconColumnRemove,
IconRowInsertBottom, IconRowInsertBottom,
IconRowInsertTop, IconRowInsertTop,
IconRowRemove, IconRowRemove, IconTableColumn, IconTableRow,
IconTableColumn,
IconTableRow,
IconTrashX, IconTrashX,
} from "@tabler/icons-react"; } from '@tabler/icons-react';
import { BubbleMenu } from "@tiptap/react/menus";
import { isCellSelection } from "@docmost/editor-ext"; import { isCellSelection } from "@docmost/editor-ext";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -32,28 +34,20 @@ export const TableMenu = React.memo(
return editor.isActive("table") && !isCellSelection(state.selection); return editor.isActive("table") && !isCellSelection(state.selection);
}, },
[editor] [editor],
); );
const getReferencedVirtualElement = useCallback(() => { const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "table"; const predicate = (node: PMNode) => node.type.name === "table";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
if (parent) { if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement; const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
const rect = dom.getBoundingClientRect(); return dom.getBoundingClientRect();
return {
getBoundingClientRect: () => rect,
getClientRects: () => [rect],
};
} }
const rect = posToDOMRect(editor.view, selection.from, selection.to); return posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => rect,
getClientRects: () => [rect],
};
}, [editor]); }, [editor]);
const toggleHeaderColumn = useCallback(() => { const toggleHeaderColumn = useCallback(() => {
@@ -93,33 +87,42 @@ export const TableMenu = React.memo(
}, [editor]); }, [editor]);
return ( return (
<BubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
pluginKey="table-menu" pluginKey="table-menu"
resizeDelay={0} updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement} tippyOptions={{
ref={(element) => { getReferenceClientRect: getReferenceClientRect,
element.style.zIndex = "99"; offset: [0, 15],
}} zIndex: 99,
options={{ popperOptions: {
placement: "top", modifiers: [
offset: { {
mainAxis: 15, name: "preventOverflow",
}, enabled: true,
flip: { options: {
fallbackPlacements: ["top", "bottom"], altAxis: true,
padding: { top: 35 + 15, left: 8, right: 8, bottom: -Infinity }, boundary: "clippingParents",
boundary: editor.options.element as HTMLElement, padding: 8,
}, },
shift: { },
padding: 8 + 15, {
crossAxis: true, name: "flip",
enabled: true,
options: {
boundary: editor.options.element,
fallbackPlacements: ["top", "bottom"],
padding: { top: 35, left: 8, right: 8, bottom: -Infinity },
},
},
],
}, },
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
<ActionIcon.Group> <ActionIcon.Group>
<Tooltip position="top" label={t("Add left column")}> <Tooltip position="top" label={t("Add left column")}
>
<ActionIcon <ActionIcon
onClick={addColumnLeft} onClick={addColumnLeft}
variant="default" variant="default"
@@ -185,7 +188,8 @@ export const TableMenu = React.memo(
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip position="top" label={t("Toggle header row")}> <Tooltip position="top" label={t("Toggle header row")}
>
<ActionIcon <ActionIcon
onClick={toggleHeaderRow} onClick={toggleHeaderRow}
variant="default" variant="default"
@@ -196,7 +200,8 @@ export const TableMenu = React.memo(
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip position="top" label={t("Toggle header column")}> <Tooltip position="top" label={t("Toggle header column")}
>
<ActionIcon <ActionIcon
onClick={toggleHeaderColumn} onClick={toggleHeaderColumn}
variant="default" variant="default"
@@ -219,9 +224,9 @@ export const TableMenu = React.memo(
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</ActionIcon.Group> </ActionIcon.Group>
</BubbleMenu> </BaseBubbleMenu>
); );
} },
); );
export default TableMenu; export default TableMenu;
@@ -1,6 +1,11 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import {
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model"; import { Node as PMNode } from "prosemirror-model";
import { import {
EditorMenuProps, EditorMenuProps,
@@ -17,6 +22,16 @@ import { useTranslation } from "react-i18next";
export function VideoMenu({ editor }: EditorMenuProps) { export function VideoMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive("video");
},
[editor],
);
const editorState = useEditorState({ const editorState = useEditorState({
editor, editor,
@@ -37,37 +52,17 @@ export function VideoMenu({ editor }: EditorMenuProps) {
}, },
}); });
const shouldShow = useCallback( const getReferenceClientRect = useCallback(() => {
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive("video") && editor.getAttributes("video").src;
},
[editor],
);
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "video"; const predicate = (node: PMNode) => node.type.name === "video";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
if (parent) { if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement; const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
const domRect = dom.getBoundingClientRect(); return dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
} }
const domRect = posToDOMRect(editor.view, selection.from, selection.to); return posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}, [editor]); }, [editor]);
const alignVideoLeft = useCallback(() => { const alignVideoLeft = useCallback(() => {
@@ -110,11 +105,15 @@ export function VideoMenu({ editor }: EditorMenuProps) {
editor={editor} editor={editor}
pluginKey={`video-menu`} pluginKey={`video-menu`}
updateDelay={0} updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement} tippyOptions={{
options={{ getReferenceClientRect,
placement: "top", offset: [0, 8],
offset: 8, zIndex: 99,
flip: false, popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
@@ -1,33 +0,0 @@
.videoWrapper {
display: flex;
justify-content: center;
align-items: center;
border-radius: 8px;
overflow: hidden;
animation: pulse 1.2s ease-in-out infinite;
@mixin light {
background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%);
background-size: 400% 400%;
}
@mixin dark {
background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%);
background-size: 400% 400%;
}
@keyframes pulse {
0% {
background-position: 0% 0%;
}
100% {
background-position: -135% 0%;
}
}
}
.video {
display: block;
width: 100%;
height: 100%;
border-radius: 8px;
}
@@ -1,75 +1,29 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Group, Loader, Text } from "@mantine/core";
import { useMemo } from "react"; import { useMemo } from "react";
import { getFileUrl } from "@/lib/config.ts"; import { getFileUrl } from "@/lib/config.ts";
import clsx from "clsx"; import clsx from "clsx";
import classes from "./video-view.module.css";
import { useTranslation } from "react-i18next";
export default function VideoView(props: NodeViewProps) { export default function VideoView(props: NodeViewProps) {
const { t } = useTranslation(); const { node, selected } = props;
const { editor, node, selected } = props; const { src, width, align } = node.attrs;
const { src, width, align, aspectRatio, placeholder } = node.attrs;
const alignClass = useMemo(() => { const alignClass = useMemo(() => {
if (align === "left") return "alignLeft"; if (align === "left") return "alignLeft";
if (align === "right") return "alignRight"; if (align === "right") return "alignRight";
if (align === "center") return "alignCenter"; if (align === "center") return "alignCenter";
return "alignCenter"; return "alignCenter";
}, [align]); }, [align]);
const previewSrc = useMemo(() => {
editor.storage.shared.videoPreviews =
editor.storage.shared.videoPreviews || {};
if (placeholder?.id) {
return editor.storage.shared.videoPreviews[placeholder.id];
}
return null;
}, [placeholder, editor]);
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper>
<div <video
className={clsx( preload="metadata"
selected && "ProseMirror-selectednode", width={width}
classes.videoWrapper, controls
alignClass, src={getFileUrl(src)}
)} className={clsx(selected ? "ProseMirror-selectednode" : "", alignClass)}
style={{ style={{ display: "block" }}
aspectRatio: aspectRatio ? aspectRatio : src ? undefined : "16 / 9", />
width,
}}
>
{src && (
<video
className={classes.video}
preload="metadata"
controls
src={getFileUrl(src)}
/>
)}
{!src && previewSrc && (
<Group pos="relative" h="100%" w="100%">
<video
className={classes.video}
preload="metadata"
controls
src={previewSrc}
/>
<Loader size={20} pos="absolute" top={6} right={6} />
</Group>
)}
{!src && !previewSrc && (
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md">
<Loader size={20} style={{ flexShrink: 0 }} />
<Text component="span" size="sm" truncate="end">
{placeholder?.name
? t("Uploading {{name}}", { name: placeholder.name })
: t("Uploading file")}
</Text>
</Group>
)}
</div>
</NodeViewWrapper> </NodeViewWrapper>
); );
} }

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