diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..f189893d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,154 @@ +name: Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + version: + description: 'Version tag (e.g. v0.25.3)' + required: true + +permissions: + contents: write + +env: + VERSION: ${{ inputs.version || github.ref_name }} + +jobs: + build: + strategy: + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + suffix: amd64 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + suffix: arm64 + runs-on: ${{ matrix.runner }} + steps: + - name: Generate token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.BUILD_APP_ID }} + private-key: ${{ secrets.BUILD_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + + - name: Checkout with submodules + uses: actions/checkout@v4 + with: + submodules: recursive + token: ${{ steps.app-token.outputs.token }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push by digest + id: build + uses: docker/build-push-action@v6 + with: + context: . + platforms: ${{ matrix.platform }} + outputs: type=image,name=docmost/docmost,push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha,scope=${{ matrix.suffix }} + cache-to: type=gha,scope=${{ matrix.suffix }},mode=max + + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digest-${{ matrix.suffix }} + path: /tmp/digests/* + if-no-files-found: error + + - name: Strip v prefix + id: strip-v + run: echo "version=${VERSION#v}" >> "$GITHUB_OUTPUT" + + - name: Export Docker image + uses: docker/build-push-action@v6 + with: + context: . + platforms: ${{ matrix.platform }} + push: false + tags: | + docmost/docmost:latest + docmost/docmost:${{ steps.strip-v.outputs.version }} + outputs: type=docker,dest=docmost-${{ matrix.suffix }}.docker.tar + cache-from: type=gha,scope=${{ matrix.suffix }} + + - name: Compress image + run: gzip docmost-${{ matrix.suffix }}.docker.tar + + - name: Upload image archive + uses: actions/upload-artifact@v4 + with: + name: docker-image-${{ matrix.suffix }} + path: docmost-${{ matrix.suffix }}.docker.tar.gz + if-no-files-found: error + + release: + needs: build + runs-on: ubuntu-latest + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + pattern: digest-* + path: /tmp/digests + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract metadata for tags + id: meta + uses: docker/metadata-action@v5 + with: + images: docmost/docmost + tags: | + type=semver,pattern={{version}},value=${{ env.VERSION }} + type=semver,pattern={{major}}.{{minor}},value=${{ env.VERSION }},enable=${{ !contains(env.VERSION, '-') }} + type=raw,value=latest,enable=${{ !contains(env.VERSION, '-') }} + + - name: Create manifest list and push + working-directory: /tmp/digests + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf 'docmost/docmost@sha256:%s ' *) + + - name: Download image archives + uses: actions/download-artifact@v4 + with: + pattern: docker-image-* + path: /tmp/images + merge-multiple: true + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ env.VERSION }} + files: | + /tmp/images/docmost-amd64.docker.tar.gz + /tmp/images/docmost-arm64.docker.tar.gz + draft: true diff --git a/apps/client/package.json b/apps/client/package.json index 504f0f5f..4a079308 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -1,7 +1,7 @@ { "name": "client", "private": true, - "version": "0.24.1", + "version": "0.70.1", "scripts": { "dev": "vite", "build": "tsc && vite build", @@ -14,18 +14,19 @@ "@docmost/editor-ext": "workspace:*", "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", - "@excalidraw/excalidraw": "0.18.0-c158187", - "@mantine/core": "^8.3.12", - "@mantine/dates": "^8.3.12", - "@mantine/form": "^8.3.12", - "@mantine/hooks": "^8.3.12", - "@mantine/modals": "^8.3.12", - "@mantine/notifications": "^8.3.12", - "@mantine/spotlight": "^8.3.12", + "@excalidraw/excalidraw": "0.18.0-3a5ef40", + "@mantine/core": "^8.3.14", + "@mantine/dates": "^8.3.14", + "@mantine/form": "^8.3.14", + "@mantine/hooks": "^8.3.14", + "@mantine/modals": "^8.3.14", + "@mantine/notifications": "^8.3.14", + "@mantine/spotlight": "^8.3.14", "@tabler/icons-react": "^3.36.1", "@tanstack/react-query": "^5.90.17", "alfaaz": "^1.1.0", - "axios": "^1.13.2", + "axios": "^1.13.5", + "blueimp-load-image": "^5.16.0", "clsx": "^2.1.1", "emoji-mart": "^5.6.0", "file-saver": "^2.0.5", @@ -41,7 +42,7 @@ "mantine-form-zod-resolver": "^1.3.0", "mermaid": "^11.12.2", "mitt": "^3.0.1", - "posthog-js": "^1.255.1", + "posthog-js": "1.345.5", "react": "^18.3.1", "react-arborist": "3.4.0", "react-clear-modal": "^2.0.17", @@ -54,11 +55,12 @@ "semver": "^7.7.3", "socket.io-client": "^4.8.3", "tiptap-extension-global-drag-handle": "^0.1.18", - "zod": "^3.25.76" + "zod": "^4.3.6" }, "devDependencies": { "@eslint/js": "^9.16.0", "@tanstack/eslint-plugin-query": "^5.62.1", + "@types/blueimp-load-image": "^5.16.0", "@types/file-saver": "^2.0.7", "@types/js-cookie": "^3.0.6", "@types/katex": "^0.16.7", @@ -66,7 +68,7 @@ "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^5.1.1", - "eslint": "^9.15.0", + "eslint": "^9.39.2", "eslint-plugin-react": "^7.37.2", "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.16", diff --git a/apps/client/public/locales/de-DE/translation.json b/apps/client/public/locales/de-DE/translation.json index 93c6f265..850374cd 100644 --- a/apps/client/public/locales/de-DE/translation.json +++ b/apps/client/public/locales/de-DE/translation.json @@ -41,7 +41,7 @@ "Date": "Datum", "Delete": "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? Dadurch werden ihre Unterseiten und die Seitengeschichte gelöscht. Diese Aktion ist unwiderruflich.", + "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Sind Sie sicher, dass Sie diese Seite löschen möchten? Dabei werden auch alle Unterseiten und der Seitenverlauf gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.", "Description": "Beschreibung", "Details": "Details", "e.g ACME": "z.B. ACME", @@ -66,7 +66,7 @@ "Enter your new preferred email": "Geben Sie Ihre neue bevorzugte E-Mail ein", "Enter your password": "Geben Sie Ihr Passwort ein", "Error fetching page data.": "Fehler beim Abrufen der Seitendaten.", - "Error loading page history.": "Fehler beim Laden der Seitengeschichte.", + "Error loading page history.": "Fehler beim Laden des Seitenverlaufs.", "Export": "Exportieren", "Failed to create page": "Erstellung der Seite fehlgeschlagen", "Failed to delete page": "Löschen der Seite fehlgeschlagen", @@ -114,20 +114,24 @@ "New page": "Neue Seite", "New password": "Neues Passwort", "No group found": "Keine Gruppe gefunden", - "No page history saved yet.": "Es wurde noch keine Seitengeschichte gespeichert.", + "No page history saved yet.": "Es wurde noch kein Seitenverlauf gespeichert.", "No pages yet": "Noch keine Seiten", + "No shared pages": "Keine freigegebenen Seiten", "No results found...": "Keine Ergebnisse gefunden...", "No user found": "Kein Benutzer gefunden", "Overview": "Überblick", "Owner": "Besitzer", "page": "Seite", "Page deleted successfully": "Seite erfolgreich gelöscht", - "Page history": "Seitengeschichte", + "Page history": "Seitenverlauf", + "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.", "Pages": "Seiten", "pages": "Seiten", "Password": "Passwort", "Password changed successfully": "Passwort erfolgreich geändert", + "People": "Personen", "Pending": "Ausstehend", "Please confirm your action": "Bitte bestätigen Sie Ihre Aktion", "Preferences": "Vorlieben", @@ -205,6 +209,9 @@ "Reply...": "Antworten...", "Error loading comments.": "Fehler beim Laden der Kommentare.", "No comments yet.": "Noch keine Kommentare.", + "No open comments.": "Keine offenen Kommentare.", + "No resolved comments.": "Keine gelösten Kommentare.", + "Add a comment...": "Kommentar hinzufügen...", "Edit comment": "Kommentar bearbeiten", "Delete comment": "Kommentar löschen", "Are you sure you want to delete this comment?": "Sind Sie sicher, dass Sie diesen Kommentar löschen möchten?", @@ -226,7 +233,6 @@ "Are you sure you want to unresolve this comment thread?": "Sind Sie sicher, dass Sie diesen Kommentarthread nicht lösen möchten?", "Resolved": "Gelöst", "No active comments.": "Keine aktiven Kommentare.", - "No resolved comments.": "Keine gelösten Kommentare.", "Revoke invitation": "Einladung widerrufen", "Revoke": "Widerrufen", "Don't": "Nicht", @@ -272,6 +278,7 @@ "Add row below": "Zeile unten hinzufügen", "Delete table": "Tabelle löschen", "Info": "Info", + "Note": "Hinweis", "Success": "Erfolg", "Warning": "Warnung", "Danger": "Gefahr", @@ -353,9 +360,23 @@ "Insert current date": "Aktuelles Datum einfügen", "Draw and sketch excalidraw diagrams": "Excalidraw-Diagramme zeichnen und skizzieren", "Multiple": "Mehrere", + "Turn into": "In verwandeln", + "Text align": "Text ausrichten", + "This page may have been deleted, moved, or you may not have access.": "\"Diese Seite wurde möglicherweise gelöscht, verschoben oder Sie haben keinen Zugriff darauf.\"", + "Go to homepage": "Zur Startseite", + "Pages you create will show up here.": "\"Die von Ihnen erstellten Seiten werden hier angezeigt.\"", "Heading {{level}}": "Überschrift {{level}}", "Toggle title": "Titel umschalten", "Write anything. Enter \"/\" for commands": "Schreiben Sie irgendetwas. Geben Sie \"/\" für Befehle ein", + "Write...": "\"Schreiben...\"", + "Column count": "Spaltenanzahl", + "{{count}} Columns": "{count, plural, one {# Spalte} other {# Spalten}}", + "Equal columns": "Gleich breite Spalten", + "Left sidebar": "Linke Seitenleiste", + "Right sidebar": "Rechte Seitenleiste", + "Wide center": "Breiter Mittelbereich", + "Left wide": "Breiter linker Bereich", + "Right wide": "Breiter rechter Bereich", "Names do not match": "Namen stimmen nicht überein", "Today, {{time}}": "Heute, {{time}}", "Yesterday, {{time}}": "Gestern, {{time}}", @@ -378,6 +399,13 @@ "Delete member": "Mitglied löschen", "Member deleted successfully": "Mitglied erfolgreich gelöscht", "Are you sure you want to delete this workspace member? This action is irreversible.": "Sind Sie sicher, dass Sie dieses Arbeitsbereichsmitglied löschen möchten? Diese Aktion ist unwiderruflich.", + "Deactivate member": "Mitglied deaktivieren", + "Activate member": "Mitglied aktivieren", + "Are you sure you want to deactivate this workspace member? They will no longer be able to access this workspace.": "Sind Sie sicher, dass Sie dieses Mitglied des Arbeitsbereichs deaktivieren möchten? Dieses Mitglied kann danach nicht mehr auf diesen Arbeitsbereich zugreifen.", + "Are you sure you want to activate this workspace member?": "Sind Sie sicher, dass Sie dieses Mitglied des Arbeitsbereichs aktivieren möchten?", + "Deactivate": "Deaktivieren", + "Activate": "Aktivieren", + "Deactivated": "Deaktiviert", "Move": "Verschieben", "Move page": "Seite verschieben", "Move page to a different space.": "Seite in einen anderen Bereich verschieben.", @@ -405,6 +433,23 @@ "Share deleted successfully": "Freigabe erfolgreich gelöscht", "Share not found": "Freigabe nicht gefunden", "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", + "Page permissions": "Seitenberechtigungen", + "Control who can view and edit individual pages. Available with an enterprise license.": "Steuern Sie, wer einzelne Seiten ansehen und bearbeiten kann. Verfügbar mit einer Enterprise-Lizenz.", + "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 to a different space.": "Seite in einen anderen Bereich kopieren.", "Page copied successfully": "Seite erfolgreich kopiert", @@ -487,7 +532,7 @@ "Enter one of your backup codes. Each backup code can only be used once.": "Geben Sie einen Ihrer Sicherungscodes ein. Jeder Sicherungscode kann nur einmal verwendet werden.", "Verify": "Überprüfen", "Trash": "Papierkorb", - "Pages in trash will be permanently deleted after 30 days.": "Seiten im Papierkorb werden nach 30 Tagen endgültig gelöscht.", + "Pages in trash will be permanently deleted after {{count}} days.": "Seiten im Papierkorb werden nach {{count}} Tagen endgültig gelöscht.", "Deleted": "Gelöscht", "No pages in trash": "Keine Seiten im Papierkorb", "Permanently delete page?": "Seite endgültig löschen?", @@ -559,19 +604,94 @@ "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", + "Restrict API key creation to admins": "API-Schlüsselerstellung auf Administratoren beschränken", + "Only admins and owners can create new API keys. Existing member keys will continue to work.": "Nur Administratoren und Eigentümer können neue API-Schlüssel erstellen. Bestehende Mitgliederschlüssel funktionieren weiterhin.", + "Toggle restrict API keys to admins": "Beschränkung der API-Schlüssel auf Administratoren umschalten", + "API key creation is restricted to admins by your workspace administrator.": "Die Erstellung von API-Schlüsseln ist durch Ihren Workspace-Administrator auf Administratoren beschränkt.", "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-powered search (Ask AI)": "KI-gestützte Suche (KI fragen)", + "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", + "Enterprise feature": "Enterprise-Funktion", + "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "KI ist nur in der Docmost Enterprise-Edition verfügbar. Kontaktieren Sie sales@docmost.com.", + "AI & MCP": "KI & MCP", + "AI": "KI", + "MCP": "MCP", + "Model Context Protocol (MCP)": "Model Context Protocol (MCP)", + "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Aktivieren Sie den MCP-Server, damit KI-Assistenten und -Tools mit den Inhalten Ihres Arbeitsbereichs interagieren können.", + "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP ist nur in der Docmost Enterprise-Edition verfügbar. Kontaktieren Sie sales@docmost.com.", + "MCP documentation": "MCP-Dokumentation", + "MCP Server URL": "MCP-Server-URL", + "Use your API key for authentication. You can manage API keys in your account settings.": "Verwenden Sie Ihren API-Schlüssel zur Authentifizierung. API-Schlüssel können in Ihren Kontoeinstellungen verwaltet werden.", + "Supported tools": "Unterstützte Tools", + "Your workspace has MCP enabled. Use your API key to connect AI assistants.": "In Ihrem Arbeitsbereich ist MCP aktiviert. Verwenden Sie Ihren API-Schlüssel, um KI-Assistenten anzubinden.", + "MCP server URL:": "MCP-Server-URL:", + "Learn more": "Mehr erfahren", + "View the": "Anzeigen", + "for usage details.": "für Informationen zur Nutzung.", + "for setup instructions.": "für Einrichtungshinweise.", + "API documentation": "API-Dokumentation", "Sources": "Quellen", - "Ask AI not available for attachments": "KI fragen nicht für Anhänge verfügbar", + "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" + "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", + "gave you edit access to a page": "hat Ihnen Bearbeitungsrechte für eine Seite gegeben", + "gave you view access to a page": "hat Ihnen Leserechte für eine Seite gewährt", + "Today": "Heute", + "Yesterday": "Gestern", + "This week": "Diese Woche", + "Older": "Älter", + "Restricted page": "Eingeschränkte Seite", + "Restricted pages cannot be shared publicly.": "Eingeschränkte Seiten können nicht öffentlich geteilt werden.", + "Restricted by parent": "Eingeschränkt durch die übergeordnete Seite", + "Restricted": "Eingeschränkt", + "Open": "Offen", + "Inherits restrictions from ancestor page": "Erbt Einschränkungen von einer übergeordneten Seite", + "Only people listed below can access this page": "Nur die unten aufgeführten Personen können auf diese Seite zugreifen.", + "Everyone in this space can access": "Jeder in diesem Bereich kann darauf zugreifen.", + "No additional restrictions on this page": "Keine zusätzlichen Einschränkungen auf dieser Seite", + "Only specific people can access": "Nur bestimmte Personen können zugreifen", + "Use only inherited restrictions": "Nur geerbte Einschränkungen verwenden", + "Add restrictions on top of inherited": "Einschränkungen zusätzlich zu den geerbten hinzufügen", + "Inherited restriction": "Geerbte Einschränkung", + "Access limited by": "Zugriff beschränkt durch", + "Restrict access to control who can view and edit this page": "Beschränken Sie den Zugriff, um festzulegen, wer diese Seite ansehen und bearbeiten kann.", + "Add additional restrictions specific to this page": "Fügen Sie zusätzliche, für diese Seite spezifische Einschränkungen hinzu.", + "Access": "Zugriff", + "People with access": "Personen mit Zugriff", + "Remove all": "Alle entfernen", + "Remove access": "Zugriff entfernen", + "Remove all access": "Alle Zugriffsrechte entfernen", + "Are you sure you want to remove this member's access to the page?": "Sind Sie sicher, dass Sie den Zugriff dieses Mitglieds auf die Seite entfernen möchten?", + "Are you sure you want to remove all specific access? This will make the page open to everyone in the space.": "Sind Sie sicher, dass Sie alle spezifischen Zugriffsrechte entfernen möchten? Dadurch wird die Seite für alle in diesem Bereich zugänglich.", + "Trash retention": "Aufbewahrungsdauer des Papierkorbs", + "Pages in trash will be permanently deleted after this period.": "Seiten im Papierkorb werden nach Ablauf dieses Zeitraums endgültig gelöscht.", + "Trash retention updated": "Aufbewahrungsdauer des Papierkorbs aktualisiert", + "Failed to update trash retention": "Aktualisierung der Aufbewahrungsdauer des Papierkorbs fehlgeschlagen", + "Removed page restriction": "Seitenbeschränkung entfernt", + "Added page permission": "Seitenberechtigung hinzugefügt", + "Removed page permission": "Seitenberechtigung entfernt" } diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index c0578d2b..cd2b7559 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -116,6 +116,7 @@ "No group found": "No group found", "No page history saved yet.": "No page history saved yet.", "No pages yet": "No pages yet", + "No shared pages": "No shared pages", "No results found...": "No results found...", "No user found": "No user found", "Overview": "Overview", @@ -123,11 +124,14 @@ "page": "page", "Page deleted successfully": "Page deleted successfully", "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.", "Pages": "Pages", "pages": "pages", "Password": "Password", "Password changed successfully": "Password changed successfully", + "People": "People", "Pending": "Pending", "Please confirm your action": "Please confirm your action", "Preferences": "Preferences", @@ -205,6 +209,9 @@ "Reply...": "Reply...", "Error loading comments.": "Error loading comments.", "No comments yet.": "No comments yet.", + "No open comments.": "No open comments.", + "No resolved comments.": "No resolved comments.", + "Add a comment...": "Add a comment...", "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?", @@ -226,7 +233,6 @@ "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", @@ -272,6 +278,7 @@ "Add row below": "Add row below", "Delete table": "Delete table", "Info": "Info", + "Note": "Note", "Success": "Success", "Warning": "Warning", "Danger": "Danger", @@ -353,9 +360,23 @@ "Insert current date": "Insert current date", "Draw and sketch excalidraw diagrams": "Draw and sketch excalidraw diagrams", "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}}", "Toggle title": "Toggle title", "Write anything. Enter \"/\" for commands": "Write anything. Enter \"/\" for commands", + "Write...": "Write...", + "Column count": "Column count", + "{{count}} Columns": "{{count}} Columns", + "Equal columns": "Equal columns", + "Left sidebar": "Left sidebar", + "Right sidebar": "Right sidebar", + "Wide center": "Wide center", + "Left wide": "Left wide", + "Right wide": "Right wide", "Names do not match": "Names do not match", "Today, {{time}}": "Today, {{time}}", "Yesterday, {{time}}": "Yesterday, {{time}}", @@ -378,6 +399,13 @@ "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.", + "Deactivate member": "Deactivate member", + "Activate member": "Activate member", + "Are you sure you want to deactivate this workspace member? They will no longer be able to access this workspace.": "Are you sure you want to deactivate this workspace member? They will no longer be able to access this workspace.", + "Are you sure you want to activate this workspace member?": "Are you sure you want to activate this workspace member?", + "Deactivate": "Deactivate", + "Activate": "Activate", + "Deactivated": "Deactivated", "Move": "Move", "Move page": "Move page", "Move page to a different space.": "Move page to a different space.", @@ -405,6 +433,23 @@ "Share deleted successfully": "Share deleted successfully", "Share not found": "Share not found", "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", + "Page permissions": "Page permissions", + "Control who can view and edit individual pages. Available with an enterprise license.": "Control who can view and edit individual pages. Available with 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 to a different space.": "Copy page to a different space.", "Page copied successfully": "Page copied successfully", @@ -487,7 +532,7 @@ "Enter one of your backup codes. Each backup code can only be used once.": "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.": "Pages in trash will be permanently deleted after 30 days.", + "Pages in trash will be permanently deleted after {{count}} days.": "Pages in trash will be permanently deleted after {{count}} days.", "Deleted": "Deleted", "No pages in trash": "No pages in trash", "Permanently delete page?": "Permanently delete page?", @@ -559,19 +604,94 @@ "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", "Manage API keys for all users in the workspace": "Manage API keys for all users in the workspace", + "Restrict API key creation to admins": "Restrict API key creation to admins", + "Only admins and owners can create new API keys. Existing member keys will continue to work.": "Only admins and owners can create new API keys. Existing member keys will continue to work.", + "Toggle restrict API keys to admins": "Toggle restrict API keys to admins", + "API key creation is restricted to admins by your workspace administrator.": "API key creation is restricted to admins by your workspace administrator.", "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-powered search (Ask AI)": "AI-powered search (Ask AI)", + "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", + "Enterprise feature": "Enterprise feature", + "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.", + "AI & MCP": "AI & MCP", + "AI": "AI", + "MCP": "MCP", + "Model Context Protocol (MCP)": "Model Context Protocol (MCP)", + "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.", + "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.", + "MCP documentation": "MCP documentation", + "MCP Server URL": "MCP Server URL", + "Use your API key for authentication. You can manage API keys in your account settings.": "Use your API key for authentication. You can manage API keys in your account settings.", + "Supported tools": "Supported tools", + "Your workspace has MCP enabled. Use your API key to connect AI assistants.": "Your workspace has MCP enabled. Use your API key to connect AI assistants.", + "MCP server URL:": "MCP server URL:", + "Learn more": "Learn more", + "View the": "View the", + "for usage details.": "for usage details.", + "for setup instructions.": "for setup instructions.", + "API documentation": "API documentation", "Sources": "Sources", - "Ask AI not available for attachments": "Ask AI not available for attachments", + "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" + "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", + "gave you edit access to a page": "gave you edit access to a page", + "gave you view access to a page": "gave you view access to a page", + "Today": "Today", + "Yesterday": "Yesterday", + "This week": "This week", + "Older": "Older", + "Restricted page": "Restricted page", + "Restricted pages cannot be shared publicly.": "Restricted pages cannot be shared publicly.", + "Restricted by parent": "Restricted by parent", + "Restricted": "Restricted", + "Open": "Open", + "Inherits restrictions from ancestor page": "Inherits restrictions from ancestor page", + "Only people listed below can access this page": "Only people listed below can access this page", + "Everyone in this space can access": "Everyone in this space can access", + "No additional restrictions on this page": "No additional restrictions on this page", + "Only specific people can access": "Only specific people can access", + "Use only inherited restrictions": "Use only inherited restrictions", + "Add restrictions on top of inherited": "Add restrictions on top of inherited", + "Inherited restriction": "Inherited restriction", + "Access limited by": "Access limited by", + "Restrict access to control who can view and edit this page": "Restrict access to control who can view and edit this page", + "Add additional restrictions specific to this page": "Add additional restrictions specific to this page", + "Access": "Access", + "People with access": "People with access", + "Remove all": "Remove all", + "Remove access": "Remove access", + "Remove all access": "Remove all access", + "Are you sure you want to remove this member's access to the page?": "Are you sure you want to remove this member's access to the page?", + "Are you sure you want to remove all specific access? This will make the page open to everyone in the space.": "Are you sure you want to remove all specific access? This will make the page open to everyone in the space.", + "Trash retention": "Trash retention", + "Pages in trash will be permanently deleted after this period.": "Pages in trash will be permanently deleted after this period.", + "Trash retention updated": "Trash retention updated", + "Failed to update trash retention": "Failed to update trash retention", + "Removed page restriction": "Removed page restriction", + "Added page permission": "Added page permission", + "Removed page permission": "Removed page permission" } diff --git a/apps/client/public/locales/es-ES/translation.json b/apps/client/public/locales/es-ES/translation.json index af02c493..875ba3f4 100644 --- a/apps/client/public/locales/es-ES/translation.json +++ b/apps/client/public/locales/es-ES/translation.json @@ -116,6 +116,7 @@ "No group found": "No se encontró grupo", "No page history saved yet.": "No hay historial de la página guardado aún.", "No pages yet": "No hay páginas todavía", + "No shared pages": "No hay páginas compartidas", "No results found...": "No se encontraron resultados...", "No user found": "No se encontró usuario", "Overview": "Visión general", @@ -123,11 +124,14 @@ "page": "página", "Page deleted successfully": "Página eliminada con éxito", "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.", "Pages": "Páginas", "pages": "páginas", "Password": "Contraseña", "Password changed successfully": "Contraseña cambiada con éxito", + "People": "Personas", "Pending": "Pendiente", "Please confirm your action": "Por favor, confirme su acción", "Preferences": "Preferencias", @@ -205,6 +209,9 @@ "Reply...": "Responder...", "Error loading comments.": "Error al cargar comentarios.", "No comments yet.": "No hay comentarios todavía.", + "No open comments.": "No hay comentarios abiertos.", + "No resolved comments.": "No hay comentarios resueltos.", + "Add a comment...": "Agregar un comentario...", "Edit comment": "Editar comentario", "Delete comment": "Eliminar comentario", "Are you sure you want to delete this comment?": "¿Está seguro de que desea eliminar este comentario?", @@ -226,7 +233,6 @@ "Are you sure you want to unresolve this comment thread?": "¿Está seguro de que desea no resolver este hilo de comentarios?", "Resolved": "Resuelto", "No active comments.": "No hay comentarios activos.", - "No resolved comments.": "No hay comentarios resueltos.", "Revoke invitation": "Revocar invitación", "Revoke": "Revocar", "Don't": "No", @@ -272,6 +278,7 @@ "Add row below": "Agregar fila debajo", "Delete table": "Eliminar tabla", "Info": "Información", + "Note": "Nota", "Success": "Satisfactorio", "Warning": "Advertencia", "Danger": "Peligro", @@ -353,9 +360,23 @@ "Insert current date": "Insertar fecha actual", "Draw and sketch excalidraw diagrams": "Dibujar y esbozar diagramas de Excalidraw", "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.": "Es posible que esta página haya sido eliminada, movida o que no tengas acceso.", + "Go to homepage": "Ir a la página principal", + "Pages you create will show up here.": "Las páginas que crees aparecerán aquí.", "Heading {{level}}": "Encabezado {{level}}", "Toggle title": "Alternar título", "Write anything. Enter \"/\" for commands": "Escribe cualquier cosa. Ingresa \"/\" para comandos", + "Write...": "Escribe...", + "Column count": "Número de columnas", + "{{count}} Columns": "{count, plural, one {# columna} other {# columnas}}", + "Equal columns": "Columnas iguales", + "Left sidebar": "Barra lateral izquierda", + "Right sidebar": "Barra lateral derecha", + "Wide center": "Centro ancho", + "Left wide": "Izquierda ancha", + "Right wide": "Derecha ancha", "Names do not match": "Los nombres no coinciden", "Today, {{time}}": "Hoy, {{time}}", "Yesterday, {{time}}": "Ayer, {{time}}", @@ -378,6 +399,13 @@ "Delete member": "Eliminar miembro", "Member deleted successfully": "Miembro eliminado con éxito", "Are you sure you want to delete this workspace member? This action is irreversible.": "¿Está seguro que desea eliminar este miembro del área de trabajo? Esta acción es irreversible.", + "Deactivate member": "Desactivar miembro", + "Activate member": "Activar miembro", + "Are you sure you want to deactivate this workspace member? They will no longer be able to access this workspace.": "¿Está seguro de que desea desactivar a este miembro del espacio de trabajo? Ya no podrá acceder a este espacio de trabajo.", + "Are you sure you want to activate this workspace member?": "¿Está seguro de que desea activar a este miembro del espacio de trabajo?", + "Deactivate": "Desactivar", + "Activate": "Activar", + "Deactivated": "Desactivado", "Move": "Mover", "Move page": "Mover página", "Move page to a different space.": "Mover página a un espacio diferente.", @@ -405,6 +433,23 @@ "Share deleted successfully": "Compartición eliminada con éxito", "Share not found": "Compartición no encontrada", "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", + "Page permissions": "Permisos de la página},{", + "Control who can view and edit individual pages. Available with an enterprise license.": "Controla quién puede ver y editar páginas individuales. Disponible con 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 to a different space.": "Copiar página en otro espacio", "Page copied successfully": "Página copiada exitosamente", @@ -487,7 +532,7 @@ "Enter one of your backup codes. Each backup code can only be used once.": "Introduce uno de tus códigos de seguridad. Cada código de seguridad solo puede ser usado una vez.", "Verify": "Verificar", "Trash": "Papelera", - "Pages in trash will be permanently deleted after 30 days.": "Las páginas en la papelera serán eliminadas permanentemente después de 30 días.", + "Pages in trash will be permanently deleted after {{count}} days.": "{count, plural, one{Las páginas en la papelera se eliminarán permanentemente después de # día.} other{Las páginas en la papelera se eliminarán permanentemente después de # días.}}", "Deleted": "Eliminado", "No pages in trash": "No hay páginas en la papelera", "Permanently delete page?": "¿Eliminar página permanentemente?", @@ -559,19 +604,94 @@ "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", + "Restrict API key creation to admins": "Restringir la creación de claves API a administradores", + "Only admins and owners can create new API keys. Existing member keys will continue to work.": "Solo los administradores y propietarios pueden crear nuevas claves API. Las claves de miembros existentes seguirán funcionando.", + "Toggle restrict API keys to admins": "Activar o desactivar la restricción de claves API solo a administradores", + "API key creation is restricted to admins by your workspace administrator.": "La creación de claves API está restringida a administradores por el administrador de tu 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-powered search (Ask AI)": "Búsqueda impulsada por IA (Preguntar a IA)", + "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", + "Enterprise feature": "Función empresarial", + "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "La IA solo está disponible en la edición empresarial de Docmost. Contacte con sales@docmost.com.", + "AI & MCP": "IA y MCP", + "AI": "IA", + "MCP": "MCP", + "Model Context Protocol (MCP)": "Protocolo de Contexto del Modelo (MCP)", + "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Habilite el servidor MCP para permitir que asistentes de IA y herramientas interactúen con el contenido de su espacio de trabajo.", + "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP solo está disponible en la edición empresarial de Docmost. Contacte con sales@docmost.com.", + "MCP documentation": "Documentación de MCP", + "MCP Server URL": "URL del servidor MCP", + "Use your API key for authentication. You can manage API keys in your account settings.": "Use su clave API para la autenticación. Puede gestionar las claves API en la configuración de su cuenta.", + "Supported tools": "Herramientas compatibles", + "Your workspace has MCP enabled. Use your API key to connect AI assistants.": "Su espacio de trabajo tiene MCP habilitado. Use su clave API para conectar asistentes de IA.", + "MCP server URL:": "URL del servidor MCP:", + "Learn more": "Más información", + "View the": "Ver la", + "for usage details.": "para detalles de uso.", + "for setup instructions.": "para instrucciones de configuración.", + "API documentation": "Documentación de la API", "Sources": "Fuentes", - "Ask AI not available for attachments": "Preguntar a IA no está disponible para adjuntos", + "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" + "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", + "gave you edit access to a page": "Te dio acceso para editar una página.", + "gave you view access to a page": "Te dio acceso para ver una página.", + "Today": "Hoy", + "Yesterday": "Ayer", + "This week": "Esta semana", + "Older": "Más antiguo", + "Restricted page": "Página restringida", + "Restricted pages cannot be shared publicly.": "Las páginas restringidas no pueden compartirse públicamente.", + "Restricted by parent": "Restringida por la página padre", + "Restricted": "Restringida", + "Open": "Abierta", + "Inherits restrictions from ancestor page": "Hereda las restricciones de una página superior.", + "Only people listed below can access this page": "Solo las personas que figuran a continuación pueden acceder a esta página.", + "Everyone in this space can access": "Todos en este espacio pueden acceder.", + "No additional restrictions on this page": "No hay restricciones adicionales en esta página.", + "Only specific people can access": "Solo determinadas personas pueden acceder.", + "Use only inherited restrictions": "Usar solo las restricciones heredadas.", + "Add restrictions on top of inherited": "Agregar restricciones además de las heredadas.", + "Inherited restriction": "Restricción heredada", + "Access limited by": "Acceso limitado por", + "Restrict access to control who can view and edit this page": "Restringir el acceso para controlar quién puede ver y editar esta página.", + "Add additional restrictions specific to this page": "Agregar restricciones adicionales específicas para esta página.", + "Access": "Acceso", + "People with access": "Personas con acceso", + "Remove all": "Eliminar todo", + "Remove access": "Eliminar acceso", + "Remove all access": "Eliminar todo el acceso", + "Are you sure you want to remove this member's access to the page?": "¿Está seguro de que desea eliminar el acceso de este miembro a la página?", + "Are you sure you want to remove all specific access? This will make the page open to everyone in the space.": "¿Está seguro de que desea eliminar todo el acceso específico? Esto hará que la página esté abierta a todos en el espacio.", + "Trash retention": "Retención de la papelera", + "Pages in trash will be permanently deleted after this period.": "Las páginas en la papelera se eliminarán permanentemente después de este período.", + "Trash retention updated": "Retención de la papelera actualizada", + "Failed to update trash retention": "No se pudo actualizar la retención de la papelera.", + "Removed page restriction": "Restricción de página eliminada", + "Added page permission": "Permiso de página añadido", + "Removed page permission": "Permiso de página eliminado" } diff --git a/apps/client/public/locales/fr-FR/translation.json b/apps/client/public/locales/fr-FR/translation.json index 40a1e68a..f0e4f7af 100644 --- a/apps/client/public/locales/fr-FR/translation.json +++ b/apps/client/public/locales/fr-FR/translation.json @@ -116,6 +116,7 @@ "No group found": "Aucun groupe trouvé", "No page history saved yet.": "Aucun historique de la page enregistré pour l'instant.", "No pages yet": "Aucune page pour l'instant", + "No shared pages": "Aucune page partagée", "No results found...": "Aucun résultat trouvé...", "No user found": "Aucun utilisateur trouvé", "Overview": "Vue d'ensemble", @@ -123,11 +124,14 @@ "page": "page", "Page deleted successfully": "Page supprimée avec succès", "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.", "Pages": "Pages", "pages": "pages", "Password": "Mot de passe", "Password changed successfully": "Mot de passe changé avec succès", + "People": "Personnes", "Pending": "En attente", "Please confirm your action": "Veuillez confirmer votre action", "Preferences": "Préférences", @@ -205,6 +209,9 @@ "Reply...": "Répondre...", "Error loading comments.": "Erreur lors du chargement des commentaires.", "No comments yet.": "Pas de commentaires pour l'instant.", + "No open comments.": "Aucun commentaire ouvert.", + "No resolved comments.": "Aucun commentaire résolu.", + "Add a comment...": "Ajouter un commentaire...", "Edit comment": "Modifier le commentaire", "Delete comment": "Supprimer le commentaire", "Are you sure you want to delete this comment?": "Êtes-vous sûr de vouloir supprimer ce commentaire ?", @@ -226,7 +233,6 @@ "Are you sure you want to unresolve this comment thread?": "Êtes-vous sûr de vouloir désorganiser ce fil de commentaires ?", "Resolved": "Résolu", "No active comments.": "Aucun commentaire actif.", - "No resolved comments.": "Aucun commentaire résolu.", "Revoke invitation": "Révoquer l'invitation", "Revoke": "Révoquer", "Don't": "Ne pas", @@ -272,6 +278,7 @@ "Add row below": "Ajouter une ligne en dessous", "Delete table": "Supprimer le tableau", "Info": "Info", + "Note": "Remarque", "Success": "Succès", "Warning": "Avertissement", "Danger": "Danger", @@ -353,9 +360,23 @@ "Insert current date": "Insérer la date actuelle", "Draw and sketch excalidraw diagrams": "Dessiner et esquisser des diagrammes Excalidraw", "Multiple": "Multiple", + "Turn into": "Transformer en", + "Text align": "Alignement du texte", + "This page may have been deleted, moved, or you may not have access.": "Cette page a peut-être été supprimée, déplacée ou vous n'y avez peut-être pas accès.", + "Go to homepage": "Aller à l'accueil", + "Pages you create will show up here.": "Les pages que vous créez apparaîtront ici.", "Heading {{level}}": "Titre {{level}}", "Toggle title": "Basculer le titre", "Write anything. Enter \"/\" for commands": "Écrivez n'importe quoi. Entrez \"/\" pour les commandes", + "Write...": "Écrire...", + "Column count": "Nombre de colonnes", + "{{count}} Columns": "{count, plural, one {# colonne} other {# colonnes}}", + "Equal columns": "Colonnes égales", + "Left sidebar": "Barre latérale gauche", + "Right sidebar": "Barre latérale droite", + "Wide center": "Large au centre", + "Left wide": "Large à gauche", + "Right wide": "Large à droite", "Names do not match": "Les noms ne correspondent pas", "Today, {{time}}": "Aujourd'hui, {{time}}", "Yesterday, {{time}}": "Hier, {{time}}", @@ -378,6 +399,13 @@ "Delete member": "Supprimer le membre", "Member deleted successfully": "Membre supprimé avec succès", "Are you sure you want to delete this workspace member? This action is irreversible.": "Êtes-vous sûr de vouloir supprimer ce membre de l'espace de travail? Cette action est irréversible.", + "Deactivate member": "Désactiver le membre", + "Activate member": "Activer le membre", + "Are you sure you want to deactivate this workspace member? They will no longer be able to access this workspace.": "Êtes-vous sûr de vouloir désactiver ce membre de l'espace de travail ? Cette personne ne pourra plus accéder à cet espace de travail.", + "Are you sure you want to activate this workspace member?": "Êtes-vous sûr de vouloir activer ce membre de l'espace de travail ?", + "Deactivate": "Désactiver", + "Activate": "Activer", + "Deactivated": "Désactivé", "Move": "Déplacer", "Move page": "Déplacer la page", "Move page to a different space.": "Déplacer la page vers un autre espace.", @@ -405,6 +433,23 @@ "Share deleted successfully": "Partage supprimé avec succès", "Share not found": "Partage non trouvé", "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", + "Page permissions": "Autorisations de la page", + "Control who can view and edit individual pages. Available with an enterprise license.": "Contrôlez qui peut consulter et modifier chaque page. Disponible avec une licence 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 to a different space.": "Copier la page dans un autre espace.", "Page copied successfully": "Page copiée avec succès", @@ -487,7 +532,7 @@ "Enter one of your backup codes. Each backup code can only be used once.": "Entrez un de vos codes de sauvegarde. Chaque code de sauvegarde ne peut être utilisé qu'une seule fois.", "Verify": "Vérifier", "Trash": "Corbeille", - "Pages in trash will be permanently deleted after 30 days.": "Les pages dans la corbeille seront définitivement supprimées après 30 jours.", + "Pages in trash will be permanently deleted after {{count}} days.": "Les pages dans la corbeille seront définitivement supprimées après {{count}} jours.", "Deleted": "Supprimé", "No pages in trash": "Aucune page dans la corbeille", "Permanently delete page?": "Supprimer définitivement la page ?", @@ -559,19 +604,94 @@ "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", + "Restrict API key creation to admins": "Restreindre la création de clés API aux administrateurs", + "Only admins and owners can create new API keys. Existing member keys will continue to work.": "Seuls les administrateurs et les propriétaires peuvent créer de nouvelles clés API. Les clés des membres existants continueront de fonctionner.", + "Toggle restrict API keys to admins": "Activer ou désactiver la restriction des clés API aux administrateurs", + "API key creation is restricted to admins by your workspace administrator.": "La création de clés API est restreinte aux administrateurs par l’administrateur de votre 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-powered search (Ask AI)": "Recherche assistée par l'IA (Demander à l'IA)", + "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", + "Enterprise feature": "Fonctionnalité entreprise", + "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "L'IA n'est disponible que dans l'édition Entreprise de Docmost. Contactez sales@docmost.com.", + "AI & MCP": "IA & MCP", + "AI": "IA", + "MCP": "MCP", + "Model Context Protocol (MCP)": "Protocole de contexte de modèle (MCP)", + "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Activez le serveur MCP pour permettre aux assistants et outils IA d'interagir avec le contenu de votre espace de travail.", + "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP n'est disponible que dans l'édition Entreprise de Docmost. Contactez sales@docmost.com.", + "MCP documentation": "Documentation MCP", + "MCP Server URL": "URL du serveur MCP", + "Use your API key for authentication. You can manage API keys in your account settings.": "Utilisez votre clé API pour l'authentification. Vous pouvez gérer les clés API dans les paramètres de votre compte.", + "Supported tools": "Outils pris en charge", + "Your workspace has MCP enabled. Use your API key to connect AI assistants.": "Votre espace de travail a MCP activé. Utilisez votre clé API pour connecter des assistants IA.", + "MCP server URL:": "URL du serveur MCP :", + "Learn more": "En savoir plus", + "View the": "Voir la", + "for usage details.": "pour les détails d'utilisation.", + "for setup instructions.": "pour les instructions de configuration.", + "API documentation": "Documentation de l'API", "Sources": "Sources", - "Ask AI not available for attachments": "Demande à l'IA non disponible pour les pièces jointes", + "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" + "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", + "gave you edit access to a page": "vous a donné l'accès pour modifier une page", + "gave you view access to a page": "vous a donné l'accès pour consulter une page", + "Today": "Aujourd'hui", + "Yesterday": "Hier", + "This week": "Cette semaine", + "Older": "Plus ancien", + "Restricted page": "Page restreinte", + "Restricted pages cannot be shared publicly.": "Les pages restreintes ne peuvent pas être partagées publiquement.", + "Restricted by parent": "Restreint par la page parente", + "Restricted": "Restreint", + "Open": "Ouvert", + "Inherits restrictions from ancestor page": "Hérite des restrictions d'une page ancêtre", + "Only people listed below can access this page": "Seules les personnes listées ci-dessous peuvent accéder à cette page", + "Everyone in this space can access": "Tout le monde dans cet espace y a accès", + "No additional restrictions on this page": "Aucune restriction supplémentaire sur cette page", + "Only specific people can access": "Seules certaines personnes peuvent y accéder", + "Use only inherited restrictions": "Utiliser uniquement les restrictions héritées", + "Add restrictions on top of inherited": "Ajouter des restrictions en plus de celles héritées", + "Inherited restriction": "Restriction héritée", + "Access limited by": "Accès limité par", + "Restrict access to control who can view and edit this page": "Restreindre l'accès pour contrôler qui peut consulter et modifier cette page", + "Add additional restrictions specific to this page": "Ajouter des restrictions supplémentaires propres à cette page", + "Access": "Accès", + "People with access": "Personnes ayant accès", + "Remove all": "Tout retirer", + "Remove access": "Retirer l'accès", + "Remove all access": "Retirer tous les accès", + "Are you sure you want to remove this member's access to the page?": "Êtes-vous sûr de vouloir retirer l'accès de ce membre à la page ?", + "Are you sure you want to remove all specific access? This will make the page open to everyone in the space.": "Êtes-vous sûr de vouloir supprimer tous les accès spécifiques ? Cela rendra la page accessible à tous les membres de l'espace.", + "Trash retention": "Conservation de la corbeille", + "Pages in trash will be permanently deleted after this period.": "Les pages dans la corbeille seront définitivement supprimées après cette période.", + "Trash retention updated": "Durée de conservation de la corbeille mise à jour", + "Failed to update trash retention": "Échec de la mise à jour de la durée de conservation de la corbeille", + "Removed page restriction": "Restriction de la page supprimée", + "Added page permission": "Autorisation de la page ajoutée", + "Removed page permission": "Autorisation de la page supprimée" } diff --git a/apps/client/public/locales/it-IT/translation.json b/apps/client/public/locales/it-IT/translation.json index ff80df0f..78a13f5c 100644 --- a/apps/client/public/locales/it-IT/translation.json +++ b/apps/client/public/locales/it-IT/translation.json @@ -116,6 +116,7 @@ "No group found": "Nessun gruppo trovato", "No page history saved yet.": "La pagina non ha una cronologia per ora.", "No pages yet": "Nessuna pagina per ora", + "No shared pages": "Nessuna pagina condivisa.", "No results found...": "Nessun risultato trovato...", "No user found": "Nessun utente trovato", "Overview": "Panoramica", @@ -123,11 +124,14 @@ "page": "pagina", "Page deleted successfully": "Pagina eliminata con successo", "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.", "Pages": "Pagine", "pages": "pagine", "Password": "Password", "Password changed successfully": "Password cambiata con successo", + "People": "Persone", "Pending": "In sospeso", "Please confirm your action": "Si prega di confermare la propria azione", "Preferences": "Preferenze", @@ -205,6 +209,9 @@ "Reply...": "Rispondi...", "Error loading comments.": "Si è verificato un errore durante il caricamento dei commenti.", "No comments yet.": "Nessun commento per ora.", + "No open comments.": "Nessun commento aperto.", + "No resolved comments.": "Nessun commento risolto.", + "Add a comment...": "Aggiungi un commento...", "Edit comment": "Modifica commento", "Delete comment": "Elimina commento", "Are you sure you want to delete this comment?": "Sei sicuro di voler eliminare questo commento?", @@ -226,7 +233,6 @@ "Are you sure you want to unresolve this comment thread?": "Sei sicuro di voler annullare la risoluzione di questa discussione di commenti?", "Resolved": "Risolto", "No active comments.": "Nessun commento attivo.", - "No resolved comments.": "Nessun commento risolto.", "Revoke invitation": "Revoca invito", "Revoke": "Revoca", "Don't": "Non", @@ -272,6 +278,7 @@ "Add row below": "Aggiungi riga sotto", "Delete table": "Elimina tabella", "Info": "Informazioni", + "Note": "Nota", "Success": "Successo", "Warning": "Avviso", "Danger": "Pericolo", @@ -353,9 +360,23 @@ "Insert current date": "Inserisci la data corrente", "Draw and sketch excalidraw diagrams": "Disegna e schizza diagrammi excalidraw", "Multiple": "Multiplo", + "Turn into": "Trasforma in", + "Text align": "Allinea testo", + "This page may have been deleted, moved, or you may not have access.": "Questa pagina potrebbe essere stata eliminata o spostata, oppure potresti non avere accesso.", + "Go to homepage": "Vai alla pagina principale", + "Pages you create will show up here.": "Le pagine che crei appariranno qui.", "Heading {{level}}": "Intestazione {{level}}", "Toggle title": "Attiva/disattiva titolo", "Write anything. Enter \"/\" for commands": "Scrivi qualcosa. Digita \"/\" per i comandi", + "Write...": "Scrivi...", + "Column count": "Numero di colonne", + "{{count}} Columns": "{{count}} colonne", + "Equal columns": "Colonne uguali", + "Left sidebar": "Barra laterale sinistra", + "Right sidebar": "Barra laterale destra", + "Wide center": "Centro ampio", + "Left wide": "Ampia a sinistra", + "Right wide": "Ampia a destra", "Names do not match": "I nomi non corrispondono", "Today, {{time}}": "Oggi, {{time}}", "Yesterday, {{time}}": "Ieri, {{time}}", @@ -378,6 +399,13 @@ "Delete member": "Elimina membro", "Member deleted successfully": "Membro eliminato con successo", "Are you sure you want to delete this workspace member? This action is irreversible.": "Sei sicuro di voler eliminare questo membro del workspace? Questa azione è irreversibile.", + "Deactivate member": "Disattiva membro", + "Activate member": "Attiva membro", + "Are you sure you want to deactivate this workspace member? They will no longer be able to access this workspace.": "Sei sicuro di voler disattivare questo membro dello spazio di lavoro? Non potrà più accedere a questo spazio di lavoro.", + "Are you sure you want to activate this workspace member?": "Sei sicuro di voler attivare questo membro dello spazio di lavoro?", + "Deactivate": "Disattiva", + "Activate": "Attiva", + "Deactivated": "Disattivato", "Move": "Sposta", "Move page": "Sposta pagina", "Move page to a different space.": "Sposta la pagina in un altro spazio.", @@ -405,6 +433,23 @@ "Share deleted successfully": "Condivisione eliminata con successo", "Share not found": "Condivisione non trovata", "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", + "Page permissions": "Autorizzazioni della pagina.", + "Control who can view and edit individual pages. Available with an enterprise license.": "Controlla chi può visualizzare e modificare le singole pagine. Disponibile con 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 to a different space.": "Copia pagina in un altro spazio.", "Page copied successfully": "Pagina copiata con successo", @@ -487,7 +532,7 @@ "Enter one of your backup codes. Each backup code can only be used once.": "Inserisci uno dei tuoi codici di backup. Ogni codice di backup può essere utilizzato solo una volta.", "Verify": "Verifica", "Trash": "Cestino", - "Pages in trash will be permanently deleted after 30 days.": "Le pagine nel cestino verranno eliminate definitivamente dopo 30 giorni.", + "Pages in trash will be permanently deleted after {{count}} days.": "Le pagine nel cestino verranno eliminate definitivamente dopo {{count}} giorni.", "Deleted": "Eliminato", "No pages in trash": "Nessuna pagina nel cestino", "Permanently delete page?": "Eliminare definitivamente la pagina?", @@ -559,19 +604,94 @@ "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", + "Restrict API key creation to admins": "Limita la creazione delle chiavi API agli amministratori", + "Only admins and owners can create new API keys. Existing member keys will continue to work.": "Solo gli amministratori e i proprietari possono creare nuove chiavi API. Le chiavi dei membri esistenti continueranno a funzionare.", + "Toggle restrict API keys to admins": "Attiva/disattiva la limitazione delle chiavi API agli amministratori", + "API key creation is restricted to admins by your workspace administrator.": "La creazione delle chiavi API è limitata agli amministratori dal tuo amministratore dello spazio 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-powered search (Ask AI)": "Ricerca potenziata dall'AI (Chiedi all'AI)", + "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", + "Enterprise feature": "Funzionalità Enterprise", + "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "L'IA è disponibile solo nell'edizione Enterprise di Docmost. Contatta sales@docmost.com.", + "AI & MCP": "IA e MCP", + "AI": "IA", + "MCP": "MCP", + "Model Context Protocol (MCP)": "Model Context Protocol (MCP)", + "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Abilita il server MCP per consentire ad assistenti e strumenti IA di interagire con i contenuti del tuo spazio di lavoro.", + "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP è disponibile solo nell'edizione Enterprise di Docmost. Contatta sales@docmost.com.", + "MCP documentation": "Documentazione MCP", + "MCP Server URL": "URL del server MCP", + "Use your API key for authentication. You can manage API keys in your account settings.": "Usa la tua chiave API per l'autenticazione. Puoi gestire le chiavi API nelle impostazioni del tuo account.", + "Supported tools": "Strumenti supportati", + "Your workspace has MCP enabled. Use your API key to connect AI assistants.": "Il tuo spazio di lavoro ha MCP abilitato. Usa la tua chiave API per collegare gli assistenti IA.", + "MCP server URL:": "URL del server MCP:", + "Learn more": "Scopri di più", + "View the": "Visualizza la", + "for usage details.": "per i dettagli sull'utilizzo.", + "for setup instructions.": "per le istruzioni di configurazione.", + "API documentation": "Documentazione API", "Sources": "Fonti", - "Ask AI not available for attachments": "Chiedere all'AI non è disponibile per gli allegati", + "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" + "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", + "gave you edit access to a page": "ti ha concesso l'accesso per modificare una pagina", + "gave you view access to a page": "ti ha concesso l'accesso per visualizzare una pagina", + "Today": "Oggi", + "Yesterday": "Ieri", + "This week": "Questa settimana", + "Older": "Più vecchie", + "Restricted page": "Pagina con accesso ristretto", + "Restricted pages cannot be shared publicly.": "Le pagine con accesso ristretto non possono essere condivise pubblicamente.", + "Restricted by parent": "Limitata dalla pagina genitore", + "Restricted": "Limitata", + "Open": "Aperta", + "Inherits restrictions from ancestor page": "Eredita le restrizioni dalla pagina genitore", + "Only people listed below can access this page": "Solo le persone elencate di seguito possono accedere a questa pagina", + "Everyone in this space can access": "Chiunque in questo spazio può accedere", + "No additional restrictions on this page": "Nessuna restrizione aggiuntiva su questa pagina", + "Only specific people can access": "Solo persone specifiche possono accedere", + "Use only inherited restrictions": "Usa solo le restrizioni ereditate", + "Add restrictions on top of inherited": "Aggiungi restrizioni oltre a quelle ereditate", + "Inherited restriction": "Restrizione ereditata", + "Access limited by": "Accesso limitato da", + "Restrict access to control who can view and edit this page": "Limita l'accesso per controllare chi può visualizzare e modificare questa pagina", + "Add additional restrictions specific to this page": "Aggiungi restrizioni aggiuntive specifiche per questa pagina", + "Access": "Accesso", + "People with access": "Persone con accesso", + "Remove all": "Rimuovi tutto", + "Remove access": "Rimuovi accesso", + "Remove all access": "Rimuovi tutti gli accessi", + "Are you sure you want to remove this member's access to the page?": "Sei sicuro di voler rimuovere l'accesso di questo membro alla pagina?", + "Are you sure you want to remove all specific access? This will make the page open to everyone in the space.": "Sei sicuro di voler rimuovere tutti gli accessi specifici? Questo renderà la pagina accessibile a tutti nello spazio.", + "Trash retention": "Conservazione del cestino", + "Pages in trash will be permanently deleted after this period.": "Le pagine nel cestino verranno eliminate definitivamente dopo questo periodo.", + "Trash retention updated": "Conservazione del cestino aggiornata", + "Failed to update trash retention": "Impossibile aggiornare la conservazione del cestino", + "Removed page restriction": "Restrizione della pagina rimossa", + "Added page permission": "Permesso sulla pagina aggiunto", + "Removed page permission": "Permesso sulla pagina rimosso" } diff --git a/apps/client/public/locales/ja-JP/translation.json b/apps/client/public/locales/ja-JP/translation.json index 4d18e074..09f10ab0 100644 --- a/apps/client/public/locales/ja-JP/translation.json +++ b/apps/client/public/locales/ja-JP/translation.json @@ -116,6 +116,7 @@ "No group found": "グループが見つかりません", "No page history saved yet.": "ページ履歴がありません", "No pages yet": "ページがありません", + "No shared pages": "共有ページはありません。", "No results found...": "結果が見つかりません", "No user found": "ユーザーが見つかりません", "Overview": "概要", @@ -123,11 +124,14 @@ "page": "ページ", "Page deleted successfully": "ページを削除しました", "Page history": "ページ履歴", + "Select version": "バージョンを選択", + "Highlight changes": "変更を強調表示", "Page import is in progress. Please do not close this tab.": "ページをインポート中です。このタブを閉じないでください", "Pages": "ページ", "pages": "ページ", "Password": "パスワード", "Password changed successfully": "パスワードを変更しました", + "People": "メンバー", "Pending": "保留中", "Please confirm your action": "アクションを確認してください", "Preferences": "設定", @@ -205,6 +209,9 @@ "Reply...": "返信...", "Error loading comments.": "コメントの読み込みに失敗しました", "No comments yet.": "コメントがありません", + "No open comments.": "未解決のコメントはありません。", + "No resolved comments.": "解決済みのコメントはありません", + "Add a comment...": "コメントを追加...", "Edit comment": "コメントを編集する", "Delete comment": "コメントを削除する", "Are you sure you want to delete this comment?": "このコメントを削除してもよろしいですか?", @@ -226,7 +233,6 @@ "Are you sure you want to unresolve this comment thread?": "このコメントスレッドを未解決に戻しますか?", "Resolved": "解決済", "No active comments.": "アクティブなコメントはありません", - "No resolved comments.": "解決済みのコメントはありません", "Revoke invitation": "招待を取り消す", "Revoke": "取り消す", "Don't": "取り消さない", @@ -272,6 +278,7 @@ "Add row below": "下に行を追加", "Delete table": "テーブルを削除", "Info": "情報", + "Note": "ノート", "Success": "成功", "Warning": "警告", "Danger": "危険", @@ -353,9 +360,23 @@ "Insert current date": "現在の日付を挿入します", "Draw and sketch excalidraw diagrams": "Excalidraw 図を挿入します", "Multiple": "複数", + "Turn into": "変換する", + "Text align": "テキストの配置", + "This page may have been deleted, moved, or you may not have access.": "このページは削除されたか移動されたか、またはアクセス権がない可能性があります。},{", + "Go to homepage": "ホームページへ移動", + "Pages you create will show up here.": "ここに作成したページが表示されます。", "Heading {{level}}": "見出し {{level}}", "Toggle title": "タイトルの表示/非表示を切り替える", "Write anything. Enter \"/\" for commands": "文字を入力するか、「/」でコマンドを呼び出します", + "Write...": "ここに入力...", + "Column count": "列数", + "{{count}} Columns": "{{count}}列", + "Equal columns": "均等な列", + "Left sidebar": "左サイドバー", + "Right sidebar": "右サイドバー", + "Wide center": "中央ワイド", + "Left wide": "左ワイド", + "Right wide": "右ワイド", "Names do not match": "名前が一致しません", "Today, {{time}}": "今日、{{time}}", "Yesterday, {{time}}": "昨日、{{time}}", @@ -378,6 +399,13 @@ "Delete member": "メンバーを削除する", "Member deleted successfully": "メンバーを削除しました", "Are you sure you want to delete this workspace member? This action is irreversible.": "このメンバーを削除してもよろしいですか?この操作は取り消せません", + "Deactivate member": "メンバーを無効化", + "Activate member": "メンバーを有効化", + "Are you sure you want to deactivate this workspace member? They will no longer be able to access this workspace.": "本当にこのワークスペースのメンバーを無効化しますか?無効化すると、このワークスペースにアクセスできなくなります。", + "Are you sure you want to activate this workspace member?": "本当にこのワークスペースのメンバーを有効化しますか?", + "Deactivate": "無効化", + "Activate": "有効化", + "Deactivated": "無効化済み", "Move": "移動", "Move page": "ページを移動", "Move page to a different space.": "ページを別のスペースに移動します", @@ -405,6 +433,23 @@ "Share deleted successfully": "共有を削除しました", "Share not found": "共有が見つかりません", "Failed to share page": "ページの共有に失敗しました", + "Disable public sharing": "公開共有を無効にする", + "Prevent members from sharing pages publicly.": "メンバーがページを公開で共有するのを防ぐ。", + "Toggle public sharing": "公開共有を切り替える", + "Toggle space public sharing": "スペースの公開共有を切り替える", + "Public sharing is disabled at the workspace level": "ワークスペースレベルで公開共有が無効になっています", + "Prevent pages in this space from being shared publicly.": "このスペース内のページが公開で共有されるのを防ぐ。", + "Requires an enterprise license": "エンタープライズライセンスが必要です", + "Page permissions": "ページのアクセス権", + "Control who can view and edit individual pages. Available with an enterprise license.": "個々のページを誰が表示・編集できるかを制御します。エンタープライズライセンスで利用可能です。", + "Enable public sharing": "公開共有を有効にする", + "Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "本当に公開共有を有効にしますか?メンバーはページを公開で共有できるようになります。", + "Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "本当に公開共有を無効にしますか?このワークスペース内のすべての既存の共有リンクが削除されます。", + "Are you sure you want to enable public sharing for this space?": "本当にこのスペースの公開共有を有効にしますか?", + "Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "本当に公開共有を無効にしますか?このスペースのすべての既存の共有リンクが削除されます。", + "Public sharing is disabled": "公開共有が無効になっています", + "Public sharing has been disabled at the workspace level.": "ワークスペースレベルで公開共有が無効になりました。", + "Public sharing has been disabled for this space.": "このスペースで公開共有が無効になりました。", "Copy page": "ページをコピー", "Copy page to a different space.": "ページを別のスペースにコピーします", "Page copied successfully": "ページをコピーしました", @@ -487,7 +532,7 @@ "Enter one of your backup codes. Each backup code can only be used once.": "バックアップコードを入力してください。各コードは1回のみ使用可能です", "Verify": "確認", "Trash": "ごみ箱", - "Pages in trash will be permanently deleted after 30 days.": "ごみ箱内のページは30日後に完全に削除されます", + "Pages in trash will be permanently deleted after {{count}} days.": "{count, plural, other {ゴミ箱内のページは#日後に完全に削除されます。}}", "Deleted": "削除", "No pages in trash": "ごみ箱にページがありません", "Permanently delete page?": "ページを完全に削除しますか?", @@ -559,19 +604,94 @@ "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キーを管理", + "Restrict API key creation to admins": "APIキーの作成を管理者のみに制限する", + "Only admins and owners can create new API keys. Existing member keys will continue to work.": "新しいAPIキーを作成できるのは管理者とオーナーのみです。既存のメンバーキーは引き続き有効です。", + "Toggle restrict API keys to admins": "APIキーの作成制限(管理者のみ)を切り替える", + "API key creation is restricted to admins by your workspace administrator.": "ワークスペース管理者によってAPIキーの作成が管理者のみに制限されています。", "AI settings": "AI設定", "AI search": "AI検索", "AI Answer": "AI回答", "Ask AI": "AIに質問する", "AI is thinking...": "AIが考え中...", "Ask a question...": "質問を入力...", - "AI-powered search (Ask AI)": "AIによる検索(AIに質問)", + "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を切り替える", + "Enterprise feature": "エンタープライズ機能", + "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI は Docmost のエンタープライズ版でのみ利用可能です。sales@docmost.com までお問い合わせください。", + "AI & MCP": "AI と MCP", + "AI": "AI", + "MCP": "MCP", + "Model Context Protocol (MCP)": "モデルコンテキストプロトコル(MCP)", + "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "MCP サーバーを有効にして、AI アシスタントやツールがワークスペースのコンテンツとやり取りできるようにします。", + "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP は Docmost のエンタープライズ版でのみ利用可能です。sales@docmost.com までお問い合わせください。", + "MCP documentation": "MCP ドキュメント", + "MCP Server URL": "MCP サーバーの URL", + "Use your API key for authentication. You can manage API keys in your account settings.": "認証には API キーを使用してください。API キーはアカウント設定で管理できます。", + "Supported tools": "サポートされているツール", + "Your workspace has MCP enabled. Use your API key to connect AI assistants.": "このワークスペースでは MCP が有効になっています。AI アシスタントを接続するには API キーを使用してください。", + "MCP server URL:": "MCP サーバーの URL:", + "Learn more": "詳細を見る", + "View the": "表示", + "for usage details.": "使用方法の詳細については。", + "for setup instructions.": "設定手順については。", + "API documentation": "API ドキュメント", "Sources": "ソース", - "Ask AI not available for attachments": "添付ファイルにはAI質問は利用できません", + "AI Answers not available for attachments": "添付ファイルにはAI回答を利用できません", "No answer available": "回答がありません", "Background color": "背景色", "Highlight color": "ハイライト色", - "Remove 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": "ページ上であなたに言及しました", + "gave you edit access to a page": "あなたにページの編集アクセス権を付与しました", + "gave you view access to a page": "あなたにページの閲覧アクセス権を付与しました", + "Today": "今日", + "Yesterday": "昨日", + "This week": "今週", + "Older": "以前のもの", + "Restricted page": "アクセス制限されたページ", + "Restricted pages cannot be shared publicly.": "アクセス制限されたページは公開共有できません。", + "Restricted by parent": "親ページによって制限されています", + "Restricted": "制限あり", + "Open": "制限なし", + "Inherits restrictions from ancestor page": "上位ページから制限を継承しています", + "Only people listed below can access this page": "以下に記載されている人のみがこのページにアクセスできます", + "Everyone in this space can access": "このスペース内の全員がアクセスできます", + "No additional restrictions on this page": "このページに追加の制限はありません", + "Only specific people can access": "特定の人のみがアクセスできます", + "Use only inherited restrictions": "継承された制限のみを適用する", + "Add restrictions on top of inherited": "継承された制限に追加の制限を加える", + "Inherited restriction": "継承された制限", + "Access limited by": "アクセス制限元", + "Restrict access to control who can view and edit this page": "このページを誰が表示・編集できるかを制御するためにアクセスを制限します", + "Add additional restrictions specific to this page": "このページ固有の追加制限を設定する", + "Access": "アクセス", + "People with access": "アクセスできる人", + "Remove all": "すべてを削除", + "Remove access": "アクセス権を削除", + "Remove all access": "すべてのアクセス権を削除", + "Are you sure you want to remove this member's access to the page?": "このメンバーのページへのアクセス権を削除してもよろしいですか?", + "Are you sure you want to remove all specific access? This will make the page open to everyone in the space.": "すべての特定のアクセスを削除してもよろしいですか?これによりページはスペース内の全員に公開されます。", + "Trash retention": "ゴミ箱の保持期間", + "Pages in trash will be permanently deleted after this period.": "この期間を過ぎるとゴミ箱内のページは完全に削除されます。", + "Trash retention updated": "ゴミ箱保持期間が更新されました", + "Failed to update trash retention": "ゴミ箱保持期間の更新に失敗しました", + "Removed page restriction": "ページの制限を解除しました", + "Added page permission": "ページの権限を追加しました", + "Removed page permission": "ページの権限を削除しました" } diff --git a/apps/client/public/locales/ko-KR/translation.json b/apps/client/public/locales/ko-KR/translation.json index d9b48b04..eb763685 100644 --- a/apps/client/public/locales/ko-KR/translation.json +++ b/apps/client/public/locales/ko-KR/translation.json @@ -116,6 +116,7 @@ "No group found": "팀을 찾을 수 없음", "No page history saved yet.": "아직 저장된 페이지 기록이 없습니다.", "No pages yet": "아직 페이지가 없습니다", + "No shared pages": "공유된 페이지가 없습니다.", "No results found...": "결과를 찾을 수 없습니다...", "No user found": "사용자를 찾을 수 없음", "Overview": "개요", @@ -123,11 +124,14 @@ "page": "페이지", "Page deleted successfully": "페이지 삭제 완료", "Page history": "페이지 기록", + "Select version": "버전 선택", + "Highlight changes": "변경 사항 강조", "Page import is in progress. Please do not close this tab.": "페이지 가져오기가 진행 중입니다. 이 탭을 닫지 마세요.", "Pages": "페이지", "pages": "페이지", "Password": "비밀번호", "Password changed successfully": "비밀번호 변경 완료", + "People": "사용자", "Pending": "대기 중", "Please confirm your action": "작업을 확인해 주세요", "Preferences": "설정", @@ -205,6 +209,9 @@ "Reply...": "답글...", "Error loading comments.": "댓글 불러오기 오류.", "No comments yet.": "아직 댓글이 없습니다.", + "No open comments.": "열린 댓글이 없습니다.", + "No resolved comments.": "해결된 댓글이 없습니다.", + "Add a comment...": "댓글 추가...", "Edit comment": "댓글 수정", "Delete comment": "댓글 삭제", "Are you sure you want to delete this comment?": "이 댓글을 삭제하시겠습니까?", @@ -226,7 +233,6 @@ "Are you sure you want to unresolve this comment thread?": "이 댓글 스레드를 미해결로 변경하시겠습니까?", "Resolved": "해결됨", "No active comments.": "활성 댓글이 없습니다.", - "No resolved comments.": "해결된 댓글이 없습니다.", "Revoke invitation": "초대 취소", "Revoke": "취소", "Don't": "하지 않음", @@ -272,6 +278,7 @@ "Add row below": "아래에 행 추가", "Delete table": "테이블 삭제", "Info": "정보", + "Note": "참고", "Success": "완료", "Warning": "주의", "Danger": "위험", @@ -353,9 +360,23 @@ "Insert current date": "현재 날짜 삽입", "Draw and sketch excalidraw diagrams": "Excalidraw diagram 그리기 및 스케치", "Multiple": "복제", + "Turn into": "변경하기", + "Text align": "텍스트 정렬", + "This page may have been deleted, moved, or you may not have access.": "이 페이지는 삭제되었거나 이동되었거나 접근 권한이 없을 수 있습니다.", + "Go to homepage": "홈으로 이동", + "Pages you create will show up here.": "여기에 생성한 페이지가 표시됩니다.", "Heading {{level}}": "제목 {{level}}", "Toggle title": "제목 토글", "Write anything. Enter \"/\" for commands": "아무거나 입력하세요. 명령어를 사용하려면 \"/\"를 입력하세요", + "Write...": "작성...", + "Column count": "열 개수", + "{{count}} Columns": "{{count}}열", + "Equal columns": "열 너비 균등", + "Left sidebar": "왼쪽 사이드바", + "Right sidebar": "오른쪽 사이드바", + "Wide center": "가운데 넓게", + "Left wide": "왼쪽 넓게", + "Right wide": "오른쪽 넓게", "Names do not match": "이름이 일치하지 않습니다", "Today, {{time}}": "오늘, {{time}}", "Yesterday, {{time}}": "어제, {{time}}", @@ -378,6 +399,13 @@ "Delete member": "회원 삭제", "Member deleted successfully": "멤버가 성공적으로 제거되었습니다", "Are you sure you want to delete this workspace member? This action is irreversible.": "이 워크스페이스 멤버를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", + "Deactivate member": "멤버 비활성화", + "Activate member": "멤버 활성화", + "Are you sure you want to deactivate this workspace member? They will no longer be able to access this workspace.": "이 워크스페이스 멤버를 비활성화하시겠습니까? 해당 사용자는 더 이상 이 워크스페이스에 접근할 수 없습니다.", + "Are you sure you want to activate this workspace member?": "이 워크스페이스 멤버를 활성화하시겠습니까?", + "Deactivate": "비활성화", + "Activate": "활성화", + "Deactivated": "비활성화됨", "Move": "이동", "Move page": "페이지 이동", "Move page to a different space.": "페이지를 다른 공간으로 이동합니다.", @@ -405,6 +433,23 @@ "Share deleted successfully": "공유가 성공적으로 삭제되었습니다", "Share not found": "공유를 찾을 수 없습니다", "Failed to share page": "페이지 공유에 실패했습니다", + "Disable public sharing": "공유 비활성화", + "Prevent members from sharing pages publicly.": "멤버들이 페이지를 공개적으로 공유하지 못하도록 방지하십시오.", + "Toggle public sharing": "공유 전환", + "Toggle space public sharing": "공간 공유 전환", + "Public sharing is disabled at the workspace level": "워크스페이스 수준에서 공유가 비활성화되었습니다.", + "Prevent pages in this space from being shared publicly.": "이 공간의 페이지가 공개적으로 공유되지 않도록 방지하십시오.", + "Requires an enterprise license": "기업 라이센스가 필요합니다.", + "Page permissions": "페이지 권한},{", + "Control who can view and edit individual pages. Available with an enterprise license.": "개별 페이지의 조회 및 편집 권한을 제어합니다. 엔터프라이즈 라이선스에서 이용 가능합니다.", + "Enable public sharing": "공유 활성화", + "Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "공유를 활성화하시겠습니까? 멤버들이 페이지를 공개적으로 공유할 수 있게 됩니다.", + "Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "정말로 공유를 비활성화하시겠습니까? 이 워크스페이스의 모든 기존 공유 링크가 삭제됩니다.", + "Are you sure you want to enable public sharing for this space?": "이 공간의 공유를 활성화하시겠습니까?", + "Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "정말로 공유를 비활성화하시겠습니까? 이 공간의 모든 기존 공유 링크가 삭제됩니다.", + "Public sharing is disabled": "공유가 비활성화되었습니다.", + "Public sharing has been disabled at the workspace level.": "워크스페이스 수준에서 공유가 비활성화되었습니다.", + "Public sharing has been disabled for this space.": "이 공간의 공유가 비활성화되었습니다.", "Copy page": "페이지 복사하기", "Copy page to a different space.": "다른 공간으로 페이지 복사하기.", "Page copied successfully": "페이지가 성공적으로 복사되었습니다", @@ -487,7 +532,7 @@ "Enter one of your backup codes. Each backup code can only be used once.": "백업 코드 중 하나를 입력하세요. 각 백업 코드는 한 번만 사용할 수 있습니다.", "Verify": "확인", "Trash": "휴지통", - "Pages in trash will be permanently deleted after 30 days.": "휴지통의 페이지는 30일 후에 영구적으로 삭제됩니다.", + "Pages in trash will be permanently deleted after {{count}} days.": "휴지통의 페이지는 {{count}}일 후 영구적으로 삭제됩니다.", "Deleted": "삭제됨", "No pages in trash": "휴지통에 페이지가 없습니다", "Permanently delete page?": "페이지를 영구적으로 삭제하시겠습니까?", @@ -559,19 +604,94 @@ "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 키 관리", + "Restrict API key creation to admins": "API 키 생성 권한을 관리자에게만 제한합니다", + "Only admins and owners can create new API keys. Existing member keys will continue to work.": "새로운 API 키는 관리자와 소유자만 생성할 수 있습니다. 기존 멤버 키는 계속 사용할 수 있습니다.", + "Toggle restrict API keys to admins": "API 키 생성 제한(관리자 전용) 설정 전환", + "API key creation is restricted to admins by your workspace administrator.": "API 키 생성이 워크스페이스 관리자로 인해 관리자에게만 제한되었습니다.", "AI settings": "AI 설정", "AI search": "AI 검색", "AI Answer": "AI 답변", "Ask AI": "AI에게 묻기", "AI is thinking...": "AI가 생각 중입니다...", "Ask a question...": "질문하세요...", - "AI-powered search (Ask AI)": "AI 지원 검색 (AI에게 묻기)", + "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 토글", + "Enterprise feature": "엔터프라이즈 기능", + "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI는 Docmost 엔터프라이즈 에디션에서만 제공됩니다. sales@docmost.com으로 문의하세요.", + "AI & MCP": "AI 및 MCP", + "AI": "AI", + "MCP": "MCP", + "Model Context Protocol (MCP)": "모델 컨텍스트 프로토콜(MCP)", + "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "AI 어시스턴트와 도구가 워크스페이스 콘텐츠와 상호작용할 수 있도록 MCP 서버를 활성화하세요.", + "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP는 Docmost 엔터프라이즈 에디션에서만 제공됩니다. sales@docmost.com으로 문의하세요.", + "MCP documentation": "MCP 문서", + "MCP Server URL": "MCP 서버 URL", + "Use your API key for authentication. You can manage API keys in your account settings.": "인증을 위해 API 키를 사용하세요. API 키는 계정 설정에서 관리할 수 있습니다.", + "Supported tools": "지원되는 도구", + "Your workspace has MCP enabled. Use your API key to connect AI assistants.": "워크스페이스에 MCP가 활성화되어 있습니다. AI 어시스턴트를 연결하려면 API 키를 사용하세요.", + "MCP server URL:": "MCP 서버 URL:", + "Learn more": "자세히 알아보기", + "View the": "다음을", + "for usage details.": "에서 사용 방법을 확인하세요.", + "for setup instructions.": "에서 설정 지침을 확인하세요.", + "API documentation": "API 문서", "Sources": "출처", - "Ask AI not available for attachments": "AI에게 묻기 기능은 첨부 파일에 대해 사용할 수 없습니다", + "AI Answers not available for attachments": "첨부 파일에 대해 AI 답변을 사용할 수 없습니다", "No answer available": "답변을 제공할 수 없습니다", "Background color": "배경 색", "Highlight color": "강조 색", - "Remove 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": "페이지에서 당신을 언급했습니다", + "gave you edit access to a page": "페이지 편집 권한을 부여했습니다", + "gave you view access to a page": "페이지 보기 권한을 부여했습니다", + "Today": "오늘", + "Yesterday": "어제", + "This week": "이번 주", + "Older": "이전", + "Restricted page": "제한된 페이지", + "Restricted pages cannot be shared publicly.": "제한된 페이지는 공개적으로 공유할 수 없습니다.", + "Restricted by parent": "상위 페이지에 의해 제한됨", + "Restricted": "제한됨", + "Open": "공개", + "Inherits restrictions from ancestor page": "상위 페이지로부터 제한을 상속함", + "Only people listed below can access this page": "아래에 나열된 사용자만 이 페이지에 접근할 수 있습니다.", + "Everyone in this space can access": "이 공간의 모든 사용자가 접근할 수 있습니다.", + "No additional restrictions on this page": "이 페이지에는 추가 제한이 없습니다.", + "Only specific people can access": "특정 사용자만 접근할 수 있습니다.", + "Use only inherited restrictions": "상속된 제한만 사용", + "Add restrictions on top of inherited": "상속된 제한 위에 추가 제한 적용", + "Inherited restriction": "상속된 제한", + "Access limited by": "접근 제한:", + "Restrict access to control who can view and edit this page": "이 페이지를 누가 조회하고 편집할 수 있는지 제어하려면 접근을 제한하세요.", + "Add additional restrictions specific to this page": "이 페이지에 대한 추가 제한을 적용하세요.", + "Access": "접근", + "People with access": "접근 권한이 있는 사용자", + "Remove all": "모두 제거", + "Remove access": "접근 권한 제거", + "Remove all access": "모든 접근 권한 제거", + "Are you sure you want to remove this member's access to the page?": "이 멤버의 페이지 접근 권한을 제거하시겠습니까?", + "Are you sure you want to remove all specific access? This will make the page open to everyone in the space.": "모든 특정 접근 권한을 제거하시겠습니까? 이렇게 하면 페이지가 공간의 모든 사용자에게 공개됩니다.", + "Trash retention": "휴지통 보관 기간", + "Pages in trash will be permanently deleted after this period.": "이 기간이 지나면 휴지통의 페이지는 영구적으로 삭제됩니다.", + "Trash retention updated": "휴지통 보관 기간이 업데이트되었습니다.", + "Failed to update trash retention": "휴지통 보관 기간 업데이트에 실패했습니다.", + "Removed page restriction": "페이지 제한이 제거됨", + "Added page permission": "페이지 권한이 추가됨", + "Removed page permission": "페이지 권한이 제거됨" } diff --git a/apps/client/public/locales/nl-NL/translation.json b/apps/client/public/locales/nl-NL/translation.json index a7923b98..eb414753 100644 --- a/apps/client/public/locales/nl-NL/translation.json +++ b/apps/client/public/locales/nl-NL/translation.json @@ -116,6 +116,7 @@ "No group found": "Geen groep gevonden", "No page history saved yet.": "Er is nog geen pagina geschiedenis opgeslagen.", "No pages yet": "Nog geen pagina's", + "No shared pages": "Geen gedeelde pagina's", "No results found...": "Geen resultaten gevonden...", "No user found": "Geen gebruiker gevonden", "Overview": "Overzicht", @@ -123,11 +124,14 @@ "page": "pagina", "Page deleted successfully": "Pagina succesvol verwijderd", "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.", "Pages": "Pagina's", "pages": "pagina's", "Password": "Wachtwoord", "Password changed successfully": "Wachtwoord met succes gewijzigd", + "People": "Personen", "Pending": "Wachtende", "Please confirm your action": "Bevestig alstublieft uw actie", "Preferences": "Voorkeuren", @@ -205,6 +209,9 @@ "Reply...": "Antwoord...", "Error loading comments.": "Fout bij het laden van reacties.", "No comments yet.": "Nog geen reacties.", + "No open comments.": "Geen openstaande opmerkingen.", + "No resolved comments.": "Geen opgeloste reacties.", + "Add a comment...": "Voeg een opmerking toe...", "Edit comment": "Bewerk reactie", "Delete comment": "Verwijder reactie", "Are you sure you want to delete this comment?": "Weet je zeker dat je deze reactie wilt verwijderen?", @@ -226,7 +233,6 @@ "Are you sure you want to unresolve this comment thread?": "Weet u zeker dat u deze reactiedraad niet wilt oplossen?", "Resolved": "Opgelost", "No active comments.": "Geen actieve reacties.", - "No resolved comments.": "Geen opgeloste reacties.", "Revoke invitation": "Uitnodiging intrekken", "Revoke": "Intrekken", "Don't": "Niet doen", @@ -272,6 +278,7 @@ "Add row below": "Rij hieronder toevoegen", "Delete table": "Verwijder tabel", "Info": "Info", + "Note": "Opmerking", "Success": "Geslaagd", "Warning": "Waarschuwing", "Danger": "Gevaar", @@ -353,9 +360,23 @@ "Insert current date": "Huidige datum invoeren", "Draw and sketch excalidraw diagrams": "Teken en schets excalidraw diagrammen", "Multiple": "Meerdere", + "Turn into": "Omzetten naar", + "Text align": "Tekstuitlijning", + "This page may have been deleted, moved, or you may not have access.": "Deze pagina is mogelijk verwijderd of verplaatst, of u heeft er geen toegang toe.", + "Go to homepage": "Ga naar de startpagina", + "Pages you create will show up here.": "Pagina's die u aanmaakt, verschijnen hier.", "Heading {{level}}": "Kop {{level}}", "Toggle title": "Schakel titel in/uit", "Write anything. Enter \"/\" for commands": "Schrijf iets. Voer \"/\" in voor commando's", + "Write...": "Typ...", + "Column count": "Aantal kolommen", + "{{count}} Columns": "{{count}} kolommen", + "Equal columns": "Gelijke kolommen", + "Left sidebar": "Linker zijbalk", + "Right sidebar": "Rechter zijbalk", + "Wide center": "Brede middenkolom", + "Left wide": "Brede linkerkolom", + "Right wide": "Brede rechterkolom", "Names do not match": "Namen komen niet overeen", "Today, {{time}}": "Vandaag, {{time}}", "Yesterday, {{time}}": "Gisteren, {{time}}", @@ -378,6 +399,13 @@ "Delete member": "Verwijder lid", "Member deleted successfully": "Lid succesvol verwijderd", "Are you sure you want to delete this workspace member? This action is irreversible.": "Weet u zeker dat u dit lid van de werkruimte wilt verwijderen? Deze actie kan niet ongedaan gemaakt worden.", + "Deactivate member": "Lid deactiveren", + "Activate member": "Lid activeren", + "Are you sure you want to deactivate this workspace member? They will no longer be able to access this workspace.": "Weet u zeker dat u dit lid van de werkruimte wilt deactiveren? Deze persoon heeft daarna geen toegang meer tot deze werkruimte.", + "Are you sure you want to activate this workspace member?": "Weet u zeker dat u dit lid van de werkruimte wilt activeren?", + "Deactivate": "Deactiveren", + "Activate": "Activeren", + "Deactivated": "Gedeactiveerd", "Move": "Verplaatsen", "Move page": "Pagina verplaatsen", "Move page to a different space.": "Verplaats pagina naar een andere ruimte.", @@ -405,6 +433,23 @@ "Share deleted successfully": "Delen succesvol verwijderd", "Share not found": "Delen niet gevonden", "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", + "Page permissions": "Pagina rechten", + "Control who can view and edit individual pages. Available with an enterprise license.": "Beheer wie individuele pagina's kan bekijken en bewerken. Beschikbaar met een Enterprise-licentie.", + "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 to a different space.": "Kopieer pagina naar een andere ruimte.", "Page copied successfully": "Pagina succesvol gekopieerd", @@ -487,7 +532,7 @@ "Enter one of your backup codes. Each backup code can only be used once.": "Voer een van uw back-up codes in. Elke back-up code kan slechts één keer worden gebruikt.", "Verify": "Verifiëren", "Trash": "Prullenbak", - "Pages in trash will be permanently deleted after 30 days.": "Pagina's in de prullenbak worden na 30 dagen permanent verwijderd.", + "Pages in trash will be permanently deleted after {{count}} days.": "Pagina's in de prullenbak worden na {{count}} dagen permanent verwijderd.", "Deleted": "Verwijderd", "No pages in trash": "Geen pagina's in de prullenbak", "Permanently delete page?": "Pagina permanent verwijderen?", @@ -559,19 +604,94 @@ "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", + "Restrict API key creation to admins": "Beperk het aanmaken van API-sleutels tot beheerders.", + "Only admins and owners can create new API keys. Existing member keys will continue to work.": "Alleen beheerders en eigenaren kunnen nieuwe API-sleutels aanmaken. Bestaande leden-sleutels blijven werken.", + "Toggle restrict API keys to admins": "Schakel het beperken van API-sleutels tot beheerders in/uit", + "API key creation is restricted to admins by your workspace administrator.": "Het aanmaken van API-sleutels is door je werkruimtebeheerder beperkt tot beheerders.", "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-powered search (Ask AI)": "AI-ondersteunde zoekopdracht (Vraag AI)", + "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", + "Enterprise feature": "Enterprise-functie", + "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI is alleen beschikbaar in de Docmost Enterprise-editie. Neem contact op met sales@docmost.com.", + "AI & MCP": "AI & MCP", + "AI": "AI", + "MCP": "MCP", + "Model Context Protocol (MCP)": "Model Context Protocol (MCP)", + "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Schakel de MCP-server in zodat AI-assistenten en tools kunnen interageren met de inhoud van uw werkruimte.", + "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP is alleen beschikbaar in de Docmost Enterprise-editie. Neem contact op met sales@docmost.com.", + "MCP documentation": "MCP-documentatie", + "MCP Server URL": "MCP-server-URL", + "Use your API key for authentication. You can manage API keys in your account settings.": "Gebruik uw API-sleutel voor authenticatie. U kunt API-sleutels beheren in uw accountinstellingen.", + "Supported tools": "Ondersteunde tools", + "Your workspace has MCP enabled. Use your API key to connect AI assistants.": "In uw werkruimte is MCP ingeschakeld. Gebruik uw API-sleutel om AI-assistenten te koppelen.", + "MCP server URL:": "MCP-server-URL:", + "Learn more": "Meer informatie", + "View the": "Bekijk de", + "for usage details.": "voor details over het gebruik.", + "for setup instructions.": "voor installatie-instructies.", + "API documentation": "API-documentatie", "Sources": "Bronnen", - "Ask AI not available for attachments": "Vraag AI is niet beschikbaar voor bijlages", + "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" + "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", + "gave you edit access to a page": "heeft je toegang gegeven om een pagina te bewerken", + "gave you view access to a page": "heeft je toegang gegeven om een pagina te bekijken", + "Today": "Vandaag", + "Yesterday": "Gisteren", + "This week": "Deze week", + "Older": "Ouder", + "Restricted page": "Beperkte pagina", + "Restricted pages cannot be shared publicly.": "Beperkte pagina's kunnen niet openbaar worden gedeeld.", + "Restricted by parent": "Beperkt door bovenliggende", + "Restricted": "Beperkt", + "Open": "Open", + "Inherits restrictions from ancestor page": "Erft restricties van de bovenliggende pagina", + "Only people listed below can access this page": "Alleen onderstaande personen hebben toegang tot deze pagina", + "Everyone in this space can access": "Iedereen in deze ruimte heeft toegang", + "No additional restrictions on this page": "Geen aanvullende restricties op deze pagina", + "Only specific people can access": "Alleen specifieke personen hebben toegang", + "Use only inherited restrictions": "Gebruik alleen overgenomen restricties", + "Add restrictions on top of inherited": "Restricties toevoegen bovenop geërfd", + "Inherited restriction": "Overgenomen restrictie", + "Access limited by": "Toegang beperkt door", + "Restrict access to control who can view and edit this page": "Beperk de toegang om te bepalen wie deze pagina kan bekijken en bewerken", + "Add additional restrictions specific to this page": "Voeg extra beperkingen toe voor deze pagina", + "Access": "Toegang", + "People with access": "Personen die toegang", + "Remove all": "Alles verwijderen", + "Remove access": "Toegang verwijderen", + "Remove all access": "Alle toegang verwijderen", + "Are you sure you want to remove this member's access to the page?": "Weet u zeker dat u de toegang van dit lid tot de pagina wilt intrekken?", + "Are you sure you want to remove all specific access? This will make the page open to everyone in the space.": "Weet u zeker dat u alle specifieke toegang wilt verwijderen? Hiermee wordt de pagina voor iedereen in de ruimte beschikbaar.", + "Trash retention": "Bewaartermijn prullenbak", + "Pages in trash will be permanently deleted after this period.": "Pagina's in de prullenbak worden na deze periode permanent verwijderd.", + "Trash retention updated": "Bewaartermijn prullenbak bijgewerkt", + "Failed to update trash retention": "Bijwerken van de bewaartermijn voor de prullenbak is mislukt.", + "Removed page restriction": "Pagina-restrictie verwijderd", + "Added page permission": "Paginatoestemming toegevoegd", + "Removed page permission": "Paginatoestemming verwijderd" } diff --git a/apps/client/public/locales/pt-BR/translation.json b/apps/client/public/locales/pt-BR/translation.json index 30cc0b21..961e9a8f 100644 --- a/apps/client/public/locales/pt-BR/translation.json +++ b/apps/client/public/locales/pt-BR/translation.json @@ -116,6 +116,7 @@ "No group found": "Nenhum grupo encontrado", "No page history saved yet.": "Nenhum histórico de página salvo ainda.", "No pages yet": "Nenhuma página ainda", + "No shared pages": "Sem páginas compartilhadas", "No results found...": "Nenhum resultado encontrado...", "No user found": "Nenhum usuário encontrado", "Overview": "Visão geral", @@ -123,11 +124,14 @@ "page": "página", "Page deleted successfully": "Página excluída com sucesso", "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.", "Pages": "Páginas", "pages": "páginas", "Password": "Senha", "Password changed successfully": "Senha alterada com sucesso", + "People": "Pessoas", "Pending": "Pendente", "Please confirm your action": "Por favor, confirme sua ação", "Preferences": "Preferências", @@ -205,6 +209,9 @@ "Reply...": "Responder...", "Error loading comments.": "Erro ao carregar comentários.", "No comments yet.": "Ainda sem comentários.", + "No open comments.": "Sem comentários em aberto.", + "No resolved comments.": "Sem comentários resolvidos.", + "Add a comment...": "Adicione um comentário...", "Edit comment": "Editar comentário", "Delete comment": "Excluir comentário", "Are you sure you want to delete this comment?": "Você tem certeza de que deseja excluir este comentário?", @@ -226,7 +233,6 @@ "Are you sure you want to unresolve this comment thread?": "Tem certeza de que deseja não resolver este fio de comentários?", "Resolved": "Resolvido", "No active comments.": "Sem comentários ativos.", - "No resolved comments.": "Sem comentários resolvidos.", "Revoke invitation": "Cancelar o convite", "Revoke": "Anular", "Don't": "Não", @@ -272,6 +278,7 @@ "Add row below": "Adicionar linha abaixo", "Delete table": "Excluir tabela", "Info": "Informação", + "Note": "Observação", "Success": "Sucesso", "Warning": "Aviso", "Danger": "Perigo", @@ -353,9 +360,23 @@ "Insert current date": "Insira a data atual", "Draw and sketch excalidraw diagrams": "Desenhe e esboce diagramas Excalidraw", "Multiple": "Múltiplo", + "Turn into": "Transformar em", + "Text align": "Alinhar texto", + "This page may have been deleted, moved, or you may not have access.": "Esta página pode ter sido excluída, movida ou você pode não ter acesso a ela.", + "Go to homepage": "Ir para a página inicial", + "Pages you create will show up here.": "As páginas que você criar aparecerão aqui.", "Heading {{level}}": "Título {{level}}", "Toggle title": "Alternar título", "Write anything. Enter \"/\" for commands": "Escreva qualquer coisa. Digite \"/\" para comandos", + "Write...": "Escreva...", + "Column count": "Número de colunas", + "{{count}} Columns": "{{count}} colunas", + "Equal columns": "Colunas iguais", + "Left sidebar": "Barra lateral esquerda", + "Right sidebar": "Barra lateral direita", + "Wide center": "Centro largo", + "Left wide": "Largo à esquerda", + "Right wide": "Largo à direita", "Names do not match": "Os nomes não coincidem", "Today, {{time}}": "Hoje, {{time}}", "Yesterday, {{time}}": "Ontem, {{time}}", @@ -378,6 +399,13 @@ "Delete member": "Excluir membro", "Member deleted successfully": "Membro removido com sucesso", "Are you sure you want to delete this workspace member? This action is irreversible.": "Você tem certeza que deseja deletar este membro do workspace? Esta ação é irreversível.", + "Deactivate member": "Desativar membro", + "Activate member": "Ativar membro", + "Are you sure you want to deactivate this workspace member? They will no longer be able to access this workspace.": "Tem certeza de que deseja desativar este membro do espaço de trabalho? Ele não poderá mais acessar este espaço de trabalho.", + "Are you sure you want to activate this workspace member?": "Tem certeza de que deseja ativar este membro do espaço de trabalho?", + "Deactivate": "Desativar", + "Activate": "Ativar", + "Deactivated": "Desativado", "Move": "Mover", "Move page": "Mover página", "Move page to a different space.": "Mover página para um espaço diferente.", @@ -405,6 +433,23 @@ "Share deleted successfully": "Compartilhamento excluído com sucesso", "Share not found": "Compartilhamento não encontrado", "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", + "Page permissions": "Permissões da página},{", + "Control who can view and edit individual pages. Available with an enterprise license.": "Controle quem pode visualizar e editar páginas individuais. Disponível com 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 to a different space.": "Copiar página para um espaço diferente.", "Page copied successfully": "Página copiada com sucesso", @@ -487,7 +532,7 @@ "Enter one of your backup codes. Each backup code can only be used once.": "Digite um de seus códigos de backup. Cada código de backup só pode ser usado uma vez.", "Verify": "Verificar", "Trash": "Lixeira", - "Pages in trash will be permanently deleted after 30 days.": "Páginas na lixeira serão excluídas permanentemente após 30 dias.", + "Pages in trash will be permanently deleted after {{count}} days.": "{count, plural, one {A página na lixeira será excluída permanentemente após # dia.} other {As páginas na lixeira serão excluídas permanentemente após # dias.}}", "Deleted": "Excluído", "No pages in trash": "Sem páginas na lixeira", "Permanently delete page?": "Excluir página permanentemente?", @@ -559,19 +604,94 @@ "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", + "Restrict API key creation to admins": "Restringir a criação de chave de API aos administradores", + "Only admins and owners can create new API keys. Existing member keys will continue to work.": "Somente administradores e proprietários podem criar novas chaves de API. As chaves de membros já existentes continuarão funcionando.", + "Toggle restrict API keys to admins": "Alternar restrição de chaves de API para administradores", + "API key creation is restricted to admins by your workspace administrator.": "A criação de chaves de API foi restringida aos administradores pelo administrador do seu workspace.", "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-powered search (Ask AI)": "Pesquisa com IA (Pergunte à IA)", + "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", + "Enterprise feature": "Recurso empresarial", + "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "A IA está disponível apenas na edição empresarial do Docmost. Contate sales@docmost.com.", + "AI & MCP": "IA e MCP", + "AI": "IA", + "MCP": "MCP", + "Model Context Protocol (MCP)": "Protocolo de Contexto de Modelo (MCP)", + "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Ative o servidor MCP para permitir que assistentes de IA e ferramentas interajam com o conteúdo do seu espaço de trabalho.", + "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "O MCP está disponível apenas na edição empresarial do Docmost. Contate sales@docmost.com.", + "MCP documentation": "Documentação do MCP", + "MCP Server URL": "URL do servidor MCP", + "Use your API key for authentication. You can manage API keys in your account settings.": "Use sua chave de API para autenticação. Você pode gerenciar chaves de API nas configurações da sua conta.", + "Supported tools": "Ferramentas compatíveis", + "Your workspace has MCP enabled. Use your API key to connect AI assistants.": "Seu espaço de trabalho tem MCP habilitado. Use sua chave de API para conectar assistentes de IA.", + "MCP server URL:": "URL do servidor MCP:", + "Learn more": "Saiba mais", + "View the": "Veja o", + "for usage details.": "para detalhes de uso.", + "for setup instructions.": "para instruções de configuração.", + "API documentation": "Documentação da API", "Sources": "Fontes", - "Ask AI not available for attachments": "Perguntar à IA não está disponível para anexos", + "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" + "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", + "gave you edit access to a page": "concedeu a você acesso para editar a página", + "gave you view access to a page": "concedeu a você acesso para visualizar a página", + "Today": "Hoje", + "Yesterday": "Ontem", + "This week": "Esta semana", + "Older": "Mais antigo", + "Restricted page": "Página restrita", + "Restricted pages cannot be shared publicly.": "Páginas restritas não podem ser compartilhadas publicamente.", + "Restricted by parent": "Restrita pela página pai", + "Restricted": "Restrito", + "Open": "Aberto", + "Inherits restrictions from ancestor page": "Herda restrições da página ancestral", + "Only people listed below can access this page": "Apenas as pessoas listadas abaixo podem acessar esta página", + "Everyone in this space can access": "Todos neste espaço podem acessar", + "No additional restrictions on this page": "Sem restrições adicionais nesta página", + "Only specific people can access": "Apenas pessoas específicas podem acessar", + "Use only inherited restrictions": "Usar apenas restrições herdadas", + "Add restrictions on top of inherited": "Adicionar restrições além das herdadas", + "Inherited restriction": "Restrição herdada", + "Access limited by": "Acesso limitado por", + "Restrict access to control who can view and edit this page": "Restringir o acesso para controlar quem pode visualizar e editar esta página", + "Add additional restrictions specific to this page": "Adicionar restrições adicionais específicas para esta página", + "Access": "Acesso", + "People with access": "Pessoas com acesso", + "Remove all": "Remover tudo", + "Remove access": "Remover acesso", + "Remove all access": "Remover todo o acesso", + "Are you sure you want to remove this member's access to the page?": "Tem certeza de que deseja remover o acesso deste membro à página?", + "Are you sure you want to remove all specific access? This will make the page open to everyone in the space.": "Tem certeza de que deseja remover todo o acesso específico? Isso fará com que a página fique aberta para todos no espaço.", + "Trash retention": "Retenção da lixeira", + "Pages in trash will be permanently deleted after this period.": "As páginas na lixeira serão excluídas permanentemente após este período.", + "Trash retention updated": "Retenção da lixeira atualizada", + "Failed to update trash retention": "Falha ao atualizar a retenção da lixeira", + "Removed page restriction": "Restrição de página removida", + "Added page permission": "Permissão de página adicionada", + "Removed page permission": "Permissão de página removida" } diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index 88e1f701..e6a14df8 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -10,7 +10,7 @@ "Admin": "Администратор", "Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "Вы уверены, что хотите удалить эту группу? Участники потеряют доступ к материалам, к которым у этой группы есть доступ.", "Are you sure you want to delete this page?": "Вы уверены, что хотите удалить эту страницу?", - "Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Вы уверены, что хотите удалить этого пользователя из группы? Пользователь потеряет доступ к материалам, к которым у этой группы есть доступ.", + "Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Вы уверены, что хотите удалить этого пользователя из группы? Пользователь потеряет доступ к материалам, к которым есть доступ у этой группы.", "Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Вы уверены, что хотите удалить этого пользователя из пространства? Пользователь потеряет весь доступ к этому пространству.", "Are you sure you want to restore this version? Any changes not versioned will be lost.": "Вы уверены, что хотите восстановить эту версию? Все не зафиксированные изменения будут потеряны.", "Can become members of groups and spaces in workspace": "Могут становиться участниками групп и пространств в рабочей области", @@ -116,6 +116,7 @@ "No group found": "Группа не найдена", "No page history saved yet.": "История страниц ещё не сохранена.", "No pages yet": "Страниц пока нет", + "No shared pages": "Нет общих страниц", "No results found...": "Результаты не найдены...", "No user found": "Пользователь не найден", "Overview": "Обзор", @@ -123,11 +124,14 @@ "page": "страница", "Page deleted successfully": "Страница успешно удалена", "Page history": "История страницы", + "Select version": "Выбрать версию", + "Highlight changes": "Выделить изменения", "Page import is in progress. Please do not close this tab.": "Импорт страницы в процессе. Пожалуйста, не закрывайте эту вкладку.", "Pages": "Страницы", "pages": "страницы", "Password": "Пароль", "Password changed successfully": "Пароль успешно изменён", + "People": "Люди", "Pending": "В ожидании", "Please confirm your action": "Пожалуйста, подтвердите ваше действие", "Preferences": "Настройки", @@ -205,6 +209,9 @@ "Reply...": "Ответить...", "Error loading comments.": "Ошибка при загрузке комментариев.", "No comments yet.": "Комментариев пока нет.", + "No open comments.": "Нет открытых комментариев.", + "No resolved comments.": "Нет решённых комментариев.", + "Add a comment...": "Добавить комментарий...", "Edit comment": "Редактировать комментарий", "Delete comment": "Удалить комментарий", "Are you sure you want to delete this comment?": "Вы уверены, что хотите удалить этот комментарий?", @@ -226,7 +233,6 @@ "Are you sure you want to unresolve this comment thread?": "Вы уверены, что хотите отметить эту цепочку комментариев как нерешённую?", "Resolved": "Решено", "No active comments.": "Нет активных комментариев.", - "No resolved comments.": "Нет решённых комментариев.", "Revoke invitation": "Отозвать приглашение", "Revoke": "Отозвать", "Don't": "Нет", @@ -272,6 +278,7 @@ "Add row below": "Добавить строку ниже", "Delete table": "Удалить таблицу", "Info": "Информация", + "Note": "Примечание", "Success": "Успешно", "Warning": "Предупреждение", "Danger": "Важно", @@ -353,9 +360,23 @@ "Insert current date": "Вставить текущую дату", "Draw and sketch excalidraw diagrams": "Вставить и рисовать диаграммы Excalidraw", "Multiple": "Несколько", + "Turn into": "Преобразовать в", + "Text align": "Выравнивание текста", + "This page may have been deleted, moved, or you may not have access.": "Эта страница могла быть удалена, перемещена, или у вас может отсутствовать доступ к ней.", + "Go to homepage": "Вернуться на главную", + "Pages you create will show up here.": "Созданные вами страницы появятся здесь.", "Heading {{level}}": "Заголовок {{level}}", "Toggle title": "Переключить заголовок", "Write anything. Enter \"/\" for commands": "Начните писать. Введите \"/\" для списка команд", + "Write...": "Напишите...", + "Column count": "Количество столбцов", + "{{count}} Columns": "{count, plural, one{# столбец} few{# столбца} many{# столбцов} other{# столбца}}", + "Equal columns": "Равные столбцы", + "Left sidebar": "Левая боковая панель", + "Right sidebar": "Правая боковая панель", + "Wide center": "Широкий по центру", + "Left wide": "Широкий слева", + "Right wide": "Широкий справа", "Names do not match": "Названия не совпадают", "Today, {{time}}": "Сегодня, {{time}}", "Yesterday, {{time}}": "Вчера, {{time}}", @@ -378,6 +399,13 @@ "Delete member": "Удалить участника", "Member deleted successfully": "Участник успешно удален", "Are you sure you want to delete this workspace member? This action is irreversible.": "Вы уверены, что хотите удалить этого участника рабочей области? Это действие необратимо.", + "Deactivate member": "Деактивировать участника", + "Activate member": "Активировать участника", + "Are you sure you want to deactivate this workspace member? They will no longer be able to access this workspace.": "Вы уверены, что хотите деактивировать этого участника рабочего пространства? Они больше не смогут получить доступ к этому рабочему пространству.", + "Are you sure you want to activate this workspace member?": "Вы уверены, что хотите активировать этого участника рабочего пространства?", + "Deactivate": "Деактивировать", + "Activate": "Активировать", + "Deactivated": "Деактивирован", "Move": "Переместить", "Move page": "Переместить страницу", "Move page to a different space.": "Переместите страницу в другое пространство.", @@ -405,6 +433,23 @@ "Share deleted successfully": "Общий доступ успешно удален", "Share not found": "Общий доступ не найден", "Failed to share page": "Не удалось поделиться страницей", + "Disable public sharing": "Отключить общий доступ", + "Prevent members from sharing pages publicly.": "Запретить участникам делиться страницами публично.", + "Toggle public sharing": "Переключить общий доступ", + "Toggle space public sharing": "Переключить общий доступ для пространства", + "Public sharing is disabled at the workspace level": "Общий доступ отключен на уровне рабочего пространства", + "Prevent pages in this space from being shared publicly.": "Запретить делиться страницами в этом пространстве публично.", + "Requires an enterprise license": "Требуется корпоративная лицензия", + "Page permissions": "Права доступа к странице},{", + "Control who can view and edit individual pages. Available with an enterprise license.": "Контролируйте, кто может просматривать и редактировать отдельные страницы. Доступно при наличии лицензии Enterprise.", + "Enable public sharing": "Включить общий доступ", + "Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Вы уверены, что хотите включить общий доступ? Участники смогут делиться страницами публично.", + "Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Вы уверены, что хотите отключить общий доступ? Все существующие ссылки в этом рабочем пространстве будут удалены.", + "Are you sure you want to enable public sharing for this space?": "Вы уверены, что хотите включить общий доступ для этого пространства?", + "Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Вы уверены, что хотите отключить общий доступ? Все существующие ссылки в этом пространстве будут удалены.", + "Public sharing is disabled": "Общий доступ отключен", + "Public sharing has been disabled at the workspace level.": "Общий доступ был отключен на уровне рабочего пространства.", + "Public sharing has been disabled for this space.": "Общий доступ был отключен для этого пространства.", "Copy page": "Копировать страницу", "Copy page to a different space.": "Копировать страницу в другое пространство.", "Page copied successfully": "Страница успешно скопирована", @@ -487,7 +532,7 @@ "Enter one of your backup codes. Each backup code can only be used once.": "Введите один из ваших резервных кодов. Каждый резервный код можно использовать только один раз.", "Verify": "Проверить", "Trash": "Корзина", - "Pages in trash will be permanently deleted after 30 days.": "Страницы в корзине будут окончательно удалены через 30 дней.", + "Pages in trash will be permanently deleted after {{count}} days.": "{count, plural, one {Страница в корзине будет окончательно удалена через # день.} few {Страницы в корзине будут окончательно удалены через # дня.} many {Страницы в корзине будут окончательно удалены через # дней.} other {Страницы в корзине будут окончательно удалены через # дней.}}", "Deleted": "Удалено", "No pages in trash": "В корзине нет страниц", "Permanently delete page?": "Удалить страницу окончательно?", @@ -559,19 +604,94 @@ "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 ключами для всех пользователей в рабочей области", + "Restrict API key creation to admins": "Ограничить создание API-ключей только администраторами.", + "Only admins and owners can create new API keys. Existing member keys will continue to work.": "Только администраторы и владельцы могут создавать новые API-ключи. Существующие ключи участников продолжат работать.", + "Toggle restrict API keys to admins": "Переключить ограничение создания API-ключей только для администраторов", + "API key creation is restricted to admins by your workspace administrator.": "Создание API-ключей ограничено администраторами вашего рабочего пространства.", "AI settings": "Настройки ИИ", "AI search": "Поиск ИИ", "AI Answer": "Ответ ИИ", "Ask AI": "Спросить ИИ", "AI is thinking...": "ИИ обрабатывает запрос...", "Ask a question...": "Задайте вопрос...", - "AI-powered search (Ask AI)": "Поиск на базе ИИ (Спросить ИИ)", + "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": "Переключить генеративный ИИ", + "Enterprise feature": "Корпоративная функция", + "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "ИИ доступен только в корпоративной версии Docmost. Свяжитесь по адресу sales@docmost.com.", + "AI & MCP": "ИИ и MCP", + "AI": "ИИ", + "MCP": "MCP", + "Model Context Protocol (MCP)": "Протокол контекста модели (MCP)", + "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Включите сервер MCP, чтобы ИИ-ассистенты и инструменты могли взаимодействовать с содержимым вашего рабочего пространства.", + "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP доступен только в корпоративной версии Docmost. Свяжитесь по адресу sales@docmost.com.", + "MCP documentation": "Документация MCP", + "MCP Server URL": "URL сервера MCP", + "Use your API key for authentication. You can manage API keys in your account settings.": "Используйте ваш API-ключ для аутентификации. Управлять API-ключами можно в настройках аккаунта.", + "Supported tools": "Поддерживаемые инструменты", + "Your workspace has MCP enabled. Use your API key to connect AI assistants.": "В вашем рабочем пространстве включён MCP. Используйте свой API-ключ для подключения ИИ-ассистентов.", + "MCP server URL:": "URL сервера MCP:", + "Learn more": "Подробнее", + "View the": "Просмотреть", + "for usage details.": "для подробностей использования.", + "for setup instructions.": "для инструкций по настройке.", + "API documentation": "Документация API", "Sources": "Источники", - "Ask AI not available for attachments": "Функция \"Спросить ИИ\" недоступна для вложений", + "AI Answers not available for attachments": "Ответы ИИ недоступны для вложений", "No answer available": "Ответ недоступен", "Background color": "Цвет фона", "Highlight color": "Цвет выделения", - "Remove 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": "упомянул вас на странице", + "gave you edit access to a page": "предоставил вам доступ на редактирование страницы", + "gave you view access to a page": "предоставил вам доступ для просмотра страницы", + "Today": "Сегодня", + "Yesterday": "Вчера", + "This week": "На этой неделе", + "Older": "Старше", + "Restricted page": "Страница с ограниченным доступом", + "Restricted pages cannot be shared publicly.": "Страницы с ограниченным доступом нельзя сделать общедоступными.", + "Restricted by parent": "Ограничено родительской страницей", + "Restricted": "Ограничено", + "Open": "Открыто", + "Inherits restrictions from ancestor page": "Наследует ограничения от родительской страницы", + "Only people listed below can access this page": "Доступ к этой странице имеют только перечисленные ниже пользователи", + "Everyone in this space can access": "Доступ имеют все участники этого пространства", + "No additional restrictions on this page": "На этой странице нет дополнительных ограничений", + "Only specific people can access": "Доступ имеют только определённые пользователи", + "Use only inherited restrictions": "Использовать только унаследованные ограничения", + "Add restrictions on top of inherited": "Добавить ограничения поверх унаследованных", + "Inherited restriction": "Унаследованное ограничение", + "Access limited by": "Доступ ограничен", + "Restrict access to control who can view and edit this page": "Ограничьте доступ, чтобы контролировать, кто может просматривать и редактировать эту страницу", + "Add additional restrictions specific to this page": "Добавить дополнительные ограничения, применимые только к этой странице", + "Access": "Доступ", + "People with access": "Пользователи с доступом", + "Remove all": "Удалить всё", + "Remove access": "Удалить доступ", + "Remove all access": "Удалить весь доступ", + "Are you sure you want to remove this member's access to the page?": "Вы уверены, что хотите удалить доступ этого участника к странице?", + "Are you sure you want to remove all specific access? This will make the page open to everyone in the space.": "Вы уверены, что хотите удалить все специальные права доступа? Это сделает страницу доступной всем участникам пространства.", + "Trash retention": "Срок хранения корзины", + "Pages in trash will be permanently deleted after this period.": "Страницы в корзине будут окончательно удалены по истечении этого срока.", + "Trash retention updated": "Срок хранения корзины обновлён", + "Failed to update trash retention": "Не удалось обновить срок хранения корзины", + "Removed page restriction": "Ограничение доступа к странице удалено", + "Added page permission": "Добавлено разрешение доступа к странице", + "Removed page permission": "Удалено разрешение доступа к странице" } diff --git a/apps/client/public/locales/uk-UA/translation.json b/apps/client/public/locales/uk-UA/translation.json index e5cdaa40..83f96bb7 100644 --- a/apps/client/public/locales/uk-UA/translation.json +++ b/apps/client/public/locales/uk-UA/translation.json @@ -116,6 +116,7 @@ "No group found": "Групу не знайдено", "No page history saved yet.": "Історія сторінок ще не збережена.", "No pages yet": "Сторінок поки немає", + "No shared pages": "Немає спільних сторінок", "No results found...": "Результати не знайдено...", "No user found": "Користувача не знайдено", "Overview": "Огляд", @@ -123,11 +124,14 @@ "page": "сторінка", "Page deleted successfully": "Сторінку успішно видалено", "Page history": "Історія сторінки", + "Select version": "Вибрати версію", + "Highlight changes": "Підсвітити зміни", "Page import is in progress. Please do not close this tab.": "Імпорт сторінки в процесі. Будь ласка, не закривайте цю вкладку.", "Pages": "Сторінки", "pages": "сторінки", "Password": "Пароль", "Password changed successfully": "Пароль успішно змінено", + "People": "Користувачі", "Pending": "В очікуванні", "Please confirm your action": "Будь ласка, підтвердіть вашу дію", "Preferences": "Налаштування", @@ -205,6 +209,9 @@ "Reply...": "Відповісти...", "Error loading comments.": "Помилка при завантаженні коментарів.", "No comments yet.": "Коментарів поки немає.", + "No open comments.": "Немає відкритих коментарів.", + "No resolved comments.": "Немає вирішених коментарів.", + "Add a comment...": "Додати коментар...", "Edit comment": "Редагувати коментар", "Delete comment": "Видалити коментар", "Are you sure you want to delete this comment?": "Ви впевнені, що хочете видалити цей коментар?", @@ -226,7 +233,6 @@ "Are you sure you want to unresolve this comment thread?": "Ви впевнені, що хочете розв'язати цей ланцюжок коментарів?", "Resolved": "Вирішено", "No active comments.": "Немає активних коментарів.", - "No resolved comments.": "Немає вирішених коментарів.", "Revoke invitation": "Відкликати запрошення", "Revoke": "Відкликати", "Don't": "Ні", @@ -272,6 +278,7 @@ "Add row below": "Додати рядок нижче", "Delete table": "Видалити таблицю", "Info": "Інформація", + "Note": "Примітка", "Success": "Успішно", "Warning": "Попередження", "Danger": "Важливо", @@ -353,9 +360,23 @@ "Insert current date": "Вставити поточну дату", "Draw and sketch excalidraw diagrams": "Вставити та малювати діаграми Excalidraw", "Multiple": "Декілька", + "Turn into": "Перетворити", + "Text align": "Вирівнювання тексту", + "This page may have been deleted, moved, or you may not have access.": "Цю сторінку могли видалити, перемістити або у вас може не бути до неї доступу.", + "Go to homepage": "Перейти на головну", + "Pages you create will show up here.": "Сторінки, які ви створите, з'являться тут.", "Heading {{level}}": "Заголовок {{level}}", "Toggle title": "Перемкнути заголовок", "Write anything. Enter \"/\" for commands": "Почніть писати. Введіть \"/\" для списку команд", + "Write...": "Напишіть...", + "Column count": "Кількість колонок", + "{{count}} Columns": "{count, plural, one{# колонка} few{# колонки} many{# колонок} other{# колонки}}", + "Equal columns": "Рівні колонки", + "Left sidebar": "Ліва бічна панель", + "Right sidebar": "Права бічна панель", + "Wide center": "Широка центральна колонка", + "Left wide": "Широка ліва колонка", + "Right wide": "Широка права колонка", "Names do not match": "Назви не співпадають", "Today, {{time}}": "Сьогодні, {{time}}", "Yesterday, {{time}}": "Вчора, {{time}}", @@ -378,6 +399,13 @@ "Delete member": "Видалити учасника", "Member deleted successfully": "Учасника успішно видалено", "Are you sure you want to delete this workspace member? This action is irreversible.": "Ви впевнені, що хочете видалити цього учасника робочої області? Ця дія незворотна.", + "Deactivate member": "Деактивувати учасника", + "Activate member": "Активувати учасника", + "Are you sure you want to deactivate this workspace member? They will no longer be able to access this workspace.": "Ви впевнені, що хочете деактивувати цього учасника робочого простору? Вони більше не зможуть отримати доступ до цього робочого простору.", + "Are you sure you want to activate this workspace member?": "Ви впевнені, що хочете активувати цього учасника робочого простору?", + "Deactivate": "Деактивувати", + "Activate": "Активувати", + "Deactivated": "Деактивовано", "Move": "Перемістити", "Move page": "Перемістити сторінку", "Move page to a different space.": "Перемістити сторінку в інший простір.", @@ -405,6 +433,23 @@ "Share deleted successfully": "Спільний доступ успішно видалено", "Share not found": "Спільний доступ не знайдено", "Failed to share page": "Не вдалося поділитися сторінкою", + "Disable public sharing": "Вимкнути публічний доступ", + "Prevent members from sharing pages publicly.": "Перешкодити учасникам публічно ділитися сторінками.", + "Toggle public sharing": "Перемикання публічного доступу", + "Toggle space public sharing": "Перемикання публічного доступу до просторів", + "Public sharing is disabled at the workspace level": "Публічний доступ вимкнуто на рівні робочого простору", + "Prevent pages in this space from being shared publicly.": "Перешкодити публічному поширенню сторінок у цьому просторі.", + "Requires an enterprise license": "Потребує корпоративної ліцензії", + "Page permissions": "Права доступу до сторінки.", + "Control who can view and edit individual pages. Available with an enterprise license.": "Керуйте тим, хто може переглядати та редагувати окремі сторінки. Доступно з корпоративною ліцензією.", + "Enable public sharing": "Увімкнути публічний доступ", + "Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Ви впевнені, що хочете увімкнути публічний доступ? Учасники зможуть публічно ділитися сторінками.", + "Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Ви впевнені, що хочете вимкнути публічний доступ? Усі існуючі посилання для спільного доступу в цьому робочому просторі будуть видалені.", + "Are you sure you want to enable public sharing for this space?": "Ви впевнені, що хочете увімкнути публічний доступ для цього простору?", + "Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Ви впевнені, що хочете вимкнути публічний доступ? Усі існуючі посилання для спільного доступу в цьому просторі будуть видалені.", + "Public sharing is disabled": "Публічний доступ вимкнуто", + "Public sharing has been disabled at the workspace level.": "Публічний доступ було вимкнено на рівні робочого простору.", + "Public sharing has been disabled for this space.": "Публічний доступ було вимкнено для цього простору.", "Copy page": "Копіювати сторінки", "Copy page to a different space.": "Скопіювати сторінку в інший простір.", "Page copied successfully": "Сторінку успішно скопійовано", @@ -487,7 +532,7 @@ "Enter one of your backup codes. Each backup code can only be used once.": "Введіть один з ваших резервних кодів. Кожен резервний код можна використовувати лише один раз.", "Verify": "Перевірити", "Trash": "Кошик", - "Pages in trash will be permanently deleted after 30 days.": "Сторінки у кошику будуть остаточно видалені через 30 днів.", + "Pages in trash will be permanently deleted after {{count}} days.": "Сторінки в кошику будуть остаточно видалені через {count, plural, one{# день} few{# дні} many{# днів} other{# дня}}.", "Deleted": "Видалено", "No pages in trash": "Немає сторінок у кошику", "Permanently delete page?": "Остаточно видалити сторінку?", @@ -559,19 +604,94 @@ "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 для всіх користувачів у робочій області", + "Restrict API key creation to admins": "Обмежити створення API-ключів лише для адміністраторів", + "Only admins and owners can create new API keys. Existing member keys will continue to work.": "Тільки адміністратори та власники можуть створювати нові API-ключі. Існуючі ключі учасників і надалі працюватимуть.", + "Toggle restrict API keys to admins": "Увімкнути або вимкнути обмеження створення API-ключів лише для адміністраторів", + "API key creation is restricted to admins by your workspace administrator.": "Створення API-ключів дозволено лише адміністраторам за налаштуванням адміністратора робочого простору.", "AI settings": "Налаштування ШІ", "AI search": "Пошук з ШІ", "AI Answer": "Відповідь ШІ", "Ask AI": "Запитати ШІ", "AI is thinking...": "ШІ думає...", "Ask a question...": "Задайте питання...", - "AI-powered search (Ask AI)": "Пошук на базі ШІ (Запитати ШІ)", + "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": "Переключити генеративний ШІ", + "Enterprise feature": "Функція корпоративної версії", + "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "ШІ доступний лише в корпоративній редакції Docmost. Зверніться до sales@docmost.com.", + "AI & MCP": "ШІ та MCP", + "AI": "ШІ", + "MCP": "MCP", + "Model Context Protocol (MCP)": "Протокол контексту моделі (MCP)", + "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Увімкніть MCP‑сервер, щоб дозволити ШІ‑помічникам та інструментам взаємодіяти з вмістом вашого робочого простору.", + "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP доступний лише в корпоративній редакції Docmost. Зверніться до sales@docmost.com.", + "MCP documentation": "Документація MCP", + "MCP Server URL": "URL сервера MCP", + "Use your API key for authentication. You can manage API keys in your account settings.": "Використовуйте свій API‑ключ для аутентифікації. Ви можете керувати API‑ключами в налаштуваннях облікового запису.", + "Supported tools": "Підтримувані інструменти", + "Your workspace has MCP enabled. Use your API key to connect AI assistants.": "У вашому робочому просторі MCP увімкнено. Використайте свій API‑ключ, щоб підключити ШІ‑помічників.", + "MCP server URL:": "URL сервера MCP:", + "Learn more": "Дізнатися більше", + "View the": "Переглянути", + "for usage details.": "для відомостей про використання.", + "for setup instructions.": "для інструкцій з налаштування.", + "API documentation": "Документація API", "Sources": "Джерела", - "Ask AI not available for attachments": "Запитати ШІ недоступно для вкладень", + "AI Answers not available for attachments": "Відповіді ШІ недоступні для вкладень", "No answer available": "Відповідь недоступна", "Background color": "Колір фону", "Highlight color": "Колір підсвічування", - "Remove 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": "згадали вас на сторінці", + "gave you edit access to a page": "надав вам доступ для редагування сторінки", + "gave you view access to a page": "надав вам доступ для перегляду сторінки", + "Today": "Сьогодні", + "Yesterday": "Вчора", + "This week": "Цього тижня", + "Older": "Старіші", + "Restricted page": "Сторінка з обмеженим доступом", + "Restricted pages cannot be shared publicly.": "Сторінки з обмеженим доступом не можна робити загальнодоступними.", + "Restricted by parent": "Обмежено батьківською сторінкою", + "Restricted": "Обмежено", + "Open": "Відкрита", + "Inherits restrictions from ancestor page": "Наслідує обмеження від батьківської сторінки", + "Only people listed below can access this page": "Доступ до цієї сторінки мають лише люди, вказані нижче.", + "Everyone in this space can access": "Усі в цьому просторі мають доступ", + "No additional restrictions on this page": "Додаткових обмежень на цій сторінці немає.", + "Only specific people can access": "Доступ мають лише конкретні особи.", + "Use only inherited restrictions": "Використовувати лише успадковані обмеження", + "Add restrictions on top of inherited": "Додати обмеження поверх успадкованих", + "Inherited restriction": "Успадковане обмеження", + "Access limited by": "Доступ обмежено через", + "Restrict access to control who can view and edit this page": "Обмежте доступ, щоб контролювати, хто може переглядати та редагувати цю сторінку.", + "Add additional restrictions specific to this page": "Додати додаткові обмеження для цієї сторінки.", + "Access": "Доступ", + "People with access": "Особи з доступом", + "Remove all": "Видалити все", + "Remove access": "Видалити доступ", + "Remove all access": "Видалити весь доступ", + "Are you sure you want to remove this member's access to the page?": "Ви впевнені, що хочете видалити доступ цього учасника до сторінки?", + "Are you sure you want to remove all specific access? This will make the page open to everyone in the space.": "Ви впевнені, що хочете видалити всі індивідуальні дозволи доступу? Це зробить сторінку доступною для всіх у просторі.", + "Trash retention": "Термін зберігання у кошику", + "Pages in trash will be permanently deleted after this period.": "Сторінки в кошику будуть остаточно видалені після цього періоду.", + "Trash retention updated": "Термін зберігання у кошику оновлено", + "Failed to update trash retention": "Не вдалося оновити термін зберігання у кошику", + "Removed page restriction": "Обмеження сторінки видалено", + "Added page permission": "Додано дозвіл на сторінку", + "Removed page permission": "Дозвіл на сторінку видалено" } diff --git a/apps/client/public/locales/zh-CN/translation.json b/apps/client/public/locales/zh-CN/translation.json index a5eb84f1..95883800 100644 --- a/apps/client/public/locales/zh-CN/translation.json +++ b/apps/client/public/locales/zh-CN/translation.json @@ -116,6 +116,7 @@ "No group found": "未找到群组", "No page history saved yet.": "尚未保存页面历史。", "No pages yet": "暂无页面", + "No shared pages": "没有共享页面", "No results found...": "未找到结果...", "No user found": "未找到用户", "Overview": "概览", @@ -123,11 +124,14 @@ "page": "个页面", "Page deleted successfully": "页面已成功删除", "Page history": "页面历史", + "Select version": "选择版本", + "Highlight changes": "突出显示更改", "Page import is in progress. Please do not close this tab.": "页面导入正在进行中。请不要关闭此标签页。", "Pages": "页面", "pages": "个页面", "Password": "密码", "Password changed successfully": "密码更改成功", + "People": "人员", "Pending": "待定", "Please confirm your action": "请确认您的操作", "Preferences": "偏好设置", @@ -205,6 +209,9 @@ "Reply...": "回复...", "Error loading comments.": "加载评论时出错", "No comments yet.": "目前还没有评论", + "No open comments.": "没有未解决的评论。", + "No resolved comments.": "没有已解决的评论。", + "Add a comment...": "添加评论...", "Edit comment": "编辑评论", "Delete comment": "删除评论", "Are you sure you want to delete this comment?": "你确定要删除这条评论吗?", @@ -226,7 +233,6 @@ "Are you sure you want to unresolve this comment thread?": "确定要取消解决此评论线程吗?", "Resolved": "已解决", "No active comments.": "没有活跃的评论。", - "No resolved comments.": "没有已解决的评论。", "Revoke invitation": "撤回邀请", "Revoke": "撤销", "Don't": "不要", @@ -272,6 +278,7 @@ "Add row below": "在下方插入行", "Delete table": "删除表格", "Info": "信息", + "Note": "注意", "Success": "成功", "Warning": "警告", "Danger": "危险", @@ -353,9 +360,23 @@ "Insert current date": "插入当前日期", "Draw and sketch excalidraw diagrams": "绘制 Excalidraw 图表", "Multiple": "多个", + "Turn into": "变成", + "Text align": "文本对齐", + "This page may have been deleted, moved, or you may not have access.": "此页面可能已被删除、移动,或者您可能无权访问。{", + "Go to homepage": "前往首页", + "Pages you create will show up here.": "您创建的页面将显示在此处。", "Heading {{level}}": "{{level}} 级标题", "Toggle title": "切换标题", "Write anything. Enter \"/\" for commands": "开始编写内容,输入 \"/\" 以使用指令", + "Write...": "写点内容...", + "Column count": "列数", + "{{count}} Columns": "{{count}} 列", + "Equal columns": "等宽列", + "Left sidebar": "左侧边栏", + "Right sidebar": "右侧边栏", + "Wide center": "中间加宽", + "Left wide": "左侧加宽", + "Right wide": "右侧加宽", "Names do not match": "名称不匹配", "Today, {{time}}": "今天,{{time}}", "Yesterday, {{time}}": "昨天,{{time}}", @@ -378,6 +399,13 @@ "Delete member": "删除成员", "Member deleted successfully": "成员删除成功", "Are you sure you want to delete this workspace member? This action is irreversible.": "您确定要删除此工作区成员吗?此操作不可逆。", + "Deactivate member": "停用成员", + "Activate member": "激活成员", + "Are you sure you want to deactivate this workspace member? They will no longer be able to access this workspace.": "您确定要停用此工作区成员吗?该成员将无法再访问此工作区。", + "Are you sure you want to activate this workspace member?": "您确定要激活此工作区成员吗?", + "Deactivate": "停用", + "Activate": "激活", + "Deactivated": "已停用", "Move": "移动", "Move page": "移动页面", "Move page to a different space.": "将页面移动到不同的空间。", @@ -405,6 +433,23 @@ "Share deleted successfully": "分享已成功删除", "Share not found": "未找到分享", "Failed to share page": "页面分享失败", + "Disable public sharing": "禁用公开分享", + "Prevent members from sharing pages publicly.": "阻止成员公开分享页面。", + "Toggle public sharing": "切换公开分享", + "Toggle space public sharing": "切换空间公开分享", + "Public sharing is disabled at the workspace level": "公开分享在工作区级别被禁用", + "Prevent pages in this space from being shared publicly.": "阻止此空间中的页面被公开分享。", + "Requires an enterprise license": "需要企业许可证", + "Page permissions": "页面权限},{", + "Control who can view and edit individual pages. Available with an enterprise license.": "控制谁可以查看和编辑单个页面。此功能在企业版许可下可用。", + "Enable public sharing": "启用公开分享", + "Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "您确定要启用公开分享吗?成员将能够公开分享页面。", + "Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "您确定要禁用公开分享吗?此工作区中的所有现有共享链接都将被删除。", + "Are you sure you want to enable public sharing for this space?": "您确定要为此空间启用公开分享吗?", + "Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "您确定要禁用公开分享吗?此空间中的所有现有共享链接都将被删除。", + "Public sharing is disabled": "公开分享已被禁用", + "Public sharing has been disabled at the workspace level.": "公开分享已在工作区级别被禁用。", + "Public sharing has been disabled for this space.": "此空间的公开分享已被禁用。", "Copy page": "复制页面", "Copy page to a different space.": "将页面复制到不同的空间。", "Page copied successfully": "页面复制成功", @@ -487,7 +532,7 @@ "Enter one of your backup codes. Each backup code can only be used once.": "输入您的一个备份代码。每个备份代码只能使用一次。", "Verify": "验证", "Trash": "垃圾箱", - "Pages in trash will be permanently deleted after 30 days.": "垃圾箱中的页面将在30天后被永久删除。", + "Pages in trash will be permanently deleted after {{count}} days.": "垃圾箱中的页面将在{{count}}天后被永久删除。", "Deleted": "已删除", "No pages in trash": "垃圾箱中没有页面", "Permanently delete page?": "永久删除页面?", @@ -559,19 +604,94 @@ "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密钥", + "Restrict API key creation to admins": "仅限管理员创建 API 密钥。", + "Only admins and owners can create new API keys. Existing member keys will continue to work.": "只有管理员和所有者可以创建新的 API 密钥。现有成员密钥将继续有效。", + "Toggle restrict API keys to admins": "切换仅限管理员创建 API 密钥", + "API key creation is restricted to admins by your workspace administrator.": "API 密钥的创建已被您的工作区管理员限制为仅管理员可用。", "AI settings": "AI设置", "AI search": "AI搜索", "AI Answer": "AI回答", "Ask AI": "询问AI", "AI is thinking...": "AI正在思考...", "Ask a question...": "提问...", - "AI-powered search (Ask AI)": "AI驱动的搜索(询问AI)", + "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", + "Enterprise feature": "企业版功能", + "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI 仅在 Docmost 企业版中提供。请联系 sales@docmost.com。", + "AI & MCP": "AI 与 MCP", + "AI": "AI", + "MCP": "MCP", + "Model Context Protocol (MCP)": "模型上下文协议(MCP)", + "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "启用 MCP 服务器以允许 AI 助手和工具与您的工作区内容交互。", + "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP 仅在 Docmost 企业版中提供。请联系 sales@docmost.com。", + "MCP documentation": "MCP 文档", + "MCP Server URL": "MCP 服务器 URL", + "Use your API key for authentication. You can manage API keys in your account settings.": "使用您的 API 密钥进行身份验证。您可以在账户设置中管理 API 密钥。", + "Supported tools": "支持的工具", + "Your workspace has MCP enabled. Use your API key to connect AI assistants.": "您的工作区已启用 MCP。使用您的 API 密钥连接 AI 助手。", + "MCP server URL:": "MCP 服务器 URL:", + "Learn more": "了解更多", + "View the": "查看", + "for usage details.": "以获取使用详情。", + "for setup instructions.": "以获取设置说明。", + "API documentation": "API 文档", "Sources": "来源", - "Ask AI not available for attachments": "附件不支持询问AI", + "AI Answers not available for attachments": "AI答案不适用于附件", "No answer available": "无可用答案", "Background color": "背景颜色", "Highlight color": "突出显示颜色", - "Remove 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": "在页面上提到你", + "gave you edit access to a page": "已授予你编辑该页面的权限", + "gave you view access to a page": "已授予你查看该页面的权限", + "Today": "今天", + "Yesterday": "昨天", + "This week": "本周", + "Older": "较早", + "Restricted page": "受限页面", + "Restricted pages cannot be shared publicly.": "受限页面不能公开共享。", + "Restricted by parent": "受父页面限制", + "Restricted": "受限", + "Open": "公开", + "Inherits restrictions from ancestor page": "继承自上级页面的限制", + "Only people listed below can access this page": "只有下面列出的人可以访问此页面", + "Everyone in this space can access": "此空间中的所有人均可访问", + "No additional restrictions on this page": "此页面无额外限制", + "Only specific people can access": "仅特定人员可访问", + "Use only inherited restrictions": "仅使用继承的限制", + "Add restrictions on top of inherited": "在继承的限制之上添加限制", + "Inherited restriction": "继承的限制", + "Access limited by": "访问受限于", + "Restrict access to control who can view and edit this page": "限制访问以控制谁可以查看和编辑此页面", + "Add additional restrictions specific to this page": "为此页面添加额外的特定限制", + "Access": "访问", + "People with access": "有访问权限的人员", + "Remove all": "全部移除", + "Remove access": "移除访问权限", + "Remove all access": "移除所有访问权限", + "Are you sure you want to remove this member's access to the page?": "您确定要移除此成员对该页面的访问权限吗?", + "Are you sure you want to remove all specific access? This will make the page open to everyone in the space.": "您确定要删除所有特定访问权限吗?这将使该页面对该空间中的所有人开放。", + "Trash retention": "垃圾箱保留期", + "Pages in trash will be permanently deleted after this period.": "该期限结束后,垃圾箱中的页面将被永久删除。", + "Trash retention updated": "垃圾箱保留期已更新", + "Failed to update trash retention": "更新垃圾箱保留期失败", + "Removed page restriction": "已移除页面限制", + "Added page permission": "已添加页面权限", + "Removed page permission": "已移除页面权限" } diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index e0df67a7..c290157c 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -14,7 +14,6 @@ import AccountPreferences from "@/pages/settings/account/account-preferences.tsx import SpaceHome from "@/pages/space/space-home.tsx"; import PageRedirect from "@/pages/page/page-redirect.tsx"; import Layout from "@/components/layouts/global/layout.tsx"; -import { ErrorBoundary } from "react-error-boundary"; import InviteSignup from "@/pages/auth/invite-signup.tsx"; import ForgotPassword from "@/pages/auth/forgot-password.tsx"; import PasswordReset from "./pages/auth/password-reset"; @@ -38,6 +37,7 @@ import SpaceTrash from "@/pages/space/space-trash.tsx"; import UserApiKeys from "@/ee/api-key/pages/user-api-keys"; import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys"; import AiSettings from "@/ee/ai/pages/ai-settings.tsx"; +import AuditLogs from "@/ee/audit/pages/audit-logs.tsx"; export default function App() { const { t } = useTranslation(); @@ -84,13 +84,7 @@ export default function App() { } /> {t("Failed to load page. An error occurred.")}} - > - - - } + element={} /> @@ -109,6 +103,8 @@ export default function App() { } /> } /> } /> + } /> + } /> {!isCloud() && } />} {isCloud() && } />} diff --git a/apps/client/src/components/common/avatar-uploader.tsx b/apps/client/src/components/common/avatar-uploader.tsx index 0c83411c..8d9552f6 100644 --- a/apps/client/src/components/common/avatar-uploader.tsx +++ b/apps/client/src/components/common/avatar-uploader.tsx @@ -130,7 +130,7 @@ export default function AvatarUploader({ top: "50%", left: "50%", transform: "translate(-50%, -50%)", - zIndex: 1000, + zIndex: 200, }} > diff --git a/apps/client/src/components/common/copy-button.tsx b/apps/client/src/components/common/copy-button.tsx new file mode 100644 index 00000000..eb0721d7 --- /dev/null +++ b/apps/client/src/components/common/copy-button.tsx @@ -0,0 +1,33 @@ +// 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; + +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"; diff --git a/apps/client/src/components/common/copy.tsx b/apps/client/src/components/common/copy.tsx index efae5750..81a70771 100644 --- a/apps/client/src/components/common/copy.tsx +++ b/apps/client/src/components/common/copy.tsx @@ -1,4 +1,5 @@ -import { ActionIcon, CopyButton, Tooltip } from "@mantine/core"; +import { ActionIcon, Tooltip } from "@mantine/core"; +import { CopyButton } from "@/components/common/copy-button"; import { IconCheck, IconCopy } from "@tabler/icons-react"; import React from "react"; import { useTranslation } from "react-i18next"; diff --git a/apps/client/src/components/common/paginate.tsx b/apps/client/src/components/common/paginate.tsx index d8e8106f..721c2f43 100644 --- a/apps/client/src/components/common/paginate.tsx +++ b/apps/client/src/components/common/paginate.tsx @@ -2,17 +2,17 @@ import { Button, Group } from "@mantine/core"; import { useTranslation } from "react-i18next"; export interface PagePaginationProps { - currentPage: number; hasPrevPage: boolean; hasNextPage: boolean; - onPageChange: (newPage: number) => void; + onPrev: () => void; + onNext: () => void; } export default function Paginate({ - currentPage, hasPrevPage, hasNextPage, - onPageChange, + onPrev, + onNext, }: PagePaginationProps) { const { t } = useTranslation(); @@ -25,7 +25,7 @@ export default function Paginate({ - + + {t("View the")}{" "} + + {t("API documentation")} + {" "} + {t("for usage details.")} + + + {mcpEnabled && canCreate && ( + }> + + {t( + "Your workspace has MCP enabled. Use your API key to connect AI assistants.", + )}{" "} + + {t("Learn more")} + + + + {t("MCP server URL:")}{" "} + + {`${getAppUrl()}/mcp`} + + + + )} + + {canCreate ? ( + + + + ) : restrictToAdmins ? ( + }> + + {t("API key creation is restricted to admins by your workspace administrator.")} + + + ) : null} 0 && ( goNext(data?.meta?.nextCursor)} + onPrev={goPrev} /> )} diff --git a/apps/client/src/ee/api-key/pages/workspace-api-keys.tsx b/apps/client/src/ee/api-key/pages/workspace-api-keys.tsx index cf459373..155f7651 100644 --- a/apps/client/src/ee/api-key/pages/workspace-api-keys.tsx +++ b/apps/client/src/ee/api-key/pages/workspace-api-keys.tsx @@ -1,5 +1,5 @@ import React, { useState } from "react"; -import { Button, Group, Space, Text } from "@mantine/core"; +import { Anchor, Button, Divider, Group, Space, Text } from "@mantine/core"; import { Helmet } from "react-helmet-async"; import { useTranslation } from "react-i18next"; import SettingsTitle from "@/components/settings/settings-title"; @@ -10,20 +10,21 @@ import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-moda import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal"; import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal"; import Paginate from "@/components/common/paginate"; -import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search"; +import { useCursorPaginate } from "@/hooks/use-cursor-paginate"; import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts"; import { IApiKey } from "@/ee/api-key"; import useUserRole from '@/hooks/use-user-role.tsx'; +import RestrictApiToAdmins from "@/ee/api-key/components/restrict-api-to-admins"; export default function WorkspaceApiKeys() { const { t } = useTranslation(); - const { page, setPage } = usePaginateAndSearch(); + const { cursor, goNext, goPrev } = useCursorPaginate(); const [createModalOpened, setCreateModalOpened] = useState(false); const [createdApiKey, setCreatedApiKey] = useState(null); const [updateModalOpened, setUpdateModalOpened] = useState(false); const [revokeModalOpened, setRevokeModalOpened] = useState(false); const [selectedApiKey, setSelectedApiKey] = useState(null); - const { data, isLoading } = useGetApiKeysQuery({ page, adminView: true }); + const { data, isLoading } = useGetApiKeysQuery({ cursor, adminView: true }); const { isAdmin } = useUserRole(); if (!isAdmin) { @@ -54,10 +55,18 @@ export default function WorkspaceApiKeys() { - - {t("Manage API keys for all users in the workspace")} + + {t("Manage API keys for all users in the workspace.")}{" "} + {t("View the")}{" "} + + {t("API documentation")} + {" "} + {t("for usage details.")} + + + + + + + + + + + + + + {data?.items && data.items.length > 0 && ( + goNext(data?.meta?.nextCursor)} + onPrev={goPrev} + /> + )} + + ); +} diff --git a/apps/client/src/ee/audit/queries/audit-query.ts b/apps/client/src/ee/audit/queries/audit-query.ts new file mode 100644 index 00000000..51888495 --- /dev/null +++ b/apps/client/src/ee/audit/queries/audit-query.ts @@ -0,0 +1,51 @@ +import { + keepPreviousData, + useMutation, + useQuery, + useQueryClient, + UseQueryResult, +} from "@tanstack/react-query"; +import { + getAuditLogs, + getAuditRetention, + updateAuditRetention, +} from "@/ee/audit/services/audit-service"; +import { IAuditLog, IAuditLogParams } from "@/ee/audit/types/audit.types"; +import { IPagination } from "@/lib/types"; +import { notifications } from "@mantine/notifications"; +import { useTranslation } from "react-i18next"; + +export function useAuditLogsQuery( + params?: IAuditLogParams, +): UseQueryResult, Error> { + return useQuery({ + queryKey: ["audit-logs", params], + queryFn: () => getAuditLogs(params), + placeholderData: keepPreviousData, + }); +} + +export function useAuditRetentionQuery() { + return useQuery({ + queryKey: ["audit-retention"], + queryFn: () => getAuditRetention(), + }); +} + +export function useUpdateAuditRetentionMutation() { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: (data: { auditRetentionDays: number }) => + updateAuditRetention(data), + onSuccess: () => { + notifications.show({ message: t("Audit retention updated") }); + queryClient.invalidateQueries({ queryKey: ["audit-retention"] }); + }, + onError: (error) => { + const errorMessage = error["response"]?.data?.message; + notifications.show({ message: errorMessage, color: "red" }); + }, + }); +} diff --git a/apps/client/src/ee/audit/services/audit-service.ts b/apps/client/src/ee/audit/services/audit-service.ts new file mode 100644 index 00000000..f0eb4938 --- /dev/null +++ b/apps/client/src/ee/audit/services/audit-service.ts @@ -0,0 +1,22 @@ +import api from "@/lib/api-client"; +import { IAuditLog, IAuditLogParams } from "@/ee/audit/types/audit.types"; +import { IPagination } from "@/lib/types"; + +export async function getAuditLogs( + params?: IAuditLogParams, +): Promise> { + const req = await api.post("/audit", { ...params }); + return req.data; +} + +export async function getAuditRetention(): Promise<{ retentionDays: number }> { + const req = await api.post("/audit/retention"); + return req.data; +} + +export async function updateAuditRetention(data: { + auditRetentionDays: number; +}): Promise<{ retentionDays: number }> { + const req = await api.post("/audit/retention/update", data); + return req.data; +} diff --git a/apps/client/src/ee/audit/types/audit.types.ts b/apps/client/src/ee/audit/types/audit.types.ts new file mode 100644 index 00000000..43813f97 --- /dev/null +++ b/apps/client/src/ee/audit/types/audit.types.ts @@ -0,0 +1,40 @@ +export type IAuditLog = { + id: string; + workspaceId: string; + actorId?: string; + actorType: string; + event: string; + resourceType: string; + resourceId?: string; + spaceId?: string; + changes?: { + before?: Record; + after?: Record; + }; + metadata?: Record; + ipAddress?: string; + createdAt: string; + actor?: { + id: string; + name: string; + email: string; + avatarUrl?: string; + }; + resource?: { + id: string; + name: string; + slug?: string; + slugId?: string; + }; +}; + +export type IAuditLogParams = { + event?: string; + resourceType?: string; + actorId?: string; + spaceId?: string; + startDate?: string; + endDate?: string; + cursor?: string; + limit?: number; +}; diff --git a/apps/client/src/ee/comment/queries/comment-query.ts b/apps/client/src/ee/comment/queries/comment-query.ts index b09f4e79..a7a5788a 100644 --- a/apps/client/src/ee/comment/queries/comment-query.ts +++ b/apps/client/src/ee/comment/queries/comment-query.ts @@ -1,6 +1,7 @@ import { useMutation, useQueryClient, + InfiniteData, } from "@tanstack/react-query"; import { resolveComment } from "@/features/comment/services/comment-service"; import { @@ -10,41 +11,54 @@ import { import { notifications } from "@mantine/notifications"; import { IPagination } from "@/lib/types.ts"; import { useTranslation } from "react-i18next"; -import { useQueryEmit } from "@/features/websocket/use-query-emit"; import { RQ_KEY } from "@/features/comment/queries/comment-query"; +function updateCommentInCache( + cache: InfiniteData>, + commentId: string, + updater: (comment: IComment) => IComment, +): InfiniteData> { + return { + ...cache, + pages: cache.pages.map((page) => ({ + ...page, + items: page.items.map((comment) => + comment.id === commentId ? updater(comment) : comment, + ), + })), + }; +} + export function useResolveCommentMutation() { const queryClient = useQueryClient(); const { t } = useTranslation(); - const emit = useQueryEmit(); return useMutation({ mutationFn: (data: IResolveComment) => resolveComment(data), onMutate: async (variables) => { await queryClient.cancelQueries({ queryKey: RQ_KEY(variables.pageId) }); - const previousComments = queryClient.getQueryData(RQ_KEY(variables.pageId)); - queryClient.setQueryData(RQ_KEY(variables.pageId), (old: IPagination) => { - if (!old || !old.items) return old; - const updatedItems = old.items.map((comment) => - comment.id === variables.commentId - ? { - ...comment, - resolvedAt: variables.resolved ? new Date() : null, - resolvedById: variables.resolved ? 'optimistic-user' : null, - resolvedBy: variables.resolved ? { id: 'optimistic-user', name: 'Resolving...', avatarUrl: null } : null - } - : comment, + const previousCache = queryClient.getQueryData(RQ_KEY(variables.pageId)); + + const cache = previousCache as InfiniteData> | undefined; + if (cache) { + queryClient.setQueryData( + RQ_KEY(variables.pageId), + updateCommentInCache(cache, variables.commentId, (comment) => ({ + ...comment, + resolvedAt: variables.resolved ? new Date() : null, + resolvedById: variables.resolved ? "optimistic" : null, + resolvedBy: variables.resolved + ? ({ id: "optimistic", name: "", avatarUrl: null } as IComment["resolvedBy"]) + : null, + })), ); - return { - ...old, - items: updatedItems, - }; - }); - return { previousComments }; + } + + return { previousCache }; }, - onError: (err, variables, context) => { - if (context?.previousComments) { - queryClient.setQueryData(RQ_KEY(variables.pageId), context.previousComments); + onError: (_err, variables, context) => { + if (context?.previousCache) { + queryClient.setQueryData(RQ_KEY(variables.pageId), context.previousCache); } notifications.show({ message: t("Failed to resolve comment"), @@ -52,35 +66,26 @@ export function useResolveCommentMutation() { }); }, onSuccess: (data: IComment, variables) => { - const pageId = data.pageId; - const currentComments = queryClient.getQueryData( - RQ_KEY(pageId), - ) as IPagination; - if (currentComments && currentComments.items) { - const updatedComments = currentComments.items.map((comment) => - comment.id === variables.commentId - ? { ...comment, resolvedAt: data.resolvedAt, resolvedById: data.resolvedById, resolvedBy: data.resolvedBy } - : comment, + const cache = queryClient.getQueryData( + RQ_KEY(data.pageId), + ) as InfiniteData> | undefined; + + if (cache) { + queryClient.setQueryData( + RQ_KEY(data.pageId), + updateCommentInCache(cache, variables.commentId, (comment) => ({ + ...comment, + resolvedAt: data.resolvedAt, + resolvedById: data.resolvedById, + resolvedBy: data.resolvedBy, + })), ); - queryClient.setQueryData(RQ_KEY(pageId), { - ...currentComments, - items: updatedComments, - }); } - emit({ - operation: "resolveComment", - pageId: pageId, - commentId: variables.commentId, - resolved: variables.resolved, - resolvedAt: data.resolvedAt, - resolvedById: data.resolvedById, - resolvedBy: data.resolvedBy, - }); - queryClient.invalidateQueries({ queryKey: RQ_KEY(pageId) }); - notifications.show({ - message: variables.resolved - ? t("Comment resolved successfully") - : t("Comment re-opened successfully") + + notifications.show({ + message: variables.resolved + ? t("Comment resolved successfully") + : t("Comment re-opened successfully"), }); }, }); diff --git a/apps/client/src/ee/components/cloud-login-form.tsx b/apps/client/src/ee/components/cloud-login-form.tsx index cb1af8a7..01ddd031 100644 --- a/apps/client/src/ee/components/cloud-login-form.tsx +++ b/apps/client/src/ee/components/cloud-login-form.tsx @@ -1,5 +1,6 @@ -import * as z from "zod"; -import { useForm, zodResolver } from "@mantine/form"; +import { z } from "zod/v4"; +import { useForm } from "@mantine/form"; +import { zod4Resolver } from "mantine-form-zod-resolver"; import { Container, Title, @@ -30,7 +31,7 @@ export function CloudLoginForm() { const { data: joinedWorkspaces } = useJoinedWorkspacesQuery(); const form = useForm({ - validate: zodResolver(formSchema), + validate: zod4Resolver(formSchema), initialValues: { hostname: "", }, diff --git a/apps/client/src/ee/components/ldap-login-modal.tsx b/apps/client/src/ee/components/ldap-login-modal.tsx index 9360651d..dd1fbbac 100644 --- a/apps/client/src/ee/components/ldap-login-modal.tsx +++ b/apps/client/src/ee/components/ldap-login-modal.tsx @@ -1,13 +1,13 @@ import React, { useState } from "react"; import { Modal, TextInput, PasswordInput, Button, Stack } from "@mantine/core"; import { useForm } from "@mantine/form"; -import { zodResolver } from "mantine-form-zod-resolver"; -import { z } from "zod"; +import { zod4Resolver } from "mantine-form-zod-resolver"; +import { z } from "zod/v4"; import { notifications } from "@mantine/notifications"; import { useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { IAuthProvider } from "@/ee/security/types/security.types"; -import APP_ROUTE from "@/lib/app-route"; +import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route"; import { ldapLogin } from "@/ee/security/services/ldap-auth-service"; const formSchema = z.object({ @@ -34,7 +34,7 @@ export function LdapLoginModal({ const [error, setError] = useState(null); const form = useForm({ - validate: zodResolver(formSchema), + validate: zod4Resolver(formSchema), initialValues: { username: "", password: "", @@ -59,13 +59,13 @@ export function LdapLoginModal({ // Handle MFA like the regular login if (response?.userHasMfa) { onClose(); - navigate(APP_ROUTE.AUTH.MFA_CHALLENGE); + navigate(APP_ROUTE.AUTH.MFA_CHALLENGE + window.location.search); } else if (response?.requiresMfaSetup) { onClose(); - navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED); + navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED + window.location.search); } else { onClose(); - navigate(APP_ROUTE.HOME); + navigate(getPostLoginRedirect()); } } catch (err: any) { setIsLoading(false); diff --git a/apps/client/src/ee/components/manage-hostname.tsx b/apps/client/src/ee/components/manage-hostname.tsx index c7a595ff..50090206 100644 --- a/apps/client/src/ee/components/manage-hostname.tsx +++ b/apps/client/src/ee/components/manage-hostname.tsx @@ -1,16 +1,17 @@ import { Button, Group, Text, Modal, TextInput } from "@mantine/core"; -import * as z from "zod"; +import { z } from "zod/v4"; import { useState } from "react"; import { useDisclosure } from "@mantine/hooks"; import * as React from "react"; -import { useForm, zodResolver } from "@mantine/form"; +import { useForm } from "@mantine/form"; +import { zod4Resolver } from "mantine-form-zod-resolver"; import { notifications } from "@mantine/notifications"; import { useTranslation } from "react-i18next"; import { getSubdomainHost } from "@/lib/config.ts"; import { IWorkspace } from "@/features/workspace/types/workspace.types.ts"; import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; import { getHostnameUrl } from "@/ee/utils.ts"; -import { useAtom } from "jotai/index"; +import { useAtom } from "jotai"; import { currentUserAtom, workspaceAtom, @@ -66,7 +67,7 @@ function ChangeHostnameForm({ onClose }: ChangeHostnameFormProps) { const [currentUser, setCurrentUser] = useAtom(currentUserAtom); const form = useForm({ - validate: zodResolver(formSchema), + validate: zod4Resolver(formSchema), initialValues: { hostname: currentUser?.workspace?.hostname, }, diff --git a/apps/client/src/ee/hooks/use-enterprise-access.tsx b/apps/client/src/ee/hooks/use-enterprise-access.tsx new file mode 100644 index 00000000..b7746d6f --- /dev/null +++ b/apps/client/src/ee/hooks/use-enterprise-access.tsx @@ -0,0 +1,12 @@ +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; diff --git a/apps/client/src/ee/licence/components/activate-license-modal.tsx b/apps/client/src/ee/licence/components/activate-license-modal.tsx index 9d81f29e..d9f68b22 100644 --- a/apps/client/src/ee/licence/components/activate-license-modal.tsx +++ b/apps/client/src/ee/licence/components/activate-license-modal.tsx @@ -1,7 +1,8 @@ -import * as z from "zod"; +import { z } from "zod/v4"; import React from "react"; import { Button, Group, Modal, Textarea } from "@mantine/core"; -import { useForm, zodResolver } from "@mantine/form"; +import { useForm } from "@mantine/form"; +import { zod4Resolver } from "mantine-form-zod-resolver"; import { useTranslation } from "react-i18next"; import { useActivateMutation } from "@/ee/licence/queries/license-query.ts"; import { useDisclosure } from "@mantine/hooks"; @@ -49,7 +50,7 @@ export function ActivateLicenseForm({ onClose }: ActivateLicenseFormProps) { const activateLicenseMutation = useActivateMutation(); const form = useForm({ - validate: zodResolver(formSchema), + validate: zod4Resolver(formSchema), initialValues: { licenseKey: "", }, diff --git a/apps/client/src/ee/licence/components/oss-details.tsx b/apps/client/src/ee/licence/components/oss-details.tsx index 5a3fd6c4..e08db649 100644 --- a/apps/client/src/ee/licence/components/oss-details.tsx +++ b/apps/client/src/ee/licence/components/oss-details.tsx @@ -1,39 +1,76 @@ -import { Group, Table, ThemeIcon } from "@mantine/core"; +import { Group, List, Stack, Table, Text, ThemeIcon } from "@mantine/core"; import { IconCheck } from "@tabler/icons-react"; +const enterpriseFeatures = [ + "SSO (SAML, OIDC, LDAP)", + "AI Integration (Search & Assistant)", + "Page-level Permissions", + "Audit Logs", + "API Keys", + "MCP Support", + "Multi-factor Authentication (2FA)", + "Enterprise Controls", + "Advanced Search Engine Support", + "Full-text Search in Attachments (PDF, DOCX)", + "Resolve Comments", + "Confluence Import", + "DOCX Import", +]; + export default function OssDetails() { return ( - - - - To unlock enterprise features like AI, SSO, MFA, Resolve comments, contact sales@docmost.com. - - - - Edition - - - Open Source -
- - - -
-
-
-
-
-
-
+ + + + + + Edition + + + Open Source +
+ + + +
+
+
+
+
+
+
+ + + Upgrade to the Enterprise Edition to unlock: + + + + + } + > + {enterpriseFeatures.map((feature) => ( + {feature} + ))} + + + + Contact sales@docmost.com to purchase an enterprise license. + + +
); } diff --git a/apps/client/src/ee/mfa/components/mfa-backup-codes-modal.tsx b/apps/client/src/ee/mfa/components/mfa-backup-codes-modal.tsx index c24638fe..c1215335 100644 --- a/apps/client/src/ee/mfa/components/mfa-backup-codes-modal.tsx +++ b/apps/client/src/ee/mfa/components/mfa-backup-codes-modal.tsx @@ -8,10 +8,10 @@ import { Group, List, Code, - CopyButton, Alert, PasswordInput, } from "@mantine/core"; +import { CopyButton } from "@/components/common/copy-button"; import { IconRefresh, IconCopy, @@ -23,8 +23,8 @@ import { notifications } from "@mantine/notifications"; import { useTranslation } from "react-i18next"; import { regenerateBackupCodes } from "@/ee/mfa"; import { useForm } from "@mantine/form"; -import { zodResolver } from "mantine-form-zod-resolver"; -import { z } from "zod"; +import { zod4Resolver } from "mantine-form-zod-resolver"; +import { z } from "zod/v4"; import useCurrentUser from "@/features/user/hooks/use-current-user"; interface MfaBackupCodesModalProps { @@ -51,7 +51,7 @@ export function MfaBackupCodesModal({ }); const form = useForm({ - validate: zodResolver(formSchema), + validate: zod4Resolver(formSchema), initialValues: { confirmPassword: "", }, diff --git a/apps/client/src/ee/mfa/components/mfa-challenge.tsx b/apps/client/src/ee/mfa/components/mfa-challenge.tsx index 8a9bef53..e1d15965 100644 --- a/apps/client/src/ee/mfa/components/mfa-challenge.tsx +++ b/apps/client/src/ee/mfa/components/mfa-challenge.tsx @@ -12,15 +12,15 @@ import { ThemeIcon, } from "@mantine/core"; import { useForm } from "@mantine/form"; -import { zodResolver } from "mantine-form-zod-resolver"; +import { zod4Resolver } from "mantine-form-zod-resolver"; import { IconDeviceMobile, IconLock } from "@tabler/icons-react"; import { useNavigate } from "react-router-dom"; import { notifications } from "@mantine/notifications"; import classes from "./mfa-challenge.module.css"; import { verifyMfa } from "@/ee/mfa"; -import APP_ROUTE from "@/lib/app-route"; +import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route"; import { useTranslation } from "react-i18next"; -import * as z from "zod"; +import { z } from "zod/v4"; import { MfaBackupCodeInput } from "./mfa-backup-code-input"; const formSchema = z.object({ @@ -43,7 +43,7 @@ export function MfaChallenge() { const [useBackupCode, setUseBackupCode] = useState(false); const form = useForm({ - validate: zodResolver(formSchema), + validate: zod4Resolver(formSchema), initialValues: { code: "", }, @@ -53,7 +53,7 @@ export function MfaChallenge() { setIsLoading(true); try { await verifyMfa(values.code); - navigate(APP_ROUTE.HOME); + navigate(getPostLoginRedirect()); } catch (error: any) { setIsLoading(false); notifications.show({ diff --git a/apps/client/src/ee/mfa/components/mfa-disable-modal.tsx b/apps/client/src/ee/mfa/components/mfa-disable-modal.tsx index 72bcbeca..45d912e2 100644 --- a/apps/client/src/ee/mfa/components/mfa-disable-modal.tsx +++ b/apps/client/src/ee/mfa/components/mfa-disable-modal.tsx @@ -9,11 +9,11 @@ import { } from "@mantine/core"; import { IconShieldOff, IconAlertTriangle } from "@tabler/icons-react"; import { useForm } from "@mantine/form"; -import { zodResolver } from "mantine-form-zod-resolver"; +import { zod4Resolver } from "mantine-form-zod-resolver"; import { useMutation } from "@tanstack/react-query"; import { notifications } from "@mantine/notifications"; import { useTranslation } from "react-i18next"; -import { z } from "zod"; +import { z } from "zod/v4"; import { disableMfa } from "@/ee/mfa"; import useCurrentUser from "@/features/user/hooks/use-current-user"; @@ -41,7 +41,7 @@ export function MfaDisableModal({ }); const form = useForm({ - validate: zodResolver(formSchema), + validate: zod4Resolver(formSchema), initialValues: { confirmPassword: "", }, @@ -63,7 +63,7 @@ export function MfaDisableModal({ const handleSubmit = async (values: { confirmPassword?: string }) => { // Only send confirmPassword if it's required (non-SSO users) - const payload = requiresPassword + const payload = requiresPassword ? { confirmPassword: values.confirmPassword } : {}; await disableMutation.mutateAsync(payload); diff --git a/apps/client/src/ee/mfa/components/mfa-setup-modal.tsx b/apps/client/src/ee/mfa/components/mfa-setup-modal.tsx index d01f2c9f..0046db6b 100644 --- a/apps/client/src/ee/mfa/components/mfa-setup-modal.tsx +++ b/apps/client/src/ee/mfa/components/mfa-setup-modal.tsx @@ -11,7 +11,6 @@ import { PinInput, Alert, List, - CopyButton, ActionIcon, Tooltip, Paper, @@ -20,6 +19,7 @@ import { Collapse, UnstyledButton, } from "@mantine/core"; +import { CopyButton } from "@/components/common/copy-button"; import { IconQrcode, IconShieldCheck, @@ -36,8 +36,8 @@ import { useMutation } from "@tanstack/react-query"; import { notifications } from "@mantine/notifications"; import { useTranslation } from "react-i18next"; import { setupMfa, enableMfa } from "@/ee/mfa"; -import { zodResolver } from "mantine-form-zod-resolver"; -import { z } from "zod"; +import { zod4Resolver } from "mantine-form-zod-resolver"; +import { z } from "zod/v4"; interface MfaSetupModalProps { opened: boolean; @@ -71,7 +71,7 @@ export function MfaSetupModal({ const [manualEntryOpen, setManualEntryOpen] = useState(false); const form = useForm({ - validate: zodResolver(formSchema), + validate: zod4Resolver(formSchema), initialValues: { verificationCode: "", }, diff --git a/apps/client/src/ee/mfa/components/mfa-setup-required.tsx b/apps/client/src/ee/mfa/components/mfa-setup-required.tsx index c657abe9..ab327c4d 100644 --- a/apps/client/src/ee/mfa/components/mfa-setup-required.tsx +++ b/apps/client/src/ee/mfa/components/mfa-setup-required.tsx @@ -3,7 +3,7 @@ import { Container, Paper, Title, Text, Alert, Stack } from "@mantine/core"; import { IconAlertCircle } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { MfaSetupModal } from "@/ee/mfa"; -import APP_ROUTE from "@/lib/app-route.ts"; +import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts"; import { useNavigate } from "react-router-dom"; export default function MfaSetupRequired() { @@ -11,7 +11,7 @@ export default function MfaSetupRequired() { const navigate = useNavigate(); const handleSetupComplete = () => { - navigate(APP_ROUTE.HOME); + navigate(getPostLoginRedirect()); }; return ( diff --git a/apps/client/src/ee/mfa/hooks/use-mfa-page-protection.ts b/apps/client/src/ee/mfa/hooks/use-mfa-page-protection.ts index 9200cac7..30b27427 100644 --- a/apps/client/src/ee/mfa/hooks/use-mfa-page-protection.ts +++ b/apps/client/src/ee/mfa/hooks/use-mfa-page-protection.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { useNavigate, useLocation } from "react-router-dom"; -import APP_ROUTE from "@/lib/app-route"; +import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route"; import { validateMfaAccess } from "@/ee/mfa"; export function useMfaPageProtection() { @@ -13,8 +13,10 @@ export function useMfaPageProtection() { const checkAccess = async () => { const result = await validateMfaAccess(); + const search = location.search; + if (!result.valid) { - navigate(APP_ROUTE.AUTH.LOGIN); + navigate(APP_ROUTE.AUTH.LOGIN + search); return; } @@ -26,17 +28,17 @@ export function useMfaPageProtection() { if (result.requiresMfaSetup && !isOnSetupPage) { // User needs to set up MFA but is on challenge page - navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED); + navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED + search); } else if ( !result.requiresMfaSetup && result.userHasMfa && !isOnChallengePage ) { // User has MFA and should be on challenge page - navigate(APP_ROUTE.AUTH.MFA_CHALLENGE); + navigate(APP_ROUTE.AUTH.MFA_CHALLENGE + search); } else if (!result.isTransferToken) { // User has a regular auth token, shouldn't be on MFA pages - navigate(APP_ROUTE.HOME); + navigate(getPostLoginRedirect()); } else { setIsValid(true); } diff --git a/apps/client/src/ee/page-permission/components/general-access-select.tsx b/apps/client/src/ee/page-permission/components/general-access-select.tsx new file mode 100644 index 00000000..8bee6e4b --- /dev/null +++ b/apps/client/src/ee/page-permission/components/general-access-select.tsx @@ -0,0 +1,112 @@ +import { Group, Menu, Text, UnstyledButton } from "@mantine/core"; +import { + IconChevronDown, + IconLock, + IconShieldLock, + IconCheck, +} from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import classes from "./page-permission.module.css"; + +type AccessLevel = "open" | "restricted"; + +type GeneralAccessSelectProps = { + value: AccessLevel; + onChange: (value: AccessLevel) => void; + disabled?: boolean; + hasInheritedRestriction?: boolean; +}; + +export function GeneralAccessSelect({ + value, + onChange, + disabled, + hasInheritedRestriction, +}: GeneralAccessSelectProps) { + const { t } = useTranslation(); + + const isDirectlyRestricted = value === "restricted"; + const showInheritedState = hasInheritedRestriction && !isDirectlyRestricted; + + const currentLabel = showInheritedState + ? t("Restricted by parent") + : isDirectlyRestricted + ? t("Restricted") + : t("Open"); + + const currentDescription = showInheritedState + ? t("Inherits restrictions from ancestor page") + : isDirectlyRestricted + ? t("Only people listed below can access this page") + : t("Everyone in this space can access"); + + const CurrentIcon = showInheritedState + ? IconShieldLock + : isDirectlyRestricted + ? IconLock + : IconShieldLock; + + const accessOptions = [ + { + value: "open" as const, + label: hasInheritedRestriction ? t("Restricted by parent") : t("Open"), + description: hasInheritedRestriction + ? t("Use only inherited restrictions") + : t("No additional restrictions on this page"), + icon: IconShieldLock, + }, + { + value: "restricted" as const, + label: t("Restricted"), + description: hasInheritedRestriction + ? t("Add restrictions on top of inherited") + : t("Only specific people can access"), + icon: IconLock, + }, + ]; + + return ( + + + +
+ +
+
+ + + {currentLabel} + + {!disabled && } + + + {currentDescription} + +
+
+
+ + + {accessOptions.map((option) => ( + onChange(option.value)} + leftSection={} + rightSection={ + option.value === value ? : null + } + > +
+ {option.label} + + {option.description} + +
+
+ ))} +
+
+ ); +} diff --git a/apps/client/src/ee/page-permission/components/page-permission-item.tsx b/apps/client/src/ee/page-permission/components/page-permission-item.tsx new file mode 100644 index 00000000..b0a5c5f4 --- /dev/null +++ b/apps/client/src/ee/page-permission/components/page-permission-item.tsx @@ -0,0 +1,107 @@ +import { Menu, Text, UnstyledButton, Group } from "@mantine/core"; +import { IconChevronDown, IconCheck } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { useAtomValue } from "jotai"; +import { CustomAvatar } from "@/components/ui/custom-avatar"; +import { AutoTooltipText } from "@/components/ui/auto-tooltip-text"; +import { IconGroupCircle } from "@/components/icons/icon-people-circle"; +import { userAtom } from "@/features/user/atoms/current-user-atom"; +import { formatMemberCount } from "@/lib"; +import { + IPagePermissionMember, + PagePermissionRole, +} from "@/ee/page-permission/types/page-permission.types"; +import { + pagePermissionRoleData, + getPagePermissionRoleLabel, +} from "@/ee/page-permission/types/page-permission-role-data"; +import classes from "./page-permission.module.css"; + +type PagePermissionItemProps = { + member: IPagePermissionMember; + onRoleChange: (memberId: string, type: "user" | "group", role: string) => void; + onRemove: (memberId: string, type: "user" | "group") => void; + disabled?: boolean; +}; + +export function PagePermissionItem({ + member, + onRoleChange, + onRemove, + disabled, +}: PagePermissionItemProps) { + const { t } = useTranslation(); + const currentUser = useAtomValue(userAtom); + const isCurrentUser = member.type === "user" && member.id === currentUser?.id; + const roleLabel = getPagePermissionRoleLabel(member.role); + + return ( +
+
+ {member.type === "user" && ( + + )} + {member.type === "group" && } + +
+ + {member.name} + {isCurrentUser && ({t("You")})} + + + {member.type === "user" ? member.email : formatMemberCount(member.memberCount, t)} + +
+
+ +
+ {isCurrentUser || disabled ? ( + + {t(roleLabel)} + + ) : ( + + + + + {t(roleLabel)} + + + + + + + {pagePermissionRoleData.map((role) => ( + onRoleChange(member.id, member.type, role.value)} + rightSection={ + role.value === member.role ? : null + } + > +
+ {t(role.label)} + + {t(role.description)} + +
+
+ ))} + + onRemove(member.id, member.type)} + > + {t("Remove access")} + +
+
+ )} +
+
+ ); +} diff --git a/apps/client/src/ee/page-permission/components/page-permission-list.tsx b/apps/client/src/ee/page-permission/components/page-permission-list.tsx new file mode 100644 index 00000000..e586b968 --- /dev/null +++ b/apps/client/src/ee/page-permission/components/page-permission-list.tsx @@ -0,0 +1,164 @@ +import { Center, Group, Loader, ScrollArea, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { useAtomValue } from "jotai"; +import { useEffect, useRef } from "react"; +import { modals } from "@mantine/modals"; +import { userAtom } from "@/features/user/atoms/current-user-atom"; +import { PagePermissionRole } from "@/ee/page-permission/types/page-permission.types"; +import { + usePagePermissionsQuery, + useRemovePagePermissionMutation, + useUpdatePagePermissionRoleMutation, +} from "@/ee/page-permission/queries/page-permission-query"; +import { PagePermissionItem } from "@/ee/page-permission"; +import classes from "./page-permission.module.css"; + +type PagePermissionListProps = { + pageId: string; + canManage: boolean; + onRemoveAll?: () => void; +}; + +export function PagePermissionList({ + pageId, + canManage, + onRemoveAll, +}: PagePermissionListProps) { + const { t } = useTranslation(); + const currentUser = useAtomValue(userAtom); + const updateRoleMutation = useUpdatePagePermissionRoleMutation(); + const removeMutation = useRemovePagePermissionMutation(); + + const { data, isLoading, hasNextPage, fetchNextPage, isFetchingNextPage } = + usePagePermissionsQuery(pageId); + + const sentinelRef = useRef(null); + const viewportRef = useRef(null); + + useEffect(() => { + const sentinel = sentinelRef.current; + if (!sentinel) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, + { root: viewportRef.current, threshold: 0.1 }, + ); + + observer.observe(sentinel); + return () => observer.disconnect(); + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + const handleRoleChange = async ( + memberId: string, + type: "user" | "group", + newRole: string, + ) => { + await updateRoleMutation.mutateAsync({ + pageId, + role: newRole as PagePermissionRole, + ...(type === "user" ? { userId: memberId } : { groupId: memberId }), + }); + }; + + const handleRemove = (memberId: string, type: "user" | "group") => { + modals.openConfirmModal({ + title: t("Remove access"), + children: ( + + {t( + "Are you sure you want to remove this member's access to the page?", + )} + + ), + centered: true, + labels: { confirm: t("Remove"), cancel: t("Cancel") }, + confirmProps: { color: "red" }, + onConfirm: async () => { + await removeMutation.mutateAsync({ + pageId, + ...(type === "user" + ? { userIds: [memberId] } + : { groupIds: [memberId] }), + }); + }, + }); + }; + + const handleRemoveAll = () => { + modals.openConfirmModal({ + title: t("Remove all access"), + children: ( + + {t( + "Are you sure you want to remove all specific access? This will make the page open to everyone in the space.", + )} + + ), + centered: true, + labels: { confirm: t("Remove all"), cancel: t("Cancel") }, + confirmProps: { color: "red" }, + onConfirm: () => onRemoveAll?.(), + }); + }; + + const members = data?.pages.flatMap((page) => page.items) ?? []; + + const sortedMembers = [...members].sort((a, b) => { + if (a.type === "user" && a.id === currentUser?.id) return -1; + if (b.type === "user" && b.id === currentUser?.id) return 1; + if (a.type === "group" && b.type === "user") return -1; + if (a.type === "user" && b.type === "group") return 1; + return 0; + }); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (members.length === 0) { + return null; + } + + return ( + <> + + + {t("People with access")} + + {canManage && members.length > 0 && ( + + {t("Remove all")} + + )} + + + + {sortedMembers.map((member) => ( + + ))} + +
+ + {isFetchingNextPage && ( +
+ +
+ )} + + + ); +} diff --git a/apps/client/src/ee/page-permission/components/page-permission-tab.tsx b/apps/client/src/ee/page-permission/components/page-permission-tab.tsx new file mode 100644 index 00000000..93f9277c --- /dev/null +++ b/apps/client/src/ee/page-permission/components/page-permission-tab.tsx @@ -0,0 +1,189 @@ +import { useState } from "react"; +import { + Box, + Button, + Divider, + Group, + Paper, + Select, + Stack, + Text, + ThemeIcon, +} from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { Link, useParams } from "react-router-dom"; +import { IconArrowRight, IconLock, IconShieldLock } from "@tabler/icons-react"; +import { MultiMemberSelect } from "@/features/space/components/multi-member-select"; +import { + IPageRestrictionInfo, + PagePermissionRole, +} from "@/ee/page-permission/types/page-permission.types"; +import { + useAddPagePermissionMutation, + useRestrictPageMutation, + useUnrestrictPageMutation, +} from "@/ee/page-permission/queries/page-permission-query"; +import { pagePermissionRoleData } from "@/ee/page-permission/types/page-permission-role-data"; +import { GeneralAccessSelect } from "@/ee/page-permission"; +import { PagePermissionList } from "@/ee/page-permission"; +import classes from "./page-permission.module.css"; +import { buildPageUrl } from "@/features/page/page.utils"; + +type PagePermissionTabProps = { + pageId: string; + restrictionInfo: IPageRestrictionInfo; +}; + +export function PagePermissionTab({ + pageId, + restrictionInfo, +}: PagePermissionTabProps) { + const { t } = useTranslation(); + const { spaceSlug } = useParams(); + const [memberIds, setMemberIds] = useState([]); + const [role, setRole] = useState(PagePermissionRole.WRITER); + + const restrictMutation = useRestrictPageMutation(); + const unrestrictMutation = useUnrestrictPageMutation(); + const addPermissionMutation = useAddPagePermissionMutation(); + + const hasInheritedRestriction = restrictionInfo.hasInheritedRestriction; + const hasDirectRestriction = restrictionInfo.hasDirectRestriction; + const canManage = restrictionInfo.userAccess.canManage; + + const handleDirectAccessChange = async (value: "open" | "restricted") => { + if (value === "restricted" && !hasDirectRestriction) { + await restrictMutation.mutateAsync(pageId); + } else if (value === "open" && hasDirectRestriction) { + await unrestrictMutation.mutateAsync(pageId); + } + }; + + const handleAddMembers = async () => { + if (memberIds.length === 0) return; + + const userIds = memberIds + .filter((id) => id.startsWith("user-")) + .map((id) => id.replace("user-", "")); + + const groupIds = memberIds + .filter((id) => id.startsWith("group-")) + .map((id) => id.replace("group-", "")); + + await addPermissionMutation.mutateAsync({ + pageId, + role: role as PagePermissionRole, + ...(userIds.length > 0 && { userIds }), + ...(groupIds.length > 0 && { groupIds }), + }); + + setMemberIds([]); + }; + + const handleRemoveAll = async () => { + await unrestrictMutation.mutateAsync(pageId); + }; + + return ( + + {hasInheritedRestriction && ( + + + + + + + + {t("Inherited restriction")} + + + + {t("Access limited by")} + + {restrictionInfo.inheritedFrom && ( + + + + {restrictionInfo.inheritedFrom.title || t("Untitled")} + + + + + )} + + + + + )} + + + + {!hasDirectRestriction && !hasInheritedRestriction && ( + + {t("Restrict access to control who can view and edit this page")} + + )} + {!hasDirectRestriction && hasInheritedRestriction && ( + + {t("Add additional restrictions specific to this page")} + + )} + + + {hasDirectRestriction && ( + <> + + + {canManage && ( + + + + + { + if (value === "days" || value === "months" || value === "years") { + setRetentionUnit(value); + } + }} + size="sm" + style={{ flex: 1 }} + disabled={!hasAccess} + /> + + + +
+ ); +} diff --git a/apps/client/src/ee/security/pages/security.tsx b/apps/client/src/ee/security/pages/security.tsx index 82d8640f..a9530fad 100644 --- a/apps/client/src/ee/security/pages/security.tsx +++ b/apps/client/src/ee/security/pages/security.tsx @@ -9,15 +9,17 @@ import CreateSsoProvider from "@/ee/security/components/create-sso-provider.tsx" import EnforceSso from "@/ee/security/components/enforce-sso.tsx"; import AllowedDomains from "@/ee/security/components/allowed-domains.tsx"; 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 DisablePublicSharing from "@/ee/security/components/disable-public-sharing.tsx"; +import TrashRetention from "@/ee/security/components/trash-retention.tsx"; +import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx"; +import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx"; export default function Security() { const { t } = useTranslation(); const { isAdmin } = useUserRole(); - const { hasLicenseKey } = useLicense(); - const { isBusiness } = usePlan(); + const hasEnterpriseAccess = useEnterpriseAccess(); + const isCloudEE = useIsCloudEE(); if (!isAdmin) { return null; @@ -30,26 +32,48 @@ export default function Security() { - - - - + {(!isCloud() || hasEnterpriseAccess) && ( + <> + + + + )} + + {!isCloud() && ( + <> + + + + )} + Single sign-on (SSO) - {(isCloud() && isBusiness) || (!isCloud() && hasLicenseKey) ? ( + {hasEnterpriseAccess && ( <> + + )} + + {isCloudEE && ( + <> + + + + )} + + {hasEnterpriseAccess && ( + <> - ) : null} + )} diff --git a/apps/client/src/ee/security/queries/security-query.ts b/apps/client/src/ee/security/queries/security-query.ts index a8d88299..7de43b03 100644 --- a/apps/client/src/ee/security/queries/security-query.ts +++ b/apps/client/src/ee/security/queries/security-query.ts @@ -13,8 +13,9 @@ import { } from "@/ee/security/services/security-service.ts"; import { notifications } from "@mantine/notifications"; import { IAuthProvider } from "@/ee/security/types/security.types.ts"; +import { IPagination } from "@/lib/types.ts"; -export function useGetSsoProviders(): UseQueryResult { +export function useGetSsoProviders(): UseQueryResult, Error> { return useQuery({ queryKey: ["sso-providers"], queryFn: () => getSsoProviders(), diff --git a/apps/client/src/ee/security/services/security-service.ts b/apps/client/src/ee/security/services/security-service.ts index 21e59892..0abdafc2 100644 --- a/apps/client/src/ee/security/services/security-service.ts +++ b/apps/client/src/ee/security/services/security-service.ts @@ -1,5 +1,6 @@ import api from "@/lib/api-client.ts"; import { IAuthProvider } from "@/ee/security/types/security.types.ts"; +import { IPagination } from "@/lib/types.ts"; export async function getSsoProviderById(data: { providerId: string; @@ -8,8 +9,8 @@ export async function getSsoProviderById(data: { return req.data; } -export async function getSsoProviders(): Promise { - const req = await api.post("/sso/providers"); +export async function getSsoProviders(): Promise> { + const req = await api.post>("/sso/providers"); return req.data; } diff --git a/apps/client/src/features/attachments/services/attachment-service.ts b/apps/client/src/features/attachments/services/attachment-service.ts index 9550e06d..fa43da3c 100644 --- a/apps/client/src/features/attachments/services/attachment-service.ts +++ b/apps/client/src/features/attachments/services/attachment-service.ts @@ -1,20 +1,62 @@ import api from "@/lib/api-client"; +import loadImage from "blueimp-load-image"; import { AvatarIconType, IAttachment, } from "@/features/attachments/types/attachment.types.ts"; +async function compressAndResizeIcon( + file: File, + type: AvatarIconType, +): Promise { + 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((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( file: File, type: AvatarIconType, spaceId?: string, ): Promise { + const processed = await compressAndResizeIcon(file, type); + const formData = new FormData(); formData.append("type", type); if (spaceId) { formData.append("spaceId", spaceId); } - formData.append("image", file); + formData.append("image", processed); return await api.post("/attachments/upload-image", formData, { headers: { diff --git a/apps/client/src/features/auth/components/forgot-password-form.tsx b/apps/client/src/features/auth/components/forgot-password-form.tsx index 2860242e..b5987b49 100644 --- a/apps/client/src/features/auth/components/forgot-password-form.tsx +++ b/apps/client/src/features/auth/components/forgot-password-form.tsx @@ -1,8 +1,8 @@ import { useState } from "react"; -import * as z from "zod"; -import { useForm, zodResolver } from "@mantine/form"; +import { z } from "zod/v4"; +import { useForm } from "@mantine/form"; +import { zod4Resolver } from "mantine-form-zod-resolver"; import useAuth from "@/features/auth/hooks/use-auth"; -import { IForgotPassword } from "@/features/auth/types/auth.types"; import { Box, Button, Container, Text, TextInput, Title } from "@mantine/core"; import classes from "./auth.module.css"; import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts"; @@ -10,10 +10,10 @@ import { useTranslation } from "react-i18next"; const formSchema = z.object({ email: z - .string() - .min(1, { message: "Email is required" }) - .email({ message: "Invalid email address" }), + .email() + .min(1, { message: "Email is required" }), }); +type FormValues = z.infer; export function ForgotPasswordForm() { const { t } = useTranslation(); @@ -21,14 +21,14 @@ export function ForgotPasswordForm() { const [isTokenSent, setIsTokenSent] = useState(false); useRedirectIfAuthenticated(); - const form = useForm({ - validate: zodResolver(formSchema), + const form = useForm({ + validate: zod4Resolver(formSchema), initialValues: { email: "", }, }); - async function onSubmit(data: IForgotPassword) { + async function onSubmit(data: FormValues) { if (await forgotPassword(data)) { setIsTokenSent(true); } diff --git a/apps/client/src/features/auth/components/invite-sign-up-form.tsx b/apps/client/src/features/auth/components/invite-sign-up-form.tsx index 37397ef8..d06ef307 100644 --- a/apps/client/src/features/auth/components/invite-sign-up-form.tsx +++ b/apps/client/src/features/auth/components/invite-sign-up-form.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import * as z from "zod"; +import { z } from "zod/v4"; import { useForm } from "@mantine/form"; import { @@ -11,9 +11,8 @@ import { Box, Stack, } from "@mantine/core"; -import { zodResolver } from "mantine-form-zod-resolver"; +import { zod4Resolver } from "mantine-form-zod-resolver"; import { useParams, useSearchParams } from "react-router-dom"; -import { IRegister } from "@/features/auth/types/auth.types"; import useAuth from "@/features/auth/hooks/use-auth"; import classes from "@/features/auth/components/auth.module.css"; import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-query.ts"; @@ -40,14 +39,14 @@ export function InviteSignUpForm() { useRedirectIfAuthenticated(); const form = useForm({ - validate: zodResolver(formSchema), + validate: zod4Resolver(formSchema), initialValues: { name: "", password: "", }, }); - async function onSubmit(data: IRegister) { + async function onSubmit(data: FormValues) { const invitationToken = searchParams.get("token"); await invitationSignup({ diff --git a/apps/client/src/features/auth/components/login-form.tsx b/apps/client/src/features/auth/components/login-form.tsx index e182bb32..c07ebe02 100644 --- a/apps/client/src/features/auth/components/login-form.tsx +++ b/apps/client/src/features/auth/components/login-form.tsx @@ -1,7 +1,7 @@ -import * as z from "zod"; -import { useForm, zodResolver } from "@mantine/form"; +import { z } from "zod/v4"; +import { useForm } from "@mantine/form"; +import { zod4Resolver } from "mantine-form-zod-resolver"; import useAuth from "@/features/auth/hooks/use-auth"; -import { ILogin } from "@/features/auth/types/auth.types"; import { Container, Title, @@ -24,11 +24,11 @@ import React from "react"; const formSchema = z.object({ email: z - .string() - .min(1, { message: "email is required" }) - .email({ message: "Invalid email address" }), + .email() + .min(1, { message: "email is required" }), password: z.string().min(1, { message: "Password is required" }), }); +type FormValues = z.infer; export function LoginForm() { const { t } = useTranslation(); @@ -41,15 +41,15 @@ export function LoginForm() { error, } = useWorkspacePublicDataQuery(); - const form = useForm({ - validate: zodResolver(formSchema), + const form = useForm({ + validate: zod4Resolver(formSchema), initialValues: { email: "", password: "", }, }); - async function onSubmit(data: ILogin) { + async function onSubmit(data: FormValues) { await signIn(data); } diff --git a/apps/client/src/features/auth/components/password-reset-form.tsx b/apps/client/src/features/auth/components/password-reset-form.tsx index 351dcb61..4fe836ec 100644 --- a/apps/client/src/features/auth/components/password-reset-form.tsx +++ b/apps/client/src/features/auth/components/password-reset-form.tsx @@ -1,7 +1,7 @@ -import * as z from "zod"; -import { useForm, zodResolver } from "@mantine/form"; +import { z } from "zod/v4"; +import { useForm } from "@mantine/form"; +import { zod4Resolver } from "mantine-form-zod-resolver"; import useAuth from "@/features/auth/hooks/use-auth"; -import { IPasswordReset } from "@/features/auth/types/auth.types"; import { Box, Button, Container, PasswordInput, Title } from "@mantine/core"; import classes from "./auth.module.css"; import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts"; @@ -12,6 +12,7 @@ const formSchema = z.object({ .string() .min(8, { message: "Password must contain at least 8 characters" }), }); +type FormValues = z.infer; interface PasswordResetFormProps { resetToken?: string; @@ -22,14 +23,14 @@ export function PasswordResetForm({ resetToken }: PasswordResetFormProps) { const { passwordReset, isLoading } = useAuth(); useRedirectIfAuthenticated(); - const form = useForm({ - validate: zodResolver(formSchema), + const form = useForm({ + validate: zod4Resolver(formSchema), initialValues: { newPassword: "", }, }); - async function onSubmit(data: IPasswordReset) { + async function onSubmit(data: FormValues) { await passwordReset({ token: resetToken, newPassword: data.newPassword, diff --git a/apps/client/src/features/auth/components/setup-workspace-form.tsx b/apps/client/src/features/auth/components/setup-workspace-form.tsx index adba98e5..261412a9 100644 --- a/apps/client/src/features/auth/components/setup-workspace-form.tsx +++ b/apps/client/src/features/auth/components/setup-workspace-form.tsx @@ -1,6 +1,7 @@ import * as React from "react"; -import * as z from "zod"; -import { useForm, zodResolver } from "@mantine/form"; +import { z } from "zod/v4"; +import { useForm } from "@mantine/form"; +import { zod4Resolver } from "mantine-form-zod-resolver"; import { Container, Title, @@ -11,7 +12,6 @@ import { Anchor, Text, } from "@mantine/core"; -import { ISetupWorkspace } from "@/features/auth/types/auth.types"; import useAuth from "@/features/auth/hooks/use-auth"; import classes from "@/features/auth/components/auth.module.css"; import { useTranslation } from "react-i18next"; @@ -24,19 +24,19 @@ const formSchema = z.object({ workspaceName: z.string().trim().max(50).optional(), name: z.string().min(1).max(50), email: z - .string() - .min(1, { message: "email is required" }) - .email({ message: "Invalid email address" }), + .email() + .min(1, { message: "email is required" }), password: z.string().min(8), }); +type FormValues = z.infer; export function SetupWorkspaceForm() { const { t } = useTranslation(); const { setupWorkspace, isLoading } = useAuth(); // useRedirectIfAuthenticated(); - const form = useForm({ - validate: zodResolver(formSchema), + const form = useForm({ + validate: zod4Resolver(formSchema), initialValues: { workspaceName: "", name: "", @@ -45,7 +45,7 @@ export function SetupWorkspaceForm() { }, }); - async function onSubmit(data: ISetupWorkspace) { + async function onSubmit(data: FormValues) { await setupWorkspace(data); } diff --git a/apps/client/src/features/auth/hooks/use-auth.ts b/apps/client/src/features/auth/hooks/use-auth.ts index decb393f..6e1b4e34 100644 --- a/apps/client/src/features/auth/hooks/use-auth.ts +++ b/apps/client/src/features/auth/hooks/use-auth.ts @@ -23,7 +23,7 @@ import { acceptInvitation, createWorkspace, } from "@/features/workspace/services/workspace-service.ts"; -import APP_ROUTE from "@/lib/app-route.ts"; +import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts"; import { RESET } from "jotai/utils"; import { useTranslation } from "react-i18next"; import { isCloud } from "@/lib/config.ts"; @@ -44,11 +44,11 @@ export default function useAuth() { // Check if MFA is required if (response?.userHasMfa) { - navigate(APP_ROUTE.AUTH.MFA_CHALLENGE); + navigate(APP_ROUTE.AUTH.MFA_CHALLENGE + window.location.search); } else if (response?.requiresMfaSetup) { - navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED); + navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED + window.location.search); } else { - navigate(APP_ROUTE.HOME); + navigate(getPostLoginRedirect()); } } catch (err) { setIsLoading(false); diff --git a/apps/client/src/features/auth/hooks/use-redirect-if-authenticated.ts b/apps/client/src/features/auth/hooks/use-redirect-if-authenticated.ts index 8961ea93..10c76bd3 100644 --- a/apps/client/src/features/auth/hooks/use-redirect-if-authenticated.ts +++ b/apps/client/src/features/auth/hooks/use-redirect-if-authenticated.ts @@ -1,6 +1,6 @@ import { useEffect } from "react"; import useCurrentUser from "@/features/user/hooks/use-current-user.ts"; -import APP_ROUTE from "@/lib/app-route.ts"; +import { getPostLoginRedirect } from "@/lib/app-route.ts"; import { useNavigate } from "react-router-dom"; export function useRedirectIfAuthenticated() { @@ -9,7 +9,7 @@ export function useRedirectIfAuthenticated() { useEffect(() => { if (data && data?.user) { - navigate(APP_ROUTE.HOME); + navigate(getPostLoginRedirect()); } }, [isLoading, data]); } diff --git a/apps/client/src/features/comment/components/comment-dialog.tsx b/apps/client/src/features/comment/components/comment-dialog.tsx index 7a4b55aa..6248e913 100644 --- a/apps/client/src/features/comment/components/comment-dialog.tsx +++ b/apps/client/src/features/comment/components/comment-dialog.tsx @@ -15,7 +15,6 @@ import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar- import { useEditor } from "@tiptap/react"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { useTranslation } from "react-i18next"; -import { useQueryEmit } from "@/features/websocket/use-query-emit"; interface CommentDialogProps { editor: ReturnType; @@ -31,13 +30,12 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) { const [currentUser] = useAtom(currentUserAtom); const [, setAsideState] = useAtom(asideStateAtom); const useClickOutsideRef = useClickOutside(() => { + if (document.querySelector("#mention")) return; handleDialogClose(); }); const createCommentMutation = useCreateCommentMutation(); const { isPending } = createCommentMutation; - const emit = useQueryEmit(); - const handleDialogClose = () => { setShowCommentPopup(false); editor.chain().focus().unsetCommentDecoration().run(); @@ -55,6 +53,7 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) { pageId: pageId, content: JSON.stringify(comment), selection: selectedText, + type: "inline", }; const createdComment = @@ -80,10 +79,6 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) { ); }, 400); - emit({ - operation: "invalidateComment", - pageId: pageId, - }); } finally { setShowCommentPopup(false); setDraftCommentId(""); @@ -102,9 +97,11 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) { size="lg" radius="md" w={300} + zIndex={180} position={{ bottom: 500, right: 50 }} withCloseButton withBorder + data-comment-dialog > diff --git a/apps/client/src/features/comment/components/comment-editor.tsx b/apps/client/src/features/comment/components/comment-editor.tsx index a0489cdc..067391f4 100644 --- a/apps/client/src/features/comment/components/comment-editor.tsx +++ b/apps/client/src/features/comment/components/comment-editor.tsx @@ -1,14 +1,15 @@ -import { EditorContent, useEditor } from "@tiptap/react"; +import { EditorContent, ReactNodeViewRenderer, useEditor } from "@tiptap/react"; 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 { Mention, LinkExtension } from "@docmost/editor-ext"; import classes from "./comment.module.css"; import { useFocusWithin } from "@mantine/hooks"; import clsx from "clsx"; import { forwardRef, useEffect, useImperativeHandle } from "react"; import { useTranslation } from "react-i18next"; 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 { defaultContent?: any; @@ -39,13 +40,29 @@ const CommentEditor = forwardRef( StarterKit.configure({ gapcursor: false, dropcursor: false, + link: false, }), Placeholder.configure({ placeholder: placeholder || t("Reply..."), }), - Underline, - Link, + LinkExtension, EmojiCommand, + Mention.configure({ + suggestion: { + allowSpaces: true, + items: () => [], + // @ts-ignore + render: mentionRenderItems, + }, + HTMLAttributes: { + class: "mention", + }, + }).extend({ + addNodeView() { + this.editor.isInitialized = true; + return ReactNodeViewRenderer(MentionView); + }, + }), ], editorProps: { handleDOMEvents: { @@ -60,7 +77,8 @@ const CommentEditor = forwardRef( ].includes(event.key) ) { const emojiCommand = document.querySelector("#emoji-command"); - if (emojiCommand) { + const mentionPopup = document.querySelector("#mention"); + if (emojiCommand || mentionPopup) { return true; } } @@ -84,9 +102,14 @@ const CommentEditor = forwardRef( 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(() => { - commentEditor.commands.setContent(defaultContent); - }, [defaultContent]); + if (!editable && commentEditor && defaultContent) { + commentEditor.commands.setContent(defaultContent); + } + }, [defaultContent, editable, commentEditor]); useEffect(() => { setTimeout(() => { @@ -103,7 +126,11 @@ const CommentEditor = forwardRef( })); return ( -
+
(comment.content); + const editContentRef = useRef(null); const updateCommentMutation = useUpdateCommentMutation(); const deleteCommentMutation = useDeleteCommentMutation(comment.pageId); const resolveCommentMutation = useResolveCommentMutation(); const [currentUser] = useAtom(currentUserAtom); - const emit = useQueryEmit(); const isCloudEE = useIsCloudEE(); + const createdAtAgo = useTimeAgo(comment.createdAt); useEffect(() => { setContent(comment.content); @@ -56,15 +56,14 @@ function CommentListItem({ setIsLoading(true); const commentToUpdate = { commentId: comment.id, - content: JSON.stringify(content), + content: JSON.stringify(editContentRef.current ?? content), }; await updateCommentMutation.mutateAsync(commentToUpdate); + if (editContentRef.current) { + setContent(editContentRef.current); + editContentRef.current = null; + } setIsEditing(false); - - emit({ - operation: "invalidateComment", - pageId: pageId, - }); } catch (error) { console.error("Failed to update comment:", error); } finally { @@ -76,11 +75,6 @@ function CommentListItem({ try { await deleteCommentMutation.mutateAsync(comment.id); editor?.commands.unsetComment(comment.id); - - emit({ - operation: "invalidateComment", - pageId: pageId, - }); } catch (error) { console.error("Failed to delete comment:", error); } @@ -101,11 +95,6 @@ function CommentListItem({ if (editor) { editor.commands.setCommentResolved(comment.id, !isResolved); } - - emit({ - operation: "invalidateComment", - pageId: pageId, - }); } catch (error) { console.error("Failed to toggle resolved state:", error); } @@ -128,6 +117,7 @@ function CommentListItem({ setIsEditing(true); } function cancelEdit() { + editContentRef.current = null; setIsEditing(false); } @@ -171,7 +161,7 @@ function CommentListItem({ - {timeAgo(comment.createdAt)} + {createdAtAgo}
@@ -194,7 +184,7 @@ function CommentListItem({ setContent(newContent)} + onUpdate={(newContent: any) => { editContentRef.current = newContent; }} onSave={handleUpdateComment} autofocus={true} /> diff --git a/apps/client/src/features/comment/components/comment-list-with-tabs.tsx b/apps/client/src/features/comment/components/comment-list-with-tabs.tsx index 35c7d472..82dd4494 100644 --- a/apps/client/src/features/comment/components/comment-list-with-tabs.tsx +++ b/apps/client/src/features/comment/components/comment-list-with-tabs.tsx @@ -1,6 +1,17 @@ import React, { useState, useRef, useCallback, memo, useMemo } from "react"; import { useParams } from "react-router-dom"; -import { Divider, Paper, Tabs, Badge, Text, ScrollArea } from "@mantine/core"; +import { + ActionIcon, + Center, + Divider, + Group, + Paper, + Stack, + Tabs, + Badge, + Text, + ScrollArea, +} from "@mantine/core"; import CommentListItem from "@/features/comment/components/comment-list-item"; import { useCommentsQuery, @@ -14,14 +25,8 @@ import { usePageQuery } from "@/features/page/queries/page-query.ts"; import { IPagination } from "@/lib/types.ts"; import { extractPageSlugId } from "@/lib"; import { useTranslation } from "react-i18next"; -import { useQueryEmit } from "@/features/websocket/use-query-emit"; -import { useIsCloudEE } from "@/hooks/use-is-cloud-ee"; import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts"; -import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts"; -import { - SpaceCaslAction, - SpaceCaslSubject, -} from "@/features/space/permissions/permissions.type.ts"; +import { IconArrowUp, IconMessageOff } from "@tabler/icons-react"; function CommentListWithTabs() { const { t } = useTranslation(); @@ -31,21 +36,12 @@ function CommentListWithTabs() { data: comments, isLoading: isCommentsLoading, isError, - } = useCommentsQuery({ pageId: page?.id, limit: 100 }); + } = useCommentsQuery({ pageId: page?.id }); const createCommentMutation = useCreateCommentMutation(); const [isLoading, setIsLoading] = useState(false); - const emit = useQueryEmit(); - const isCloudEE = useIsCloudEE(); const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug); - const spaceRules = space?.membership?.permissions; - const spaceAbility = useSpaceAbility(spaceRules); - - - const canComment: boolean = spaceAbility.can( - SpaceCaslAction.Manage, - SpaceCaslSubject.Page - ); + const canComment = page?.permissions?.canEdit ?? false; // Separate active and resolved comments const { activeComments, resolvedComments } = useMemo(() => { @@ -54,19 +50,47 @@ function CommentListWithTabs() { } const parentComments = comments.items.filter( - (comment: IComment) => comment.parentCommentId === null + (comment: IComment) => comment.parentCommentId === null, ); const active = parentComments.filter( - (comment: IComment) => !comment.resolvedAt + (comment: IComment) => !comment.resolvedAt, ); const resolved = parentComments.filter( - (comment: IComment) => comment.resolvedAt + (comment: IComment) => comment.resolvedAt, ); return { activeComments: active, resolvedComments: resolved }; }, [comments]); + const [isPageCommentLoading, setIsPageCommentLoading] = useState(false); + + const handleAddPageComment = useCallback( + async (_commentId: string, content: string) => { + try { + setIsPageCommentLoading(true); + const createdComment = await createCommentMutation.mutateAsync({ + pageId: page?.id, + content: JSON.stringify(content), + }); + + setTimeout(() => { + const selector = `div[data-comment-id="${createdComment.id}"]`; + const commentElement = document.querySelector(selector); + commentElement?.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + }, 400); + } catch (error) { + console.error("Failed to post comment:", error); + } finally { + setIsPageCommentLoading(false); + } + }, + [createCommentMutation, page?.id], + ); + const handleAddReply = useCallback( async (commentId: string, content: string) => { try { @@ -78,18 +102,13 @@ function CommentListWithTabs() { }; await createCommentMutation.mutateAsync(commentData); - - emit({ - operation: "invalidateComment", - pageId: page?.id, - }); } catch (error) { console.error("Failed to post comment:", error); } finally { setIsLoading(false); } }, - [createCommentMutation, page?.id] + [createCommentMutation, page?.id], ); const renderComments = useCallback( @@ -131,7 +150,7 @@ function CommentListWithTabs() { )} ), - [comments, handleAddReply, isLoading, space?.membership?.role] + [comments, handleAddReply, isLoading, space?.membership?.role], ); if (isCommentsLoading) { @@ -144,63 +163,32 @@ function CommentListWithTabs() { const totalComments = activeComments.length + resolvedComments.length; - // If not cloud/enterprise, show simple list without tabs - if (!isCloudEE) { - if (totalComments === 0) { - return <>{t("No comments yet.")}; - } - - return ( - -
- {comments?.items - .filter((comment: IComment) => comment.parentCommentId === null) - .map((comment) => ( - -
- - -
- - {canComment && ( - <> - - - - )} -
- ))} -
-
- ); - } + const pageCommentInput = canComment ? ( + + ) : null; return ( -
- +
+ -
+
{activeComments.length === 0 ? ( - - {t("No open comments.")} - +
+ + + + {t("No open comments.")} + + +
) : ( activeComments.map(renderComments) )} @@ -242,9 +239,18 @@ function CommentListWithTabs() { {resolvedComments.length === 0 ? ( - - {t("No resolved comments.")} - +
+ + + + {t("No resolved comments.")} + + +
) : ( resolvedComments.map(renderComments) )} @@ -252,6 +258,7 @@ function CommentListWithTabs() {
+ {pageCommentInput}
); } @@ -273,9 +280,9 @@ const ChildComments = ({ const getChildComments = useCallback( (parentId: string) => comments.items.filter( - (comment: IComment) => comment.parentCommentId === parentId + (comment: IComment) => comment.parentCommentId === parentId, ), - [comments.items] + [comments.items], ); return ( @@ -303,7 +310,12 @@ const ChildComments = ({ const MemoizedChildComments = memo(ChildComments); -const CommentEditorWithActions = ({ commentId, onSave, isLoading }) => { +const CommentEditorWithActions = ({ + commentId, + onSave, + isLoading, + placeholder = undefined, +}) => { const [content, setContent] = useState(""); const { ref, focused } = useFocusWithin(); const commentEditorRef = useRef(null); @@ -321,10 +333,57 @@ const CommentEditorWithActions = ({ commentId, onSave, isLoading }) => { onUpdate={setContent} onSave={handleSave} editable={true} + placeholder={placeholder} /> {focused && }
); }; +const PageCommentInput = ({ onSave, isLoading }) => { + const { t } = useTranslation(); + const [content, setContent] = useState(""); + const { ref, focused } = useFocusWithin(); + const commentEditorRef = useRef(null); + + const handleSave = useCallback(() => { + onSave(null, content); + setContent(""); + commentEditorRef.current?.clearContent(); + }, [content, onSave]); + + return ( +
+ + {focused && ( + + + + )} +
+ ); +}; + export default CommentListWithTabs; diff --git a/apps/client/src/features/comment/components/comment.module.css b/apps/client/src/features/comment/components/comment.module.css index fd4ac5be..59032499 100644 --- a/apps/client/src/features/comment/components/comment.module.css +++ b/apps/client/src/features/comment/components/comment.module.css @@ -32,11 +32,14 @@ max-width: 100%; white-space: pre-wrap; word-break: break-word; - max-height: 20vh; padding-left: 6px; padding-right: 6px; margin-top: 10px; margin-bottom: 2px; + } + + &[data-editable] .ProseMirror :global(.ProseMirror){ + max-height: 50vh; overflow: hidden auto; } diff --git a/apps/client/src/features/comment/queries/comment-query.ts b/apps/client/src/features/comment/queries/comment-query.ts index c10ca418..6c86c039 100644 --- a/apps/client/src/features/comment/queries/comment-query.ts +++ b/apps/client/src/features/comment/queries/comment-query.ts @@ -1,8 +1,8 @@ import { + useInfiniteQuery, useMutation, - useQuery, useQueryClient, - UseQueryResult, + InfiniteData, } from "@tanstack/react-query"; import { createComment, @@ -17,17 +17,40 @@ import { import { notifications } from "@mantine/notifications"; import { IPagination } from "@/lib/types.ts"; import { useTranslation } from "react-i18next"; +import { useEffect, useMemo } from "react"; export const RQ_KEY = (pageId: string) => ["comments", pageId]; -export function useCommentsQuery( - params: ICommentParams, -): UseQueryResult, Error> { - return useQuery({ +export function useCommentsQuery(params: ICommentParams) { + const query = useInfiniteQuery({ queryKey: RQ_KEY(params.pageId), - queryFn: () => getPageComments(params), + queryFn: ({ pageParam }) => + getPageComments({ pageId: params.pageId, cursor: pageParam, limit: 100 }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => + lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined, enabled: !!params.pageId, }); + + useEffect(() => { + if (query.hasNextPage && !query.isFetchingNextPage) { + query.fetchNextPage(); + } + }, [query.hasNextPage, query.isFetchingNextPage, query.fetchNextPage]); + + const data = useMemo | undefined>(() => { + if (!query.data) return undefined; + return { + items: query.data.pages.flatMap((p) => p.items), + meta: query.data.pages[query.data.pages.length - 1].meta, + }; + }, [query.data]); + + return { + data, + isLoading: query.isLoading || query.hasNextPage, + isError: query.isError, + }; } export function useCreateCommentMutation() { @@ -36,18 +59,26 @@ export function useCreateCommentMutation() { return useMutation>({ mutationFn: (data) => createComment(data), - onSuccess: (data) => { - //const newComment = data; - // let comments = queryClient.getQueryData(RQ_KEY(data.pageId)); - // if (comments) { - //comments = prevComments => [...prevComments, newComment]; - //queryClient.setQueryData(RQ_KEY(data.pageId), comments); - //} + onSuccess: (newComment) => { + const cache = queryClient.getQueryData( + RQ_KEY(newComment.pageId), + ) as InfiniteData> | undefined; + + if (cache && cache.pages.length > 0) { + const lastIdx = cache.pages.length - 1; + queryClient.setQueryData(RQ_KEY(newComment.pageId), { + ...cache, + pages: cache.pages.map((page, i) => + i === lastIdx + ? { ...page, items: [...page.items, newComment] } + : page, + ), + }); + } - queryClient.refetchQueries({ queryKey: RQ_KEY(data.pageId) }); notifications.show({ message: t("Comment created successfully") }); }, - onError: (error) => { + onError: () => { notifications.show({ message: t("Error creating comment"), color: "red", @@ -57,14 +88,31 @@ export function useCreateCommentMutation() { } export function useUpdateCommentMutation() { + const queryClient = useQueryClient(); const { t } = useTranslation(); return useMutation>({ mutationFn: (data) => updateComment(data), - onSuccess: (data) => { + onSuccess: (updatedComment) => { + const cache = queryClient.getQueryData( + RQ_KEY(updatedComment.pageId), + ) as InfiniteData> | undefined; + + if (cache) { + queryClient.setQueryData(RQ_KEY(updatedComment.pageId), { + ...cache, + pages: cache.pages.map((page) => ({ + ...page, + items: page.items.map((comment) => + comment.id === updatedComment.id ? updatedComment : comment, + ), + })), + }); + } + notifications.show({ message: t("Comment updated successfully") }); }, - onError: (error) => { + onError: () => { notifications.show({ message: t("Failed to update comment"), color: "red", @@ -79,25 +127,24 @@ export function useDeleteCommentMutation(pageId?: string) { return useMutation({ mutationFn: (commentId: string) => deleteComment(commentId), - onSuccess: (data, variables) => { - const comments = queryClient.getQueryData( + onSuccess: (_data, commentId) => { + const cache = queryClient.getQueryData( RQ_KEY(pageId), - ) as IPagination; + ) as InfiniteData> | undefined; - if (comments && comments.items) { - const commentId = variables; - const newComments = comments.items.filter( - (comment) => comment.id !== commentId, - ); + if (cache) { queryClient.setQueryData(RQ_KEY(pageId), { - ...comments, - items: newComments, + ...cache, + pages: cache.pages.map((page) => ({ + ...page, + items: page.items.filter((comment) => comment.id !== commentId), + })), }); } notifications.show({ message: t("Comment deleted successfully") }); }, - onError: (error) => { + onError: () => { notifications.show({ message: t("Failed to delete comment"), color: "red", diff --git a/apps/client/src/features/editor/atoms/editor-atoms.ts b/apps/client/src/features/editor/atoms/editor-atoms.ts index d4f133f7..25d9332b 100644 --- a/apps/client/src/features/editor/atoms/editor-atoms.ts +++ b/apps/client/src/features/editor/atoms/editor-atoms.ts @@ -8,3 +8,5 @@ export const titleEditorAtom = atom(null); export const readOnlyEditorAtom = atom(null); export const yjsConnectionStatusAtom = atom(""); + +export const showAiMenuAtom = atom(false); diff --git a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.module.css b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.module.css index e43c1714..facaf7ff 100644 --- a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.module.css +++ b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.module.css @@ -1,11 +1,53 @@ .bubbleMenu { display: flex; + flex-wrap: nowrap; + overflow-x: auto; + max-width: 100vw; width: fit-content; - border-radius: 2px; + box-shadow: 0 4px 12px light-dark(#cfcfcf, #0f0f0f); + border-radius: 6px; border: 1px solid 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 { 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) + ); +} diff --git a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx index a6d143ff..e7bc89f3 100644 --- a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx +++ b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx @@ -9,10 +9,11 @@ import { IconStrikethrough, IconUnderline, IconMessage, + IconSparkles, } from "@tabler/icons-react"; import clsx from "clsx"; import classes from "./bubble-menu.module.css"; -import { ActionIcon, rem, Tooltip } from "@mantine/core"; +import { ActionIcon, Button, rem, Tooltip } from "@mantine/core"; import { ColorSelector } from "./color-selector"; import { NodeSelector } from "./node-selector"; import { TextAlignmentSelector } from "./text-alignment-selector"; @@ -20,11 +21,13 @@ import { draftCommentIdAtom, showCommentPopupAtom, } from "@/features/comment/atoms/comment-atom"; -import { useAtom } from "jotai"; +import { useAtom, useAtomValue } from "jotai"; import { v7 as uuid7 } from "uuid"; import { isCellSelection, isTextSelected } from "@docmost/editor-ext"; import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx"; 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 { name: string; @@ -39,14 +42,22 @@ type EditorBubbleMenuProps = Omit & { export const EditorBubbleMenu: FC = (props) => { const { t } = useTranslation(); + const [showAiMenu, setShowAiMenu] = useAtom(showAiMenuAtom); const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom); + const workspace = useAtomValue(workspaceAtom); + const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true; const [, setDraftCommentId] = useAtom(draftCommentIdAtom); const showCommentPopupRef = useRef(showCommentPopup); + const showAiMenuRef = useRef(showAiMenu); useEffect(() => { showCommentPopupRef.current = showCommentPopup; }, [showCommentPopup]); + useEffect(() => { + showAiMenuRef.current = showAiMenu; + }, [showAiMenu]); + const editorState = useEditorState({ editor: props.editor, selector: (ctx) => { @@ -123,6 +134,7 @@ export const EditorBubbleMenu: FC = (props) => { empty || isNodeSelection(selection) || isCellSelection(selection) || + showAiMenuRef.current || showCommentPopupRef?.current ) { return false; @@ -146,9 +158,31 @@ export const EditorBubbleMenu: FC = (props) => { const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false); const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false); + // Hide the bubble menu immediately when AI menu is shown + if (showAiMenu) return; + return ( - +
+ {isGenerativeAiEnabled && ( + <> + +
+ + )} = (props) => { }} /> - - - + + + + +
); diff --git a/apps/client/src/features/editor/components/bubble-menu/color-selector.tsx b/apps/client/src/features/editor/components/bubble-menu/color-selector.tsx index d0907b81..0d4d80c0 100644 --- a/apps/client/src/features/editor/components/bubble-menu/color-selector.tsx +++ b/apps/client/src/features/editor/components/bubble-menu/color-selector.tsx @@ -1,7 +1,6 @@ import React, { Dispatch, FC, SetStateAction } from "react"; -import { IconCheck, IconChevronDown, IconPalette } from "@tabler/icons-react"; +import { IconCheck, IconChevronDown } from "@tabler/icons-react"; import { - ActionIcon, Button, Popover, rem, @@ -15,6 +14,8 @@ import { import type { Editor } from "@tiptap/react"; import { useEditorState } from "@tiptap/react"; import { useTranslation } from "react-i18next"; +import clsx from "clsx"; +import classes from "./bubble-menu.module.css"; export interface BubbleColorMenuItem { name: string; @@ -166,14 +167,10 @@ export const ColorSelector: FC = ({ onClick={() => setIsOpen(!isOpen)} data-text-color={activeColorItem?.color || ""} data-highlight-color={activeHighlightItem?.color || ""} - className="color-selector-trigger" + className={clsx(["color-selector-trigger", classes.buttonRoot])} style={{ - height: "34px", - border: "none", fontWeight: 500, fontSize: rem(16), - paddingLeft: rem(8), - paddingRight: rem(4), }} > A diff --git a/apps/client/src/features/editor/components/bubble-menu/link-selector.tsx b/apps/client/src/features/editor/components/bubble-menu/link-selector.tsx index 67bb9f82..358dc822 100644 --- a/apps/client/src/features/editor/components/bubble-menu/link-selector.tsx +++ b/apps/client/src/features/editor/components/bubble-menu/link-selector.tsx @@ -2,6 +2,7 @@ import { Dispatch, FC, SetStateAction, useCallback } from "react"; import { IconLink } from "@tabler/icons-react"; import { ActionIcon, Popover, Tooltip } from "@mantine/core"; import { useEditor } from "@tiptap/react"; +import { TextSelection } from "@tiptap/pm/state"; import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx"; import { useTranslation } from "react-i18next"; @@ -20,7 +21,15 @@ export const LinkSelector: FC = ({ const onLink = useCallback( (url: string) => { setIsOpen(false); - editor.chain().focus().setLink({ href: url }).run(); + editor + .chain() + .focus() + .setLink({ href: url }) + .command(({ tr }) => { + tr.setSelection(TextSelection.create(tr.doc, tr.selection.to)); + return true; + }) + .run(); }, [editor, setIsOpen], ); diff --git a/apps/client/src/features/editor/components/bubble-menu/node-selector.tsx b/apps/client/src/features/editor/components/bubble-menu/node-selector.tsx index 13b2117f..7fecff9e 100644 --- a/apps/client/src/features/editor/components/bubble-menu/node-selector.tsx +++ b/apps/client/src/features/editor/components/bubble-menu/node-selector.tsx @@ -1,6 +1,7 @@ import React, { Dispatch, FC, SetStateAction } from "react"; import { IconBlockquote, + IconCaretRightFilled, IconCheck, IconCheckbox, IconChevronDown, @@ -8,14 +9,16 @@ import { IconH1, IconH2, IconH3, + IconInfoCircle, IconList, IconListNumbers, IconTypography, } from "@tabler/icons-react"; -import { Popover, Button, ScrollArea } from "@mantine/core"; +import { Popover, Button, ScrollArea, Tooltip } from "@mantine/core"; import type { Editor } from "@tiptap/react"; import { useEditorState } from "@tiptap/react"; import { useTranslation } from "react-i18next"; +import classes from "./bubble-menu.module.css"; interface NodeSelectorProps { editor: Editor | null; @@ -54,6 +57,8 @@ export const NodeSelector: FC = ({ isTaskItem: ctx.editor.isActive("taskItem"), isBlockquote: ctx.editor.isActive("blockquote"), isCodeBlock: ctx.editor.isActive("codeBlock"), + isCallout: ctx.editor.isActive("callout"), + isDetails: ctx.editor.isActive("details"), }; }, }); @@ -123,6 +128,18 @@ export const NodeSelector: FC = ({ command: () => editor.chain().focus().toggleCodeBlock().run(), 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() ?? { @@ -132,15 +149,18 @@ export const NodeSelector: FC = ({ return ( - + + + diff --git a/apps/client/src/features/editor/components/bubble-menu/text-alignment-selector.tsx b/apps/client/src/features/editor/components/bubble-menu/text-alignment-selector.tsx index b5277651..f8dba1c9 100644 --- a/apps/client/src/features/editor/components/bubble-menu/text-alignment-selector.tsx +++ b/apps/client/src/features/editor/components/bubble-menu/text-alignment-selector.tsx @@ -7,7 +7,7 @@ import { IconCheck, IconChevronDown, } from "@tabler/icons-react"; -import { Popover, Button, ScrollArea, rem } from "@mantine/core"; +import { Popover, Button, ScrollArea, Tooltip, rem } from "@mantine/core"; import type { Editor } from "@tiptap/react"; import { useEditorState } from "@tiptap/react"; import { useTranslation } from "react-i18next"; @@ -84,16 +84,18 @@ export const TextAlignmentSelector: FC = ({ return ( - + + + diff --git a/apps/client/src/features/editor/components/callout/callout-menu.tsx b/apps/client/src/features/editor/components/callout/callout-menu.tsx index e7ee2138..69c83693 100644 --- a/apps/client/src/features/editor/components/callout/callout-menu.tsx +++ b/apps/client/src/features/editor/components/callout/callout-menu.tsx @@ -1,22 +1,25 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; import React, { useCallback } from "react"; -import { Node as PMNode } from "prosemirror-model"; +import { Node as PMNode } from "@tiptap/pm/model"; import { EditorMenuProps, ShouldShowProps, } from "@/features/editor/components/table/types/types.ts"; import { ActionIcon, Tooltip } from "@mantine/core"; +import clsx from "clsx"; import { IconAlertTriangleFilled, IconCircleCheckFilled, IconCircleXFilled, IconInfoCircleFilled, IconMoodSmile, + IconNotes, } from "@tabler/icons-react"; -import { CalloutType } from "@docmost/editor-ext"; +import { CalloutType, isTextSelected } from "@docmost/editor-ext"; import { useTranslation } from "react-i18next"; import EmojiPicker from "@/components/ui/emoji-picker.tsx"; +import classes from "../common/toolbar-menu.module.css"; export function CalloutMenu({ editor }: EditorMenuProps) { const { t } = useTranslation(); @@ -26,6 +29,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) { if (!state) { return false; } + if (isTextSelected(editor)) return false; return editor.isActive("callout"); }, @@ -42,6 +46,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) { return { isCallout: ctx.editor.isActive("callout"), isInfo: ctx.editor.isActive("callout", { type: "info" }), + isNote: ctx.editor.isActive("callout", { type: "note" }), isSuccess: ctx.editor.isActive("callout", { type: "success" }), isWarning: ctx.editor.isActive("callout", { type: "warning" }), isDanger: ctx.editor.isActive("callout", { type: "danger" }), @@ -126,48 +131,73 @@ export function CalloutMenu({ editor }: EditorMenuProps) { }} shouldShow={shouldShow} > - - +
+ setCalloutType("info")} size="lg" aria-label={t("Info")} - variant={editorState?.isInfo ? "light" : "default"} + variant="subtle" + className={clsx({ [classes.active]: editorState?.isInfo })} > - + - + + setCalloutType("note")} + size="lg" + aria-label={t("Note")} + variant="subtle" + className={clsx({ [classes.active]: editorState?.isNote })} + > + + + + + setCalloutType("success")} size="lg" aria-label={t("Success")} - variant={editorState?.isSuccess ? "light" : "default"} + variant="subtle" + className={clsx({ [classes.active]: editorState?.isSuccess })} > - + - + setCalloutType("warning")} size="lg" aria-label={t("Warning")} - variant={editorState?.isWarning ? "light" : "default"} + variant="subtle" + className={clsx({ [classes.active]: editorState?.isWarning })} > - + - + setCalloutType("danger")} size="lg" aria-label={t("Danger")} - variant={editorState?.isDanger ? "light" : "default"} + variant="subtle" + className={clsx({ [classes.active]: editorState?.isDanger })} > - + @@ -178,11 +208,10 @@ export function CalloutMenu({ editor }: EditorMenuProps) { icon={currentIcon || } actionIconProps={{ size: "lg", - variant: "default", - c: undefined, + variant: "subtle", }} /> - +
); } diff --git a/apps/client/src/features/editor/components/callout/callout-view.tsx b/apps/client/src/features/editor/components/callout/callout-view.tsx index 5583bd87..3cf5bb57 100644 --- a/apps/client/src/features/editor/components/callout/callout-view.tsx +++ b/apps/client/src/features/editor/components/callout/callout-view.tsx @@ -4,6 +4,7 @@ import { IconCircleCheckFilled, IconCircleXFilled, IconInfoCircleFilled, + IconNotes, } from "@tabler/icons-react"; import { Alert } from "@mantine/core"; import classes from "./callout.module.css"; @@ -22,6 +23,7 @@ export default function CalloutView(props: NodeViewProps) { icon={getCalloutIcon(type, icon)} p="xs" classNames={{ + root: classes.root, message: classes.message, icon: classes.icon, }} @@ -34,12 +36,14 @@ export default function CalloutView(props: NodeViewProps) { function getCalloutIcon(type: CalloutType, customIcon?: string) { if (customIcon && customIcon.trim() !== "") { - return {customIcon}; + return {customIcon}; } switch (type) { case "info": return ; + case "note": + return ; case "success": return ; case "warning": @@ -55,6 +59,8 @@ function getCalloutColor(type: CalloutType) { switch (type) { case "info": return "blue"; + case "note": + return "grape"; case "success": return "green"; case "warning": diff --git a/apps/client/src/features/editor/components/callout/callout.module.css b/apps/client/src/features/editor/components/callout/callout.module.css index 2839b426..8289f1a7 100644 --- a/apps/client/src/features/editor/components/callout/callout.module.css +++ b/apps/client/src/features/editor/components/callout/callout.module.css @@ -1,9 +1,13 @@ +.root { + overflow: visible; +} + .icon { font-size: 24px; line-height: 1; width: 20px; height: 20px; - margin-inline-end: var(--mantine-spacing-md); + margin-inline-end: var(--mantine-spacing-xs); margin-top: 4px; cursor: pointer; } @@ -11,18 +15,8 @@ .message { font-size: var(--mantine-font-size-md); color: var(--mantine-color-default-color); - - white-space: nowrap; + overflow: visible; + text-overflow: unset; word-break: break-word; overflow-wrap: break-word; } - -/* - @mixin where-light { - color: var(--mantine-color-default-color); - } - - @mixin where-dark { - color: var(--mantine-color-default-color); - } -*/ diff --git a/apps/client/src/features/editor/components/code-block/code-block-view.tsx b/apps/client/src/features/editor/components/code-block/code-block-view.tsx index 130016a3..0ff2fe36 100644 --- a/apps/client/src/features/editor/components/code-block/code-block-view.tsx +++ b/apps/client/src/features/editor/components/code-block/code-block-view.tsx @@ -1,5 +1,6 @@ import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react"; -import { ActionIcon, CopyButton, Group, Select, Tooltip } from "@mantine/core"; +import { ActionIcon, Group, Select, Tooltip } from "@mantine/core"; +import { CopyButton } from "@/components/common/copy-button"; import { useEffect, useState } from "react"; import { IconCheck, IconCopy } from "@tabler/icons-react"; import classes from "./code-block.module.css"; diff --git a/apps/client/src/features/editor/components/columns/columns-menu.tsx b/apps/client/src/features/editor/components/columns/columns-menu.tsx new file mode 100644 index 00000000..0ee99508 --- /dev/null +++ b/apps/client/src/features/editor/components/columns/columns-menu.tsx @@ -0,0 +1,361 @@ +import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; +import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; +import React, { useCallback, useRef, useState } from "react"; +import { DOMSerializer, Node as PMNode } from "@tiptap/pm/model"; +import { + EditorMenuProps, + ShouldShowProps, +} from "@/features/editor/components/table/types/types.ts"; +import { ActionIcon, Tooltip, Popover, Button } from "@mantine/core"; +import clsx from "clsx"; +import { + IconChevronDown, + IconCheck, + IconColumns2, + IconColumns3, + IconLayoutSidebar, + IconLayoutSidebarRight, + IconLayoutAlignCenter, + IconCopy, + IconTrash, +} from "@tabler/icons-react"; +import { isTextSelected } from "@docmost/editor-ext"; +import type { WidthMode, ColumnsLayout } from "@docmost/editor-ext"; +import { useTranslation } from "react-i18next"; +import classes from "../common/toolbar-menu.module.css"; + +type LayoutPreset = { + layout: ColumnsLayout; + label: string; + icon: React.ElementType; +}; + +const twoColumnPresets: LayoutPreset[] = [ + { layout: "two_equal", label: "Equal columns", icon: IconColumns2 }, + { + layout: "two_left_sidebar", + label: "Left sidebar", + icon: IconLayoutSidebar, + }, + { + layout: "two_right_sidebar", + label: "Right sidebar", + icon: IconLayoutSidebarRight, + }, +]; + +const threeColumnPresets: LayoutPreset[] = [ + { layout: "three_equal", label: "Equal columns", icon: IconColumns3 }, + { + layout: "three_with_sidebars", + label: "Wide center", + icon: IconLayoutAlignCenter, + }, + { + layout: "three_left_wide", + label: "Left wide", + icon: IconLayoutSidebarRight, + }, + { layout: "three_right_wide", label: "Right wide", icon: IconLayoutSidebar }, +]; + +function getPresetsForCount(count: number): LayoutPreset[] { + if (count === 2) return twoColumnPresets; + if (count === 3) return threeColumnPresets; + return []; +} + +export function ColumnsMenu({ editor }: EditorMenuProps) { + const { t } = useTranslation(); + const [isCountOpen, setIsCountOpen] = useState(false); + const [copied, setCopied] = useState(false); + const copyTimerRef = useRef>(); + + const nodesWithMenus = [ + "callout", + "image", + "video", + "drawio", + "excalidraw", + "table", + ]; + + const shouldShow = useCallback( + ({ state }: ShouldShowProps) => { + if (!state) return false; + if (!editor.isActive("columns")) return false; + if (isTextSelected(editor)) return false; + if (nodesWithMenus.some((name) => editor.isActive(name))) return false; + + const parent = findParentNode( + (node: PMNode) => node.type.name === "columns", + )(state.selection); + if (!parent) return false; + + const dom = editor.view.nodeDOM(parent.pos) as HTMLElement; + if (!dom) return false; + + const rect = dom.getBoundingClientRect(); + return rect.bottom > 0 && rect.top < window.innerHeight; + }, + [editor], + ); + + const editorState = useEditorState({ + editor, + selector: (ctx) => { + if (!ctx.editor) return null; + + const { selection } = ctx.editor.state; + const parent = findParentNode( + (node: PMNode) => node.type.name === "columns", + )(selection); + + return { + columnCount: parent?.node.childCount || 2, + layout: (parent?.node.attrs.layout as ColumnsLayout) || "two_equal", + isNormal: ctx.editor.isActive("columns", { widthMode: "normal" }), + isWide: ctx.editor.isActive("columns", { widthMode: "wide" }), + }; + }, + }); + + const getReferencedVirtualElement = useCallback(() => { + if (!editor) return; + const { selection } = editor.state; + const predicate = (node: PMNode) => node.type.name === "columns"; + const parent = findParentNode(predicate)(selection); + + if (parent) { + const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement; + const domRect = dom.getBoundingClientRect(); + + // Columns entirely out of viewport — return real rect so menu goes off-screen + if (domRect.bottom <= 0 || domRect.top >= window.innerHeight) { + return { + getBoundingClientRect: () => domRect, + getClientRects: () => [domRect], + }; + } + + // Clamp bottom so menu stays within viewport when columns extend below it + // 55px = 15px offset + ~40px menu height + const maxBottom = window.innerHeight - 55; + if (domRect.bottom > maxBottom) { + const clamped = new DOMRect( + domRect.x, + domRect.y, + domRect.width, + maxBottom - domRect.y, + ); + return { + getBoundingClientRect: () => clamped, + getClientRects: () => [clamped], + }; + } + + return { + getBoundingClientRect: () => domRect, + getClientRects: () => [domRect], + }; + } + + const domRect = posToDOMRect(editor.view, selection.from, selection.to); + return { + getBoundingClientRect: () => domRect, + getClientRects: () => [domRect], + }; + }, [editor]); + + const setColumnCount = useCallback( + (count: number) => { + editor + .chain() + .focus(undefined, { scrollIntoView: false }) + .setColumnCount(count) + .run(); + setIsCountOpen(false); + }, + [editor], + ); + + const setLayout = useCallback( + (layout: ColumnsLayout) => { + editor + .chain() + .focus(undefined, { scrollIntoView: false }) + .setColumnsLayout(layout) + .run(); + }, + [editor], + ); + + const handleCopy = useCallback(() => { + const { state } = editor; + const parent = findParentNode( + (node: PMNode) => node.type.name === "columns", + )(state.selection); + if (!parent) return; + + const serializer = DOMSerializer.fromSchema(state.schema); + const dom = serializer.serializeNode(parent.node); + const wrapper = document.createElement("div"); + wrapper.appendChild(dom); + + const onSuccess = () => { + clearTimeout(copyTimerRef.current); + setCopied(true); + copyTimerRef.current = setTimeout(() => setCopied(false), 1500); + }; + + if (navigator.clipboard?.write) { + navigator.clipboard + .write([ + new ClipboardItem({ + "text/html": new Blob([wrapper.innerHTML], { type: "text/html" }), + "text/plain": new Blob([parent.node.textContent], { + type: "text/plain", + }), + }), + ]) + .then(onSuccess) + .catch(execCommandFallback); + } else { + execCommandFallback(); + } + + function execCommandFallback() { + wrapper.style.position = "fixed"; + wrapper.style.left = "-9999px"; + document.body.appendChild(wrapper); + const range = document.createRange(); + range.selectNodeContents(wrapper); + const sel = window.getSelection(); + sel?.removeAllRanges(); + sel?.addRange(range); + document.execCommand("copy"); + sel?.removeAllRanges(); + document.body.removeChild(wrapper); + editor.view.focus(); + onSuccess(); + } + }, [editor]); + + const handleDelete = useCallback(() => { + const parent = findParentNode( + (node: PMNode) => node.type.name === "columns", + )(editor.state.selection); + if (!parent) return; + editor.chain().focus().setNodeSelection(parent.pos).deleteSelection().run(); + }, [editor]); + + const columnCount = editorState?.columnCount || 2; + const currentLayout = editorState?.layout || "two_equal"; + const presets = getPresetsForCount(columnCount); + + return ( + +
+ + + + + + + {[2, 3, 4, 5].map((n) => ( + + ))} + + + + + {presets.length > 0 &&
} + + {presets.map((preset) => ( + + setLayout(preset.layout)} + size="lg" + aria-label={t(preset.label)} + variant="subtle" + className={clsx({ + [classes.active]: currentLayout === preset.layout, + })} + > + + + + ))} + +
+ + + + {copied ? ( + + ) : ( + + )} + + + + + + + + +
+ + ); +} + +export default ColumnsMenu; diff --git a/apps/client/src/features/editor/components/common/editor-paste-handler.tsx b/apps/client/src/features/editor/components/common/editor-paste-handler.tsx index 61d7534e..6407d835 100644 --- a/apps/client/src/features/editor/components/common/editor-paste-handler.tsx +++ b/apps/client/src/features/editor/components/common/editor-paste-handler.tsx @@ -4,6 +4,20 @@ import { uploadAttachmentAction } from "../attachment/upload-attachment-action"; import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts"; import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts"; import { Editor } from "@tiptap/core"; +import { + getAttachmentInfo, + uploadFile, +} from "@/features/page/services/page-service.ts"; + +const ATTACHMENT_NODE_TYPES = [ + "image", + "video", + "attachment", + "excalidraw", + "drawio", +]; + +const ATTACHMENT_URL_RE = /\/api\/files\/([0-9a-f-]+)\//; export const handlePaste = ( editor: Editor, @@ -19,7 +33,6 @@ export const handlePaste = ( const url = clipboardData.trim(); const { from: pos, empty } = editor.state.selection; const match = INTERNAL_LINK_REGEX.exec(url); - const currentPageMatch = INTERNAL_LINK_REGEX.exec(window.location.href); // pasted link must be from the same workspace/domain and must not be on a selection if (!empty || match[2] !== window.location.host) { @@ -27,12 +40,6 @@ export const handlePaste = ( return false; } - // for now, we only support internal links from the same space - // compare space name - if (currentPageMatch[4].toLowerCase() !== match[4].toLowerCase()) { - return false; - } - const anchorId = match[6] ? match[6].split("#")[0] : undefined; const urlWithoutAnchor = anchorId ? url.substring(0, url.indexOf("#")) @@ -47,7 +54,10 @@ export const handlePaste = ( return true; } - if (event.clipboardData?.files.length) { + const htmlData = event.clipboardData?.getData("text/html"); + const hasHtmlTable = htmlData && /]/i.test(htmlData); + + if (event.clipboardData?.files.length && !hasHtmlTable) { event.preventDefault(); for (const file of event.clipboardData.files) { const pos = editor.state.selection.from; @@ -57,9 +67,151 @@ export const handlePaste = ( } return true; } + + if (htmlData && ATTACHMENT_URL_RE.test(htmlData)) { + const pasteFrom = editor.state.selection.from; + setTimeout(() => { + reuploadPastedAttachments(editor, pageId, pasteFrom); + }, 0); + } + return false; }; +async function reuploadPastedAttachments( + editor: Editor, + pageId: string, + pasteFrom: number, +) { + const pasteEnd = editor.state.selection.from; + if (pasteEnd <= pasteFrom) return; + + type PastedNode = { + pos: number; + attachmentId: string; + nodeTypeName: string; + src?: string; + url?: string; + fileName?: string; + }; + + const pastedNodes: PastedNode[] = []; + const seenAttachmentIds = new Set(); + + editor.state.doc.nodesBetween(pasteFrom, pasteEnd, (node, pos) => { + if (!ATTACHMENT_NODE_TYPES.includes(node.type.name)) return; + const attachmentId = node.attrs.attachmentId; + if (!attachmentId) return; + + const src = node.attrs.src || node.attrs.url || ""; + const match = ATTACHMENT_URL_RE.exec(src); + if (!match) return; + + const fileName = + node.attrs.name || src.split("/").pop() || "file"; + + pastedNodes.push({ + pos, + attachmentId, + nodeTypeName: node.type.name, + src: node.attrs.src, + url: node.attrs.url, + fileName, + }); + seenAttachmentIds.add(attachmentId); + }); + + if (pastedNodes.length === 0) return; + + const attachmentPageMap = new Map(); + await Promise.all( + [...seenAttachmentIds].map(async (id) => { + try { + const info = await getAttachmentInfo(id); + attachmentPageMap.set(id, info.pageId); + } catch { + attachmentPageMap.set(id, null); + } + }), + ); + + const nodesToReupload = pastedNodes.filter((n) => { + const ownerPageId = attachmentPageMap.get(n.attachmentId); + return ownerPageId !== null && ownerPageId !== pageId; + }); + + if (nodesToReupload.length === 0) return; + + const uniqueNodes = new Map(); + for (const node of nodesToReupload) { + if (!uniqueNodes.has(node.attachmentId)) { + uniqueNodes.set(node.attachmentId, node); + } + } + + const reuploadResults = new Map< + string, + { id: string; fileName: string; fileSize: number; mimeType: string } + >(); + + await Promise.all( + [...uniqueNodes.values()].map(async (node) => { + const fileUrl = node.src || node.url; + if (!fileUrl) return; + + try { + const response = await fetch(fileUrl, { credentials: "include" }); + if (!response.ok) return; + const blob = await response.blob(); + const file = new File([blob], node.fileName, { type: blob.type }); + const newAttachment = await uploadFile(file, pageId); + reuploadResults.set(node.attachmentId, { + id: newAttachment.id, + fileName: newAttachment.fileName, + fileSize: newAttachment.fileSize, + mimeType: newAttachment.mimeType, + }); + } catch { + // keep original reference on failure + } + }), + ); + + if (reuploadResults.size === 0) return; + + editor.chain().command(({ tr }) => { + const sorted = [...nodesToReupload].sort((a, b) => b.pos - a.pos); + + for (const pastedNode of sorted) { + const result = reuploadResults.get(pastedNode.attachmentId); + if (!result) continue; + + const node = tr.doc.nodeAt(pastedNode.pos); + if (!node || node.attrs.attachmentId !== pastedNode.attachmentId) + continue; + + const newAttrs = { ...node.attrs }; + newAttrs.attachmentId = result.id; + + if (newAttrs.src) { + newAttrs.src = `/api/files/${result.id}/${result.fileName}`; + } + if (newAttrs.url) { + newAttrs.url = `/api/files/${result.id}/${result.fileName}`; + } + if (pastedNode.nodeTypeName === "attachment") { + newAttrs.name = result.fileName; + newAttrs.mime = result.mimeType; + newAttrs.size = result.fileSize; + } + + tr.setNodeMarkup(pastedNode.pos, undefined, newAttrs); + } + + return true; + }).run(); +} + export const handleFileDrop = ( editor: Editor, event: DragEvent, diff --git a/apps/client/src/features/editor/components/common/node-resize-handles.ts b/apps/client/src/features/editor/components/common/node-resize-handles.ts new file mode 100644 index 00000000..0785845d --- /dev/null +++ b/apps/client/src/features/editor/components/common/node-resize-handles.ts @@ -0,0 +1,35 @@ +import type { ResizableNodeViewDirection } from "@tiptap/core"; +import classes from "./node-resize.module.css"; + +export function createResizeHandle( + direction: ResizableNodeViewDirection, +): HTMLElement { + const handle = document.createElement("div"); + handle.dataset.resizeHandle = direction; + handle.style.position = "absolute"; + handle.className = classes.handle; + + if (direction === "left") { + handle.style.left = "-8px"; + handle.style.top = "0"; + handle.style.bottom = "0"; + } else if (direction === "right") { + handle.style.right = "-8px"; + handle.style.top = "0"; + handle.style.bottom = "0"; + } + + const bar = document.createElement("div"); + bar.className = classes.handleBar; + handle.appendChild(bar); + + return handle; +} + +export function buildResizeClasses(nodeClass: string) { + return { + container: `${classes.container} ${nodeClass}`, + wrapper: classes.wrapper, + resizing: classes.resizing, + }; +} diff --git a/apps/client/src/features/editor/components/common/node-resize.module.css b/apps/client/src/features/editor/components/common/node-resize.module.css new file mode 100644 index 00000000..4159e44e --- /dev/null +++ b/apps/client/src/features/editor/components/common/node-resize.module.css @@ -0,0 +1,75 @@ +.container { + display: flex; +} + +.wrapper { + position: relative; + border-radius: 8px; + overflow: visible; + max-width: 100%; +} + +.wrapper img, +.wrapper video { + height: auto !important; +} + +.resizing { + user-select: none; +} + +.handle { + position: absolute; + top: 0; + bottom: 0; + width: 16px; + display: flex; + align-items: center; + justify-content: center; + cursor: ew-resize; + opacity: 0; + transition: opacity 0.2s ease; + z-index: 2; +} + +.handle[data-resize-handle="left"] { + left: -8px; +} + +.handle[data-resize-handle="right"] { + right: -8px; +} + +.wrapper:hover .handle { + opacity: 1; +} + +.container:global(.ProseMirror-selectednode) .handle { + opacity: 1; +} + +.resizing .handle { + opacity: 1; +} + +.handleBar { + width: 4px; + height: 48px; + border-radius: 4px; + transition: background-color 0.15s ease; + background-color: light-dark(var(--mantine-color-blue-4), var(--mantine-color-blue-5)); +} + +.handle:hover .handleBar { + background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4)); +} + +.resizing .handleBar { + background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4)); +} + +@media print { + .handle { + display: none !important; + } +} diff --git a/apps/client/src/features/editor/components/common/resizable-wrapper.module.css b/apps/client/src/features/editor/components/common/resizable-wrapper.module.css index 02791e86..0d0a7688 100644 --- a/apps/client/src/features/editor/components/common/resizable-wrapper.module.css +++ b/apps/client/src/features/editor/components/common/resizable-wrapper.module.css @@ -1,13 +1,11 @@ .wrapper { position: relative; - width: 100%; - overflow: hidden; + overflow: visible; border-radius: 8px; } .resizing { user-select: none; - cursor: ns-resize; } .overlay { @@ -20,12 +18,118 @@ background: transparent; } +.cornerHandle { + position: absolute; + width: 36px; + height: 36px; + z-index: 2; + opacity: 0; + transition: opacity 0.2s ease; + touch-action: none; + -webkit-user-select: none; + user-select: none; + + &::before, + &::after { + content: ""; + position: absolute; + border-radius: 1px; + background-color: light-dark( + var(--mantine-color-blue-4), + var(--mantine-color-blue-5) + ); + transition: background-color 0.15s ease; + } + + &::before { + width: 28px; + height: 3px; + } + + &::after { + width: 3px; + height: 28px; + } + + &:hover::before, + &:hover::after { + background-color: light-dark( + var(--mantine-color-blue-6), + var(--mantine-color-blue-4) + ); + } +} + +.cornerHandleTL { + top: -2px; + left: -2px; + cursor: nwse-resize; + + &::before { + top: 0; + left: 0; + } + + &::after { + top: 0; + left: 0; + } +} + +.cornerHandleTR { + top: -2px; + right: -2px; + cursor: nesw-resize; + + &::before { + top: 0; + right: 0; + } + + &::after { + top: 0; + right: 0; + } +} + +.cornerHandleBL { + bottom: -2px; + left: -2px; + cursor: nesw-resize; + + &::before { + bottom: 0; + left: 0; + } + + &::after { + bottom: 0; + left: 0; + } +} + +.cornerHandleBR { + bottom: -2px; + right: -2px; + cursor: nwse-resize; + + &::before { + bottom: 0; + right: 0; + } + + &::after { + bottom: 0; + right: 0; + } +} + .resizeHandleBottom { position: absolute; - bottom: 0; - left: 0; - right: 0; - height: 24px; + bottom: -4px; + left: 20px; + right: 20px; + height: 12px; cursor: ns-resize; opacity: 0; transition: opacity 0.2s ease; @@ -36,61 +140,53 @@ touch-action: none; -webkit-user-select: none; user-select: none; - - @mixin light { - background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.05)); - } - - @mixin dark { - background: linear-gradient( - to bottom, - transparent, - rgba(255, 255, 255, 0.05) - ); - } - - &:hover { - @mixin light { - background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.1)); - } - - @mixin dark { - background: linear-gradient( - to bottom, - transparent, - rgba(255, 255, 255, 0.1) - ); - } - } -} - -.wrapper:hover .resizeHandleBottom, -.resizing .resizeHandleBottom { - opacity: 1; } .resizeBar { width: 50px; - height: 4px; + height: 3px; border-radius: 2px; - transition: background-color 0.2s ease; - - @mixin light { - background-color: var(--mantine-color-gray-5); - } - - @mixin dark { - background-color: var(--mantine-color-gray-6); - } + transition: background-color 0.15s ease; + background-color: light-dark( + var(--mantine-color-blue-4), + var(--mantine-color-blue-5) + ); +} + +.resizeHandleBottom:hover .resizeBar { + background-color: light-dark( + var(--mantine-color-blue-6), + var(--mantine-color-blue-4) + ); +} + +.wrapper:hover .cornerHandle, +.wrapper:hover .resizeHandleBottom, +.wrapper:global(.ProseMirror-selectednode) .cornerHandle, +.wrapper:global(.ProseMirror-selectednode) .resizeHandleBottom, +.resizing .cornerHandle, +.resizing .resizeHandleBottom { + opacity: 1; +} + +.resizing .cornerHandle::before, +.resizing .cornerHandle::after { + background-color: light-dark( + var(--mantine-color-blue-6), + var(--mantine-color-blue-4) + ); } -.resizeHandleBottom:hover .resizeBar, .resizing .resizeBar { - @mixin light { - background-color: var(--mantine-color-gray-7); - } + background-color: light-dark( + var(--mantine-color-blue-6), + var(--mantine-color-blue-4) + ); +} - @mixin dark { - background-color: var(--mantine-color-gray-4); +@media print { + .cornerHandle, + .resizeHandleBottom { + display: none !important; } } diff --git a/apps/client/src/features/editor/components/common/resizable-wrapper.tsx b/apps/client/src/features/editor/components/common/resizable-wrapper.tsx index c3cd1b62..ebb9cd78 100644 --- a/apps/client/src/features/editor/components/common/resizable-wrapper.tsx +++ b/apps/client/src/features/editor/components/common/resizable-wrapper.tsx @@ -2,111 +2,163 @@ import React, { ReactNode, useCallback, useEffect, useRef, useState } from "reac import clsx from "clsx"; import classes from "./resizable-wrapper.module.css"; +type Handle = "tl" | "tr" | "bl" | "br" | "bottom"; + +const HANDLE_SIGN: Record = { + br: { x: 1, y: 1 }, + bl: { x: -1, y: 1 }, + tr: { x: 1, y: -1 }, + tl: { x: -1, y: -1 }, + bottom: { x: 0, y: 1 }, +}; + +const HANDLE_CURSOR: Record = { + br: "nwse-resize", + tl: "nwse-resize", + bl: "nesw-resize", + tr: "nesw-resize", + bottom: "ns-resize", +}; + +const CORNER_CLASSES: Record = { + tl: classes.cornerHandleTL, + tr: classes.cornerHandleTR, + bl: classes.cornerHandleBL, + br: classes.cornerHandleBR, +}; + interface ResizableWrapperProps { children: ReactNode; + initialWidth?: number; initialHeight?: number; + minWidth?: number; + maxWidth?: number; minHeight?: number; maxHeight?: number; - onResize?: (height: number) => void; + onResize?: (width: number, height: number) => void; isEditable?: boolean; className?: string; - showHandles?: "always" | "hover"; - direction?: "vertical" | "horizontal" | "both"; + selected?: boolean; } +type DragState = { + handle: Handle; + startX: number; + startY: number; + startWidth: number; + startHeight: number; +}; + export const ResizableWrapper: React.FC = ({ children, + initialWidth = 640, initialHeight = 480, + minWidth = 200, + maxWidth = 1200, minHeight = 200, maxHeight = 1200, onResize, isEditable = true, className, - showHandles = "hover", - direction = "vertical", + selected = false, }) => { - const [resizeParams, setResizeParams] = useState<{ - initialSize: number; - initialClientY: number; - initialClientX: number; - } | null>(null); - const [currentHeight, setCurrentHeight] = useState(initialHeight); + const [isResizing, setIsResizing] = useState(false); const [isHovered, setIsHovered] = useState(false); const wrapperRef = useRef(null); - useEffect(() => { - if (!resizeParams) return; + const dragRef = useRef(null); + const widthRef = useRef(initialWidth); + const heightRef = useRef(initialHeight); + const onResizeRef = useRef(onResize); + onResizeRef.current = onResize; + const constraintsRef = useRef({ minWidth, maxWidth, minHeight, maxHeight }); + constraintsRef.current = { minWidth, maxWidth, minHeight, maxHeight }; - const handleMouseMove = (e: MouseEvent) => { - if (!wrapperRef.current) return; + const handleMouseMove = useRef((e: MouseEvent) => { + const drag = dragRef.current; + if (!drag || !wrapperRef.current) return; - if (direction === "vertical" || direction === "both") { - const deltaY = e.clientY - resizeParams.initialClientY; - const newHeight = Math.min( - Math.max(resizeParams.initialSize + deltaY, minHeight), - maxHeight - ); - setCurrentHeight(newHeight); - wrapperRef.current.style.height = `${newHeight}px`; - } + const sign = HANDLE_SIGN[drag.handle]; + const { minWidth, maxWidth, minHeight, maxHeight } = constraintsRef.current; + + const deltaY = e.clientY - drag.startY; + const newHeight = Math.min(Math.max(drag.startHeight + deltaY * sign.y, minHeight), maxHeight); + heightRef.current = newHeight; + wrapperRef.current.style.height = `${newHeight}px`; + + if (sign.x !== 0) { + const deltaX = e.clientX - drag.startX; + const newWidth = Math.min(Math.max(drag.startWidth + deltaX * sign.x, minWidth), maxWidth); + widthRef.current = newWidth; + wrapperRef.current.style.width = `${newWidth}px`; + } + }).current; + + const handleMouseUp = useRef(() => { + dragRef.current = null; + setIsResizing(false); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + onResizeRef.current?.(widthRef.current, heightRef.current); + }).current; + + const handleResizeStart = useCallback((e: React.MouseEvent, handle: Handle) => { + e.preventDefault(); + e.stopPropagation(); + dragRef.current = { + handle, + startX: e.clientX, + startY: e.clientY, + startWidth: widthRef.current, + startHeight: heightRef.current, }; - - const handleMouseUp = () => { - setResizeParams(null); - if (onResize && currentHeight !== initialHeight) { - onResize(currentHeight); - } - document.body.style.cursor = ""; - document.body.style.userSelect = ""; - }; - + setIsResizing(true); + document.body.style.cursor = HANDLE_CURSOR[handle]; + document.body.style.userSelect = "none"; document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); + }, [handleMouseMove, handleMouseUp]); + useEffect(() => { return () => { document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); }; - }, [resizeParams, currentHeight, initialHeight, onResize, minHeight, maxHeight, direction]); + }, [handleMouseMove, handleMouseUp]); - const handleResizeStart = useCallback((e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - - setResizeParams({ - initialSize: currentHeight, - initialClientY: e.clientY, - initialClientX: e.clientX, - }); - - document.body.style.cursor = "ns-resize"; - document.body.style.userSelect = "none"; - }, [currentHeight]); - - const shouldShowHandles = - isEditable && - (showHandles === "always" || (showHandles === "hover" && (isHovered || resizeParams))); + const shouldShowHandles = isEditable && (isHovered || isResizing || selected); return (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > {children} - {!!resizeParams &&
} - {shouldShowHandles && direction === "vertical" && ( -
-
-
+ {isResizing &&
} + {shouldShowHandles && ( + <> + {(["tl", "tr", "bl", "br"] as const).map((corner) => ( +
handleResizeStart(e, corner)} + /> + ))} +
handleResizeStart(e, "bottom")} + > +
+
+ )}
); -}; \ No newline at end of file +}; diff --git a/apps/client/src/features/editor/components/common/toolbar-menu.module.css b/apps/client/src/features/editor/components/common/toolbar-menu.module.css new file mode 100644 index 00000000..7fd91f56 --- /dev/null +++ b/apps/client/src/features/editor/components/common/toolbar-menu.module.css @@ -0,0 +1,29 @@ +.toolbar { + display: flex; + align-items: center; + gap: 2px; + padding: 3px; + border-radius: 8px; + border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4)); + background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6)); + box-shadow: 0 2px 12px light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.35)); +} + +.toolbar :global(.mantine-ActionIcon-root) { + --ai-color: light-dark(var(--mantine-color-dark-7), var(--mantine-color-gray-4)) !important; + --ai-hover: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5)) !important; +} + +.toolbar .active { + --ai-color: light-dark(var(--mantine-color-blue-7), var(--mantine-color-blue-3)) !important; + --ai-hover: light-dark(var(--mantine-color-blue-0), var(--mantine-color-dark-5)) !important; + background-color: light-dark(var(--mantine-color-blue-0), var(--mantine-color-dark-5)); +} + +.divider { + width: 1px; + height: 16px; + align-self: center; + margin: 0 2px; + background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-3)); +} diff --git a/apps/client/src/features/editor/components/drawio/drawio-menu.tsx b/apps/client/src/features/editor/components/drawio/drawio-menu.tsx index 937b8e7d..bdd1461b 100644 --- a/apps/client/src/features/editor/components/drawio/drawio-menu.tsx +++ b/apps/client/src/features/editor/components/drawio/drawio-menu.tsx @@ -1,24 +1,46 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; -import { useCallback } from "react"; -import { Node as PMNode } from "prosemirror-model"; +import { useCallback, useRef, useState } from "react"; +import { Node as PMNode } from "@tiptap/pm/model"; import { EditorMenuProps, ShouldShowProps, } from "@/features/editor/components/table/types/types.ts"; -import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx"; +import { + ActionIcon, + Modal, + Tooltip, + useComputedColorScheme, +} from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import clsx from "clsx"; +import { + IconLayoutAlignCenter, + IconLayoutAlignLeft, + IconLayoutAlignRight, + IconDownload, + IconEdit, + IconTrash, +} from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { getDrawioUrl, getFileUrl } from "@/lib/config.ts"; +import { uploadFile } from "@/features/page/services/page-service.ts"; +import { + DrawIoEmbed, + DrawIoEmbedRef, + EventExit, + EventSave, +} from "react-drawio"; +import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils"; +import { IAttachment } from "@/features/attachments/types/attachment.types"; +import classes from "../common/toolbar-menu.module.css"; export function DrawioMenu({ editor }: EditorMenuProps) { - const shouldShow = useCallback( - ({ state }: ShouldShowProps) => { - if (!state) { - return false; - } - - return editor.isActive("drawio") && editor.getAttributes("drawio")?.src; - }, - [editor], - ); + const { t } = useTranslation(); + const [opened, { open, close }] = useDisclosure(false); + const [initialXML, setInitialXML] = useState(""); + const drawioRef = useRef(null); + const computedColorScheme = useComputedColorScheme(); const editorState = useEditorState({ editor, @@ -30,11 +52,26 @@ export function DrawioMenu({ editor }: EditorMenuProps) { const drawioAttr = ctx.editor.getAttributes("drawio"); return { isDrawio: ctx.editor.isActive("drawio"), - width: drawioAttr?.width ? parseInt(drawioAttr.width) : null, + isAlignLeft: ctx.editor.isActive("drawio", { align: "left" }), + isAlignCenter: ctx.editor.isActive("drawio", { align: "center" }), + isAlignRight: ctx.editor.isActive("drawio", { align: "right" }), + src: drawioAttr?.src || null, + attachmentId: drawioAttr?.attachmentId || null, }; }, }); + const shouldShow = useCallback( + ({ state }: ShouldShowProps) => { + if (!state) { + return false; + } + + return editor.isActive("drawio") && editor.getAttributes("drawio")?.src; + }, + [editor], + ); + const getReferencedVirtualElement = useCallback(() => { if (!editor) return; const { selection } = editor.state; @@ -57,38 +94,222 @@ export function DrawioMenu({ editor }: EditorMenuProps) { }; }, [editor]); - const onWidthChange = useCallback( - (value: number) => { - editor.commands.updateAttributes("drawio", { width: `${value}%` }); + const alignLeft = useCallback(() => { + editor + .chain() + .focus(undefined, { scrollIntoView: false }) + .setDrawioAlign("left") + .run(); + }, [editor]); + + const alignCenter = useCallback(() => { + editor + .chain() + .focus(undefined, { scrollIntoView: false }) + .setDrawioAlign("center") + .run(); + }, [editor]); + + const alignRight = useCallback(() => { + editor + .chain() + .focus(undefined, { scrollIntoView: false }) + .setDrawioAlign("right") + .run(); + }, [editor]); + + const handleDownload = useCallback(() => { + if (!editorState?.src) return; + const url = getFileUrl(editorState.src); + const a = document.createElement("a"); + a.href = url; + a.download = ""; + a.click(); + }, [editorState?.src]); + + const handleDelete = useCallback(() => { + editor.commands.deleteSelection(); + }, [editor]); + + const handleOpen = useCallback(async () => { + if (!editorState?.src) return; + + try { + const url = getFileUrl(editorState.src); + const request = await fetch(url, { + credentials: "include", + cache: "no-store", + }); + const blob = await request.blob(); + + const reader = new FileReader(); + reader.readAsDataURL(blob); + reader.onloadend = () => { + const base64data = (reader.result || "") as string; + setInitialXML(base64data); + }; + } catch (err) { + console.error(err); + } finally { + open(); + } + }, [editorState?.src, open]); + + const handleSave = useCallback( + async (data: EventSave) => { + const svgString = decodeBase64ToSvgString(data.xml); + const fileName = "diagram.drawio.svg"; + const drawioSVGFile = await svgStringToFile(svgString, fileName); + + // @ts-ignore + const pageId = editor.storage?.pageId; + const attachmentId = editorState?.attachmentId; + + let attachment: IAttachment = null; + if (attachmentId) { + attachment = await uploadFile(drawioSVGFile, pageId, attachmentId); + } else { + attachment = await uploadFile(drawioSVGFile, pageId); + } + + editor.commands.updateAttributes("drawio", { + src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`, + title: attachment.fileName, + size: attachment.fileSize, + attachmentId: attachment.id, + }); + + close(); }, - [editor], + [editor, editorState?.attachmentId, close], ); return ( - -
+ - {editorState?.width && ( - - )} -
-
+
+ + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ { + if (data.parentEvent !== "save") { + return; + } + handleSave(data); + }} + onClose={(data: EventExit) => { + if (data.parentEvent) { + return; + } + close(); + }} + /> +
+
+
+
+ ); } diff --git a/apps/client/src/features/editor/components/drawio/drawio-view.tsx b/apps/client/src/features/editor/components/drawio/drawio-view.tsx index b51e8936..0b1580ec 100644 --- a/apps/client/src/features/editor/components/drawio/drawio-view.tsx +++ b/apps/client/src/features/editor/components/drawio/drawio-view.tsx @@ -2,7 +2,6 @@ import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; import { ActionIcon, Card, - Image, Modal, Text, useComputedColorScheme, @@ -10,7 +9,7 @@ import { import { useRef, useState } from "react"; import { uploadFile } from "@/features/page/services/page-service.ts"; import { useDisclosure } from "@mantine/hooks"; -import { getDrawioUrl, getFileUrl } from "@/lib/config.ts"; +import { getDrawioUrl } from "@/lib/config.ts"; import { DrawIoEmbed, DrawIoEmbedRef, @@ -26,7 +25,7 @@ import { useTranslation } from "react-i18next"; export default function DrawioView(props: NodeViewProps) { const { t } = useTranslation(); const { node, updateAttributes, editor, selected } = props; - const { src, title, width, attachmentId } = node.attrs; + const { attachmentId } = node.attrs; const drawioRef = useRef(null); const [initialXML, setInitialXML] = useState(""); const [opened, { open, close }] = useDisclosure(false); @@ -36,33 +35,11 @@ export default function DrawioView(props: NodeViewProps) { if (!editor.isEditable) { return; } - - try { - if (src) { - const url = getFileUrl(src); - const request = await fetch(url, { - credentials: "include", - cache: "no-store", - }); - const blob = await request.blob(); - - const reader = new FileReader(); - reader.readAsDataURL(blob); - reader.onloadend = () => { - const base64data = (reader.result || "") as string; - setInitialXML(base64data); - }; - } - } catch (err) { - console.error(err); - } finally { - open(); - } + open(); }; const handleSave = async (data: EventSave) => { const svgString = decodeBase64ToSvgString(data.xml); - const fileName = "diagram.drawio.svg"; const drawioSVGFile = await svgStringToFile(svgString, fileName); @@ -70,7 +47,6 @@ export default function DrawioView(props: NodeViewProps) { const pageId = editor.storage?.pageId; let attachment: IAttachment = null; - if (attachmentId) { attachment = await uploadFile(drawioSVGFile, pageId, attachmentId); } else { @@ -106,14 +82,12 @@ export default function DrawioView(props: NodeViewProps) { noSaveBtn: true, }} onSave={(data: EventSave) => { - // If the save is triggered by another event, then do nothing if (data.parentEvent !== "save") { return; } handleSave(data); }} onClose={(data: EventExit) => { - // If the exit is triggered by another event, then do nothing if (data.parentEvent) { return; } @@ -125,62 +99,28 @@ export default function DrawioView(props: NodeViewProps) { - {src ? ( -
- e.detail === 2 && handleOpen()} - radius="md" - fit="contain" - w={width} - src={getFileUrl(src)} - alt={title} - className={clsx( - selected ? "ProseMirror-selectednode" : "", - "alignCenter", - )} - /> + e.detail === 2 && handleOpen()} + p="xs" + style={{ + display: "flex", + justifyContent: "center", + alignItems: "center", + }} + withBorder + className={clsx(selected ? "ProseMirror-selectednode" : "")} + > +
+ + + - {selected && editor.isEditable && ( - - - - )} + + {t("Double-click to edit Draw.io diagram")} +
- ) : ( - e.detail === 2 && handleOpen()} - p="xs" - style={{ - display: "flex", - justifyContent: "center", - alignItems: "center", - }} - withBorder - className={clsx(selected ? "ProseMirror-selectednode" : "")} - > -
- - - - - - {t("Double-click to edit Draw.io diagram")} - -
-
- )} +
); } diff --git a/apps/client/src/features/editor/components/embed/embed-view.module.css b/apps/client/src/features/editor/components/embed/embed-view.module.css index c58f3965..0ecb0d61 100644 --- a/apps/client/src/features/editor/components/embed/embed-view.module.css +++ b/apps/client/src/features/editor/components/embed/embed-view.module.css @@ -1,3 +1,12 @@ +:global(.ProseMirror .node-embed.ProseMirror-selectednode) { + outline: none; +} + +.embedContainer { + display: flex; + justify-content: center; +} + .embedWrapper { @mixin light { background-color: var(--mantine-color-gray-0); @@ -13,4 +22,4 @@ height: 100%; border: none; border-radius: 8px; -} \ No newline at end of file +} diff --git a/apps/client/src/features/editor/components/embed/embed-view.tsx b/apps/client/src/features/editor/components/embed/embed-view.tsx index efdaa950..021f4f3a 100644 --- a/apps/client/src/features/editor/components/embed/embed-view.tsx +++ b/apps/client/src/features/editor/components/embed/embed-view.tsx @@ -12,9 +12,9 @@ import { TextInput, } from "@mantine/core"; import { IconEdit } from "@tabler/icons-react"; -import { z } from "zod"; +import { z } from "zod/v4"; import { useForm } from "@mantine/form"; -import { zodResolver } from "mantine-form-zod-resolver"; +import { zod4Resolver } from "mantine-form-zod-resolver"; import { notifications } from "@mantine/notifications"; import { useTranslation } from "react-i18next"; import i18n from "i18next"; @@ -27,16 +27,13 @@ import { ResizableWrapper } from "../common/resizable-wrapper"; import classes from "./embed-view.module.css"; const schema = z.object({ - url: z - .string() - .trim() - .url({ message: i18n.t("Please enter a valid url") }), + url: z.url({ message: i18n.t("Please enter a valid url") }).trim(), }); export default function EmbedView(props: NodeViewProps) { const { t } = useTranslation(); const { node, selected, updateAttributes, editor } = props; - const { src, provider, height: nodeHeight } = node.attrs; + const { src, provider, width: nodeWidth, height: nodeHeight } = node.attrs; const embedUrl = useMemo(() => { if (src) { @@ -49,12 +46,12 @@ export default function EmbedView(props: NodeViewProps) { initialValues: { url: "", }, - validate: zodResolver(schema), + validate: zod4Resolver(schema), }); const handleResize = useCallback( - (newHeight: number) => { - updateAttributes({ height: newHeight }); + (newWidth: number, newHeight: number) => { + updateAttributes({ width: newWidth, height: newHeight }); }, [updateAttributes], ); @@ -85,27 +82,33 @@ export default function EmbedView(props: NodeViewProps) { } return ( - + {embedUrl ? ( - -