mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 14:43:06 +08:00
Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4f009513e | |||
| fcffa3dfa0 | |||
| 1980b94825 | |||
| bea1637519 | |||
| 37355452e1 | |||
| 057360c6be | |||
| f12bfc1ff7 | |||
| f5d794220e | |||
| a3c1c6cccd | |||
| 4b105586a9 | |||
| d2641db895 | |||
| 1111df65cd | |||
| e455154b7d | |||
| ef24b3c07d | |||
| 2352f3c5d9 | |||
| 568dd4c321 | |||
| b6478fee84 | |||
| 5d2aad3668 | |||
| 9331ac2df8 | |||
| 9f4728e279 | |||
| 628b08339a | |||
| 68842dbea2 | |||
| b1510cd6d7 | |||
| af92224e10 | |||
| c24ff44e09 | |||
| 90c190df78 | |||
| 17ec2f4ac5 | |||
| 9881c53f00 | |||
| 721651e2e2 | |||
| a3fd79dae8 | |||
| 616d9297eb | |||
| ee6b98edaa | |||
| cf43e2b4fe | |||
| 614baf153b | |||
| 4f3577f009 | |||
| d5e4b8bb59 | |||
| 1a897faaa2 | |||
| 6f1a91cc05 | |||
| 60848ea903 | |||
| 2309d1434b | |||
| dcc2bacb22 | |||
| 69d7532c6c | |||
| 85ce0d32bf | |||
| fc0997fd90 | |||
| df64de5306 | |||
| ea44468fad | |||
| 59e945562d | |||
| 22f33bab7c | |||
| e0a8521566 | |||
| b5803f42da | |||
| 5de1c8e3ed | |||
| ef87210b3d |
@@ -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
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.25.3",
|
"version": "0.70.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
"semver": "^7.7.3",
|
"semver": "^7.7.3",
|
||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.3",
|
||||||
"tiptap-extension-global-drag-handle": "^0.1.18",
|
"tiptap-extension-global-drag-handle": "^0.1.18",
|
||||||
"zod": "^3.25.76"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.16.0",
|
"@eslint/js": "^9.16.0",
|
||||||
|
|||||||
@@ -116,6 +116,7 @@
|
|||||||
"No group found": "Keine Gruppe gefunden",
|
"No group found": "Keine Gruppe gefunden",
|
||||||
"No page history saved yet.": "Es wurde noch kein Seitenverlauf gespeichert.",
|
"No page history saved yet.": "Es wurde noch kein Seitenverlauf gespeichert.",
|
||||||
"No pages yet": "Noch keine Seiten",
|
"No pages yet": "Noch keine Seiten",
|
||||||
|
"No shared pages": "Keine freigegebenen Seiten",
|
||||||
"No results found...": "Keine Ergebnisse gefunden...",
|
"No results found...": "Keine Ergebnisse gefunden...",
|
||||||
"No user found": "Kein Benutzer gefunden",
|
"No user found": "Kein Benutzer gefunden",
|
||||||
"Overview": "Überblick",
|
"Overview": "Überblick",
|
||||||
@@ -130,6 +131,7 @@
|
|||||||
"pages": "Seiten",
|
"pages": "Seiten",
|
||||||
"Password": "Passwort",
|
"Password": "Passwort",
|
||||||
"Password changed successfully": "Passwort erfolgreich geändert",
|
"Password changed successfully": "Passwort erfolgreich geändert",
|
||||||
|
"People": "Personen",
|
||||||
"Pending": "Ausstehend",
|
"Pending": "Ausstehend",
|
||||||
"Please confirm your action": "Bitte bestätigen Sie Ihre Aktion",
|
"Please confirm your action": "Bitte bestätigen Sie Ihre Aktion",
|
||||||
"Preferences": "Vorlieben",
|
"Preferences": "Vorlieben",
|
||||||
@@ -207,6 +209,9 @@
|
|||||||
"Reply...": "Antworten...",
|
"Reply...": "Antworten...",
|
||||||
"Error loading comments.": "Fehler beim Laden der Kommentare.",
|
"Error loading comments.": "Fehler beim Laden der Kommentare.",
|
||||||
"No comments yet.": "Noch keine 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",
|
"Edit comment": "Kommentar bearbeiten",
|
||||||
"Delete comment": "Kommentar löschen",
|
"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?",
|
"Are you sure you want to delete this comment?": "Sind Sie sicher, dass Sie diesen Kommentar löschen möchten?",
|
||||||
@@ -228,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?",
|
"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",
|
"Resolved": "Gelöst",
|
||||||
"No active comments.": "Keine aktiven Kommentare.",
|
"No active comments.": "Keine aktiven Kommentare.",
|
||||||
"No resolved comments.": "Keine gelösten Kommentare.",
|
|
||||||
"Revoke invitation": "Einladung widerrufen",
|
"Revoke invitation": "Einladung widerrufen",
|
||||||
"Revoke": "Widerrufen",
|
"Revoke": "Widerrufen",
|
||||||
"Don't": "Nicht",
|
"Don't": "Nicht",
|
||||||
@@ -274,6 +278,7 @@
|
|||||||
"Add row below": "Zeile unten hinzufügen",
|
"Add row below": "Zeile unten hinzufügen",
|
||||||
"Delete table": "Tabelle löschen",
|
"Delete table": "Tabelle löschen",
|
||||||
"Info": "Info",
|
"Info": "Info",
|
||||||
|
"Note": "Hinweis",
|
||||||
"Success": "Erfolg",
|
"Success": "Erfolg",
|
||||||
"Warning": "Warnung",
|
"Warning": "Warnung",
|
||||||
"Danger": "Gefahr",
|
"Danger": "Gefahr",
|
||||||
@@ -357,12 +362,21 @@
|
|||||||
"Multiple": "Mehrere",
|
"Multiple": "Mehrere",
|
||||||
"Turn into": "In verwandeln",
|
"Turn into": "In verwandeln",
|
||||||
"Text align": "Text ausrichten",
|
"Text align": "Text ausrichten",
|
||||||
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
|
"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": "Go to homepage",
|
"Go to homepage": "Zur Startseite",
|
||||||
"Pages you create will show up here.": "Pages you create will show up here.",
|
"Pages you create will show up here.": "\"Die von Ihnen erstellten Seiten werden hier angezeigt.\"",
|
||||||
"Heading {{level}}": "Überschrift {{level}}",
|
"Heading {{level}}": "Überschrift {{level}}",
|
||||||
"Toggle title": "Titel umschalten",
|
"Toggle title": "Titel umschalten",
|
||||||
"Write anything. Enter \"/\" for commands": "Schreiben Sie irgendetwas. Geben Sie \"/\" für Befehle ein",
|
"Write anything. Enter \"/\" for commands": "Schreiben Sie irgendetwas. Geben Sie \"/\" für Befehle ein",
|
||||||
|
"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",
|
"Names do not match": "Namen stimmen nicht überein",
|
||||||
"Today, {{time}}": "Heute, {{time}}",
|
"Today, {{time}}": "Heute, {{time}}",
|
||||||
"Yesterday, {{time}}": "Gestern, {{time}}",
|
"Yesterday, {{time}}": "Gestern, {{time}}",
|
||||||
@@ -385,6 +399,13 @@
|
|||||||
"Delete member": "Mitglied löschen",
|
"Delete member": "Mitglied löschen",
|
||||||
"Member deleted successfully": "Mitglied erfolgreich gelöscht",
|
"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.",
|
"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": "Verschieben",
|
||||||
"Move page": "Seite verschieben",
|
"Move page": "Seite verschieben",
|
||||||
"Move page to a different space.": "Seite in einen anderen Bereich verschieben.",
|
"Move page to a different space.": "Seite in einen anderen Bereich verschieben.",
|
||||||
@@ -419,6 +440,8 @@
|
|||||||
"Public sharing is disabled at the workspace level": "Öffentliches Teilen ist auf der Arbeitsbereichsebene deaktiviert",
|
"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.",
|
"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",
|
"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",
|
"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 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 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.",
|
||||||
@@ -509,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.",
|
"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",
|
"Verify": "Überprüfen",
|
||||||
"Trash": "Papierkorb",
|
"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",
|
"Deleted": "Gelöscht",
|
||||||
"No pages in trash": "Keine Seiten im Papierkorb",
|
"No pages in trash": "Keine Seiten im Papierkorb",
|
||||||
"Permanently delete page?": "Seite endgültig löschen?",
|
"Permanently delete page?": "Seite endgültig löschen?",
|
||||||
@@ -581,6 +604,10 @@
|
|||||||
"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.",
|
"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",
|
"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",
|
"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 settings": "KI-Einstellungen",
|
||||||
"AI search": "KI-Suche",
|
"AI search": "KI-Suche",
|
||||||
"AI Answer": "KI-Antwort",
|
"AI Answer": "KI-Antwort",
|
||||||
@@ -594,6 +621,25 @@
|
|||||||
"Generative AI (Ask AI)": "Generative KI (KI fragen)",
|
"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.",
|
"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",
|
"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",
|
"Sources": "Quellen",
|
||||||
"AI Answers not available for attachments": "KI-Antworten sind für Anhänge nicht 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",
|
"No answer available": "Keine Antwort verfügbar",
|
||||||
@@ -612,8 +658,40 @@
|
|||||||
"commented on a page": "hat auf einer Seite kommentiert",
|
"commented on a page": "hat auf einer Seite kommentiert",
|
||||||
"resolved a comment": "hat einen Kommentar gelöst",
|
"resolved a comment": "hat einen Kommentar gelöst",
|
||||||
"mentioned you on a page": "hat Sie auf einer Seite erwähnt",
|
"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",
|
"Today": "Heute",
|
||||||
"Yesterday": "Gestern",
|
"Yesterday": "Gestern",
|
||||||
"This week": "Diese Woche",
|
"This week": "Diese Woche",
|
||||||
"Older": "Älter"
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,7 @@
|
|||||||
"No group found": "No group found",
|
"No group found": "No group found",
|
||||||
"No page history saved yet.": "No page history saved yet.",
|
"No page history saved yet.": "No page history saved yet.",
|
||||||
"No pages yet": "No pages yet",
|
"No pages yet": "No pages yet",
|
||||||
|
"No shared pages": "No shared pages",
|
||||||
"No results found...": "No results found...",
|
"No results found...": "No results found...",
|
||||||
"No user found": "No user found",
|
"No user found": "No user found",
|
||||||
"Overview": "Overview",
|
"Overview": "Overview",
|
||||||
@@ -130,6 +131,7 @@
|
|||||||
"pages": "pages",
|
"pages": "pages",
|
||||||
"Password": "Password",
|
"Password": "Password",
|
||||||
"Password changed successfully": "Password changed successfully",
|
"Password changed successfully": "Password changed successfully",
|
||||||
|
"People": "People",
|
||||||
"Pending": "Pending",
|
"Pending": "Pending",
|
||||||
"Please confirm your action": "Please confirm your action",
|
"Please confirm your action": "Please confirm your action",
|
||||||
"Preferences": "Preferences",
|
"Preferences": "Preferences",
|
||||||
@@ -207,6 +209,9 @@
|
|||||||
"Reply...": "Reply...",
|
"Reply...": "Reply...",
|
||||||
"Error loading comments.": "Error loading comments.",
|
"Error loading comments.": "Error loading comments.",
|
||||||
"No comments yet.": "No comments yet.",
|
"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",
|
"Edit comment": "Edit comment",
|
||||||
"Delete comment": "Delete comment",
|
"Delete comment": "Delete comment",
|
||||||
"Are you sure you want to delete this comment?": "Are you sure you want to delete this comment?",
|
"Are you sure you want to delete this comment?": "Are you sure you want to delete this comment?",
|
||||||
@@ -228,7 +233,6 @@
|
|||||||
"Are you sure you want to unresolve this comment thread?": "Are you sure you want to unresolve this comment thread?",
|
"Are you sure you want to unresolve this comment thread?": "Are you sure you want to unresolve this comment thread?",
|
||||||
"Resolved": "Resolved",
|
"Resolved": "Resolved",
|
||||||
"No active comments.": "No active comments.",
|
"No active comments.": "No active comments.",
|
||||||
"No resolved comments.": "No resolved comments.",
|
|
||||||
"Revoke invitation": "Revoke invitation",
|
"Revoke invitation": "Revoke invitation",
|
||||||
"Revoke": "Revoke",
|
"Revoke": "Revoke",
|
||||||
"Don't": "Don't",
|
"Don't": "Don't",
|
||||||
@@ -395,6 +399,13 @@
|
|||||||
"Delete member": "Delete member",
|
"Delete member": "Delete member",
|
||||||
"Member deleted successfully": "Member deleted successfully",
|
"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.",
|
"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": "Move",
|
||||||
"Move page": "Move page",
|
"Move page": "Move page",
|
||||||
"Move page to a different space.": "Move page to a different space.",
|
"Move page to a different space.": "Move page to a different space.",
|
||||||
@@ -429,6 +440,8 @@
|
|||||||
"Public sharing is disabled at the workspace level": "Public sharing is disabled at the workspace level",
|
"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.",
|
"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",
|
"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",
|
"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 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 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.",
|
||||||
@@ -519,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.",
|
"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",
|
"Verify": "Verify",
|
||||||
"Trash": "Trash",
|
"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",
|
"Deleted": "Deleted",
|
||||||
"No pages in trash": "No pages in trash",
|
"No pages in trash": "No pages in trash",
|
||||||
"Permanently delete page?": "Permanently delete page?",
|
"Permanently delete page?": "Permanently delete page?",
|
||||||
@@ -591,6 +604,10 @@
|
|||||||
"This action cannot be undone. Any applications using this API key will stop working.": "This action cannot be undone. Any applications using this API key will stop working.",
|
"This action cannot be undone. Any applications using this API key will stop working.": "This action cannot be undone. Any applications using this API key will stop working.",
|
||||||
"Update API key": "Update API key",
|
"Update API key": "Update API key",
|
||||||
"Manage API keys for all users in the workspace": "Manage API keys for all users in the workspace",
|
"Manage API keys for all users in the workspace": "Manage API keys for all users in the workspace",
|
||||||
|
"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 settings": "AI settings",
|
||||||
"AI search": "AI search",
|
"AI search": "AI search",
|
||||||
"AI Answer": "AI Answer",
|
"AI Answer": "AI Answer",
|
||||||
@@ -604,6 +621,25 @@
|
|||||||
"Generative AI (Ask AI)": "Generative AI (Ask AI)",
|
"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.",
|
"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",
|
"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",
|
"Sources": "Sources",
|
||||||
"AI Answers not available for attachments": "AI Answers not available for attachments",
|
"AI Answers not available for attachments": "AI Answers not available for attachments",
|
||||||
"No answer available": "No answer available",
|
"No answer available": "No answer available",
|
||||||
@@ -622,8 +658,40 @@
|
|||||||
"commented on a page": "commented on a page",
|
"commented on a page": "commented on a page",
|
||||||
"resolved a comment": "resolved a comment",
|
"resolved a comment": "resolved a comment",
|
||||||
"mentioned you on a page": "mentioned you on a page",
|
"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",
|
"Today": "Today",
|
||||||
"Yesterday": "Yesterday",
|
"Yesterday": "Yesterday",
|
||||||
"This week": "This week",
|
"This week": "This week",
|
||||||
"Older": "Older"
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,7 @@
|
|||||||
"No group found": "No se encontró grupo",
|
"No group found": "No se encontró grupo",
|
||||||
"No page history saved yet.": "No hay historial de la página guardado aún.",
|
"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 pages yet": "No hay páginas todavía",
|
||||||
|
"No shared pages": "No hay páginas compartidas",
|
||||||
"No results found...": "No se encontraron resultados...",
|
"No results found...": "No se encontraron resultados...",
|
||||||
"No user found": "No se encontró usuario",
|
"No user found": "No se encontró usuario",
|
||||||
"Overview": "Visión general",
|
"Overview": "Visión general",
|
||||||
@@ -130,6 +131,7 @@
|
|||||||
"pages": "páginas",
|
"pages": "páginas",
|
||||||
"Password": "Contraseña",
|
"Password": "Contraseña",
|
||||||
"Password changed successfully": "Contraseña cambiada con éxito",
|
"Password changed successfully": "Contraseña cambiada con éxito",
|
||||||
|
"People": "Personas",
|
||||||
"Pending": "Pendiente",
|
"Pending": "Pendiente",
|
||||||
"Please confirm your action": "Por favor, confirme su acción",
|
"Please confirm your action": "Por favor, confirme su acción",
|
||||||
"Preferences": "Preferencias",
|
"Preferences": "Preferencias",
|
||||||
@@ -207,6 +209,9 @@
|
|||||||
"Reply...": "Responder...",
|
"Reply...": "Responder...",
|
||||||
"Error loading comments.": "Error al cargar comentarios.",
|
"Error loading comments.": "Error al cargar comentarios.",
|
||||||
"No comments yet.": "No hay comentarios todavía.",
|
"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",
|
"Edit comment": "Editar comentario",
|
||||||
"Delete comment": "Eliminar comentario",
|
"Delete comment": "Eliminar comentario",
|
||||||
"Are you sure you want to delete this comment?": "¿Está seguro de que desea eliminar este comentario?",
|
"Are you sure you want to delete this comment?": "¿Está seguro de que desea eliminar este comentario?",
|
||||||
@@ -228,7 +233,6 @@
|
|||||||
"Are you sure you want to unresolve this comment thread?": "¿Está seguro de que desea no resolver este hilo de comentarios?",
|
"Are you sure you want to unresolve this comment thread?": "¿Está seguro de que desea no resolver este hilo de comentarios?",
|
||||||
"Resolved": "Resuelto",
|
"Resolved": "Resuelto",
|
||||||
"No active comments.": "No hay comentarios activos.",
|
"No active comments.": "No hay comentarios activos.",
|
||||||
"No resolved comments.": "No hay comentarios resueltos.",
|
|
||||||
"Revoke invitation": "Revocar invitación",
|
"Revoke invitation": "Revocar invitación",
|
||||||
"Revoke": "Revocar",
|
"Revoke": "Revocar",
|
||||||
"Don't": "No",
|
"Don't": "No",
|
||||||
@@ -274,6 +278,7 @@
|
|||||||
"Add row below": "Agregar fila debajo",
|
"Add row below": "Agregar fila debajo",
|
||||||
"Delete table": "Eliminar tabla",
|
"Delete table": "Eliminar tabla",
|
||||||
"Info": "Información",
|
"Info": "Información",
|
||||||
|
"Note": "Nota",
|
||||||
"Success": "Satisfactorio",
|
"Success": "Satisfactorio",
|
||||||
"Warning": "Advertencia",
|
"Warning": "Advertencia",
|
||||||
"Danger": "Peligro",
|
"Danger": "Peligro",
|
||||||
@@ -357,12 +362,21 @@
|
|||||||
"Multiple": "Múltiple",
|
"Multiple": "Múltiple",
|
||||||
"Turn into": "Convertir en",
|
"Turn into": "Convertir en",
|
||||||
"Text align": "Alineación del texto",
|
"Text align": "Alineación del texto",
|
||||||
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
|
"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": "Go to homepage",
|
"Go to homepage": "Ir a la página principal",
|
||||||
"Pages you create will show up here.": "Pages you create will show up here.",
|
"Pages you create will show up here.": "Las páginas que crees aparecerán aquí.",
|
||||||
"Heading {{level}}": "Encabezado {{level}}",
|
"Heading {{level}}": "Encabezado {{level}}",
|
||||||
"Toggle title": "Alternar título",
|
"Toggle title": "Alternar título",
|
||||||
"Write anything. Enter \"/\" for commands": "Escribe cualquier cosa. Ingresa \"/\" para comandos",
|
"Write anything. Enter \"/\" for commands": "Escribe cualquier cosa. Ingresa \"/\" para comandos",
|
||||||
|
"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",
|
"Names do not match": "Los nombres no coinciden",
|
||||||
"Today, {{time}}": "Hoy, {{time}}",
|
"Today, {{time}}": "Hoy, {{time}}",
|
||||||
"Yesterday, {{time}}": "Ayer, {{time}}",
|
"Yesterday, {{time}}": "Ayer, {{time}}",
|
||||||
@@ -385,6 +399,13 @@
|
|||||||
"Delete member": "Eliminar miembro",
|
"Delete member": "Eliminar miembro",
|
||||||
"Member deleted successfully": "Miembro eliminado con éxito",
|
"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.",
|
"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": "Mover",
|
||||||
"Move page": "Mover página",
|
"Move page": "Mover página",
|
||||||
"Move page to a different space.": "Mover página a un espacio diferente.",
|
"Move page to a different space.": "Mover página a un espacio diferente.",
|
||||||
@@ -419,6 +440,8 @@
|
|||||||
"Public sharing is disabled at the workspace level": "El uso compartido público está desactivado a nivel de espacio de trabajo",
|
"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.",
|
"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",
|
"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",
|
"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 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 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.",
|
||||||
@@ -509,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.",
|
"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",
|
"Verify": "Verificar",
|
||||||
"Trash": "Papelera",
|
"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",
|
"Deleted": "Eliminado",
|
||||||
"No pages in trash": "No hay páginas en la papelera",
|
"No pages in trash": "No hay páginas en la papelera",
|
||||||
"Permanently delete page?": "¿Eliminar página permanentemente?",
|
"Permanently delete page?": "¿Eliminar página permanentemente?",
|
||||||
@@ -581,6 +604,10 @@
|
|||||||
"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.",
|
"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",
|
"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",
|
"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 settings": "Configuración de IA",
|
||||||
"AI search": "Búsqueda de IA",
|
"AI search": "Búsqueda de IA",
|
||||||
"AI Answer": "Respuesta de IA",
|
"AI Answer": "Respuesta de IA",
|
||||||
@@ -594,6 +621,25 @@
|
|||||||
"Generative AI (Ask AI)": "IA generativa (Preguntar a la 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.",
|
"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",
|
"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",
|
"Sources": "Fuentes",
|
||||||
"AI Answers not available for attachments": "Respuestas de IA no disponibles para archivos adjuntos",
|
"AI Answers not available for attachments": "Respuestas de IA no disponibles para archivos adjuntos",
|
||||||
"No answer available": "No hay respuesta disponible",
|
"No answer available": "No hay respuesta disponible",
|
||||||
@@ -612,8 +658,40 @@
|
|||||||
"commented on a page": "comentó en una página",
|
"commented on a page": "comentó en una página",
|
||||||
"resolved a comment": "resolvió un comentario",
|
"resolved a comment": "resolvió un comentario",
|
||||||
"mentioned you on a page": "te mencionó en una página",
|
"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",
|
"Today": "Hoy",
|
||||||
"Yesterday": "Ayer",
|
"Yesterday": "Ayer",
|
||||||
"This week": "Esta semana",
|
"This week": "Esta semana",
|
||||||
"Older": "Más antiguo"
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,7 @@
|
|||||||
"No group found": "Aucun groupe trouvé",
|
"No group found": "Aucun groupe trouvé",
|
||||||
"No page history saved yet.": "Aucun historique de la page enregistré pour l'instant.",
|
"No page history saved yet.": "Aucun historique de la page enregistré pour l'instant.",
|
||||||
"No pages yet": "Aucune page 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 results found...": "Aucun résultat trouvé...",
|
||||||
"No user found": "Aucun utilisateur trouvé",
|
"No user found": "Aucun utilisateur trouvé",
|
||||||
"Overview": "Vue d'ensemble",
|
"Overview": "Vue d'ensemble",
|
||||||
@@ -130,6 +131,7 @@
|
|||||||
"pages": "pages",
|
"pages": "pages",
|
||||||
"Password": "Mot de passe",
|
"Password": "Mot de passe",
|
||||||
"Password changed successfully": "Mot de passe changé avec succès",
|
"Password changed successfully": "Mot de passe changé avec succès",
|
||||||
|
"People": "Personnes",
|
||||||
"Pending": "En attente",
|
"Pending": "En attente",
|
||||||
"Please confirm your action": "Veuillez confirmer votre action",
|
"Please confirm your action": "Veuillez confirmer votre action",
|
||||||
"Preferences": "Préférences",
|
"Preferences": "Préférences",
|
||||||
@@ -207,6 +209,9 @@
|
|||||||
"Reply...": "Répondre...",
|
"Reply...": "Répondre...",
|
||||||
"Error loading comments.": "Erreur lors du chargement des commentaires.",
|
"Error loading comments.": "Erreur lors du chargement des commentaires.",
|
||||||
"No comments yet.": "Pas de commentaires pour l'instant.",
|
"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",
|
"Edit comment": "Modifier le commentaire",
|
||||||
"Delete comment": "Supprimer 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 ?",
|
"Are you sure you want to delete this comment?": "Êtes-vous sûr de vouloir supprimer ce commentaire ?",
|
||||||
@@ -228,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 ?",
|
"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",
|
"Resolved": "Résolu",
|
||||||
"No active comments.": "Aucun commentaire actif.",
|
"No active comments.": "Aucun commentaire actif.",
|
||||||
"No resolved comments.": "Aucun commentaire résolu.",
|
|
||||||
"Revoke invitation": "Révoquer l'invitation",
|
"Revoke invitation": "Révoquer l'invitation",
|
||||||
"Revoke": "Révoquer",
|
"Revoke": "Révoquer",
|
||||||
"Don't": "Ne pas",
|
"Don't": "Ne pas",
|
||||||
@@ -274,6 +278,7 @@
|
|||||||
"Add row below": "Ajouter une ligne en dessous",
|
"Add row below": "Ajouter une ligne en dessous",
|
||||||
"Delete table": "Supprimer le tableau",
|
"Delete table": "Supprimer le tableau",
|
||||||
"Info": "Info",
|
"Info": "Info",
|
||||||
|
"Note": "Remarque",
|
||||||
"Success": "Succès",
|
"Success": "Succès",
|
||||||
"Warning": "Avertissement",
|
"Warning": "Avertissement",
|
||||||
"Danger": "Danger",
|
"Danger": "Danger",
|
||||||
@@ -357,12 +362,21 @@
|
|||||||
"Multiple": "Multiple",
|
"Multiple": "Multiple",
|
||||||
"Turn into": "Transformer en",
|
"Turn into": "Transformer en",
|
||||||
"Text align": "Alignement du texte",
|
"Text align": "Alignement du texte",
|
||||||
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
|
"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": "Go to homepage",
|
"Go to homepage": "Aller à l'accueil",
|
||||||
"Pages you create will show up here.": "Pages you create will show up here.",
|
"Pages you create will show up here.": "Les pages que vous créez apparaîtront ici.",
|
||||||
"Heading {{level}}": "Titre {{level}}",
|
"Heading {{level}}": "Titre {{level}}",
|
||||||
"Toggle title": "Basculer le titre",
|
"Toggle title": "Basculer le titre",
|
||||||
"Write anything. Enter \"/\" for commands": "Écrivez n'importe quoi. Entrez \"/\" pour les commandes",
|
"Write anything. Enter \"/\" for commands": "Écrivez n'importe quoi. Entrez \"/\" pour les commandes",
|
||||||
|
"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",
|
"Names do not match": "Les noms ne correspondent pas",
|
||||||
"Today, {{time}}": "Aujourd'hui, {{time}}",
|
"Today, {{time}}": "Aujourd'hui, {{time}}",
|
||||||
"Yesterday, {{time}}": "Hier, {{time}}",
|
"Yesterday, {{time}}": "Hier, {{time}}",
|
||||||
@@ -385,6 +399,13 @@
|
|||||||
"Delete member": "Supprimer le membre",
|
"Delete member": "Supprimer le membre",
|
||||||
"Member deleted successfully": "Membre supprimé avec succès",
|
"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.",
|
"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": "Déplacer",
|
||||||
"Move page": "Déplacer la page",
|
"Move page": "Déplacer la page",
|
||||||
"Move page to a different space.": "Déplacer la page vers un autre espace.",
|
"Move page to a different space.": "Déplacer la page vers un autre espace.",
|
||||||
@@ -419,6 +440,8 @@
|
|||||||
"Public sharing is disabled at the workspace level": "Le partage public est désactivé au niveau de l'espace de travail",
|
"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.",
|
"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",
|
"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",
|
"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 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 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.",
|
||||||
@@ -509,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.",
|
"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",
|
"Verify": "Vérifier",
|
||||||
"Trash": "Corbeille",
|
"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é",
|
"Deleted": "Supprimé",
|
||||||
"No pages in trash": "Aucune page dans la corbeille",
|
"No pages in trash": "Aucune page dans la corbeille",
|
||||||
"Permanently delete page?": "Supprimer définitivement la page ?",
|
"Permanently delete page?": "Supprimer définitivement la page ?",
|
||||||
@@ -581,6 +604,10 @@
|
|||||||
"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.",
|
"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",
|
"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",
|
"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 settings": "Paramètres de l'IA",
|
||||||
"AI search": "Recherche IA",
|
"AI search": "Recherche IA",
|
||||||
"AI Answer": "Réponse IA",
|
"AI Answer": "Réponse IA",
|
||||||
@@ -594,6 +621,25 @@
|
|||||||
"Generative AI (Ask AI)": "IA générative (Demandez à l'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.",
|
"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",
|
"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",
|
"Sources": "Sources",
|
||||||
"AI Answers not available for attachments": "Réponses IA non disponibles 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",
|
"No answer available": "Pas de réponse disponible",
|
||||||
@@ -612,8 +658,40 @@
|
|||||||
"commented on a page": "a commenté une page",
|
"commented on a page": "a commenté une page",
|
||||||
"resolved a comment": "a résolu un commentaire",
|
"resolved a comment": "a résolu un commentaire",
|
||||||
"mentioned you on a page": "vous a mentionné sur une page",
|
"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",
|
"Today": "Aujourd'hui",
|
||||||
"Yesterday": "Hier",
|
"Yesterday": "Hier",
|
||||||
"This week": "Cette semaine",
|
"This week": "Cette semaine",
|
||||||
"Older": "Plus ancien"
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,7 @@
|
|||||||
"No group found": "Nessun gruppo trovato",
|
"No group found": "Nessun gruppo trovato",
|
||||||
"No page history saved yet.": "La pagina non ha una cronologia per ora.",
|
"No page history saved yet.": "La pagina non ha una cronologia per ora.",
|
||||||
"No pages yet": "Nessuna pagina per ora",
|
"No pages yet": "Nessuna pagina per ora",
|
||||||
|
"No shared pages": "Nessuna pagina condivisa.",
|
||||||
"No results found...": "Nessun risultato trovato...",
|
"No results found...": "Nessun risultato trovato...",
|
||||||
"No user found": "Nessun utente trovato",
|
"No user found": "Nessun utente trovato",
|
||||||
"Overview": "Panoramica",
|
"Overview": "Panoramica",
|
||||||
@@ -130,6 +131,7 @@
|
|||||||
"pages": "pagine",
|
"pages": "pagine",
|
||||||
"Password": "Password",
|
"Password": "Password",
|
||||||
"Password changed successfully": "Password cambiata con successo",
|
"Password changed successfully": "Password cambiata con successo",
|
||||||
|
"People": "Persone",
|
||||||
"Pending": "In sospeso",
|
"Pending": "In sospeso",
|
||||||
"Please confirm your action": "Si prega di confermare la propria azione",
|
"Please confirm your action": "Si prega di confermare la propria azione",
|
||||||
"Preferences": "Preferenze",
|
"Preferences": "Preferenze",
|
||||||
@@ -207,6 +209,9 @@
|
|||||||
"Reply...": "Rispondi...",
|
"Reply...": "Rispondi...",
|
||||||
"Error loading comments.": "Si è verificato un errore durante il caricamento dei commenti.",
|
"Error loading comments.": "Si è verificato un errore durante il caricamento dei commenti.",
|
||||||
"No comments yet.": "Nessun commento per ora.",
|
"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",
|
"Edit comment": "Modifica commento",
|
||||||
"Delete comment": "Elimina commento",
|
"Delete comment": "Elimina commento",
|
||||||
"Are you sure you want to delete this comment?": "Sei sicuro di voler eliminare questo commento?",
|
"Are you sure you want to delete this comment?": "Sei sicuro di voler eliminare questo commento?",
|
||||||
@@ -228,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?",
|
"Are you sure you want to unresolve this comment thread?": "Sei sicuro di voler annullare la risoluzione di questa discussione di commenti?",
|
||||||
"Resolved": "Risolto",
|
"Resolved": "Risolto",
|
||||||
"No active comments.": "Nessun commento attivo.",
|
"No active comments.": "Nessun commento attivo.",
|
||||||
"No resolved comments.": "Nessun commento risolto.",
|
|
||||||
"Revoke invitation": "Revoca invito",
|
"Revoke invitation": "Revoca invito",
|
||||||
"Revoke": "Revoca",
|
"Revoke": "Revoca",
|
||||||
"Don't": "Non",
|
"Don't": "Non",
|
||||||
@@ -274,6 +278,7 @@
|
|||||||
"Add row below": "Aggiungi riga sotto",
|
"Add row below": "Aggiungi riga sotto",
|
||||||
"Delete table": "Elimina tabella",
|
"Delete table": "Elimina tabella",
|
||||||
"Info": "Informazioni",
|
"Info": "Informazioni",
|
||||||
|
"Note": "Nota",
|
||||||
"Success": "Successo",
|
"Success": "Successo",
|
||||||
"Warning": "Avviso",
|
"Warning": "Avviso",
|
||||||
"Danger": "Pericolo",
|
"Danger": "Pericolo",
|
||||||
@@ -357,12 +362,21 @@
|
|||||||
"Multiple": "Multiplo",
|
"Multiple": "Multiplo",
|
||||||
"Turn into": "Trasforma in",
|
"Turn into": "Trasforma in",
|
||||||
"Text align": "Allinea testo",
|
"Text align": "Allinea testo",
|
||||||
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
|
"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": "Go to homepage",
|
"Go to homepage": "Vai alla pagina principale",
|
||||||
"Pages you create will show up here.": "Pages you create will show up here.",
|
"Pages you create will show up here.": "Le pagine che crei appariranno qui.",
|
||||||
"Heading {{level}}": "Intestazione {{level}}",
|
"Heading {{level}}": "Intestazione {{level}}",
|
||||||
"Toggle title": "Attiva/disattiva titolo",
|
"Toggle title": "Attiva/disattiva titolo",
|
||||||
"Write anything. Enter \"/\" for commands": "Scrivi qualcosa. Digita \"/\" per i comandi",
|
"Write anything. Enter \"/\" for commands": "Scrivi qualcosa. Digita \"/\" per i comandi",
|
||||||
|
"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",
|
"Names do not match": "I nomi non corrispondono",
|
||||||
"Today, {{time}}": "Oggi, {{time}}",
|
"Today, {{time}}": "Oggi, {{time}}",
|
||||||
"Yesterday, {{time}}": "Ieri, {{time}}",
|
"Yesterday, {{time}}": "Ieri, {{time}}",
|
||||||
@@ -385,6 +399,13 @@
|
|||||||
"Delete member": "Elimina membro",
|
"Delete member": "Elimina membro",
|
||||||
"Member deleted successfully": "Membro eliminato con successo",
|
"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.",
|
"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": "Sposta",
|
||||||
"Move page": "Sposta pagina",
|
"Move page": "Sposta pagina",
|
||||||
"Move page to a different space.": "Sposta la pagina in un altro spazio.",
|
"Move page to a different space.": "Sposta la pagina in un altro spazio.",
|
||||||
@@ -419,6 +440,8 @@
|
|||||||
"Public sharing is disabled at the workspace level": "La condivisione pubblica è disabilitata a livello di area di lavoro",
|
"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.",
|
"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",
|
"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",
|
"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 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 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.",
|
||||||
@@ -509,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.",
|
"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",
|
"Verify": "Verifica",
|
||||||
"Trash": "Cestino",
|
"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",
|
"Deleted": "Eliminato",
|
||||||
"No pages in trash": "Nessuna pagina nel cestino",
|
"No pages in trash": "Nessuna pagina nel cestino",
|
||||||
"Permanently delete page?": "Eliminare definitivamente la pagina?",
|
"Permanently delete page?": "Eliminare definitivamente la pagina?",
|
||||||
@@ -581,6 +604,10 @@
|
|||||||
"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.",
|
"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",
|
"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",
|
"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 settings": "Impostazioni AI",
|
||||||
"AI search": "Ricerca AI",
|
"AI search": "Ricerca AI",
|
||||||
"AI Answer": "Risposta AI",
|
"AI Answer": "Risposta AI",
|
||||||
@@ -594,6 +621,25 @@
|
|||||||
"Generative AI (Ask AI)": "AI generativa (Chiedi 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.",
|
"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",
|
"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",
|
"Sources": "Fonti",
|
||||||
"AI Answers not available for attachments": "Risposte AI non disponibili per gli allegati",
|
"AI Answers not available for attachments": "Risposte AI non disponibili per gli allegati",
|
||||||
"No answer available": "Nessuna risposta disponibile",
|
"No answer available": "Nessuna risposta disponibile",
|
||||||
@@ -612,8 +658,40 @@
|
|||||||
"commented on a page": "ha commentato una pagina",
|
"commented on a page": "ha commentato una pagina",
|
||||||
"resolved a comment": "ha risolto un commento",
|
"resolved a comment": "ha risolto un commento",
|
||||||
"mentioned you on a page": "ti ha menzionato in una pagina",
|
"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",
|
"Today": "Oggi",
|
||||||
"Yesterday": "Ieri",
|
"Yesterday": "Ieri",
|
||||||
"This week": "Questa settimana",
|
"This week": "Questa settimana",
|
||||||
"Older": "Più vecchie"
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 results found...": "結果が見つかりません",
|
"No results found...": "結果が見つかりません",
|
||||||
"No user found": "ユーザーが見つかりません",
|
"No user found": "ユーザーが見つかりません",
|
||||||
"Overview": "概要",
|
"Overview": "概要",
|
||||||
@@ -130,6 +131,7 @@
|
|||||||
"pages": "ページ",
|
"pages": "ページ",
|
||||||
"Password": "パスワード",
|
"Password": "パスワード",
|
||||||
"Password changed successfully": "パスワードを変更しました",
|
"Password changed successfully": "パスワードを変更しました",
|
||||||
|
"People": "メンバー",
|
||||||
"Pending": "保留中",
|
"Pending": "保留中",
|
||||||
"Please confirm your action": "アクションを確認してください",
|
"Please confirm your action": "アクションを確認してください",
|
||||||
"Preferences": "設定",
|
"Preferences": "設定",
|
||||||
@@ -207,6 +209,9 @@
|
|||||||
"Reply...": "返信...",
|
"Reply...": "返信...",
|
||||||
"Error loading comments.": "コメントの読み込みに失敗しました",
|
"Error loading comments.": "コメントの読み込みに失敗しました",
|
||||||
"No comments yet.": "コメントがありません",
|
"No comments yet.": "コメントがありません",
|
||||||
|
"No open comments.": "未解決のコメントはありません。",
|
||||||
|
"No resolved comments.": "解決済みのコメントはありません",
|
||||||
|
"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?": "このコメントを削除してもよろしいですか?",
|
||||||
@@ -228,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.": "解決済みのコメントはありません",
|
|
||||||
"Revoke invitation": "招待を取り消す",
|
"Revoke invitation": "招待を取り消す",
|
||||||
"Revoke": "取り消す",
|
"Revoke": "取り消す",
|
||||||
"Don't": "取り消さない",
|
"Don't": "取り消さない",
|
||||||
@@ -274,6 +278,7 @@
|
|||||||
"Add row below": "下に行を追加",
|
"Add row below": "下に行を追加",
|
||||||
"Delete table": "テーブルを削除",
|
"Delete table": "テーブルを削除",
|
||||||
"Info": "情報",
|
"Info": "情報",
|
||||||
|
"Note": "ノート",
|
||||||
"Success": "成功",
|
"Success": "成功",
|
||||||
"Warning": "警告",
|
"Warning": "警告",
|
||||||
"Danger": "危険",
|
"Danger": "危険",
|
||||||
@@ -357,12 +362,21 @@
|
|||||||
"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.",
|
"This page may have been deleted, moved, or you may not have access.": "このページは削除されたか移動されたか、またはアクセス権がない可能性があります。},{",
|
||||||
"Go to homepage": "Go to homepage",
|
"Go to homepage": "ホームページへ移動",
|
||||||
"Pages you create will show up here.": "Pages you create will show up here.",
|
"Pages you create will show up here.": "ここに作成したページが表示されます。",
|
||||||
"Heading {{level}}": "見出し {{level}}",
|
"Heading {{level}}": "見出し {{level}}",
|
||||||
"Toggle title": "タイトルの表示/非表示を切り替える",
|
"Toggle title": "タイトルの表示/非表示を切り替える",
|
||||||
"Write anything. Enter \"/\" for commands": "文字を入力するか、「/」でコマンドを呼び出します",
|
"Write anything. Enter \"/\" for commands": "文字を入力するか、「/」でコマンドを呼び出します",
|
||||||
|
"Write...": "ここに入力...",
|
||||||
|
"Column count": "列数",
|
||||||
|
"{{count}} Columns": "{{count}}列",
|
||||||
|
"Equal columns": "均等な列",
|
||||||
|
"Left sidebar": "左サイドバー",
|
||||||
|
"Right sidebar": "右サイドバー",
|
||||||
|
"Wide center": "中央ワイド",
|
||||||
|
"Left wide": "左ワイド",
|
||||||
|
"Right wide": "右ワイド",
|
||||||
"Names do not match": "名前が一致しません",
|
"Names do not match": "名前が一致しません",
|
||||||
"Today, {{time}}": "今日、{{time}}",
|
"Today, {{time}}": "今日、{{time}}",
|
||||||
"Yesterday, {{time}}": "昨日、{{time}}",
|
"Yesterday, {{time}}": "昨日、{{time}}",
|
||||||
@@ -385,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": "メンバーを無効化",
|
||||||
|
"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": "移動",
|
||||||
"Move page": "ページを移動",
|
"Move page": "ページを移動",
|
||||||
"Move page to a different space.": "ページを別のスペースに移動します",
|
"Move page to a different space.": "ページを別のスペースに移動します",
|
||||||
@@ -419,6 +440,8 @@
|
|||||||
"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": "ページのアクセス権",
|
||||||
|
"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.": "本当に公開共有を無効にしますか?このワークスペース内のすべての既存の共有リンクが削除されます。",
|
||||||
@@ -509,7 +532,7 @@
|
|||||||
"Enter one of your backup codes. Each backup code can only be used once.": "バックアップコードを入力してください。各コードは1回のみ使用可能です",
|
"Enter one of your backup codes. Each backup code can only be used once.": "バックアップコードを入力してください。各コードは1回のみ使用可能です",
|
||||||
"Verify": "確認",
|
"Verify": "確認",
|
||||||
"Trash": "ごみ箱",
|
"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": "削除",
|
"Deleted": "削除",
|
||||||
"No pages in trash": "ごみ箱にページがありません",
|
"No pages in trash": "ごみ箱にページがありません",
|
||||||
"Permanently delete page?": "ページを完全に削除しますか?",
|
"Permanently delete page?": "ページを完全に削除しますか?",
|
||||||
@@ -581,6 +604,10 @@
|
|||||||
"This action cannot be undone. Any applications using this API key will stop working.": "この操作は取り消せません。このAPIキーを使用しているアプリケーションは動作しなくなります",
|
"This action cannot be undone. Any applications using this API key will stop working.": "この操作は取り消せません。このAPIキーを使用しているアプリケーションは動作しなくなります",
|
||||||
"Update API key": "APIキーを更新",
|
"Update API key": "APIキーを更新",
|
||||||
"Manage API keys for all users in the workspace": "ワークスペース内のすべてのユーザーの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 settings": "AI設定",
|
||||||
"AI search": "AI検索",
|
"AI search": "AI検索",
|
||||||
"AI Answer": "AI回答",
|
"AI Answer": "AI回答",
|
||||||
@@ -594,6 +621,25 @@
|
|||||||
"Generative AI (Ask AI)": "生成AI (Ask 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を活用したコンテンツ生成を有効にします。ユーザーがテキストの生成、改善、翻訳、および変換を行うことができます。",
|
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "エディターでAIを活用したコンテンツ生成を有効にします。ユーザーがテキストの生成、改善、翻訳、および変換を行うことができます。",
|
||||||
"Toggle generative AI": "生成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": "ソース",
|
"Sources": "ソース",
|
||||||
"AI Answers not available for attachments": "添付ファイルにはAI回答を利用できません",
|
"AI Answers not available for attachments": "添付ファイルにはAI回答を利用できません",
|
||||||
"No answer available": "回答がありません",
|
"No answer available": "回答がありません",
|
||||||
@@ -612,8 +658,40 @@
|
|||||||
"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 view access to a page": "あなたにページの閲覧アクセス権を付与しました",
|
||||||
"Today": "今日",
|
"Today": "今日",
|
||||||
"Yesterday": "昨日",
|
"Yesterday": "昨日",
|
||||||
"This week": "今週",
|
"This week": "今週",
|
||||||
"Older": "以前のもの"
|
"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": "ページの権限を削除しました"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 results found...": "결과를 찾을 수 없습니다...",
|
"No results found...": "결과를 찾을 수 없습니다...",
|
||||||
"No user found": "사용자를 찾을 수 없음",
|
"No user found": "사용자를 찾을 수 없음",
|
||||||
"Overview": "개요",
|
"Overview": "개요",
|
||||||
@@ -130,6 +131,7 @@
|
|||||||
"pages": "페이지",
|
"pages": "페이지",
|
||||||
"Password": "비밀번호",
|
"Password": "비밀번호",
|
||||||
"Password changed successfully": "비밀번호 변경 완료",
|
"Password changed successfully": "비밀번호 변경 완료",
|
||||||
|
"People": "사용자",
|
||||||
"Pending": "대기 중",
|
"Pending": "대기 중",
|
||||||
"Please confirm your action": "작업을 확인해 주세요",
|
"Please confirm your action": "작업을 확인해 주세요",
|
||||||
"Preferences": "설정",
|
"Preferences": "설정",
|
||||||
@@ -207,6 +209,9 @@
|
|||||||
"Reply...": "답글...",
|
"Reply...": "답글...",
|
||||||
"Error loading comments.": "댓글 불러오기 오류.",
|
"Error loading comments.": "댓글 불러오기 오류.",
|
||||||
"No comments yet.": "아직 댓글이 없습니다.",
|
"No comments yet.": "아직 댓글이 없습니다.",
|
||||||
|
"No open comments.": "열린 댓글이 없습니다.",
|
||||||
|
"No resolved comments.": "해결된 댓글이 없습니다.",
|
||||||
|
"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?": "이 댓글을 삭제하시겠습니까?",
|
||||||
@@ -228,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.": "해결된 댓글이 없습니다.",
|
|
||||||
"Revoke invitation": "초대 취소",
|
"Revoke invitation": "초대 취소",
|
||||||
"Revoke": "취소",
|
"Revoke": "취소",
|
||||||
"Don't": "하지 않음",
|
"Don't": "하지 않음",
|
||||||
@@ -274,6 +278,7 @@
|
|||||||
"Add row below": "아래에 행 추가",
|
"Add row below": "아래에 행 추가",
|
||||||
"Delete table": "테이블 삭제",
|
"Delete table": "테이블 삭제",
|
||||||
"Info": "정보",
|
"Info": "정보",
|
||||||
|
"Note": "참고",
|
||||||
"Success": "완료",
|
"Success": "완료",
|
||||||
"Warning": "주의",
|
"Warning": "주의",
|
||||||
"Danger": "위험",
|
"Danger": "위험",
|
||||||
@@ -357,12 +362,21 @@
|
|||||||
"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.",
|
"This page may have been deleted, moved, or you may not have access.": "이 페이지는 삭제되었거나 이동되었거나 접근 권한이 없을 수 있습니다.",
|
||||||
"Go to homepage": "Go to homepage",
|
"Go to homepage": "홈으로 이동",
|
||||||
"Pages you create will show up here.": "Pages you create will show up here.",
|
"Pages you create will show up here.": "여기에 생성한 페이지가 표시됩니다.",
|
||||||
"Heading {{level}}": "제목 {{level}}",
|
"Heading {{level}}": "제목 {{level}}",
|
||||||
"Toggle title": "제목 토글",
|
"Toggle title": "제목 토글",
|
||||||
"Write anything. Enter \"/\" for commands": "아무거나 입력하세요. 명령어를 사용하려면 \"/\"를 입력하세요",
|
"Write anything. Enter \"/\" for commands": "아무거나 입력하세요. 명령어를 사용하려면 \"/\"를 입력하세요",
|
||||||
|
"Write...": "작성...",
|
||||||
|
"Column count": "열 개수",
|
||||||
|
"{{count}} Columns": "{{count}}열",
|
||||||
|
"Equal columns": "열 너비 균등",
|
||||||
|
"Left sidebar": "왼쪽 사이드바",
|
||||||
|
"Right sidebar": "오른쪽 사이드바",
|
||||||
|
"Wide center": "가운데 넓게",
|
||||||
|
"Left wide": "왼쪽 넓게",
|
||||||
|
"Right wide": "오른쪽 넓게",
|
||||||
"Names do not match": "이름이 일치하지 않습니다",
|
"Names do not match": "이름이 일치하지 않습니다",
|
||||||
"Today, {{time}}": "오늘, {{time}}",
|
"Today, {{time}}": "오늘, {{time}}",
|
||||||
"Yesterday, {{time}}": "어제, {{time}}",
|
"Yesterday, {{time}}": "어제, {{time}}",
|
||||||
@@ -385,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": "멤버 비활성화",
|
||||||
|
"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": "이동",
|
||||||
"Move page": "페이지 이동",
|
"Move page": "페이지 이동",
|
||||||
"Move page to a different space.": "페이지를 다른 공간으로 이동합니다.",
|
"Move page to a different space.": "페이지를 다른 공간으로 이동합니다.",
|
||||||
@@ -419,6 +440,8 @@
|
|||||||
"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": "페이지 권한},{",
|
||||||
|
"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.": "정말로 공유를 비활성화하시겠습니까? 이 워크스페이스의 모든 기존 공유 링크가 삭제됩니다.",
|
||||||
@@ -509,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.": "휴지통의 페이지는 30일 후에 영구적으로 삭제됩니다.",
|
"Pages in trash will be permanently deleted after {{count}} days.": "휴지통의 페이지는 {{count}}일 후 영구적으로 삭제됩니다.",
|
||||||
"Deleted": "삭제됨",
|
"Deleted": "삭제됨",
|
||||||
"No pages in trash": "휴지통에 페이지가 없습니다",
|
"No pages in trash": "휴지통에 페이지가 없습니다",
|
||||||
"Permanently delete page?": "페이지를 영구적으로 삭제하시겠습니까?",
|
"Permanently delete page?": "페이지를 영구적으로 삭제하시겠습니까?",
|
||||||
@@ -581,6 +604,10 @@
|
|||||||
"This action cannot be undone. Any applications using this API key will stop working.": "이 작업은 되돌릴 수 없습니다. 이 API 키를 사용하는 모든 응용 프로그램이 작동을 멈출 것입니다.",
|
"This action cannot be undone. Any applications using this API key will stop working.": "이 작업은 되돌릴 수 없습니다. 이 API 키를 사용하는 모든 응용 프로그램이 작동을 멈출 것입니다.",
|
||||||
"Update API key": "API 키 갱신",
|
"Update API key": "API 키 갱신",
|
||||||
"Manage API keys for all users in the workspace": "워크스페이스 내 모든 사용자의 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 settings": "AI 설정",
|
||||||
"AI search": "AI 검색",
|
"AI search": "AI 검색",
|
||||||
"AI Answer": "AI 답변",
|
"AI Answer": "AI 답변",
|
||||||
@@ -594,6 +621,25 @@
|
|||||||
"Generative AI (Ask AI)": "생성 AI (Ask 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 구동 콘텐츠 생성을 활성화합니다. 사용자가 텍스트를 생성, 개선, 번역 및 변환할 수 있습니다.",
|
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "편집기에서 AI 구동 콘텐츠 생성을 활성화합니다. 사용자가 텍스트를 생성, 개선, 번역 및 변환할 수 있습니다.",
|
||||||
"Toggle generative AI": "생성 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": "출처",
|
"Sources": "출처",
|
||||||
"AI Answers not available for attachments": "첨부 파일에 대해 AI 답변을 사용할 수 없습니다",
|
"AI Answers not available for attachments": "첨부 파일에 대해 AI 답변을 사용할 수 없습니다",
|
||||||
"No answer available": "답변을 제공할 수 없습니다",
|
"No answer available": "답변을 제공할 수 없습니다",
|
||||||
@@ -612,8 +658,40 @@
|
|||||||
"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 view access to a page": "페이지 보기 권한을 부여했습니다",
|
||||||
"Today": "오늘",
|
"Today": "오늘",
|
||||||
"Yesterday": "어제",
|
"Yesterday": "어제",
|
||||||
"This week": "이번 주",
|
"This week": "이번 주",
|
||||||
"Older": "이전"
|
"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": "페이지 권한이 제거됨"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,7 @@
|
|||||||
"No group found": "Geen groep gevonden",
|
"No group found": "Geen groep gevonden",
|
||||||
"No page history saved yet.": "Er is nog geen pagina geschiedenis opgeslagen.",
|
"No page history saved yet.": "Er is nog geen pagina geschiedenis opgeslagen.",
|
||||||
"No pages yet": "Nog geen pagina's",
|
"No pages yet": "Nog geen pagina's",
|
||||||
|
"No shared pages": "Geen gedeelde pagina's",
|
||||||
"No results found...": "Geen resultaten gevonden...",
|
"No results found...": "Geen resultaten gevonden...",
|
||||||
"No user found": "Geen gebruiker gevonden",
|
"No user found": "Geen gebruiker gevonden",
|
||||||
"Overview": "Overzicht",
|
"Overview": "Overzicht",
|
||||||
@@ -130,6 +131,7 @@
|
|||||||
"pages": "pagina's",
|
"pages": "pagina's",
|
||||||
"Password": "Wachtwoord",
|
"Password": "Wachtwoord",
|
||||||
"Password changed successfully": "Wachtwoord met succes gewijzigd",
|
"Password changed successfully": "Wachtwoord met succes gewijzigd",
|
||||||
|
"People": "Personen",
|
||||||
"Pending": "Wachtende",
|
"Pending": "Wachtende",
|
||||||
"Please confirm your action": "Bevestig alstublieft uw actie",
|
"Please confirm your action": "Bevestig alstublieft uw actie",
|
||||||
"Preferences": "Voorkeuren",
|
"Preferences": "Voorkeuren",
|
||||||
@@ -207,6 +209,9 @@
|
|||||||
"Reply...": "Antwoord...",
|
"Reply...": "Antwoord...",
|
||||||
"Error loading comments.": "Fout bij het laden van reacties.",
|
"Error loading comments.": "Fout bij het laden van reacties.",
|
||||||
"No comments yet.": "Nog geen 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",
|
"Edit comment": "Bewerk reactie",
|
||||||
"Delete comment": "Verwijder reactie",
|
"Delete comment": "Verwijder reactie",
|
||||||
"Are you sure you want to delete this comment?": "Weet je zeker dat je deze reactie wilt verwijderen?",
|
"Are you sure you want to delete this comment?": "Weet je zeker dat je deze reactie wilt verwijderen?",
|
||||||
@@ -228,7 +233,6 @@
|
|||||||
"Are you sure you want to unresolve this comment thread?": "Weet u zeker dat u deze reactiedraad niet wilt oplossen?",
|
"Are you sure you want to unresolve this comment thread?": "Weet u zeker dat u deze reactiedraad niet wilt oplossen?",
|
||||||
"Resolved": "Opgelost",
|
"Resolved": "Opgelost",
|
||||||
"No active comments.": "Geen actieve reacties.",
|
"No active comments.": "Geen actieve reacties.",
|
||||||
"No resolved comments.": "Geen opgeloste reacties.",
|
|
||||||
"Revoke invitation": "Uitnodiging intrekken",
|
"Revoke invitation": "Uitnodiging intrekken",
|
||||||
"Revoke": "Intrekken",
|
"Revoke": "Intrekken",
|
||||||
"Don't": "Niet doen",
|
"Don't": "Niet doen",
|
||||||
@@ -274,6 +278,7 @@
|
|||||||
"Add row below": "Rij hieronder toevoegen",
|
"Add row below": "Rij hieronder toevoegen",
|
||||||
"Delete table": "Verwijder tabel",
|
"Delete table": "Verwijder tabel",
|
||||||
"Info": "Info",
|
"Info": "Info",
|
||||||
|
"Note": "Opmerking",
|
||||||
"Success": "Geslaagd",
|
"Success": "Geslaagd",
|
||||||
"Warning": "Waarschuwing",
|
"Warning": "Waarschuwing",
|
||||||
"Danger": "Gevaar",
|
"Danger": "Gevaar",
|
||||||
@@ -357,12 +362,21 @@
|
|||||||
"Multiple": "Meerdere",
|
"Multiple": "Meerdere",
|
||||||
"Turn into": "Omzetten naar",
|
"Turn into": "Omzetten naar",
|
||||||
"Text align": "Tekstuitlijning",
|
"Text align": "Tekstuitlijning",
|
||||||
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
|
"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": "Go to homepage",
|
"Go to homepage": "Ga naar de startpagina",
|
||||||
"Pages you create will show up here.": "Pages you create will show up here.",
|
"Pages you create will show up here.": "Pagina's die u aanmaakt, verschijnen hier.",
|
||||||
"Heading {{level}}": "Kop {{level}}",
|
"Heading {{level}}": "Kop {{level}}",
|
||||||
"Toggle title": "Schakel titel in/uit",
|
"Toggle title": "Schakel titel in/uit",
|
||||||
"Write anything. Enter \"/\" for commands": "Schrijf iets. Voer \"/\" in voor commando's",
|
"Write anything. Enter \"/\" for commands": "Schrijf iets. Voer \"/\" in voor commando's",
|
||||||
|
"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",
|
"Names do not match": "Namen komen niet overeen",
|
||||||
"Today, {{time}}": "Vandaag, {{time}}",
|
"Today, {{time}}": "Vandaag, {{time}}",
|
||||||
"Yesterday, {{time}}": "Gisteren, {{time}}",
|
"Yesterday, {{time}}": "Gisteren, {{time}}",
|
||||||
@@ -385,6 +399,13 @@
|
|||||||
"Delete member": "Verwijder lid",
|
"Delete member": "Verwijder lid",
|
||||||
"Member deleted successfully": "Lid succesvol verwijderd",
|
"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.",
|
"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": "Verplaatsen",
|
||||||
"Move page": "Pagina verplaatsen",
|
"Move page": "Pagina verplaatsen",
|
||||||
"Move page to a different space.": "Verplaats pagina naar een andere ruimte.",
|
"Move page to a different space.": "Verplaats pagina naar een andere ruimte.",
|
||||||
@@ -419,6 +440,8 @@
|
|||||||
"Public sharing is disabled at the workspace level": "Openbaar delen is uitgeschakeld op werkruimteniveau",
|
"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.",
|
"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",
|
"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",
|
"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 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 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.",
|
||||||
@@ -509,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.",
|
"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",
|
"Verify": "Verifiëren",
|
||||||
"Trash": "Prullenbak",
|
"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",
|
"Deleted": "Verwijderd",
|
||||||
"No pages in trash": "Geen pagina's in de prullenbak",
|
"No pages in trash": "Geen pagina's in de prullenbak",
|
||||||
"Permanently delete page?": "Pagina permanent verwijderen?",
|
"Permanently delete page?": "Pagina permanent verwijderen?",
|
||||||
@@ -581,6 +604,10 @@
|
|||||||
"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.",
|
"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",
|
"Update API key": "API-sleutel bijwerken",
|
||||||
"Manage API keys for all users in the workspace": "Beheer API-sleutels voor alle gebruikers in de werkruimte",
|
"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 settings": "AI-instellingen",
|
||||||
"AI search": "AI-zoekopdracht",
|
"AI search": "AI-zoekopdracht",
|
||||||
"AI Answer": "AI Antwoord",
|
"AI Answer": "AI Antwoord",
|
||||||
@@ -594,6 +621,25 @@
|
|||||||
"Generative AI (Ask AI)": "Generatieve AI (Vraag het AI)",
|
"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.",
|
"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",
|
"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",
|
"Sources": "Bronnen",
|
||||||
"AI Answers not available for attachments": "AI Antwoorden niet beschikbaar voor bijlagen",
|
"AI Answers not available for attachments": "AI Antwoorden niet beschikbaar voor bijlagen",
|
||||||
"No answer available": "Geen antwoord beschikbaar",
|
"No answer available": "Geen antwoord beschikbaar",
|
||||||
@@ -612,8 +658,40 @@
|
|||||||
"commented on a page": "reageerde op een pagina",
|
"commented on a page": "reageerde op een pagina",
|
||||||
"resolved a comment": "heeft een opmerking opgelost",
|
"resolved a comment": "heeft een opmerking opgelost",
|
||||||
"mentioned you on a page": "noemde je op een pagina",
|
"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",
|
"Today": "Vandaag",
|
||||||
"Yesterday": "Gisteren",
|
"Yesterday": "Gisteren",
|
||||||
"This week": "Deze week",
|
"This week": "Deze week",
|
||||||
"Older": "Ouder"
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,7 @@
|
|||||||
"No group found": "Nenhum grupo encontrado",
|
"No group found": "Nenhum grupo encontrado",
|
||||||
"No page history saved yet.": "Nenhum histórico de página salvo ainda.",
|
"No page history saved yet.": "Nenhum histórico de página salvo ainda.",
|
||||||
"No pages yet": "Nenhuma página ainda",
|
"No pages yet": "Nenhuma página ainda",
|
||||||
|
"No shared pages": "Sem páginas compartilhadas",
|
||||||
"No results found...": "Nenhum resultado encontrado...",
|
"No results found...": "Nenhum resultado encontrado...",
|
||||||
"No user found": "Nenhum usuário encontrado",
|
"No user found": "Nenhum usuário encontrado",
|
||||||
"Overview": "Visão geral",
|
"Overview": "Visão geral",
|
||||||
@@ -130,6 +131,7 @@
|
|||||||
"pages": "páginas",
|
"pages": "páginas",
|
||||||
"Password": "Senha",
|
"Password": "Senha",
|
||||||
"Password changed successfully": "Senha alterada com sucesso",
|
"Password changed successfully": "Senha alterada com sucesso",
|
||||||
|
"People": "Pessoas",
|
||||||
"Pending": "Pendente",
|
"Pending": "Pendente",
|
||||||
"Please confirm your action": "Por favor, confirme sua ação",
|
"Please confirm your action": "Por favor, confirme sua ação",
|
||||||
"Preferences": "Preferências",
|
"Preferences": "Preferências",
|
||||||
@@ -207,6 +209,9 @@
|
|||||||
"Reply...": "Responder...",
|
"Reply...": "Responder...",
|
||||||
"Error loading comments.": "Erro ao carregar comentários.",
|
"Error loading comments.": "Erro ao carregar comentários.",
|
||||||
"No comments yet.": "Ainda sem 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",
|
"Edit comment": "Editar comentário",
|
||||||
"Delete comment": "Excluir 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?",
|
"Are you sure you want to delete this comment?": "Você tem certeza de que deseja excluir este comentário?",
|
||||||
@@ -228,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?",
|
"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",
|
"Resolved": "Resolvido",
|
||||||
"No active comments.": "Sem comentários ativos.",
|
"No active comments.": "Sem comentários ativos.",
|
||||||
"No resolved comments.": "Sem comentários resolvidos.",
|
|
||||||
"Revoke invitation": "Cancelar o convite",
|
"Revoke invitation": "Cancelar o convite",
|
||||||
"Revoke": "Anular",
|
"Revoke": "Anular",
|
||||||
"Don't": "Não",
|
"Don't": "Não",
|
||||||
@@ -274,6 +278,7 @@
|
|||||||
"Add row below": "Adicionar linha abaixo",
|
"Add row below": "Adicionar linha abaixo",
|
||||||
"Delete table": "Excluir tabela",
|
"Delete table": "Excluir tabela",
|
||||||
"Info": "Informação",
|
"Info": "Informação",
|
||||||
|
"Note": "Observação",
|
||||||
"Success": "Sucesso",
|
"Success": "Sucesso",
|
||||||
"Warning": "Aviso",
|
"Warning": "Aviso",
|
||||||
"Danger": "Perigo",
|
"Danger": "Perigo",
|
||||||
@@ -357,12 +362,21 @@
|
|||||||
"Multiple": "Múltiplo",
|
"Multiple": "Múltiplo",
|
||||||
"Turn into": "Transformar em",
|
"Turn into": "Transformar em",
|
||||||
"Text align": "Alinhar texto",
|
"Text align": "Alinhar texto",
|
||||||
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
|
"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": "Go to homepage",
|
"Go to homepage": "Ir para a página inicial",
|
||||||
"Pages you create will show up here.": "Pages you create will show up here.",
|
"Pages you create will show up here.": "As páginas que você criar aparecerão aqui.",
|
||||||
"Heading {{level}}": "Título {{level}}",
|
"Heading {{level}}": "Título {{level}}",
|
||||||
"Toggle title": "Alternar título",
|
"Toggle title": "Alternar título",
|
||||||
"Write anything. Enter \"/\" for commands": "Escreva qualquer coisa. Digite \"/\" para comandos",
|
"Write anything. Enter \"/\" for commands": "Escreva qualquer coisa. Digite \"/\" para comandos",
|
||||||
|
"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",
|
"Names do not match": "Os nomes não coincidem",
|
||||||
"Today, {{time}}": "Hoje, {{time}}",
|
"Today, {{time}}": "Hoje, {{time}}",
|
||||||
"Yesterday, {{time}}": "Ontem, {{time}}",
|
"Yesterday, {{time}}": "Ontem, {{time}}",
|
||||||
@@ -385,6 +399,13 @@
|
|||||||
"Delete member": "Excluir membro",
|
"Delete member": "Excluir membro",
|
||||||
"Member deleted successfully": "Membro removido com sucesso",
|
"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.",
|
"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": "Mover",
|
||||||
"Move page": "Mover página",
|
"Move page": "Mover página",
|
||||||
"Move page to a different space.": "Mover página para um espaço diferente.",
|
"Move page to a different space.": "Mover página para um espaço diferente.",
|
||||||
@@ -419,6 +440,8 @@
|
|||||||
"Public sharing is disabled at the workspace level": "O compartilhamento público está desativado no nível do espaço de trabalho",
|
"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.",
|
"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",
|
"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",
|
"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 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 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.",
|
||||||
@@ -509,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.",
|
"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",
|
"Verify": "Verificar",
|
||||||
"Trash": "Lixeira",
|
"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",
|
"Deleted": "Excluído",
|
||||||
"No pages in trash": "Sem páginas na lixeira",
|
"No pages in trash": "Sem páginas na lixeira",
|
||||||
"Permanently delete page?": "Excluir página permanentemente?",
|
"Permanently delete page?": "Excluir página permanentemente?",
|
||||||
@@ -581,6 +604,10 @@
|
|||||||
"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.",
|
"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",
|
"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",
|
"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 settings": "Configurações de IA",
|
||||||
"AI search": "Pesquisa IA",
|
"AI search": "Pesquisa IA",
|
||||||
"AI Answer": "Resposta de IA",
|
"AI Answer": "Resposta de IA",
|
||||||
@@ -594,6 +621,25 @@
|
|||||||
"Generative AI (Ask AI)": "IA generativa (Perguntar à 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.",
|
"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",
|
"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",
|
"Sources": "Fontes",
|
||||||
"AI Answers not available for attachments": "Respostas de IA não disponíveis para anexos",
|
"AI Answers not available for attachments": "Respostas de IA não disponíveis para anexos",
|
||||||
"No answer available": "Nenhuma resposta disponível",
|
"No answer available": "Nenhuma resposta disponível",
|
||||||
@@ -612,8 +658,40 @@
|
|||||||
"commented on a page": "comentou em uma página",
|
"commented on a page": "comentou em uma página",
|
||||||
"resolved a comment": "resolveu um comentário",
|
"resolved a comment": "resolveu um comentário",
|
||||||
"mentioned you on a page": "mencionou você em uma página",
|
"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",
|
"Today": "Hoje",
|
||||||
"Yesterday": "Ontem",
|
"Yesterday": "Ontem",
|
||||||
"This week": "Esta semana",
|
"This week": "Esta semana",
|
||||||
"Older": "Mais antigo"
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"Admin": "Администратор",
|
"Admin": "Администратор",
|
||||||
"Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "Вы уверены, что хотите удалить эту группу? Участники потеряют доступ к материалам, к которым у этой группы есть доступ.",
|
"Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "Вы уверены, что хотите удалить эту группу? Участники потеряют доступ к материалам, к которым у этой группы есть доступ.",
|
||||||
"Are you sure you want to delete this page?": "Вы уверены, что хотите удалить эту страницу?",
|
"Are you sure you want to 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 remove this user from the space? The user will lose all access to this space.": "Вы уверены, что хотите удалить этого пользователя из пространства? Пользователь потеряет весь доступ к этому пространству.",
|
||||||
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Вы уверены, что хотите восстановить эту версию? Все не зафиксированные изменения будут потеряны.",
|
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Вы уверены, что хотите восстановить эту версию? Все не зафиксированные изменения будут потеряны.",
|
||||||
"Can become members of groups and spaces in workspace": "Могут становиться участниками групп и пространств в рабочей области",
|
"Can become members of groups and spaces in workspace": "Могут становиться участниками групп и пространств в рабочей области",
|
||||||
@@ -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 results found...": "Результаты не найдены...",
|
"No results found...": "Результаты не найдены...",
|
||||||
"No user found": "Пользователь не найден",
|
"No user found": "Пользователь не найден",
|
||||||
"Overview": "Обзор",
|
"Overview": "Обзор",
|
||||||
@@ -130,6 +131,7 @@
|
|||||||
"pages": "страницы",
|
"pages": "страницы",
|
||||||
"Password": "Пароль",
|
"Password": "Пароль",
|
||||||
"Password changed successfully": "Пароль успешно изменён",
|
"Password changed successfully": "Пароль успешно изменён",
|
||||||
|
"People": "Люди",
|
||||||
"Pending": "В ожидании",
|
"Pending": "В ожидании",
|
||||||
"Please confirm your action": "Пожалуйста, подтвердите ваше действие",
|
"Please confirm your action": "Пожалуйста, подтвердите ваше действие",
|
||||||
"Preferences": "Настройки",
|
"Preferences": "Настройки",
|
||||||
@@ -207,6 +209,9 @@
|
|||||||
"Reply...": "Ответить...",
|
"Reply...": "Ответить...",
|
||||||
"Error loading comments.": "Ошибка при загрузке комментариев.",
|
"Error loading comments.": "Ошибка при загрузке комментариев.",
|
||||||
"No comments yet.": "Комментариев пока нет.",
|
"No comments yet.": "Комментариев пока нет.",
|
||||||
|
"No open comments.": "Нет открытых комментариев.",
|
||||||
|
"No resolved comments.": "Нет решённых комментариев.",
|
||||||
|
"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?": "Вы уверены, что хотите удалить этот комментарий?",
|
||||||
@@ -228,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.": "Нет решённых комментариев.",
|
|
||||||
"Revoke invitation": "Отозвать приглашение",
|
"Revoke invitation": "Отозвать приглашение",
|
||||||
"Revoke": "Отозвать",
|
"Revoke": "Отозвать",
|
||||||
"Don't": "Нет",
|
"Don't": "Нет",
|
||||||
@@ -274,6 +278,7 @@
|
|||||||
"Add row below": "Добавить строку ниже",
|
"Add row below": "Добавить строку ниже",
|
||||||
"Delete table": "Удалить таблицу",
|
"Delete table": "Удалить таблицу",
|
||||||
"Info": "Информация",
|
"Info": "Информация",
|
||||||
|
"Note": "Примечание",
|
||||||
"Success": "Успешно",
|
"Success": "Успешно",
|
||||||
"Warning": "Предупреждение",
|
"Warning": "Предупреждение",
|
||||||
"Danger": "Важно",
|
"Danger": "Важно",
|
||||||
@@ -357,12 +362,21 @@
|
|||||||
"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.",
|
"This page may have been deleted, moved, or you may not have access.": "Эта страница могла быть удалена, перемещена, или у вас может отсутствовать доступ к ней.",
|
||||||
"Go to homepage": "Go to homepage",
|
"Go to homepage": "Вернуться на главную",
|
||||||
"Pages you create will show up here.": "Pages you create will show up here.",
|
"Pages you create will show up here.": "Созданные вами страницы появятся здесь.",
|
||||||
"Heading {{level}}": "Заголовок {{level}}",
|
"Heading {{level}}": "Заголовок {{level}}",
|
||||||
"Toggle title": "Переключить заголовок",
|
"Toggle title": "Переключить заголовок",
|
||||||
"Write anything. Enter \"/\" for commands": "Начните писать. Введите \"/\" для списка команд",
|
"Write anything. Enter \"/\" for commands": "Начните писать. Введите \"/\" для списка команд",
|
||||||
|
"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": "Названия не совпадают",
|
"Names do not match": "Названия не совпадают",
|
||||||
"Today, {{time}}": "Сегодня, {{time}}",
|
"Today, {{time}}": "Сегодня, {{time}}",
|
||||||
"Yesterday, {{time}}": "Вчера, {{time}}",
|
"Yesterday, {{time}}": "Вчера, {{time}}",
|
||||||
@@ -385,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": "Деактивировать участника",
|
||||||
|
"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": "Переместить",
|
||||||
"Move page": "Переместить страницу",
|
"Move page": "Переместить страницу",
|
||||||
"Move page to a different space.": "Переместите страницу в другое пространство.",
|
"Move page to a different space.": "Переместите страницу в другое пространство.",
|
||||||
@@ -419,6 +440,8 @@
|
|||||||
"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": "Права доступа к странице},{",
|
||||||
|
"Control who can view and edit individual pages. Available with an enterprise license.": "Контролируйте, кто может просматривать и редактировать отдельные страницы. Доступно при наличии лицензии Enterprise.",
|
||||||
"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.": "Вы уверены, что хотите отключить общий доступ? Все существующие ссылки в этом рабочем пространстве будут удалены.",
|
||||||
@@ -509,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.": "Страницы в корзине будут окончательно удалены через 30 дней.",
|
"Pages in trash will be permanently deleted after {{count}} days.": "{count, plural, one {Страница в корзине будет окончательно удалена через # день.} few {Страницы в корзине будут окончательно удалены через # дня.} many {Страницы в корзине будут окончательно удалены через # дней.} other {Страницы в корзине будут окончательно удалены через # дней.}}",
|
||||||
"Deleted": "Удалено",
|
"Deleted": "Удалено",
|
||||||
"No pages in trash": "В корзине нет страниц",
|
"No pages in trash": "В корзине нет страниц",
|
||||||
"Permanently delete page?": "Удалить страницу окончательно?",
|
"Permanently delete page?": "Удалить страницу окончательно?",
|
||||||
@@ -581,6 +604,10 @@
|
|||||||
"This action cannot be undone. Any applications using this API key will stop working.": "Это действие необратимо. Любые приложения, использующие этот API ключ, перестанут работать.",
|
"This action cannot be undone. Any applications using this API key will stop working.": "Это действие необратимо. Любые приложения, использующие этот API ключ, перестанут работать.",
|
||||||
"Update API key": "Обновить API ключ",
|
"Update API key": "Обновить API ключ",
|
||||||
"Manage API keys for all users in the workspace": "Управлять 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 settings": "Настройки ИИ",
|
||||||
"AI search": "Поиск ИИ",
|
"AI search": "Поиск ИИ",
|
||||||
"AI Answer": "Ответ ИИ",
|
"AI Answer": "Ответ ИИ",
|
||||||
@@ -594,6 +621,25 @@
|
|||||||
"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": "Корпоративная функция",
|
||||||
|
"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": "Источники",
|
"Sources": "Источники",
|
||||||
"AI Answers not available for attachments": "Ответы ИИ недоступны для вложений",
|
"AI Answers not available for attachments": "Ответы ИИ недоступны для вложений",
|
||||||
"No answer available": "Ответ недоступен",
|
"No answer available": "Ответ недоступен",
|
||||||
@@ -612,8 +658,40 @@
|
|||||||
"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 view access to a page": "предоставил вам доступ для просмотра страницы",
|
||||||
"Today": "Сегодня",
|
"Today": "Сегодня",
|
||||||
"Yesterday": "Вчера",
|
"Yesterday": "Вчера",
|
||||||
"This week": "На этой неделе",
|
"This week": "На этой неделе",
|
||||||
"Older": "Старше"
|
"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": "Удалено разрешение доступа к странице"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 results found...": "Результати не знайдено...",
|
"No results found...": "Результати не знайдено...",
|
||||||
"No user found": "Користувача не знайдено",
|
"No user found": "Користувача не знайдено",
|
||||||
"Overview": "Огляд",
|
"Overview": "Огляд",
|
||||||
@@ -130,6 +131,7 @@
|
|||||||
"pages": "сторінки",
|
"pages": "сторінки",
|
||||||
"Password": "Пароль",
|
"Password": "Пароль",
|
||||||
"Password changed successfully": "Пароль успішно змінено",
|
"Password changed successfully": "Пароль успішно змінено",
|
||||||
|
"People": "Користувачі",
|
||||||
"Pending": "В очікуванні",
|
"Pending": "В очікуванні",
|
||||||
"Please confirm your action": "Будь ласка, підтвердіть вашу дію",
|
"Please confirm your action": "Будь ласка, підтвердіть вашу дію",
|
||||||
"Preferences": "Налаштування",
|
"Preferences": "Налаштування",
|
||||||
@@ -207,6 +209,9 @@
|
|||||||
"Reply...": "Відповісти...",
|
"Reply...": "Відповісти...",
|
||||||
"Error loading comments.": "Помилка при завантаженні коментарів.",
|
"Error loading comments.": "Помилка при завантаженні коментарів.",
|
||||||
"No comments yet.": "Коментарів поки немає.",
|
"No comments yet.": "Коментарів поки немає.",
|
||||||
|
"No open comments.": "Немає відкритих коментарів.",
|
||||||
|
"No resolved comments.": "Немає вирішених коментарів.",
|
||||||
|
"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?": "Ви впевнені, що хочете видалити цей коментар?",
|
||||||
@@ -228,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.": "Немає вирішених коментарів.",
|
|
||||||
"Revoke invitation": "Відкликати запрошення",
|
"Revoke invitation": "Відкликати запрошення",
|
||||||
"Revoke": "Відкликати",
|
"Revoke": "Відкликати",
|
||||||
"Don't": "Ні",
|
"Don't": "Ні",
|
||||||
@@ -274,6 +278,7 @@
|
|||||||
"Add row below": "Додати рядок нижче",
|
"Add row below": "Додати рядок нижче",
|
||||||
"Delete table": "Видалити таблицю",
|
"Delete table": "Видалити таблицю",
|
||||||
"Info": "Інформація",
|
"Info": "Інформація",
|
||||||
|
"Note": "Примітка",
|
||||||
"Success": "Успішно",
|
"Success": "Успішно",
|
||||||
"Warning": "Попередження",
|
"Warning": "Попередження",
|
||||||
"Danger": "Важливо",
|
"Danger": "Важливо",
|
||||||
@@ -357,12 +362,21 @@
|
|||||||
"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.",
|
"This page may have been deleted, moved, or you may not have access.": "Цю сторінку могли видалити, перемістити або у вас може не бути до неї доступу.",
|
||||||
"Go to homepage": "Go to homepage",
|
"Go to homepage": "Перейти на головну",
|
||||||
"Pages you create will show up here.": "Pages you create will show up here.",
|
"Pages you create will show up here.": "Сторінки, які ви створите, з'являться тут.",
|
||||||
"Heading {{level}}": "Заголовок {{level}}",
|
"Heading {{level}}": "Заголовок {{level}}",
|
||||||
"Toggle title": "Перемкнути заголовок",
|
"Toggle title": "Перемкнути заголовок",
|
||||||
"Write anything. Enter \"/\" for commands": "Почніть писати. Введіть \"/\" для списку команд",
|
"Write anything. Enter \"/\" for commands": "Почніть писати. Введіть \"/\" для списку команд",
|
||||||
|
"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": "Назви не співпадають",
|
"Names do not match": "Назви не співпадають",
|
||||||
"Today, {{time}}": "Сьогодні, {{time}}",
|
"Today, {{time}}": "Сьогодні, {{time}}",
|
||||||
"Yesterday, {{time}}": "Вчора, {{time}}",
|
"Yesterday, {{time}}": "Вчора, {{time}}",
|
||||||
@@ -385,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": "Деактивувати учасника",
|
||||||
|
"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": "Перемістити",
|
||||||
"Move page": "Перемістити сторінку",
|
"Move page": "Перемістити сторінку",
|
||||||
"Move page to a different space.": "Перемістити сторінку в інший простір.",
|
"Move page to a different space.": "Перемістити сторінку в інший простір.",
|
||||||
@@ -419,6 +440,8 @@
|
|||||||
"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": "Права доступу до сторінки.",
|
||||||
|
"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.": "Ви впевнені, що хочете вимкнути публічний доступ? Усі існуючі посилання для спільного доступу в цьому робочому просторі будуть видалені.",
|
||||||
@@ -509,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.": "Сторінки у кошику будуть остаточно видалені через 30 днів.",
|
"Pages in trash will be permanently deleted after {{count}} days.": "Сторінки в кошику будуть остаточно видалені через {count, plural, one{# день} few{# дні} many{# днів} other{# дня}}.",
|
||||||
"Deleted": "Видалено",
|
"Deleted": "Видалено",
|
||||||
"No pages in trash": "Немає сторінок у кошику",
|
"No pages in trash": "Немає сторінок у кошику",
|
||||||
"Permanently delete page?": "Остаточно видалити сторінку?",
|
"Permanently delete page?": "Остаточно видалити сторінку?",
|
||||||
@@ -581,6 +604,10 @@
|
|||||||
"This action cannot be undone. Any applications using this API key will stop working.": "Цю дію не можна скасувати. Будь-які додатки, що використовують цей ключ API, перестануть працювати.",
|
"This action cannot be undone. Any applications using this API key will stop working.": "Цю дію не можна скасувати. Будь-які додатки, що використовують цей ключ API, перестануть працювати.",
|
||||||
"Update API key": "Оновити ключ API",
|
"Update API key": "Оновити ключ API",
|
||||||
"Manage API keys for all users in the workspace": "Керувати ключами 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 settings": "Налаштування ШІ",
|
||||||
"AI search": "Пошук з ШІ",
|
"AI search": "Пошук з ШІ",
|
||||||
"AI Answer": "Відповідь ШІ",
|
"AI Answer": "Відповідь ШІ",
|
||||||
@@ -594,6 +621,25 @@
|
|||||||
"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": "Функція корпоративної версії",
|
||||||
|
"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": "Джерела",
|
"Sources": "Джерела",
|
||||||
"AI Answers not available for attachments": "Відповіді ШІ недоступні для вкладень",
|
"AI Answers not available for attachments": "Відповіді ШІ недоступні для вкладень",
|
||||||
"No answer available": "Відповідь недоступна",
|
"No answer available": "Відповідь недоступна",
|
||||||
@@ -612,8 +658,40 @@
|
|||||||
"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 view access to a page": "надав вам доступ для перегляду сторінки",
|
||||||
"Today": "Сьогодні",
|
"Today": "Сьогодні",
|
||||||
"Yesterday": "Вчора",
|
"Yesterday": "Вчора",
|
||||||
"This week": "Цього тижня",
|
"This week": "Цього тижня",
|
||||||
"Older": "Старіші"
|
"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": "Дозвіл на сторінку видалено"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 results found...": "未找到结果...",
|
"No results found...": "未找到结果...",
|
||||||
"No user found": "未找到用户",
|
"No user found": "未找到用户",
|
||||||
"Overview": "概览",
|
"Overview": "概览",
|
||||||
@@ -130,6 +131,7 @@
|
|||||||
"pages": "个页面",
|
"pages": "个页面",
|
||||||
"Password": "密码",
|
"Password": "密码",
|
||||||
"Password changed successfully": "密码更改成功",
|
"Password changed successfully": "密码更改成功",
|
||||||
|
"People": "人员",
|
||||||
"Pending": "待定",
|
"Pending": "待定",
|
||||||
"Please confirm your action": "请确认您的操作",
|
"Please confirm your action": "请确认您的操作",
|
||||||
"Preferences": "偏好设置",
|
"Preferences": "偏好设置",
|
||||||
@@ -207,6 +209,9 @@
|
|||||||
"Reply...": "回复...",
|
"Reply...": "回复...",
|
||||||
"Error loading comments.": "加载评论时出错",
|
"Error loading comments.": "加载评论时出错",
|
||||||
"No comments yet.": "目前还没有评论",
|
"No comments yet.": "目前还没有评论",
|
||||||
|
"No open comments.": "没有未解决的评论。",
|
||||||
|
"No resolved comments.": "没有已解决的评论。",
|
||||||
|
"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?": "你确定要删除这条评论吗?",
|
||||||
@@ -228,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.": "没有已解决的评论。",
|
|
||||||
"Revoke invitation": "撤回邀请",
|
"Revoke invitation": "撤回邀请",
|
||||||
"Revoke": "撤销",
|
"Revoke": "撤销",
|
||||||
"Don't": "不要",
|
"Don't": "不要",
|
||||||
@@ -274,6 +278,7 @@
|
|||||||
"Add row below": "在下方插入行",
|
"Add row below": "在下方插入行",
|
||||||
"Delete table": "删除表格",
|
"Delete table": "删除表格",
|
||||||
"Info": "信息",
|
"Info": "信息",
|
||||||
|
"Note": "注意",
|
||||||
"Success": "成功",
|
"Success": "成功",
|
||||||
"Warning": "警告",
|
"Warning": "警告",
|
||||||
"Danger": "危险",
|
"Danger": "危险",
|
||||||
@@ -357,12 +362,21 @@
|
|||||||
"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.",
|
"This page may have been deleted, moved, or you may not have access.": "此页面可能已被删除、移动,或者您可能无权访问。{",
|
||||||
"Go to homepage": "Go to homepage",
|
"Go to homepage": "前往首页",
|
||||||
"Pages you create will show up here.": "Pages you create will show up here.",
|
"Pages you create will show up here.": "您创建的页面将显示在此处。",
|
||||||
"Heading {{level}}": "{{level}} 级标题",
|
"Heading {{level}}": "{{level}} 级标题",
|
||||||
"Toggle title": "切换标题",
|
"Toggle title": "切换标题",
|
||||||
"Write anything. Enter \"/\" for commands": "开始编写内容,输入 \"/\" 以使用指令",
|
"Write anything. Enter \"/\" for commands": "开始编写内容,输入 \"/\" 以使用指令",
|
||||||
|
"Write...": "写点内容...",
|
||||||
|
"Column count": "列数",
|
||||||
|
"{{count}} Columns": "{{count}} 列",
|
||||||
|
"Equal columns": "等宽列",
|
||||||
|
"Left sidebar": "左侧边栏",
|
||||||
|
"Right sidebar": "右侧边栏",
|
||||||
|
"Wide center": "中间加宽",
|
||||||
|
"Left wide": "左侧加宽",
|
||||||
|
"Right wide": "右侧加宽",
|
||||||
"Names do not match": "名称不匹配",
|
"Names do not match": "名称不匹配",
|
||||||
"Today, {{time}}": "今天,{{time}}",
|
"Today, {{time}}": "今天,{{time}}",
|
||||||
"Yesterday, {{time}}": "昨天,{{time}}",
|
"Yesterday, {{time}}": "昨天,{{time}}",
|
||||||
@@ -385,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": "停用成员",
|
||||||
|
"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": "移动",
|
||||||
"Move page": "移动页面",
|
"Move page": "移动页面",
|
||||||
"Move page to a different space.": "将页面移动到不同的空间。",
|
"Move page to a different space.": "将页面移动到不同的空间。",
|
||||||
@@ -419,6 +440,8 @@
|
|||||||
"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": "页面权限},{",
|
||||||
|
"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.": "您确定要禁用公开分享吗?此工作区中的所有现有共享链接都将被删除。",
|
||||||
@@ -509,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.": "垃圾箱中的页面将在30天后被永久删除。",
|
"Pages in trash will be permanently deleted after {{count}} days.": "垃圾箱中的页面将在{{count}}天后被永久删除。",
|
||||||
"Deleted": "已删除",
|
"Deleted": "已删除",
|
||||||
"No pages in trash": "垃圾箱中没有页面",
|
"No pages in trash": "垃圾箱中没有页面",
|
||||||
"Permanently delete page?": "永久删除页面?",
|
"Permanently delete page?": "永久删除页面?",
|
||||||
@@ -581,6 +604,10 @@
|
|||||||
"This action cannot be undone. Any applications using this API key will stop working.": "此操作无法撤销。使用此API密钥的任何应用程序将停止工作。",
|
"This action cannot be undone. Any applications using this API key will stop working.": "此操作无法撤销。使用此API密钥的任何应用程序将停止工作。",
|
||||||
"Update API key": "更新API密钥",
|
"Update API key": "更新API密钥",
|
||||||
"Manage API keys for all users in the workspace": "管理工作空间中所有用户的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 settings": "AI设置",
|
||||||
"AI search": "AI搜索",
|
"AI search": "AI搜索",
|
||||||
"AI Answer": "AI回答",
|
"AI Answer": "AI回答",
|
||||||
@@ -594,6 +621,25 @@
|
|||||||
"Generative AI (Ask AI)": "生成型AI (询问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驱动的内容生成。允许用户生成、改进、翻译和转换文本。",
|
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "在编辑器中启用AI驱动的内容生成。允许用户生成、改进、翻译和转换文本。",
|
||||||
"Toggle generative AI": "切换生成型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": "来源",
|
"Sources": "来源",
|
||||||
"AI Answers not available for attachments": "AI答案不适用于附件",
|
"AI Answers not available for attachments": "AI答案不适用于附件",
|
||||||
"No answer available": "无可用答案",
|
"No answer available": "无可用答案",
|
||||||
@@ -612,8 +658,40 @@
|
|||||||
"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 view access to a page": "已授予你查看该页面的权限",
|
||||||
"Today": "今天",
|
"Today": "今天",
|
||||||
"Yesterday": "昨天",
|
"Yesterday": "昨天",
|
||||||
"This week": "本周",
|
"This week": "本周",
|
||||||
"Older": "较早"
|
"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": "已移除页面权限"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import SpaceTrash from "@/pages/space/space-trash.tsx";
|
|||||||
import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
|
import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
|
||||||
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
|
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
|
||||||
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
|
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
|
||||||
|
import AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -102,6 +103,8 @@ export default function App() {
|
|||||||
<Route path={"sharing"} element={<Shares />} />
|
<Route path={"sharing"} element={<Shares />} />
|
||||||
<Route path={"security"} element={<Security />} />
|
<Route path={"security"} element={<Security />} />
|
||||||
<Route path={"ai"} element={<AiSettings />} />
|
<Route path={"ai"} element={<AiSettings />} />
|
||||||
|
<Route path={"ai/mcp"} element={<AiSettings />} />
|
||||||
|
<Route path={"audit"} element={<AuditLogs />} />
|
||||||
{!isCloud() && <Route path={"license"} element={<License />} />}
|
{!isCloud() && <Route path={"license"} element={<License />} />}
|
||||||
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export default function AvatarUploader({
|
|||||||
top: "50%",
|
top: "50%",
|
||||||
left: "50%",
|
left: "50%",
|
||||||
transform: "translate(-50%, -50%)",
|
transform: "translate(-50%, -50%)",
|
||||||
zIndex: 1000,
|
zIndex: 200,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Loader size="sm" />
|
<Loader size="sm" />
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export default function Aside() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box p="md">
|
<Box p="md" style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||||
{component && (
|
{component && (
|
||||||
<>
|
<>
|
||||||
<Text mb="md" fw={500}>
|
<Text mb="md" fw={500}>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { getLicenseInfo } from "@/ee/licence/services/license-service.ts";
|
|||||||
import { getSsoProviders } from "@/ee/security/services/security-service.ts";
|
import { getSsoProviders } from "@/ee/security/services/security-service.ts";
|
||||||
import { getShares } from "@/features/share/services/share-service.ts";
|
import { getShares } from "@/features/share/services/share-service.ts";
|
||||||
import { getApiKeys } from "@/ee/api-key";
|
import { getApiKeys } from "@/ee/api-key";
|
||||||
|
import { getAuditLogs } from "@/ee/audit/services/audit-service";
|
||||||
|
|
||||||
export const prefetchWorkspaceMembers = () => {
|
export const prefetchWorkspaceMembers = () => {
|
||||||
const params: QueryParams = { limit: 100, query: "" };
|
const params: QueryParams = { limit: 100, query: "" };
|
||||||
@@ -80,3 +81,11 @@ export const prefetchApiKeyManagement = () => {
|
|||||||
queryFn: () => getApiKeys({ adminView: true }),
|
queryFn: () => getApiKeys({ adminView: true }),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const prefetchAuditLogs = () => {
|
||||||
|
const params = { limit: 50 };
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["audit-logs", params],
|
||||||
|
queryFn: () => getAuditLogs(params),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
IconKey,
|
IconKey,
|
||||||
IconWorld,
|
IconWorld,
|
||||||
IconSparkles,
|
IconSparkles,
|
||||||
|
IconHistory,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import classes from "./settings.module.css";
|
import classes from "./settings.module.css";
|
||||||
@@ -31,6 +32,7 @@ import {
|
|||||||
prefetchSpaces,
|
prefetchSpaces,
|
||||||
prefetchSsoProviders,
|
prefetchSsoProviders,
|
||||||
prefetchWorkspaceMembers,
|
prefetchWorkspaceMembers,
|
||||||
|
prefetchAuditLogs,
|
||||||
} from "@/components/settings/settings-queries.tsx";
|
} from "@/components/settings/settings-queries.tsx";
|
||||||
import AppVersion from "@/components/settings/app-version.tsx";
|
import AppVersion from "@/components/settings/app-version.tsx";
|
||||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
@@ -44,6 +46,7 @@ interface DataItem {
|
|||||||
isCloud?: boolean;
|
isCloud?: boolean;
|
||||||
isEnterprise?: boolean;
|
isEnterprise?: boolean;
|
||||||
isAdmin?: boolean;
|
isAdmin?: boolean;
|
||||||
|
isOwner?: boolean;
|
||||||
isSelfhosted?: boolean;
|
isSelfhosted?: boolean;
|
||||||
showDisabledInNonEE?: boolean;
|
showDisabledInNonEE?: boolean;
|
||||||
}
|
}
|
||||||
@@ -116,6 +119,15 @@ const groupedData: DataGroup[] = [
|
|||||||
path: "/settings/ai",
|
path: "/settings/ai",
|
||||||
isAdmin: true,
|
isAdmin: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Audit log",
|
||||||
|
icon: IconHistory,
|
||||||
|
path: "/settings/audit",
|
||||||
|
isEnterprise: true,
|
||||||
|
isOwner: true,
|
||||||
|
isSelfhosted: true,
|
||||||
|
showDisabledInNonEE: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -135,7 +147,7 @@ export default function SettingsSidebar() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [active, setActive] = useState(location.pathname);
|
const [active, setActive] = useState(location.pathname);
|
||||||
const { goBack } = useSettingsNavigation();
|
const { goBack } = useSettingsNavigation();
|
||||||
const { isAdmin } = useUserRole();
|
const { isAdmin, isOwner } = useUserRole();
|
||||||
const [workspace] = useAtom(workspaceAtom);
|
const [workspace] = useAtom(workspaceAtom);
|
||||||
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
|
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
|
||||||
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
|
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
|
||||||
@@ -144,34 +156,36 @@ export default function SettingsSidebar() {
|
|||||||
setActive(location.pathname);
|
setActive(location.pathname);
|
||||||
}, [location.pathname]);
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
const hasRoleAccess = (item: DataItem) => {
|
||||||
|
if (item.isOwner) return isOwner;
|
||||||
|
if (item.isAdmin) return isAdmin;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
const canShowItem = (item: DataItem) => {
|
const canShowItem = (item: DataItem) => {
|
||||||
if (item.showDisabledInNonEE && item.isEnterprise) {
|
if (item.showDisabledInNonEE && item.isEnterprise) {
|
||||||
// Check admin permission regardless of license
|
if (item.isSelfhosted && isCloud()) return false;
|
||||||
return item.isAdmin ? isAdmin : true;
|
return hasRoleAccess(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.isCloud && item.isEnterprise) {
|
if (item.isCloud && item.isEnterprise) {
|
||||||
if (!(isCloud() || workspace?.hasLicenseKey)) return false;
|
if (!(isCloud() || workspace?.hasLicenseKey)) return false;
|
||||||
return item.isAdmin ? isAdmin : true;
|
return hasRoleAccess(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.isCloud) {
|
if (item.isCloud) {
|
||||||
return isCloud() ? (item.isAdmin ? isAdmin : true) : false;
|
return isCloud() ? hasRoleAccess(item) : false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.isSelfhosted) {
|
if (item.isSelfhosted) {
|
||||||
return !isCloud() ? (item.isAdmin ? isAdmin : true) : false;
|
return !isCloud() ? hasRoleAccess(item) : false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.isEnterprise) {
|
if (item.isEnterprise) {
|
||||||
return workspace?.hasLicenseKey ? (item.isAdmin ? isAdmin : true) : false;
|
return workspace?.hasLicenseKey ? hasRoleAccess(item) : false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.isAdmin) {
|
return hasRoleAccess(item);
|
||||||
return isAdmin;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isItemDisabled = (item: DataItem) => {
|
const isItemDisabled = (item: DataItem) => {
|
||||||
@@ -227,6 +241,9 @@ export default function SettingsSidebar() {
|
|||||||
case "API management":
|
case "API management":
|
||||||
prefetchHandler = prefetchApiKeyManagement;
|
prefetchHandler = prefetchApiKeyManagement;
|
||||||
break;
|
break;
|
||||||
|
case "Audit log":
|
||||||
|
prefetchHandler = prefetchAuditLogs;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { getAvatarUrl } from "@/lib/config.ts";
|
|||||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||||
|
|
||||||
interface CustomAvatarProps {
|
interface CustomAvatarProps {
|
||||||
avatarUrl: string;
|
avatarUrl?: string;
|
||||||
name: string;
|
name: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
size?: string | number;
|
size?: string | number;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Editor } from "@tiptap/react";
|
import { Editor } from "@tiptap/react";
|
||||||
import { ActionIcon, TextInput, Tooltip } from "@mantine/core";
|
import { ActionIcon, TextInput } from "@mantine/core";
|
||||||
import { useDebouncedCallback, useMediaQuery } from "@mantine/hooks";
|
import { useDebouncedCallback, useMediaQuery } from "@mantine/hooks";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
@@ -14,7 +14,7 @@ import { ResultPreview } from "./result-preview.tsx";
|
|||||||
import classes from "./ai-menu.module.css";
|
import classes from "./ai-menu.module.css";
|
||||||
import { marked } from "marked";
|
import { marked } from "marked";
|
||||||
import { DOMSerializer } from "@tiptap/pm/model";
|
import { DOMSerializer } from "@tiptap/pm/model";
|
||||||
import { htmlToMarkdown } from "@docmost/editor-ext";
|
import { copyToClipboard, htmlToMarkdown } from "@docmost/editor-ext";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
|
|
||||||
interface EditorAiMenuProps {
|
interface EditorAiMenuProps {
|
||||||
@@ -52,16 +52,34 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
|
|||||||
if (!editor || !showAiMenu) return;
|
if (!editor || !showAiMenu) return;
|
||||||
|
|
||||||
const { view } = editor;
|
const { view } = editor;
|
||||||
const { to } = editor.state.selection;
|
const { from, to } = editor.state.selection;
|
||||||
const editorRect = view.dom.getBoundingClientRect();
|
const editorRect = view.dom.getBoundingClientRect();
|
||||||
const cursorCoords = view.coordsAtPos(to);
|
const fromCoords = view.coordsAtPos(from);
|
||||||
|
const toCoords = view.coordsAtPos(to);
|
||||||
const topOffset = 8;
|
const topOffset = 8;
|
||||||
const editorPadding = isSmBreakpoint ? 16 : 48;
|
const editorPadding = isSmBreakpoint ? 16 : 48;
|
||||||
|
|
||||||
|
const anchorBottom =
|
||||||
|
toCoords.bottom > 0 && toCoords.bottom < window.innerHeight
|
||||||
|
? toCoords.bottom
|
||||||
|
: fromCoords.bottom;
|
||||||
|
|
||||||
|
const menuMaxWidth = 600;
|
||||||
|
const editorLeft = editorRect.left + editorPadding;
|
||||||
|
const editorRight = editorRect.right - editorPadding;
|
||||||
|
const availableWidth = editorRight - editorLeft;
|
||||||
|
const menuWidth = Math.min(menuMaxWidth, availableWidth);
|
||||||
|
|
||||||
|
let menuLeft = Math.max(editorLeft, fromCoords.left);
|
||||||
|
if (menuLeft + menuWidth > editorRight) {
|
||||||
|
menuLeft = editorRight - menuWidth;
|
||||||
|
}
|
||||||
|
menuLeft = Math.max(editorLeft, menuLeft);
|
||||||
|
|
||||||
setMenuPlacement({
|
setMenuPlacement({
|
||||||
top: cursorCoords.bottom + topOffset + window.scrollY,
|
top: anchorBottom + topOffset + window.scrollY,
|
||||||
left: editorRect.left + editorPadding + window.scrollX,
|
left: menuLeft + window.scrollX,
|
||||||
width: editorRect.width - editorPadding * 2,
|
width: menuWidth,
|
||||||
});
|
});
|
||||||
}, [editor, showAiMenu, isSmBreakpoint]);
|
}, [editor, showAiMenu, isSmBreakpoint]);
|
||||||
const resetMenu = useCallback(() => {
|
const resetMenu = useCallback(() => {
|
||||||
@@ -110,6 +128,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
|
|||||||
setOutput((output) => output + chunk.content);
|
setOutput((output) => output + chunk.content);
|
||||||
},
|
},
|
||||||
onComplete: () => {
|
onComplete: () => {
|
||||||
|
setPrompt("");
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setActiveCommandSet("result");
|
setActiveCommandSet("result");
|
||||||
},
|
},
|
||||||
@@ -146,13 +165,18 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const html = (marked.parse(output) as string).trim();
|
const html = (marked.parse(output) as string).trim();
|
||||||
// Strip <p> wrapper for single-paragraph output to preserve inline context
|
const isSingleParagraph =
|
||||||
const content =
|
|
||||||
html.startsWith("<p>") &&
|
html.startsWith("<p>") &&
|
||||||
html.endsWith("</p>") &&
|
html.endsWith("</p>") &&
|
||||||
html.lastIndexOf("<p>") === 0
|
html.lastIndexOf("<p>") === 0;
|
||||||
? html.slice(3, -4)
|
|
||||||
: html;
|
// Strip <p> wrapper for single-paragraph output to preserve inline context,
|
||||||
|
// then decode HTML entities via DOMParser since TipTap would otherwise
|
||||||
|
// treat the tagless string as plain text and insert entities literally.
|
||||||
|
const content = isSingleParagraph
|
||||||
|
? new DOMParser().parseFromString(html.slice(3, -4), "text/html")
|
||||||
|
.body.innerHTML
|
||||||
|
: html;
|
||||||
|
|
||||||
chain.insertContent(content).run();
|
chain.insertContent(content).run();
|
||||||
|
|
||||||
@@ -169,7 +193,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
|
|||||||
return setShowAiMenu(false);
|
return setShowAiMenu(false);
|
||||||
}
|
}
|
||||||
if (item.id === "result-copy") {
|
if (item.id === "result-copy") {
|
||||||
navigator.clipboard.writeText(output);
|
copyToClipboard(output);
|
||||||
|
|
||||||
return setShowAiMenu(false);
|
return setShowAiMenu(false);
|
||||||
}
|
}
|
||||||
@@ -271,7 +295,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
|
|||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
zIndex: 200,
|
zIndex: 199,
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: menuPlacement.top,
|
top: menuPlacement.top,
|
||||||
left: menuPlacement.left,
|
left: menuPlacement.left,
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import {
|
||||||
|
Anchor,
|
||||||
|
Group,
|
||||||
|
List,
|
||||||
|
Text,
|
||||||
|
Switch,
|
||||||
|
TextInput,
|
||||||
|
ActionIcon,
|
||||||
|
Tooltip,
|
||||||
|
Stack,
|
||||||
|
Alert,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx";
|
||||||
|
import { getAppUrl } from "@/lib/config.ts";
|
||||||
|
import { IconCheck, IconCopy, IconInfoCircle } from "@tabler/icons-react";
|
||||||
|
import { CopyButton } from "@/components/common/copy-button.tsx";
|
||||||
|
|
||||||
|
export default function McpSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
||||||
|
const [checked, setChecked] = useState(workspace?.settings?.ai?.mcp);
|
||||||
|
const hasAccess = useIsCloudEE();
|
||||||
|
|
||||||
|
const mcpUrl = `${getAppUrl()}/mcp`;
|
||||||
|
|
||||||
|
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = event.currentTarget.checked;
|
||||||
|
try {
|
||||||
|
const updatedWorkspace = await updateWorkspace({ mcpEnabled: value });
|
||||||
|
setChecked(value);
|
||||||
|
setWorkspace(updatedWorkspace);
|
||||||
|
} catch (err) {
|
||||||
|
notifications.show({
|
||||||
|
message: err?.response?.data?.message,
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="lg">
|
||||||
|
{!hasAccess && (
|
||||||
|
<Alert
|
||||||
|
icon={<IconInfoCircle />}
|
||||||
|
title={t("Enterprise feature")}
|
||||||
|
color="blue"
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
|
||||||
|
)}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||||
|
<div>
|
||||||
|
<Text size="md">{t("Model Context Protocol (MCP)")}</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t(
|
||||||
|
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.",
|
||||||
|
)}{" "}
|
||||||
|
{t("View the")}{" "}
|
||||||
|
<Anchor
|
||||||
|
href="https://docmost.com/docs/user-guide/mcp"
|
||||||
|
target="_blank"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{t("MCP documentation")}
|
||||||
|
</Anchor>
|
||||||
|
.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
defaultChecked={checked}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={!hasAccess}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{checked && (
|
||||||
|
<div>
|
||||||
|
<Text size="sm" fw={500} mb={4}>
|
||||||
|
{t("MCP Server URL")}
|
||||||
|
</Text>
|
||||||
|
<Group gap="xs">
|
||||||
|
<TextInput
|
||||||
|
value={mcpUrl}
|
||||||
|
readOnly
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<CopyButton value={mcpUrl} timeout={2000}>
|
||||||
|
{({ copied, copy }) => (
|
||||||
|
<Tooltip
|
||||||
|
label={copied ? t("Copied") : t("Copy")}
|
||||||
|
withArrow
|
||||||
|
position="right"
|
||||||
|
>
|
||||||
|
<ActionIcon
|
||||||
|
color={copied ? "teal" : "gray"}
|
||||||
|
variant="subtle"
|
||||||
|
onClick={copy}
|
||||||
|
>
|
||||||
|
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</CopyButton>
|
||||||
|
</Group>
|
||||||
|
<Text size="sm" c="dimmed" mt="xs">
|
||||||
|
{t(
|
||||||
|
"Use your API key for authentication. You can manage API keys in your account settings.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Text size="sm" fw={500} mt="md" mb={4}>
|
||||||
|
{t("Supported tools")}
|
||||||
|
</Text>
|
||||||
|
<List size="sm" spacing={2}>
|
||||||
|
<List.Item><Text size="sm" c="dimmed" span>search_pages, get_page, create_page, update_page</Text></List.Item>
|
||||||
|
<List.Item><Text size="sm" c="dimmed" span>list_pages, list_child_pages, duplicate_page</Text></List.Item>
|
||||||
|
<List.Item><Text size="sm" c="dimmed" span>copy_page_to_space, move_page, move_page_to_space</Text></List.Item>
|
||||||
|
<List.Item><Text size="sm" c="dimmed" span>get_space, list_spaces, create_space, update_space</Text></List.Item>
|
||||||
|
<List.Item><Text size="sm" c="dimmed" span>get_comments, create_comment, update_comment</Text></List.Item>
|
||||||
|
<List.Item><Text size="sm" c="dimmed" span>search_attachments, list_workspace_members, get_current_user</Text></List.Item>
|
||||||
|
</List>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,44 +6,75 @@ import useUserRole from "@/hooks/use-user-role.tsx";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import EnableAiSearch from "@/ee/ai/components/enable-ai-search.tsx";
|
import EnableAiSearch from "@/ee/ai/components/enable-ai-search.tsx";
|
||||||
import EnableGenerativeAi from "@/ee/ai/components/enable-generative-ai.tsx";
|
import EnableGenerativeAi from "@/ee/ai/components/enable-generative-ai.tsx";
|
||||||
import { Alert, Stack } from "@mantine/core";
|
import McpSettings from "@/ee/ai/components/mcp-settings.tsx";
|
||||||
|
import { Alert, Stack, Tabs } from "@mantine/core";
|
||||||
import { IconInfoCircle } from "@tabler/icons-react";
|
import { IconInfoCircle } from "@tabler/icons-react";
|
||||||
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx";
|
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx";
|
||||||
import { isCloud } from "@/lib/config.ts";
|
import { isCloud } from "@/lib/config.ts";
|
||||||
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
export default function AiSettings() {
|
export default function AiSettings() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isAdmin } = useUserRole();
|
const { isAdmin } = useUserRole();
|
||||||
const hasAccess = useIsCloudEE();
|
const hasAccess = useIsCloudEE();
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const activeTab = location.pathname.endsWith("/mcp") ? "mcp" : "ai";
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleTabChange = (value: string | null) => {
|
||||||
|
if (value === "mcp") {
|
||||||
|
navigate("/settings/ai/mcp");
|
||||||
|
} else {
|
||||||
|
navigate("/settings/ai");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>AI - {getAppName()}</title>
|
<title>AI settings - {getAppName()}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<SettingsTitle title={t("AI settings")} />
|
<SettingsTitle title={t("AI settings")} />
|
||||||
|
|
||||||
{!hasAccess && (
|
<Tabs color="dark" value={activeTab} onChange={handleTabChange}>
|
||||||
<Alert
|
<Tabs.List>
|
||||||
icon={<IconInfoCircle />}
|
<Tabs.Tab fw={500} value="ai">
|
||||||
title={t("Enterprise feature")}
|
{t("AI")}
|
||||||
color="blue"
|
</Tabs.Tab>
|
||||||
mb="lg"
|
<Tabs.Tab fw={500} value="mcp">
|
||||||
>
|
{t("MCP")}
|
||||||
{t(
|
</Tabs.Tab>
|
||||||
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
|
</Tabs.List>
|
||||||
)}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Stack gap="md">
|
<Tabs.Panel value="ai" pt="md">
|
||||||
{!isCloud() && <EnableAiSearch />}
|
{!hasAccess && (
|
||||||
<EnableGenerativeAi />
|
<Alert
|
||||||
</Stack>
|
icon={<IconInfoCircle />}
|
||||||
|
title={t("Enterprise feature")}
|
||||||
|
color="blue"
|
||||||
|
mb="lg"
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
|
||||||
|
)}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Stack gap="md">
|
||||||
|
{!isCloud() && <EnableAiSearch />}
|
||||||
|
<EnableGenerativeAi />
|
||||||
|
</Stack>
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="mcp" pt="md">
|
||||||
|
<McpSettings />
|
||||||
|
</Tabs.Panel>
|
||||||
|
</Tabs>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { lazy, Suspense, useState } from "react";
|
import { lazy, Suspense, useState } from "react";
|
||||||
import { Modal, TextInput, Button, Group, Stack, Select } from "@mantine/core";
|
import { Modal, TextInput, Button, Group, Stack, Select } from "@mantine/core";
|
||||||
import { useForm } from "@mantine/form";
|
import { useForm } from "@mantine/form";
|
||||||
import { zodResolver } from "mantine-form-zod-resolver";
|
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||||
import { z } from "zod";
|
import { z } from "zod/v4";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useCreateApiKeyMutation } from "@/ee/api-key/queries/api-key-query";
|
import { useCreateApiKeyMutation } from "@/ee/api-key/queries/api-key-query";
|
||||||
import { IconCalendar } from "@tabler/icons-react";
|
import { IconCalendar } from "@tabler/icons-react";
|
||||||
@@ -36,7 +36,7 @@ export function CreateApiKeyModal({
|
|||||||
const createApiKeyMutation = useCreateApiKeyMutation();
|
const createApiKeyMutation = useCreateApiKeyMutation();
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
validate: zodResolver(formSchema),
|
validate: zod4Resolver(formSchema),
|
||||||
initialValues: {
|
initialValues: {
|
||||||
name: "",
|
name: "",
|
||||||
expiresAt: "",
|
expiresAt: "",
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { Text, Switch, Tooltip } from "@mantine/core";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx";
|
||||||
|
import {
|
||||||
|
ResponsiveSettingsRow,
|
||||||
|
ResponsiveSettingsContent,
|
||||||
|
ResponsiveSettingsControl,
|
||||||
|
} from "@/components/ui/responsive-settings-row";
|
||||||
|
|
||||||
|
export default function RestrictApiToAdmins() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
||||||
|
const [checked, setChecked] = useState(
|
||||||
|
workspace?.settings?.api?.restrictToAdmins === true,
|
||||||
|
);
|
||||||
|
const hasAccess = useEnterpriseAccess();
|
||||||
|
|
||||||
|
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = event.currentTarget.checked;
|
||||||
|
try {
|
||||||
|
const updatedWorkspace = await updateWorkspace({
|
||||||
|
restrictApiToAdmins: value,
|
||||||
|
});
|
||||||
|
setChecked(value);
|
||||||
|
setWorkspace(updatedWorkspace);
|
||||||
|
} catch (err) {
|
||||||
|
notifications.show({
|
||||||
|
message: err?.response?.data?.message,
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveSettingsRow>
|
||||||
|
<ResponsiveSettingsContent>
|
||||||
|
<Text size="md">
|
||||||
|
{t("Restrict API key creation to admins")}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t(
|
||||||
|
"Only admins and owners can create new API keys. Existing member keys will continue to work.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</ResponsiveSettingsContent>
|
||||||
|
|
||||||
|
<ResponsiveSettingsControl>
|
||||||
|
<Tooltip
|
||||||
|
label={t("Requires an enterprise license")}
|
||||||
|
disabled={hasAccess}
|
||||||
|
refProp="rootRef"
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checked={checked}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={!hasAccess}
|
||||||
|
aria-label={t("Toggle restrict API keys to admins")}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</ResponsiveSettingsControl>
|
||||||
|
</ResponsiveSettingsRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Modal, TextInput, Button, Group, Stack } from "@mantine/core";
|
import { Modal, TextInput, Button, Group, Stack } from "@mantine/core";
|
||||||
import { useForm } from "@mantine/form";
|
import { useForm } from "@mantine/form";
|
||||||
import { zodResolver } from "mantine-form-zod-resolver";
|
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||||
import { z } from "zod";
|
import { z } from "zod/v4";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useUpdateApiKeyMutation } from "@/ee/api-key/queries/api-key-query";
|
import { useUpdateApiKeyMutation } from "@/ee/api-key/queries/api-key-query";
|
||||||
import { IApiKey } from "@/ee/api-key";
|
import { IApiKey } from "@/ee/api-key";
|
||||||
@@ -27,7 +27,7 @@ export function UpdateApiKeyModal({
|
|||||||
const updateApiKeyMutation = useUpdateApiKeyMutation();
|
const updateApiKeyMutation = useUpdateApiKeyMutation();
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
validate: zodResolver(formSchema),
|
validate: zod4Resolver(formSchema),
|
||||||
initialValues: {
|
initialValues: {
|
||||||
name: "",
|
name: "",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Button, Group, Space } from "@mantine/core";
|
import { Anchor, Alert, Button, Group, Space, Text } from "@mantine/core";
|
||||||
|
import { IconInfoCircle } from "@tabler/icons-react";
|
||||||
import { Helmet } from "react-helmet-async";
|
import { Helmet } from "react-helmet-async";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import SettingsTitle from "@/components/settings/settings-title";
|
import SettingsTitle from "@/components/settings/settings-title";
|
||||||
import { getAppName } from "@/lib/config";
|
import { getAppName, getAppUrl } from "@/lib/config";
|
||||||
import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
|
import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
|
||||||
import { CreateApiKeyModal } from "@/ee/api-key/components/create-api-key-modal";
|
import { CreateApiKeyModal } from "@/ee/api-key/components/create-api-key-modal";
|
||||||
import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-modal";
|
import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-modal";
|
||||||
@@ -13,6 +14,9 @@ import Paginate from "@/components/common/paginate";
|
|||||||
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
||||||
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
|
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
|
||||||
import { IApiKey } from "@/ee/api-key";
|
import { IApiKey } from "@/ee/api-key";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
|
|
||||||
export default function UserApiKeys() {
|
export default function UserApiKeys() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -23,6 +27,11 @@ export default function UserApiKeys() {
|
|||||||
const [revokeModalOpened, setRevokeModalOpened] = useState(false);
|
const [revokeModalOpened, setRevokeModalOpened] = useState(false);
|
||||||
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
|
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
|
||||||
const { data, isLoading } = useGetApiKeysQuery({ cursor });
|
const { data, isLoading } = useGetApiKeysQuery({ cursor });
|
||||||
|
const [workspace] = useAtom(workspaceAtom);
|
||||||
|
const { isAdmin } = useUserRole();
|
||||||
|
const mcpEnabled = workspace?.settings?.ai?.mcp === true;
|
||||||
|
const restrictToAdmins = workspace?.settings?.api?.restrictToAdmins === true;
|
||||||
|
const canCreate = !restrictToAdmins || isAdmin;
|
||||||
|
|
||||||
const handleCreateSuccess = (response: IApiKey) => {
|
const handleCreateSuccess = (response: IApiKey) => {
|
||||||
setCreatedApiKey(response);
|
setCreatedApiKey(response);
|
||||||
@@ -48,11 +57,50 @@ export default function UserApiKeys() {
|
|||||||
|
|
||||||
<SettingsTitle title={t("API keys")} />
|
<SettingsTitle title={t("API keys")} />
|
||||||
|
|
||||||
<Group justify="flex-end" mb="md">
|
<Text size="sm" c="dimmed" mb="md">
|
||||||
<Button onClick={() => setCreateModalOpened(true)}>
|
{t("View the")}{" "}
|
||||||
{t("Create API Key")}
|
<Anchor href="https://docmost.com/api-docs" target="_blank" size="sm">
|
||||||
</Button>
|
{t("API documentation")}
|
||||||
</Group>
|
</Anchor>{" "}
|
||||||
|
{t("for usage details.")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{mcpEnabled && canCreate && (
|
||||||
|
<Alert variant="light" color="blue" mb="md" p="sm" icon={<IconInfoCircle />}>
|
||||||
|
<Text size="sm">
|
||||||
|
{t(
|
||||||
|
"Your workspace has MCP enabled. Use your API key to connect AI assistants.",
|
||||||
|
)}{" "}
|
||||||
|
<Anchor
|
||||||
|
href="https://docmost.com/docs/user-guide/mcp"
|
||||||
|
target="_blank"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{t("Learn more")}
|
||||||
|
</Anchor>
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" mt={4}>
|
||||||
|
{t("MCP server URL:")}{" "}
|
||||||
|
<Text size="sm" fw={500} span ff="monospace">
|
||||||
|
{`${getAppUrl()}/mcp`}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canCreate ? (
|
||||||
|
<Group justify="flex-end" mb="md">
|
||||||
|
<Button onClick={() => setCreateModalOpened(true)}>
|
||||||
|
{t("Create API Key")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
) : restrictToAdmins ? (
|
||||||
|
<Alert variant="light" color="yellow" mb="md" p="sm" icon={<IconInfoCircle />}>
|
||||||
|
<Text size="sm">
|
||||||
|
{t("API key creation is restricted to admins by your workspace administrator.")}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<ApiKeyTable
|
<ApiKeyTable
|
||||||
apiKeys={data?.items || []}
|
apiKeys={data?.items || []}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from "react";
|
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 { Helmet } from "react-helmet-async";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import SettingsTitle from "@/components/settings/settings-title";
|
import SettingsTitle from "@/components/settings/settings-title";
|
||||||
@@ -14,6 +14,7 @@ import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
|||||||
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
|
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
|
||||||
import { IApiKey } from "@/ee/api-key";
|
import { IApiKey } from "@/ee/api-key";
|
||||||
import useUserRole from '@/hooks/use-user-role.tsx';
|
import useUserRole from '@/hooks/use-user-role.tsx';
|
||||||
|
import RestrictApiToAdmins from "@/ee/api-key/components/restrict-api-to-admins";
|
||||||
|
|
||||||
export default function WorkspaceApiKeys() {
|
export default function WorkspaceApiKeys() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -54,10 +55,18 @@ export default function WorkspaceApiKeys() {
|
|||||||
|
|
||||||
<SettingsTitle title={t("API management")} />
|
<SettingsTitle title={t("API management")} />
|
||||||
|
|
||||||
<Text size="md" c="dimmed" mb="md">
|
<Text size="sm" c="dimmed" mb="md">
|
||||||
{t("Manage API keys for all users in the workspace")}
|
{t("Manage API keys for all users in the workspace.")}{" "}
|
||||||
|
{t("View the")}{" "}
|
||||||
|
<Anchor href="https://docmost.com/api-docs" target="_blank" size="sm">
|
||||||
|
{t("API documentation")}
|
||||||
|
</Anchor>{" "}
|
||||||
|
{t("for usage details.")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
<RestrictApiToAdmins />
|
||||||
|
<Divider my="lg" />
|
||||||
|
|
||||||
<Group justify="flex-end" mb="md">
|
<Group justify="flex-end" mb="md">
|
||||||
<Button onClick={() => setCreateModalOpened(true)}>
|
<Button onClick={() => setCreateModalOpened(true)}>
|
||||||
{t("Create API Key")}
|
{t("Create API Key")}
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
import { Fragment, useState } from "react";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
Group,
|
||||||
|
Skeleton,
|
||||||
|
Anchor,
|
||||||
|
Collapse,
|
||||||
|
Box,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
IconChevronRight,
|
||||||
|
IconChevronDown,
|
||||||
|
IconArrowRight,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { IAuditLog } from "@/ee/audit/types/audit.types";
|
||||||
|
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||||
|
import { getEventLabel } from "@/ee/audit/lib/audit-event-labels";
|
||||||
|
import { formattedDate } from "@/lib/time";
|
||||||
|
import NoTableResults from "@/components/common/no-table-results";
|
||||||
|
import classes from "./audit-logs.module.css";
|
||||||
|
|
||||||
|
type AuditLogsTableProps = {
|
||||||
|
items?: IAuditLog[];
|
||||||
|
isLoading: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function hasDetails(entry: IAuditLog): boolean {
|
||||||
|
return !!(entry.changes?.before || entry.changes?.after || entry.metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResourceUrl(entry: IAuditLog): string | null {
|
||||||
|
if (!entry.resource) return null;
|
||||||
|
|
||||||
|
switch (entry.resourceType) {
|
||||||
|
case "group":
|
||||||
|
return `/settings/groups/${entry.resource.id}`;
|
||||||
|
case "space":
|
||||||
|
case "space_member":
|
||||||
|
return entry.resource.slug ? `/s/${entry.resource.slug}` : null;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return "—";
|
||||||
|
if (typeof value === "boolean") return value ? "true" : "false";
|
||||||
|
if (typeof value === "object") return JSON.stringify(value);
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChangesDiff({ changes }: { changes: IAuditLog["changes"] }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (!changes) return null;
|
||||||
|
|
||||||
|
const { before, after } = changes;
|
||||||
|
const allKeys = new Set([
|
||||||
|
...Object.keys(before ?? {}),
|
||||||
|
...Object.keys(after ?? {}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (allKeys.size === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Text fz="xs" fw={600} mb={4}>
|
||||||
|
{t("Changes")}
|
||||||
|
</Text>
|
||||||
|
{[...allKeys].map((key) => {
|
||||||
|
const hasBefore = before && key in before;
|
||||||
|
const hasAfter = after && key in after;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group key={key} gap={6} mb={2} wrap="nowrap" align="center">
|
||||||
|
<Text
|
||||||
|
fz="xs"
|
||||||
|
c="dimmed"
|
||||||
|
fw={500}
|
||||||
|
style={{ minWidth: "fit-content" }}
|
||||||
|
>
|
||||||
|
{key}:
|
||||||
|
</Text>
|
||||||
|
{hasBefore && (
|
||||||
|
<Text fz="xs" component="span">
|
||||||
|
{formatValue(before[key])}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{hasBefore && hasAfter && (
|
||||||
|
<IconArrowRight size={10} color="var(--mantine-color-dimmed)" />
|
||||||
|
)}
|
||||||
|
{hasAfter && (
|
||||||
|
<Text fz="xs" component="span">
|
||||||
|
{formatValue(after[key])}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetadataDisplay({ metadata }: { metadata: Record<string, any> }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const entries = Object.entries(metadata);
|
||||||
|
if (entries.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Text fz="xs" fw={600} mb={4}>
|
||||||
|
{t("Metadata")}
|
||||||
|
</Text>
|
||||||
|
{entries.map(([key, value]) => (
|
||||||
|
<Group key={key} gap={6} mb={2} wrap="nowrap">
|
||||||
|
<Text fz="xs" c="dimmed" fw={500}>
|
||||||
|
{key}:
|
||||||
|
</Text>
|
||||||
|
<Text fz="xs">{formatValue(value)}</Text>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableSkeleton() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<Table.Tr key={i}>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<Skeleton circle height={36} />
|
||||||
|
<div>
|
||||||
|
<Skeleton height={14} width={120} mb={4} />
|
||||||
|
<Skeleton height={10} width={160} />
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Skeleton height={14} width={140} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Skeleton height={14} width={120} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Skeleton height={14} width={120} />
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResourceCell({ entry }: { entry: IAuditLog }) {
|
||||||
|
if (!entry.resource?.name) {
|
||||||
|
return (
|
||||||
|
<Text fz="sm" c="dimmed">
|
||||||
|
—
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = getResourceUrl(entry);
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
return (
|
||||||
|
<Anchor
|
||||||
|
size="sm"
|
||||||
|
underline="never"
|
||||||
|
style={{
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "var(--mantine-color-text)",
|
||||||
|
}}
|
||||||
|
component={Link}
|
||||||
|
to={url}
|
||||||
|
>
|
||||||
|
<div className={classes.resourceLinkText}>
|
||||||
|
<Text fz="sm" fw={500} lineClamp={1}>
|
||||||
|
{entry.resource.name}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Anchor>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text fz="sm" lineClamp={1}>
|
||||||
|
{entry.resource.name}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuditLogsTable({
|
||||||
|
items,
|
||||||
|
isLoading,
|
||||||
|
}: AuditLogsTableProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const toggleExpanded = (id: string) => {
|
||||||
|
setExpanded((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) {
|
||||||
|
next.delete(id);
|
||||||
|
} else {
|
||||||
|
next.add(id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table.ScrollContainer minWidth={700}>
|
||||||
|
<Table highlightOnHover verticalSpacing="xs" className={classes.table}>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>{t("Actor")}</Table.Th>
|
||||||
|
<Table.Th>{t("Event")}</Table.Th>
|
||||||
|
<Table.Th>{t("Resource")}</Table.Th>
|
||||||
|
<Table.Th>{t("Date")}</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
|
||||||
|
<Table.Tbody>
|
||||||
|
{isLoading ? (
|
||||||
|
<TableSkeleton />
|
||||||
|
) : items && items.length > 0 ? (
|
||||||
|
items.map((entry) => {
|
||||||
|
const expandable = hasDetails(entry);
|
||||||
|
const isExpanded = expanded.has(entry.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment key={entry.id}>
|
||||||
|
<Table.Tr
|
||||||
|
onClick={
|
||||||
|
expandable ? () => toggleExpanded(entry.id) : undefined
|
||||||
|
}
|
||||||
|
style={{ cursor: expandable ? "pointer" : undefined }}
|
||||||
|
>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
{expandable ? (
|
||||||
|
isExpanded ? (
|
||||||
|
<IconChevronDown
|
||||||
|
size={16}
|
||||||
|
color="var(--mantine-color-dimmed)"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<IconChevronRight
|
||||||
|
size={16}
|
||||||
|
color="var(--mantine-color-dimmed)"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Box w={16} />
|
||||||
|
)}
|
||||||
|
{entry.actor ? (
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<CustomAvatar
|
||||||
|
avatarUrl={entry.actor.avatarUrl}
|
||||||
|
name={entry.actor.name}
|
||||||
|
size={36}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Text fz="sm" fw={500} lineClamp={1}>
|
||||||
|
{entry.actor.name}
|
||||||
|
</Text>
|
||||||
|
<Text fz="xs" c="dimmed">
|
||||||
|
{entry.actor.email}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<Text fz="sm" c="dimmed" fs="italic">
|
||||||
|
{entry.actorType === "system"
|
||||||
|
? t("System")
|
||||||
|
: t("System")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
|
||||||
|
<Table.Td>
|
||||||
|
<Text fz="sm">{t(getEventLabel(entry.event))}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
|
||||||
|
<Table.Td>
|
||||||
|
<ResourceCell entry={entry} />
|
||||||
|
</Table.Td>
|
||||||
|
|
||||||
|
<Table.Td>
|
||||||
|
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{formattedDate(new Date(entry.createdAt))}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
|
||||||
|
{expandable && (
|
||||||
|
<Table.Tr className={classes.detailRow}>
|
||||||
|
<Table.Td colSpan={4} p={0}>
|
||||||
|
<Collapse in={isExpanded}>
|
||||||
|
<Box
|
||||||
|
px="md"
|
||||||
|
py="sm"
|
||||||
|
className={classes.detailContent}
|
||||||
|
>
|
||||||
|
<Group gap="xl" align="flex-start">
|
||||||
|
{entry.changes && (
|
||||||
|
<ChangesDiff changes={entry.changes} />
|
||||||
|
)}
|
||||||
|
{entry.metadata && (
|
||||||
|
<MetadataDisplay metadata={entry.metadata} />
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<NoTableResults colSpan={4} />
|
||||||
|
)}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Table.ScrollContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
.table {
|
||||||
|
--table-border-color: var(--mantine-color-gray-2);
|
||||||
|
|
||||||
|
@mixin dark {
|
||||||
|
--table-border-color: var(--mantine-color-dark-5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.resourceLinkText {
|
||||||
|
width: fit-content;
|
||||||
|
|
||||||
|
@mixin light {
|
||||||
|
border-bottom: 0.05em solid var(--mantine-color-dark-0);
|
||||||
|
}
|
||||||
|
@mixin dark {
|
||||||
|
border-bottom: 0.05em solid var(--mantine-color-dark-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailRow {
|
||||||
|
&:hover {
|
||||||
|
background: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailContent {
|
||||||
|
@mixin light {
|
||||||
|
background: var(--mantine-color-gray-0);
|
||||||
|
}
|
||||||
|
@mixin dark {
|
||||||
|
background: var(--mantine-color-dark-7);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
type EventOption = {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EventGroup = {
|
||||||
|
group: string;
|
||||||
|
items: EventOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const auditEventLabels: Record<string, string> = {
|
||||||
|
"workspace.created": "Created workspace",
|
||||||
|
"workspace.updated": "Updated workspace",
|
||||||
|
"workspace.invite_created": "Created invitation",
|
||||||
|
"workspace.invite_resent": "Resent invitation",
|
||||||
|
"workspace.invite_revoked": "Revoked invitation",
|
||||||
|
|
||||||
|
"user.created": "Created user",
|
||||||
|
"user.deleted": "Deleted user",
|
||||||
|
"user.login": "Logged in",
|
||||||
|
"user.logout": "Logged out",
|
||||||
|
"user.role_changed": "Changed user role",
|
||||||
|
"user.password_changed": "Changed password",
|
||||||
|
"user.password_reset": "Reset password",
|
||||||
|
"user.updated": "Updated user",
|
||||||
|
"user.deactivated": "Deactivated user",
|
||||||
|
"user.activated": "Activated user",
|
||||||
|
"user.mfa_enabled": "Enabled MFA",
|
||||||
|
"user.mfa_disabled": "Disabled MFA",
|
||||||
|
"user.mfa_backup_code_generated": "Generated MFA backup codes",
|
||||||
|
|
||||||
|
"api_key.created": "Created API key",
|
||||||
|
"api_key.updated": "Updated API key",
|
||||||
|
"api_key.deleted": "Deleted API key",
|
||||||
|
|
||||||
|
"space.created": "Created space",
|
||||||
|
"space.updated": "Updated space",
|
||||||
|
"space.deleted": "Deleted space",
|
||||||
|
"space.member_added": "Added space member",
|
||||||
|
"space.member_removed": "Removed space member",
|
||||||
|
"space.member_role_changed": "Changed space member role",
|
||||||
|
"space.exported": "Exported space",
|
||||||
|
|
||||||
|
"group.created": "Created group",
|
||||||
|
"group.updated": "Updated group",
|
||||||
|
"group.deleted": "Deleted group",
|
||||||
|
"group.member_added": "Added group member",
|
||||||
|
"group.member_removed": "Removed group member",
|
||||||
|
|
||||||
|
"comment.deleted": "Deleted comment",
|
||||||
|
|
||||||
|
"page.trashed": "Trashed page",
|
||||||
|
"page.deleted": "Deleted page",
|
||||||
|
"page.restored": "Restored page",
|
||||||
|
"page.imported": "Imported page",
|
||||||
|
"page.exported": "Exported page",
|
||||||
|
"page.restricted": "Restricted page",
|
||||||
|
"page.restriction_removed": "Removed page restriction",
|
||||||
|
"page.permission_added": "Added page permission",
|
||||||
|
"page.permission_removed": "Removed page permission",
|
||||||
|
|
||||||
|
"share.created": "Created share link",
|
||||||
|
"share.deleted": "Deleted share link",
|
||||||
|
|
||||||
|
"sso.provider_created": "Created SSO provider",
|
||||||
|
"sso.provider_updated": "Updated SSO provider",
|
||||||
|
"sso.provider_deleted": "Deleted SSO provider",
|
||||||
|
|
||||||
|
"license.activated": "Activated license",
|
||||||
|
"license.removed": "Removed license",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getEventLabel(event: string): string {
|
||||||
|
return auditEventLabels[event] ?? event;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const eventFilterOptions: EventGroup[] = [
|
||||||
|
{
|
||||||
|
group: "Workspace",
|
||||||
|
items: [
|
||||||
|
{ value: "workspace.updated", label: "Updated workspace" },
|
||||||
|
{ value: "workspace.invite_created", label: "Created invitation" },
|
||||||
|
{ value: "workspace.invite_revoked", label: "Revoked invitation" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "User",
|
||||||
|
items: [
|
||||||
|
{ value: "user.login", label: "Logged in" },
|
||||||
|
{ value: "user.logout", label: "Logged out" },
|
||||||
|
{ value: "user.created", label: "Created user" },
|
||||||
|
{ value: "user.deleted", label: "Deleted user" },
|
||||||
|
{ value: "user.deactivated", label: "Deactivated user" },
|
||||||
|
{ value: "user.activated", label: "Activated user" },
|
||||||
|
{ value: "user.role_changed", label: "Changed user role" },
|
||||||
|
{ value: "user.password_changed", label: "Changed password" },
|
||||||
|
{ value: "user.mfa_enabled", label: "Enabled MFA" },
|
||||||
|
{ value: "user.mfa_disabled", label: "Disabled MFA" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "Space",
|
||||||
|
items: [
|
||||||
|
{ value: "space.created", label: "Created space" },
|
||||||
|
{ value: "space.updated", label: "Updated space" },
|
||||||
|
{ value: "space.deleted", label: "Deleted space" },
|
||||||
|
{ value: "space.member_added", label: "Added space member" },
|
||||||
|
{ value: "space.member_removed", label: "Removed space member" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "Group",
|
||||||
|
items: [
|
||||||
|
{ value: "group.created", label: "Created group" },
|
||||||
|
{ value: "group.updated", label: "Updated group" },
|
||||||
|
{ value: "group.deleted", label: "Deleted group" },
|
||||||
|
{ value: "group.member_added", label: "Added group member" },
|
||||||
|
{ value: "group.member_removed", label: "Removed group member" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "Comment",
|
||||||
|
items: [
|
||||||
|
{ value: "comment.deleted", label: "Deleted comment" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "Page",
|
||||||
|
items: [
|
||||||
|
{ value: "page.trashed", label: "Trashed page" },
|
||||||
|
{ value: "page.deleted", label: "Deleted page" },
|
||||||
|
{ value: "page.restored", label: "Restored page" },
|
||||||
|
{ value: "page.imported", label: "Imported page" },
|
||||||
|
{ value: "page.exported", label: "Exported page" },
|
||||||
|
{ value: "page.restricted", label: "Restricted page" },
|
||||||
|
{ value: "page.restriction_removed", label: "Removed page restriction" },
|
||||||
|
{ value: "page.permission_added", label: "Added page permission" },
|
||||||
|
{ value: "page.permission_removed", label: "Removed page permission" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "Share",
|
||||||
|
items: [
|
||||||
|
{ value: "share.created", label: "Created share link" },
|
||||||
|
{ value: "share.deleted", label: "Deleted share link" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "SSO",
|
||||||
|
items: [
|
||||||
|
{ value: "sso.provider_created", label: "Created SSO provider" },
|
||||||
|
{ value: "sso.provider_updated", label: "Updated SSO provider" },
|
||||||
|
{ value: "sso.provider_deleted", label: "Deleted SSO provider" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "API key",
|
||||||
|
items: [
|
||||||
|
{ value: "api_key.created", label: "Created API key" },
|
||||||
|
{ value: "api_key.deleted", label: "Deleted API key" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "License",
|
||||||
|
items: [
|
||||||
|
{ value: "license.activated", label: "Activated license" },
|
||||||
|
{ value: "license.removed", label: "Removed license" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { useState, useMemo, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
NumberInput,
|
||||||
|
Popover,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { Helmet } from "react-helmet-async";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { IconSettings } from "@tabler/icons-react";
|
||||||
|
import SettingsTitle from "@/components/settings/settings-title";
|
||||||
|
import { getAppName } from "@/lib/config";
|
||||||
|
import Paginate from "@/components/common/paginate";
|
||||||
|
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
||||||
|
import {
|
||||||
|
useAuditLogsQuery,
|
||||||
|
useAuditRetentionQuery,
|
||||||
|
useUpdateAuditRetentionMutation,
|
||||||
|
} from "@/ee/audit/queries/audit-query";
|
||||||
|
import { IAuditLogParams } from "@/ee/audit/types/audit.types";
|
||||||
|
import { eventFilterOptions } from "@/ee/audit/lib/audit-event-labels";
|
||||||
|
import AuditLogsTable from "@/ee/audit/components/audit-logs-table";
|
||||||
|
import useUserRole from "@/hooks/use-user-role";
|
||||||
|
|
||||||
|
type RetentionUnit = "days" | "months" | "years";
|
||||||
|
|
||||||
|
function daysToRetention(days: number): { amount: number; unit: RetentionUnit } {
|
||||||
|
if (days >= 365 && days % 365 === 0) {
|
||||||
|
return { amount: days / 365, unit: "years" };
|
||||||
|
}
|
||||||
|
if (days >= 30 && days % 30 === 0) {
|
||||||
|
return { amount: days / 30, unit: "months" };
|
||||||
|
}
|
||||||
|
return { amount: days, unit: "days" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function retentionToDays(amount: number, unit: RetentionUnit): number {
|
||||||
|
if (unit === "years") return amount * 365;
|
||||||
|
if (unit === "months") return amount * 30;
|
||||||
|
return amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuditLogs() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { isOwner } = useUserRole();
|
||||||
|
const { cursor, goNext, goPrev, resetCursor } = useCursorPaginate();
|
||||||
|
|
||||||
|
const [eventFilter, setEventFilter] = useState<string | null>(null);
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
|
||||||
|
const { data: retentionData } = useAuditRetentionQuery();
|
||||||
|
const updateRetention = useUpdateAuditRetentionMutation();
|
||||||
|
|
||||||
|
const currentDays = retentionData?.retentionDays ?? 365;
|
||||||
|
const parsed = daysToRetention(currentDays);
|
||||||
|
const [retentionAmount, setRetentionAmount] = useState<number | string>(parsed.amount);
|
||||||
|
const [retentionUnit, setRetentionUnit] = useState<RetentionUnit>(parsed.unit);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (retentionData) {
|
||||||
|
const { amount, unit } = daysToRetention(retentionData.retentionDays);
|
||||||
|
setRetentionAmount(amount);
|
||||||
|
setRetentionUnit(unit);
|
||||||
|
}
|
||||||
|
}, [retentionData?.retentionDays]);
|
||||||
|
|
||||||
|
const resetRetentionForm = () => {
|
||||||
|
const { amount, unit } = daysToRetention(currentDays);
|
||||||
|
setRetentionAmount(amount);
|
||||||
|
setRetentionUnit(unit);
|
||||||
|
};
|
||||||
|
|
||||||
|
const params: IAuditLogParams = useMemo(
|
||||||
|
() => ({
|
||||||
|
cursor,
|
||||||
|
limit: 50,
|
||||||
|
event: eventFilter ?? undefined,
|
||||||
|
}),
|
||||||
|
[cursor, eventFilter],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading } = useAuditLogsQuery(params);
|
||||||
|
|
||||||
|
if (!isOwner) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEventChange = (value: string | null) => {
|
||||||
|
setEventFilter(value);
|
||||||
|
resetCursor();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Helmet>
|
||||||
|
<title>
|
||||||
|
{t("Audit log")} - {getAppName()}
|
||||||
|
</title>
|
||||||
|
</Helmet>
|
||||||
|
|
||||||
|
<SettingsTitle title={t("Audit log")} />
|
||||||
|
|
||||||
|
<Group mb="md" gap="sm">
|
||||||
|
<Select
|
||||||
|
placeholder={t("Filter by event")}
|
||||||
|
data={eventFilterOptions.map((group) => ({
|
||||||
|
group: t(group.group),
|
||||||
|
items: group.items.map((item) => ({
|
||||||
|
value: item.value,
|
||||||
|
label: t(item.label),
|
||||||
|
})),
|
||||||
|
}))}
|
||||||
|
value={eventFilter}
|
||||||
|
onChange={handleEventChange}
|
||||||
|
clearable
|
||||||
|
searchable
|
||||||
|
w={220}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Popover
|
||||||
|
position="bottom-end"
|
||||||
|
shadow="md"
|
||||||
|
width={260}
|
||||||
|
withArrow
|
||||||
|
opened={settingsOpen}
|
||||||
|
onChange={(opened) => {
|
||||||
|
if (!opened) resetRetentionForm();
|
||||||
|
setSettingsOpen(opened);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Popover.Target>
|
||||||
|
<Tooltip label={t("Audit settings")}>
|
||||||
|
<ActionIcon variant="default" size="input-sm" ml="auto" onClick={() => setSettingsOpen((o) => !o)}>
|
||||||
|
<IconSettings size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Popover.Target>
|
||||||
|
<Popover.Dropdown>
|
||||||
|
<Text fz="sm" fw={500} mb={4}>
|
||||||
|
{t("Retention")}
|
||||||
|
</Text>
|
||||||
|
<Text fz="xs" c="dimmed" mb="sm">
|
||||||
|
{t("Logs older than this period are automatically deleted.")}
|
||||||
|
</Text>
|
||||||
|
<Group gap="xs" wrap="nowrap" mb="sm">
|
||||||
|
<NumberInput
|
||||||
|
value={retentionAmount}
|
||||||
|
onChange={(val) => setRetentionAmount(val)}
|
||||||
|
min={1}
|
||||||
|
hideControls
|
||||||
|
size="sm"
|
||||||
|
w={60}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
data={[
|
||||||
|
{ value: "days", label: t("days") },
|
||||||
|
{ value: "months", label: t("months") },
|
||||||
|
{ value: "years", label: t("years") },
|
||||||
|
]}
|
||||||
|
value={retentionUnit}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (value === "days" || value === "months" || value === "years") {
|
||||||
|
setRetentionUnit(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
comboboxProps={{ withinPortal: false }}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
<Group gap="xs" grow>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="default"
|
||||||
|
onClick={() => {
|
||||||
|
resetRetentionForm();
|
||||||
|
setSettingsOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("Cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
onClick={() => {
|
||||||
|
const num = typeof retentionAmount === "number" ? retentionAmount : 1;
|
||||||
|
const clamped = Math.max(1, num);
|
||||||
|
setRetentionAmount(clamped);
|
||||||
|
const days = retentionToDays(clamped, retentionUnit);
|
||||||
|
if (days !== currentDays) {
|
||||||
|
updateRetention.mutate({ auditRetentionDays: days });
|
||||||
|
}
|
||||||
|
setSettingsOpen(false);
|
||||||
|
}}
|
||||||
|
loading={updateRetention.isPending}
|
||||||
|
>
|
||||||
|
{t("Save")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<AuditLogsTable items={data?.items} isLoading={isLoading} />
|
||||||
|
|
||||||
|
<Space h="md" />
|
||||||
|
|
||||||
|
{data?.items && data.items.length > 0 && (
|
||||||
|
<Paginate
|
||||||
|
hasPrevPage={data?.meta?.hasPrevPage}
|
||||||
|
hasNextPage={data?.meta?.hasNextPage}
|
||||||
|
onNext={() => goNext(data?.meta?.nextCursor)}
|
||||||
|
onPrev={goPrev}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<IPagination<IAuditLog>, 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" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<IPagination<IAuditLog>> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<string, any>;
|
||||||
|
after?: Record<string, any>;
|
||||||
|
};
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
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;
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
useMutation,
|
useMutation,
|
||||||
useQueryClient,
|
useQueryClient,
|
||||||
|
InfiniteData,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import { resolveComment } from "@/features/comment/services/comment-service";
|
import { resolveComment } from "@/features/comment/services/comment-service";
|
||||||
import {
|
import {
|
||||||
@@ -10,41 +11,54 @@ import {
|
|||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { IPagination } from "@/lib/types.ts";
|
import { IPagination } from "@/lib/types.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
|
||||||
import { RQ_KEY } from "@/features/comment/queries/comment-query";
|
import { RQ_KEY } from "@/features/comment/queries/comment-query";
|
||||||
|
|
||||||
|
function updateCommentInCache(
|
||||||
|
cache: InfiniteData<IPagination<IComment>>,
|
||||||
|
commentId: string,
|
||||||
|
updater: (comment: IComment) => IComment,
|
||||||
|
): InfiniteData<IPagination<IComment>> {
|
||||||
|
return {
|
||||||
|
...cache,
|
||||||
|
pages: cache.pages.map((page) => ({
|
||||||
|
...page,
|
||||||
|
items: page.items.map((comment) =>
|
||||||
|
comment.id === commentId ? updater(comment) : comment,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function useResolveCommentMutation() {
|
export function useResolveCommentMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const emit = useQueryEmit();
|
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data: IResolveComment) => resolveComment(data),
|
mutationFn: (data: IResolveComment) => resolveComment(data),
|
||||||
onMutate: async (variables) => {
|
onMutate: async (variables) => {
|
||||||
await queryClient.cancelQueries({ queryKey: RQ_KEY(variables.pageId) });
|
await queryClient.cancelQueries({ queryKey: RQ_KEY(variables.pageId) });
|
||||||
const previousComments = queryClient.getQueryData(RQ_KEY(variables.pageId));
|
const previousCache = queryClient.getQueryData(RQ_KEY(variables.pageId));
|
||||||
queryClient.setQueryData(RQ_KEY(variables.pageId), (old: IPagination<IComment>) => {
|
|
||||||
if (!old || !old.items) return old;
|
const cache = previousCache as InfiniteData<IPagination<IComment>> | undefined;
|
||||||
const updatedItems = old.items.map((comment) =>
|
if (cache) {
|
||||||
comment.id === variables.commentId
|
queryClient.setQueryData(
|
||||||
? {
|
RQ_KEY(variables.pageId),
|
||||||
...comment,
|
updateCommentInCache(cache, variables.commentId, (comment) => ({
|
||||||
resolvedAt: variables.resolved ? new Date() : null,
|
...comment,
|
||||||
resolvedById: variables.resolved ? 'optimistic-user' : null,
|
resolvedAt: variables.resolved ? new Date() : null,
|
||||||
resolvedBy: variables.resolved ? { id: 'optimistic-user', name: 'Resolving...', avatarUrl: null } : null
|
resolvedById: variables.resolved ? "optimistic" : null,
|
||||||
}
|
resolvedBy: variables.resolved
|
||||||
: comment,
|
? ({ id: "optimistic", name: "", avatarUrl: null } as IComment["resolvedBy"])
|
||||||
|
: null,
|
||||||
|
})),
|
||||||
);
|
);
|
||||||
return {
|
}
|
||||||
...old,
|
|
||||||
items: updatedItems,
|
return { previousCache };
|
||||||
};
|
|
||||||
});
|
|
||||||
return { previousComments };
|
|
||||||
},
|
},
|
||||||
onError: (err, variables, context) => {
|
onError: (_err, variables, context) => {
|
||||||
if (context?.previousComments) {
|
if (context?.previousCache) {
|
||||||
queryClient.setQueryData(RQ_KEY(variables.pageId), context.previousComments);
|
queryClient.setQueryData(RQ_KEY(variables.pageId), context.previousCache);
|
||||||
}
|
}
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: t("Failed to resolve comment"),
|
message: t("Failed to resolve comment"),
|
||||||
@@ -52,35 +66,26 @@ export function useResolveCommentMutation() {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: (data: IComment, variables) => {
|
onSuccess: (data: IComment, variables) => {
|
||||||
const pageId = data.pageId;
|
const cache = queryClient.getQueryData(
|
||||||
const currentComments = queryClient.getQueryData(
|
RQ_KEY(data.pageId),
|
||||||
RQ_KEY(pageId),
|
) as InfiniteData<IPagination<IComment>> | undefined;
|
||||||
) as IPagination<IComment>;
|
|
||||||
if (currentComments && currentComments.items) {
|
if (cache) {
|
||||||
const updatedComments = currentComments.items.map((comment) =>
|
queryClient.setQueryData(
|
||||||
comment.id === variables.commentId
|
RQ_KEY(data.pageId),
|
||||||
? { ...comment, resolvedAt: data.resolvedAt, resolvedById: data.resolvedById, resolvedBy: data.resolvedBy }
|
updateCommentInCache(cache, variables.commentId, (comment) => ({
|
||||||
: comment,
|
...comment,
|
||||||
|
resolvedAt: data.resolvedAt,
|
||||||
|
resolvedById: data.resolvedById,
|
||||||
|
resolvedBy: data.resolvedBy,
|
||||||
|
})),
|
||||||
);
|
);
|
||||||
queryClient.setQueryData(RQ_KEY(pageId), {
|
|
||||||
...currentComments,
|
|
||||||
items: updatedComments,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
emit({
|
|
||||||
operation: "resolveComment",
|
notifications.show({
|
||||||
pageId: pageId,
|
message: variables.resolved
|
||||||
commentId: variables.commentId,
|
? t("Comment resolved successfully")
|
||||||
resolved: variables.resolved,
|
: t("Comment re-opened successfully"),
|
||||||
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")
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as z from "zod";
|
import { z } from "zod/v4";
|
||||||
import { useForm, zodResolver } from "@mantine/form";
|
import { useForm } from "@mantine/form";
|
||||||
|
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||||
import {
|
import {
|
||||||
Container,
|
Container,
|
||||||
Title,
|
Title,
|
||||||
@@ -30,7 +31,7 @@ export function CloudLoginForm() {
|
|||||||
const { data: joinedWorkspaces } = useJoinedWorkspacesQuery();
|
const { data: joinedWorkspaces } = useJoinedWorkspacesQuery();
|
||||||
|
|
||||||
const form = useForm<any>({
|
const form = useForm<any>({
|
||||||
validate: zodResolver(formSchema),
|
validate: zod4Resolver(formSchema),
|
||||||
initialValues: {
|
initialValues: {
|
||||||
hostname: "",
|
hostname: "",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Modal, TextInput, PasswordInput, Button, Stack } from "@mantine/core";
|
import { Modal, TextInput, PasswordInput, Button, Stack } from "@mantine/core";
|
||||||
import { useForm } from "@mantine/form";
|
import { useForm } from "@mantine/form";
|
||||||
import { zodResolver } from "mantine-form-zod-resolver";
|
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||||
import { z } from "zod";
|
import { z } from "zod/v4";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -34,7 +34,7 @@ export function LdapLoginModal({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
validate: zodResolver(formSchema),
|
validate: zod4Resolver(formSchema),
|
||||||
initialValues: {
|
initialValues: {
|
||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import { Button, Group, Text, Modal, TextInput } from "@mantine/core";
|
import { Button, Group, Text, Modal, TextInput } from "@mantine/core";
|
||||||
import * as z from "zod";
|
import { z } from "zod/v4";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import * as React from "react";
|
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 { notifications } from "@mantine/notifications";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { getSubdomainHost } from "@/lib/config.ts";
|
import { getSubdomainHost } from "@/lib/config.ts";
|
||||||
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
|
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
|
||||||
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
||||||
import { getHostnameUrl } from "@/ee/utils.ts";
|
import { getHostnameUrl } from "@/ee/utils.ts";
|
||||||
import { useAtom } from "jotai/index";
|
import { useAtom } from "jotai";
|
||||||
import {
|
import {
|
||||||
currentUserAtom,
|
currentUserAtom,
|
||||||
workspaceAtom,
|
workspaceAtom,
|
||||||
@@ -66,7 +67,7 @@ function ChangeHostnameForm({ onClose }: ChangeHostnameFormProps) {
|
|||||||
const [currentUser, setCurrentUser] = useAtom(currentUserAtom);
|
const [currentUser, setCurrentUser] = useAtom(currentUserAtom);
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
validate: zodResolver(formSchema),
|
validate: zod4Resolver(formSchema),
|
||||||
initialValues: {
|
initialValues: {
|
||||||
hostname: currentUser?.workspace?.hostname,
|
hostname: currentUser?.workspace?.hostname,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import * as z from "zod";
|
import { z } from "zod/v4";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Button, Group, Modal, Textarea } from "@mantine/core";
|
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 { useTranslation } from "react-i18next";
|
||||||
import { useActivateMutation } from "@/ee/licence/queries/license-query.ts";
|
import { useActivateMutation } from "@/ee/licence/queries/license-query.ts";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
@@ -49,7 +50,7 @@ export function ActivateLicenseForm({ onClose }: ActivateLicenseFormProps) {
|
|||||||
const activateLicenseMutation = useActivateMutation();
|
const activateLicenseMutation = useActivateMutation();
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
validate: zodResolver(formSchema),
|
validate: zod4Resolver(formSchema),
|
||||||
initialValues: {
|
initialValues: {
|
||||||
licenseKey: "",
|
licenseKey: "",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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";
|
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() {
|
export default function OssDetails() {
|
||||||
return (
|
return (
|
||||||
<Table.ScrollContainer minWidth={500} py="md">
|
<Stack gap="lg">
|
||||||
<Table
|
<Table.ScrollContainer minWidth={500} py="md">
|
||||||
variant="vertical"
|
<Table
|
||||||
verticalSpacing="sm"
|
variant="vertical"
|
||||||
layout="fixed"
|
verticalSpacing="sm"
|
||||||
withTableBorder
|
layout="fixed"
|
||||||
>
|
withTableBorder
|
||||||
<Table.Caption>
|
>
|
||||||
To unlock enterprise features like AI, SSO, MFA, Resolve comments, contact sales@docmost.com.
|
<Table.Tbody>
|
||||||
</Table.Caption>
|
<Table.Tr>
|
||||||
<Table.Tbody>
|
<Table.Th w={160}>Edition</Table.Th>
|
||||||
<Table.Tr>
|
<Table.Td>
|
||||||
<Table.Th w={160}>Edition</Table.Th>
|
<Group wrap="nowrap">
|
||||||
<Table.Td>
|
Open Source
|
||||||
<Group wrap="nowrap">
|
<div>
|
||||||
Open Source
|
<ThemeIcon
|
||||||
<div>
|
color="green"
|
||||||
<ThemeIcon
|
variant="light"
|
||||||
color="green"
|
size={24}
|
||||||
variant="light"
|
radius="xl"
|
||||||
size={24}
|
>
|
||||||
radius="xl"
|
<IconCheck size={16} />
|
||||||
>
|
</ThemeIcon>
|
||||||
<IconCheck size={16} />
|
</div>
|
||||||
</ThemeIcon>
|
</Group>
|
||||||
</div>
|
</Table.Td>
|
||||||
</Group>
|
</Table.Tr>
|
||||||
</Table.Td>
|
</Table.Tbody>
|
||||||
</Table.Tr>
|
</Table>
|
||||||
</Table.Tbody>
|
</Table.ScrollContainer>
|
||||||
</Table>
|
|
||||||
</Table.ScrollContainer>
|
<Stack gap="md">
|
||||||
|
<Text fw={500}>Upgrade to the Enterprise Edition to unlock:</Text>
|
||||||
|
|
||||||
|
<List
|
||||||
|
spacing={4}
|
||||||
|
size="sm"
|
||||||
|
icon={
|
||||||
|
<ThemeIcon size={20} color={"gray"} radius="xl">
|
||||||
|
<IconCheck size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{enterpriseFeatures.map((feature) => (
|
||||||
|
<List.Item key={feature}>{feature}</List.Item>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Contact <a href="mailto:sales@docmost.com?subject=Enterprise%20License%20Inquiry">sales@docmost.com </a> to purchase an enterprise license.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ import { notifications } from "@mantine/notifications";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { regenerateBackupCodes } from "@/ee/mfa";
|
import { regenerateBackupCodes } from "@/ee/mfa";
|
||||||
import { useForm } from "@mantine/form";
|
import { useForm } from "@mantine/form";
|
||||||
import { zodResolver } from "mantine-form-zod-resolver";
|
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||||
import { z } from "zod";
|
import { z } from "zod/v4";
|
||||||
import useCurrentUser from "@/features/user/hooks/use-current-user";
|
import useCurrentUser from "@/features/user/hooks/use-current-user";
|
||||||
|
|
||||||
interface MfaBackupCodesModalProps {
|
interface MfaBackupCodesModalProps {
|
||||||
@@ -51,7 +51,7 @@ export function MfaBackupCodesModal({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
validate: zodResolver(formSchema),
|
validate: zod4Resolver(formSchema),
|
||||||
initialValues: {
|
initialValues: {
|
||||||
confirmPassword: "",
|
confirmPassword: "",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useForm } from "@mantine/form";
|
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 { IconDeviceMobile, IconLock } from "@tabler/icons-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
@@ -20,7 +20,7 @@ import classes from "./mfa-challenge.module.css";
|
|||||||
import { verifyMfa } from "@/ee/mfa";
|
import { verifyMfa } from "@/ee/mfa";
|
||||||
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route";
|
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import * as z from "zod";
|
import { z } from "zod/v4";
|
||||||
import { MfaBackupCodeInput } from "./mfa-backup-code-input";
|
import { MfaBackupCodeInput } from "./mfa-backup-code-input";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
@@ -43,7 +43,7 @@ export function MfaChallenge() {
|
|||||||
const [useBackupCode, setUseBackupCode] = useState(false);
|
const [useBackupCode, setUseBackupCode] = useState(false);
|
||||||
|
|
||||||
const form = useForm<MfaChallengeFormValues>({
|
const form = useForm<MfaChallengeFormValues>({
|
||||||
validate: zodResolver(formSchema),
|
validate: zod4Resolver(formSchema),
|
||||||
initialValues: {
|
initialValues: {
|
||||||
code: "",
|
code: "",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconShieldOff, IconAlertTriangle } from "@tabler/icons-react";
|
import { IconShieldOff, IconAlertTriangle } from "@tabler/icons-react";
|
||||||
import { useForm } from "@mantine/form";
|
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 { useMutation } from "@tanstack/react-query";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { z } from "zod";
|
import { z } from "zod/v4";
|
||||||
import { disableMfa } from "@/ee/mfa";
|
import { disableMfa } from "@/ee/mfa";
|
||||||
import useCurrentUser from "@/features/user/hooks/use-current-user";
|
import useCurrentUser from "@/features/user/hooks/use-current-user";
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ export function MfaDisableModal({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
validate: zodResolver(formSchema),
|
validate: zod4Resolver(formSchema),
|
||||||
initialValues: {
|
initialValues: {
|
||||||
confirmPassword: "",
|
confirmPassword: "",
|
||||||
},
|
},
|
||||||
@@ -63,7 +63,7 @@ export function MfaDisableModal({
|
|||||||
|
|
||||||
const handleSubmit = async (values: { confirmPassword?: string }) => {
|
const handleSubmit = async (values: { confirmPassword?: string }) => {
|
||||||
// Only send confirmPassword if it's required (non-SSO users)
|
// Only send confirmPassword if it's required (non-SSO users)
|
||||||
const payload = requiresPassword
|
const payload = requiresPassword
|
||||||
? { confirmPassword: values.confirmPassword }
|
? { confirmPassword: values.confirmPassword }
|
||||||
: {};
|
: {};
|
||||||
await disableMutation.mutateAsync(payload);
|
await disableMutation.mutateAsync(payload);
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ import { useMutation } from "@tanstack/react-query";
|
|||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { setupMfa, enableMfa } from "@/ee/mfa";
|
import { setupMfa, enableMfa } from "@/ee/mfa";
|
||||||
import { zodResolver } from "mantine-form-zod-resolver";
|
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||||
import { z } from "zod";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
interface MfaSetupModalProps {
|
interface MfaSetupModalProps {
|
||||||
opened: boolean;
|
opened: boolean;
|
||||||
@@ -71,7 +71,7 @@ export function MfaSetupModal({
|
|||||||
const [manualEntryOpen, setManualEntryOpen] = useState(false);
|
const [manualEntryOpen, setManualEntryOpen] = useState(false);
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
validate: zodResolver(formSchema),
|
validate: zod4Resolver(formSchema),
|
||||||
initialValues: {
|
initialValues: {
|
||||||
verificationCode: "",
|
verificationCode: "",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Menu withArrow disabled={disabled}>
|
||||||
|
<Menu.Target>
|
||||||
|
<UnstyledButton className={classes.generalAccessBox} disabled={disabled}>
|
||||||
|
<div
|
||||||
|
className={`${classes.generalAccessIcon} ${isDirectlyRestricted || showInheritedState ? classes.generalAccessIconRestricted : ""}`}
|
||||||
|
>
|
||||||
|
<CurrentIcon size={18} stroke={1.5} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<Group gap={4}>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{currentLabel}
|
||||||
|
</Text>
|
||||||
|
{!disabled && <IconChevronDown size={14} />}
|
||||||
|
</Group>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{currentDescription}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</UnstyledButton>
|
||||||
|
</Menu.Target>
|
||||||
|
|
||||||
|
<Menu.Dropdown>
|
||||||
|
{accessOptions.map((option) => (
|
||||||
|
<Menu.Item
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => onChange(option.value)}
|
||||||
|
leftSection={<option.icon size={16} stroke={1.5} />}
|
||||||
|
rightSection={
|
||||||
|
option.value === value ? <IconCheck size={16} /> : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Text size="sm">{option.label}</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{option.description}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className={classes.permissionItem}>
|
||||||
|
<div className={classes.permissionItemInfo}>
|
||||||
|
{member.type === "user" && (
|
||||||
|
<CustomAvatar avatarUrl={member.avatarUrl} name={member.name} />
|
||||||
|
)}
|
||||||
|
{member.type === "group" && <IconGroupCircle />}
|
||||||
|
|
||||||
|
<div className={classes.permissionItemDetails}>
|
||||||
|
<AutoTooltipText
|
||||||
|
fz="sm"
|
||||||
|
fw={500}
|
||||||
|
tooltipLabel={isCurrentUser ? `${member.name} (${t("You")})` : member.name}
|
||||||
|
>
|
||||||
|
{member.name}
|
||||||
|
{isCurrentUser && <Text span c="dimmed"> ({t("You")})</Text>}
|
||||||
|
</AutoTooltipText>
|
||||||
|
<AutoTooltipText fz="xs" c="dimmed">
|
||||||
|
{member.type === "user" ? member.email : formatMemberCount(member.memberCount, t)}
|
||||||
|
</AutoTooltipText>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={classes.permissionItemRole}>
|
||||||
|
{isCurrentUser || disabled ? (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t(roleLabel)}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Menu withArrow position="bottom-end">
|
||||||
|
<Menu.Target>
|
||||||
|
<UnstyledButton>
|
||||||
|
<Group gap={4}>
|
||||||
|
<Text size="sm">{t(roleLabel)}</Text>
|
||||||
|
<IconChevronDown size={14} />
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
|
</Menu.Target>
|
||||||
|
|
||||||
|
<Menu.Dropdown>
|
||||||
|
{pagePermissionRoleData.map((role) => (
|
||||||
|
<Menu.Item
|
||||||
|
key={role.value}
|
||||||
|
onClick={() => onRoleChange(member.id, member.type, role.value)}
|
||||||
|
rightSection={
|
||||||
|
role.value === member.role ? <IconCheck size={16} /> : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Text size="sm">{t(role.label)}</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t(role.description)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
<Menu.Divider />
|
||||||
|
<Menu.Item
|
||||||
|
color="red"
|
||||||
|
onClick={() => onRemove(member.id, member.type)}
|
||||||
|
>
|
||||||
|
{t("Remove access")}
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<HTMLDivElement>(null);
|
||||||
|
const viewportRef = useRef<HTMLDivElement>(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: (
|
||||||
|
<Text size="sm">
|
||||||
|
{t(
|
||||||
|
"Are you sure you want to remove this member's access to the page?",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
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: (
|
||||||
|
<Text size="sm">
|
||||||
|
{t(
|
||||||
|
"Are you sure you want to remove all specific access? This will make the page open to everyone in the space.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
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 (
|
||||||
|
<Center py="md">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (members.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{t("People with access")}
|
||||||
|
</Text>
|
||||||
|
{canManage && members.length > 0 && (
|
||||||
|
<Text className={classes.removeAllLink} onClick={handleRemoveAll}>
|
||||||
|
{t("Remove all")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<ScrollArea mah={250} viewportRef={viewportRef}>
|
||||||
|
{sortedMembers.map((member) => (
|
||||||
|
<PagePermissionItem
|
||||||
|
key={`${member.type}-${member.id}`}
|
||||||
|
member={member}
|
||||||
|
onRoleChange={handleRoleChange}
|
||||||
|
onRemove={handleRemove}
|
||||||
|
disabled={!canManage}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div ref={sentinelRef} style={{ height: 1 }} />
|
||||||
|
|
||||||
|
{isFetchingNextPage && (
|
||||||
|
<Center py="xs">
|
||||||
|
<Loader size="xs" />
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string[]>([]);
|
||||||
|
const [role, setRole] = useState<string>(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 (
|
||||||
|
<Stack gap="md">
|
||||||
|
{hasInheritedRestriction && (
|
||||||
|
<Paper className={classes.inheritedSection} p="sm" radius="sm">
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<ThemeIcon
|
||||||
|
size="lg"
|
||||||
|
radius="sm"
|
||||||
|
variant="light"
|
||||||
|
color="orange"
|
||||||
|
>
|
||||||
|
<IconShieldLock size={18} stroke={1.5} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box style={{ flex: 1 }}>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{t("Inherited restriction")}
|
||||||
|
</Text>
|
||||||
|
<Group gap={4}>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t("Access limited by")}
|
||||||
|
</Text>
|
||||||
|
{restrictionInfo.inheritedFrom && (
|
||||||
|
<Link
|
||||||
|
to={buildPageUrl(
|
||||||
|
spaceSlug,
|
||||||
|
restrictionInfo.inheritedFrom.slugId,
|
||||||
|
restrictionInfo.inheritedFrom.title,
|
||||||
|
)}
|
||||||
|
style={{ textDecoration: "none" }}
|
||||||
|
>
|
||||||
|
<Group gap={2}>
|
||||||
|
<Text size="xs" fw={500} c="blue">
|
||||||
|
{restrictionInfo.inheritedFrom.title || t("Untitled")}
|
||||||
|
</Text>
|
||||||
|
<IconArrowRight size={12} color="var(--mantine-color-blue-6)" />
|
||||||
|
</Group>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<GeneralAccessSelect
|
||||||
|
value={hasDirectRestriction ? "restricted" : "open"}
|
||||||
|
onChange={handleDirectAccessChange}
|
||||||
|
disabled={!canManage}
|
||||||
|
hasInheritedRestriction={hasInheritedRestriction}
|
||||||
|
/>
|
||||||
|
{!hasDirectRestriction && !hasInheritedRestriction && (
|
||||||
|
<Text size="xs" c="dimmed" mt={4}>
|
||||||
|
{t("Restrict access to control who can view and edit this page")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{!hasDirectRestriction && hasInheritedRestriction && (
|
||||||
|
<Text size="xs" c="dimmed" mt={4}>
|
||||||
|
{t("Add additional restrictions specific to this page")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{hasDirectRestriction && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{canManage && (
|
||||||
|
<Group gap="xs" align="flex-end">
|
||||||
|
<Box style={{ flex: 1 }}>
|
||||||
|
<MultiMemberSelect value={memberIds} onChange={setMemberIds} />
|
||||||
|
</Box>
|
||||||
|
<Select
|
||||||
|
data={pagePermissionRoleData.map((r) => ({
|
||||||
|
label: t(r.label),
|
||||||
|
value: r.value,
|
||||||
|
}))}
|
||||||
|
value={role}
|
||||||
|
onChange={(value) => value && setRole(value)}
|
||||||
|
allowDeselect={false}
|
||||||
|
variant="filled"
|
||||||
|
w={120}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleAddMembers}
|
||||||
|
disabled={memberIds.length === 0}
|
||||||
|
loading={addPermissionMutation.isPending}
|
||||||
|
>
|
||||||
|
{t("Add")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PagePermissionList
|
||||||
|
pageId={pageId}
|
||||||
|
canManage={canManage}
|
||||||
|
onRemoveAll={handleRemoveAll}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
.generalAccessBox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--mantine-spacing-sm);
|
||||||
|
padding: var(--mantine-spacing-xs) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generalAccessIcon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: var(--mantine-radius-sm);
|
||||||
|
|
||||||
|
@mixin light {
|
||||||
|
background-color: var(--mantine-color-gray-1);
|
||||||
|
}
|
||||||
|
@mixin dark {
|
||||||
|
background-color: var(--mantine-color-dark-5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.generalAccessIconRestricted {
|
||||||
|
@mixin light {
|
||||||
|
background-color: var(--mantine-color-red-0);
|
||||||
|
color: var(--mantine-color-red-6);
|
||||||
|
}
|
||||||
|
@mixin dark {
|
||||||
|
background-color: rgba(250, 82, 82, 0.1);
|
||||||
|
color: var(--mantine-color-red-5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.permissionItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--mantine-spacing-xs) 0;
|
||||||
|
gap: var(--mantine-spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.permissionItemInfo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--mantine-spacing-sm);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permissionItemDetails {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permissionItemRole {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarStack {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarStackItem {
|
||||||
|
margin-left: -8px;
|
||||||
|
border: 2px solid var(--mantine-color-body);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarStackItem:first-child {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.specificAccessHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--mantine-spacing-xs);
|
||||||
|
margin-top: var(--mantine-spacing-md);
|
||||||
|
margin-bottom: var(--mantine-spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeAllLink {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--mantine-font-size-sm);
|
||||||
|
|
||||||
|
@mixin light {
|
||||||
|
color: var(--mantine-color-gray-6);
|
||||||
|
}
|
||||||
|
@mixin dark {
|
||||||
|
color: var(--mantine-color-dark-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.inheritedInfo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--mantine-spacing-xs);
|
||||||
|
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
|
||||||
|
border-radius: var(--mantine-radius-sm);
|
||||||
|
margin-bottom: var(--mantine-spacing-sm);
|
||||||
|
|
||||||
|
@mixin light {
|
||||||
|
background-color: var(--mantine-color-gray-0);
|
||||||
|
}
|
||||||
|
@mixin dark {
|
||||||
|
background-color: var(--mantine-color-dark-6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.inheritedSection {
|
||||||
|
@mixin light {
|
||||||
|
background-color: var(--mantine-color-orange-0);
|
||||||
|
border: 1px solid var(--mantine-color-orange-2);
|
||||||
|
}
|
||||||
|
@mixin dark {
|
||||||
|
background-color: rgba(255, 146, 43, 0.08);
|
||||||
|
border: 1px solid rgba(255, 146, 43, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Indicator,
|
||||||
|
Loader,
|
||||||
|
Modal,
|
||||||
|
Stack,
|
||||||
|
Tabs,
|
||||||
|
Text,
|
||||||
|
Center,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import { IconWorld, IconLock } from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { extractPageSlugId } from "@/lib";
|
||||||
|
import { usePageQuery } from "@/features/page/queries/page-query";
|
||||||
|
import { usePageRestrictionInfoQuery } from "@/ee/page-permission/queries/page-permission-query";
|
||||||
|
import { PagePermissionTab } from "@/ee/page-permission";
|
||||||
|
import { PublishTab } from "./publish-tab";
|
||||||
|
import { useShareForPageQuery } from "@/features/share/queries/share-query";
|
||||||
|
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
|
||||||
|
import { useSpaceQuery } from "@/features/space/queries/space-query";
|
||||||
|
|
||||||
|
type PageShareModalProps = {
|
||||||
|
readOnly?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PageShareModal({ readOnly }: PageShareModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { pageSlug, spaceSlug } = useParams();
|
||||||
|
const pageSlugId = extractPageSlugId(pageSlug);
|
||||||
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
|
const isCloudEE = useIsCloudEE();
|
||||||
|
const [activeTab, setActiveTab] = useState<string | null>(
|
||||||
|
isCloudEE ? "access" : "publish",
|
||||||
|
);
|
||||||
|
|
||||||
|
const [workspace] = useAtom(workspaceAtom);
|
||||||
|
const { data: space } = useSpaceQuery(spaceSlug);
|
||||||
|
const workspaceSharingDisabled = workspace?.settings?.sharing?.disabled === true;
|
||||||
|
const spaceSharingDisabled = space?.settings?.sharing?.disabled === true;
|
||||||
|
|
||||||
|
const { data: page } = usePageQuery({ pageId: pageSlugId });
|
||||||
|
const pageId = page?.id;
|
||||||
|
const isRestricted = page?.permissions?.hasRestriction ?? false;
|
||||||
|
|
||||||
|
const { data: share } = useShareForPageQuery(pageId);
|
||||||
|
const isPubliclyShared = !!share;
|
||||||
|
|
||||||
|
const { data: restrictionInfo, isLoading: restrictionLoading } =
|
||||||
|
usePageRestrictionInfoQuery(opened && isCloudEE ? pageId : undefined);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
style={{ border: "none" }}
|
||||||
|
size="compact-sm"
|
||||||
|
leftSection={
|
||||||
|
isRestricted ? (
|
||||||
|
<Indicator color="red" offset={5} withBorder>
|
||||||
|
<IconLock size={20} stroke={1.5} />
|
||||||
|
</Indicator>
|
||||||
|
) : isPubliclyShared ? (
|
||||||
|
<Indicator color="green" offset={5} withBorder>
|
||||||
|
<IconWorld size={20} stroke={1.5} />
|
||||||
|
</Indicator>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
variant="default"
|
||||||
|
onClick={open}
|
||||||
|
>
|
||||||
|
{t("Share")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Modal opened={opened} onClose={close} title={t("Share")} size={600}>
|
||||||
|
<Tabs value={activeTab} color="dark" onChange={setActiveTab}>
|
||||||
|
<Tabs.List mb="md">
|
||||||
|
<Tabs.Tab value="access">{t("Access")}</Tabs.Tab>
|
||||||
|
<Tabs.Tab
|
||||||
|
value="publish"
|
||||||
|
rightSection={
|
||||||
|
isPubliclyShared ? (
|
||||||
|
<Indicator color="green" size={8} processing />
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("Publish")}
|
||||||
|
</Tabs.Tab>
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
<Tabs.Panel value="access">
|
||||||
|
{!isCloudEE ? (
|
||||||
|
<Stack align="center" py="md">
|
||||||
|
<IconLock size={20} stroke={1.5} />
|
||||||
|
<Text size="sm" ta="center" fw={500}>
|
||||||
|
{t("Page permissions")}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
|
{t(
|
||||||
|
"Control who can view and edit individual pages. Available with an enterprise license.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
) : restrictionLoading || !pageId || !restrictionInfo ? (
|
||||||
|
<Center py="xl">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
) : (
|
||||||
|
<PagePermissionTab
|
||||||
|
pageId={pageId}
|
||||||
|
restrictionInfo={restrictionInfo}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="publish">
|
||||||
|
<PublishTab
|
||||||
|
pageId={pageId}
|
||||||
|
readOnly={readOnly}
|
||||||
|
isRestricted={isRestricted}
|
||||||
|
workspaceSharingDisabled={workspaceSharingDisabled}
|
||||||
|
spaceSharingDisabled={spaceSharingDisabled}
|
||||||
|
/>
|
||||||
|
</Tabs.Panel>
|
||||||
|
</Tabs>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Anchor,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Stack,
|
||||||
|
Switch,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { IconExternalLink, IconLock } from "@tabler/icons-react";
|
||||||
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { getPageIcon } from "@/lib";
|
||||||
|
import CopyTextButton from "@/components/common/copy";
|
||||||
|
import { getAppUrl, isCloud } from "@/lib/config";
|
||||||
|
import { buildPageUrl } from "@/features/page/page.utils";
|
||||||
|
import {
|
||||||
|
useCreateShareMutation,
|
||||||
|
useDeleteShareMutation,
|
||||||
|
useShareForPageQuery,
|
||||||
|
useUpdateShareMutation,
|
||||||
|
} from "@/features/share/queries/share-query";
|
||||||
|
import useTrial from "@/ee/hooks/use-trial";
|
||||||
|
|
||||||
|
type PublishTabProps = {
|
||||||
|
pageId: string;
|
||||||
|
readOnly?: boolean;
|
||||||
|
isRestricted?: boolean;
|
||||||
|
workspaceSharingDisabled?: boolean;
|
||||||
|
spaceSharingDisabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PublishTab({ pageId, readOnly, isRestricted, workspaceSharingDisabled, spaceSharingDisabled }: PublishTabProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { pageSlug, spaceSlug } = useParams();
|
||||||
|
const { isTrial } = useTrial();
|
||||||
|
|
||||||
|
const { data: share } = useShareForPageQuery(pageId);
|
||||||
|
const createShareMutation = useCreateShareMutation();
|
||||||
|
const updateShareMutation = useUpdateShareMutation();
|
||||||
|
const deleteShareMutation = useDeleteShareMutation();
|
||||||
|
|
||||||
|
const pageIsShared = share && share.level === 0;
|
||||||
|
const isDescendantShared = share && share.level > 0;
|
||||||
|
|
||||||
|
const publicLink = `${getAppUrl()}/share/${share?.key}/p/${pageSlug}`;
|
||||||
|
|
||||||
|
const [isPagePublic, setIsPagePublic] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsPagePublic(!!share);
|
||||||
|
}, [share, pageId]);
|
||||||
|
|
||||||
|
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = event.currentTarget.checked;
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
createShareMutation.mutateAsync({
|
||||||
|
pageId: pageId,
|
||||||
|
includeSubPages: true,
|
||||||
|
searchIndexing: false,
|
||||||
|
});
|
||||||
|
setIsPagePublic(value);
|
||||||
|
} else {
|
||||||
|
if (share && share.id) {
|
||||||
|
deleteShareMutation.mutateAsync(share.id);
|
||||||
|
setIsPagePublic(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubPagesChange = async (
|
||||||
|
event: React.ChangeEvent<HTMLInputElement>,
|
||||||
|
) => {
|
||||||
|
const value = event.currentTarget.checked;
|
||||||
|
updateShareMutation.mutateAsync({
|
||||||
|
shareId: share.id,
|
||||||
|
includeSubPages: value,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleIndexSearchChange = async (
|
||||||
|
event: React.ChangeEvent<HTMLInputElement>,
|
||||||
|
) => {
|
||||||
|
const value = event.currentTarget.checked;
|
||||||
|
updateShareMutation.mutateAsync({
|
||||||
|
shareId: share.id,
|
||||||
|
searchIndexing: value,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const shareLink = useMemo(
|
||||||
|
() => (
|
||||||
|
<Group my="sm" gap={4} wrap="nowrap">
|
||||||
|
<TextInput
|
||||||
|
variant="filled"
|
||||||
|
value={publicLink}
|
||||||
|
readOnly
|
||||||
|
rightSection={<CopyTextButton text={publicLink} />}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
/>
|
||||||
|
<ActionIcon
|
||||||
|
component="a"
|
||||||
|
variant="default"
|
||||||
|
target="_blank"
|
||||||
|
href={publicLink}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<IconExternalLink size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
[publicLink],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isCloud() && isTrial) {
|
||||||
|
return (
|
||||||
|
<Stack align="center" py="md">
|
||||||
|
<IconLock size={20} stroke={1.5} />
|
||||||
|
<Text size="sm" ta="center" fw={500}>
|
||||||
|
{t("Upgrade to share pages")}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
|
{t(
|
||||||
|
"Page sharing is available on paid plans. Upgrade to share your pages publicly.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Button size="xs" onClick={() => navigate("/settings/billing")}>
|
||||||
|
{t("Upgrade Plan")}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workspaceSharingDisabled || spaceSharingDisabled) {
|
||||||
|
return (
|
||||||
|
<Stack align="center" py="md">
|
||||||
|
<IconLock size={20} stroke={1.5} />
|
||||||
|
<Text size="sm" ta="center" fw={500}>
|
||||||
|
{t("Public sharing is disabled")}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
|
{workspaceSharingDisabled
|
||||||
|
? t("Public sharing has been disabled at the workspace level.")
|
||||||
|
: t("Public sharing has been disabled for this space.")}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRestricted) {
|
||||||
|
return (
|
||||||
|
<Stack align="center" py="md">
|
||||||
|
<IconLock size={20} stroke={1.5} />
|
||||||
|
<Text size="sm" ta="center" fw={500}>
|
||||||
|
{t("Restricted page")}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
|
{t("Restricted pages cannot be shared publicly.")}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDescendantShared) {
|
||||||
|
return (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Text size="sm">{t("Inherits public sharing from")}</Text>
|
||||||
|
<Anchor
|
||||||
|
size="sm"
|
||||||
|
underline="never"
|
||||||
|
style={{
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "var(--mantine-color-text)",
|
||||||
|
}}
|
||||||
|
component={Link}
|
||||||
|
to={buildPageUrl(
|
||||||
|
spaceSlug,
|
||||||
|
share.sharedPage.slugId,
|
||||||
|
share.sharedPage.title,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Group gap="4" wrap="nowrap">
|
||||||
|
{getPageIcon(share.sharedPage.icon)}
|
||||||
|
<Text fz="sm" fw={500} lineClamp={1}>
|
||||||
|
{share.sharedPage.title || t("untitled")}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Anchor>
|
||||||
|
{shareLink}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||||
|
<div>
|
||||||
|
<Text size="sm">
|
||||||
|
{isPagePublic ? t("Shared to web") : t("Share to web")}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{isPagePublic
|
||||||
|
? t("Anyone with the link can view this page")
|
||||||
|
: t("Make this page publicly accessible")}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
onChange={handleChange}
|
||||||
|
checked={isPagePublic}
|
||||||
|
disabled={readOnly}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{pageIsShared && (
|
||||||
|
<>
|
||||||
|
{shareLink}
|
||||||
|
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||||
|
<div>
|
||||||
|
<Text size="sm">{t("Include sub-pages")}</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t("Make sub-pages public too")}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
onChange={handleSubPagesChange}
|
||||||
|
checked={share.includeSubPages}
|
||||||
|
size="xs"
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||||
|
<div>
|
||||||
|
<Text size="sm">{t("Search engine indexing")}</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t("Allow search engines to index page")}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
onChange={handleIndexSearchChange}
|
||||||
|
checked={share.searchIndexing}
|
||||||
|
size="xs"
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability";
|
||||||
|
import {
|
||||||
|
SpaceCaslAction,
|
||||||
|
SpaceCaslSubject,
|
||||||
|
} from "@/features/space/permissions/permissions.type";
|
||||||
|
import { usePageRestrictionInfoQuery } from "@/ee/page-permission/queries/page-permission-query";
|
||||||
|
|
||||||
|
export function usePagePermission(pageId: string, spaceRules: any) {
|
||||||
|
const spaceAbility = useSpaceAbility(spaceRules);
|
||||||
|
const { data: restrictionInfo, isLoading } =
|
||||||
|
usePageRestrictionInfoQuery(pageId);
|
||||||
|
|
||||||
|
if (isLoading || !restrictionInfo) {
|
||||||
|
return { canEdit: false, restrictionInfo: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasRestriction =
|
||||||
|
restrictionInfo.hasDirectRestriction ||
|
||||||
|
restrictionInfo.hasInheritedRestriction;
|
||||||
|
|
||||||
|
const canEdit = hasRestriction
|
||||||
|
? (restrictionInfo.userAccess?.canEdit ?? false)
|
||||||
|
: spaceAbility.can(SpaceCaslAction.Manage, SpaceCaslSubject.Page);
|
||||||
|
|
||||||
|
return { canEdit, restrictionInfo };
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export * from "./components/page-share-modal";
|
||||||
|
export * from "./components/page-permission-tab";
|
||||||
|
export * from "./components/publish-tab";
|
||||||
|
export * from "./components/page-permission-list";
|
||||||
|
export * from "./components/page-permission-item";
|
||||||
|
export * from "./components/general-access-select";
|
||||||
|
export * from "./hooks/use-page-permission";
|
||||||
|
export * from "./queries/page-permission-query";
|
||||||
|
export * from "./services/page-permission-service";
|
||||||
|
export * from "./types/page-permission.types";
|
||||||
|
export * from "./types/page-permission-role-data";
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import {
|
||||||
|
keepPreviousData,
|
||||||
|
useInfiniteQuery,
|
||||||
|
useMutation,
|
||||||
|
useQuery,
|
||||||
|
useQueryClient,
|
||||||
|
UseQueryResult,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
IAddPagePermission,
|
||||||
|
IPageRestrictionInfo,
|
||||||
|
IRemovePagePermission,
|
||||||
|
IUpdatePagePermissionRole,
|
||||||
|
} from "@/ee/page-permission/types/page-permission.types";
|
||||||
|
import {
|
||||||
|
addPagePermission,
|
||||||
|
getPagePermissions,
|
||||||
|
getPageRestrictionInfo,
|
||||||
|
removePagePermission,
|
||||||
|
restrictPage,
|
||||||
|
unrestrictPage,
|
||||||
|
updatePagePermissionRole,
|
||||||
|
} from "@/ee/page-permission/services/page-permission-service";
|
||||||
|
import { IPage } from "@/features/page/types/page.types";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export function usePageRestrictionInfoQuery(
|
||||||
|
pageId: string | undefined,
|
||||||
|
): UseQueryResult<IPageRestrictionInfo, Error> {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["page-restriction-info", pageId],
|
||||||
|
queryFn: () => getPageRestrictionInfo(pageId),
|
||||||
|
enabled: !!pageId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePagePermissionsQuery(pageId: string) {
|
||||||
|
return useInfiniteQuery({
|
||||||
|
queryKey: ["page-permissions", pageId],
|
||||||
|
queryFn: ({ pageParam }) => getPagePermissions(pageId, pageParam),
|
||||||
|
enabled: !!pageId,
|
||||||
|
//gcTime: 5000,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
initialPageParam: undefined as string | undefined,
|
||||||
|
getNextPageParam: (lastPage) =>
|
||||||
|
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePageRestrictionCache(
|
||||||
|
queryClient: ReturnType<typeof useQueryClient>,
|
||||||
|
pageId: string,
|
||||||
|
hasRestriction: boolean,
|
||||||
|
) {
|
||||||
|
queryClient.setQueriesData<IPage>(
|
||||||
|
{ queryKey: ["pages"] },
|
||||||
|
(old) => {
|
||||||
|
if (old?.id === pageId) {
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
permissions: { ...old.permissions, hasRestriction },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return old;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["page-restriction-info", pageId],
|
||||||
|
});
|
||||||
|
queryClient.removeQueries({
|
||||||
|
queryKey: ["page-permissions", pageId],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRestrictPageMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return useMutation<void, Error, string>({
|
||||||
|
mutationFn: (pageId) => restrictPage(pageId),
|
||||||
|
onSuccess: (_, pageId) => {
|
||||||
|
updatePageRestrictionCache(queryClient, pageId, true);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const errorMessage = error["response"]?.data?.message;
|
||||||
|
notifications.show({
|
||||||
|
message: errorMessage || t("Failed to restrict page"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUnrestrictPageMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return useMutation<void, Error, string>({
|
||||||
|
mutationFn: (pageId) => unrestrictPage(pageId),
|
||||||
|
onSuccess: (_, pageId) => {
|
||||||
|
updatePageRestrictionCache(queryClient, pageId, false);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const errorMessage = error["response"]?.data?.message;
|
||||||
|
notifications.show({
|
||||||
|
message: errorMessage || t("Failed to remove page restriction"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAddPagePermissionMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return useMutation<void, Error, IAddPagePermission>({
|
||||||
|
mutationFn: (data) => addPagePermission(data),
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["page-permissions", variables.pageId],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const errorMessage = error["response"]?.data?.message;
|
||||||
|
notifications.show({
|
||||||
|
message: errorMessage || t("Failed to add permission"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRemovePagePermissionMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return useMutation<void, Error, IRemovePagePermission>({
|
||||||
|
mutationFn: (data) => removePagePermission(data),
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["page-permissions", variables.pageId],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const errorMessage = error["response"]?.data?.message;
|
||||||
|
notifications.show({
|
||||||
|
message: errorMessage || t("Failed to remove permission"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdatePagePermissionRoleMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return useMutation<void, Error, IUpdatePagePermissionRole>({
|
||||||
|
mutationFn: (data) => updatePagePermissionRole(data),
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.refetchQueries({
|
||||||
|
queryKey: ["page-permissions", variables.pageId],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const errorMessage = error["response"]?.data?.message;
|
||||||
|
notifications.show({
|
||||||
|
message: errorMessage || t("Failed to update permission"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import api from "@/lib/api-client";
|
||||||
|
import { IPagination } from "@/lib/types";
|
||||||
|
import {
|
||||||
|
IAddPagePermission,
|
||||||
|
IPagePermissionMember,
|
||||||
|
IPageRestrictionInfo,
|
||||||
|
IRemovePagePermission,
|
||||||
|
IUpdatePagePermissionRole,
|
||||||
|
} from "@/ee/page-permission/types/page-permission.types";
|
||||||
|
|
||||||
|
export async function restrictPage(pageId: string): Promise<void> {
|
||||||
|
await api.post("/pages/restrict", { pageId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addPagePermission(
|
||||||
|
data: IAddPagePermission,
|
||||||
|
): Promise<void> {
|
||||||
|
await api.post("/pages/add-permission", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removePagePermission(
|
||||||
|
data: IRemovePagePermission,
|
||||||
|
): Promise<void> {
|
||||||
|
await api.post("/pages/remove-permission", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePagePermissionRole(
|
||||||
|
data: IUpdatePagePermissionRole,
|
||||||
|
): Promise<void> {
|
||||||
|
await api.post("/pages/update-permission", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unrestrictPage(pageId: string): Promise<void> {
|
||||||
|
await api.post("/pages/remove-restriction", { pageId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPagePermissions(
|
||||||
|
pageId: string,
|
||||||
|
cursor?: string,
|
||||||
|
): Promise<IPagination<IPagePermissionMember>> {
|
||||||
|
const req = await api.post<IPagination<IPagePermissionMember>>(
|
||||||
|
"/pages/permissions",
|
||||||
|
{ pageId, ...(cursor && { cursor }) },
|
||||||
|
);
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPageRestrictionInfo(
|
||||||
|
pageId: string,
|
||||||
|
): Promise<IPageRestrictionInfo> {
|
||||||
|
const req = await api.post<IPageRestrictionInfo>("/pages/permission-info", {
|
||||||
|
pageId,
|
||||||
|
});
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { IRoleData } from "@/lib/types";
|
||||||
|
import { PagePermissionRole } from "./page-permission.types";
|
||||||
|
|
||||||
|
export const pagePermissionRoleData: IRoleData[] = [
|
||||||
|
{
|
||||||
|
label: "Can edit",
|
||||||
|
value: PagePermissionRole.WRITER,
|
||||||
|
description: "Can edit page and manage access",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Can view",
|
||||||
|
value: PagePermissionRole.READER,
|
||||||
|
description: "Can only view page",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getPagePermissionRoleLabel(value: string): string | undefined {
|
||||||
|
const role = pagePermissionRoleData.find((item) => item.value === value);
|
||||||
|
return role ? role.label : undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
export enum PagePermissionRole {
|
||||||
|
READER = "reader",
|
||||||
|
WRITER = "writer",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IAddPagePermission = {
|
||||||
|
pageId: string;
|
||||||
|
role: PagePermissionRole;
|
||||||
|
userIds?: string[];
|
||||||
|
groupIds?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IRemovePagePermission = {
|
||||||
|
pageId: string;
|
||||||
|
userIds?: string[];
|
||||||
|
groupIds?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IUpdatePagePermissionRole = {
|
||||||
|
pageId: string;
|
||||||
|
role: PagePermissionRole;
|
||||||
|
userId?: string;
|
||||||
|
groupId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IPageRestrictionInfo = {
|
||||||
|
restrictionId?: string;
|
||||||
|
hasDirectRestriction: boolean;
|
||||||
|
hasInheritedRestriction: boolean;
|
||||||
|
inheritedFrom?: {
|
||||||
|
id: string;
|
||||||
|
slugId: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
userAccess: {
|
||||||
|
canView: boolean;
|
||||||
|
canEdit: boolean;
|
||||||
|
canManage: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type IPagePermissionBase = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IPagePermissionUser = IPagePermissionBase & {
|
||||||
|
type: "user";
|
||||||
|
email: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IPagePermissionGroup = IPagePermissionBase & {
|
||||||
|
type: "group";
|
||||||
|
memberCount: number;
|
||||||
|
isDefault: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IPagePermissionMember = IPagePermissionUser | IPagePermissionGroup;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import * as z from "zod";
|
import { z } from "zod/v4";
|
||||||
import { useForm } from "@mantine/form";
|
import { useForm } from "@mantine/form";
|
||||||
import { zodResolver } from "mantine-form-zod-resolver";
|
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Button, Text, TagsInput } from "@mantine/core";
|
import { Button, Text, TagsInput } from "@mantine/core";
|
||||||
@@ -22,7 +22,7 @@ export default function AllowedDomains() {
|
|||||||
const [, setDomains] = useState<string[]>([]);
|
const [, setDomains] = useState<string[]>([]);
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
validate: zodResolver(formSchema),
|
validate: zod4Resolver(formSchema),
|
||||||
initialValues: {
|
initialValues: {
|
||||||
emailDomains: workspace?.emailDomains || [],
|
emailDomains: workspace?.emailDomains || [],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod/v4";
|
||||||
import { useForm } from "@mantine/form";
|
import { useForm } from "@mantine/form";
|
||||||
import { zodResolver } from "mantine-form-zod-resolver";
|
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||||
import { Box, Button, Group, Stack, Switch, TextInput } from "@mantine/core";
|
import { Box, Button, Group, Stack, Switch, TextInput } from "@mantine/core";
|
||||||
import classes from "@/ee/security/components/sso.module.css";
|
import classes from "@/ee/security/components/sso.module.css";
|
||||||
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
|
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
|
||||||
@@ -30,7 +30,7 @@ export function SsoGoogleForm({ provider, onClose }: SsoFormProps) {
|
|||||||
isEnabled: provider.isEnabled,
|
isEnabled: provider.isEnabled,
|
||||||
allowSignup: provider.allowSignup,
|
allowSignup: provider.allowSignup,
|
||||||
},
|
},
|
||||||
validate: zodResolver(ssoSchema),
|
validate: zod4Resolver(ssoSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = async (values: SSOFormValues) => {
|
const handleSubmit = async (values: SSOFormValues) => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod/v4";
|
||||||
import { useForm } from "@mantine/form";
|
import { useForm } from "@mantine/form";
|
||||||
import { zodResolver } from "mantine-form-zod-resolver";
|
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@@ -59,7 +59,7 @@ export function SsoLDAPForm({ provider, onClose }: SsoFormProps) {
|
|||||||
allowSignup: provider.allowSignup,
|
allowSignup: provider.allowSignup,
|
||||||
groupSync: provider.groupSync || false,
|
groupSync: provider.groupSync || false,
|
||||||
},
|
},
|
||||||
validate: zodResolver(ssoSchema),
|
validate: zod4Resolver(ssoSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = async (values: SSOFormValues) => {
|
const handleSubmit = async (values: SSOFormValues) => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod/v4";
|
||||||
import { useForm, zodResolver } from "@mantine/form";
|
import { useForm } from "@mantine/form";
|
||||||
|
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||||
import { Box, Button, Group, Stack, Switch, TextInput } from "@mantine/core";
|
import { Box, Button, Group, Stack, Switch, TextInput } from "@mantine/core";
|
||||||
import { buildCallbackUrl } from "@/ee/security/sso.utils.ts";
|
import { buildCallbackUrl } from "@/ee/security/sso.utils.ts";
|
||||||
import classes from "@/ee/security/components/sso.module.css";
|
import classes from "@/ee/security/components/sso.module.css";
|
||||||
@@ -39,7 +40,7 @@ export function SsoOIDCForm({ provider, onClose }: SsoFormProps) {
|
|||||||
allowSignup: provider.allowSignup,
|
allowSignup: provider.allowSignup,
|
||||||
groupSync: provider.groupSync || false,
|
groupSync: provider.groupSync || false,
|
||||||
},
|
},
|
||||||
validate: zodResolver(ssoSchema),
|
validate: zod4Resolver(ssoSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const callbackUrl = buildCallbackUrl({
|
const callbackUrl = buildCallbackUrl({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod/v4";
|
||||||
import { useForm } from "@mantine/form";
|
import { useForm } from "@mantine/form";
|
||||||
import { zodResolver } from "mantine-form-zod-resolver";
|
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@@ -49,7 +49,7 @@ export function SsoSamlForm({ provider, onClose }: SsoFormProps) {
|
|||||||
allowSignup: provider.allowSignup,
|
allowSignup: provider.allowSignup,
|
||||||
groupSync: provider.groupSync || false,
|
groupSync: provider.groupSync || false,
|
||||||
},
|
},
|
||||||
validate: zodResolver(ssoSchema),
|
validate: zod4Resolver(ssoSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const callbackUrl = buildCallbackUrl({
|
const callbackUrl = buildCallbackUrl({
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Group,
|
||||||
|
Text,
|
||||||
|
NumberInput,
|
||||||
|
Select,
|
||||||
|
Button,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx";
|
||||||
|
|
||||||
|
type RetentionUnit = "days" | "months" | "years";
|
||||||
|
|
||||||
|
const DEFAULT_RETENTION_DAYS = 30;
|
||||||
|
|
||||||
|
function daysToRetention(days: number): { amount: number; unit: RetentionUnit } {
|
||||||
|
if (days >= 365 && days % 365 === 0) {
|
||||||
|
return { amount: days / 365, unit: "years" };
|
||||||
|
}
|
||||||
|
if (days >= 30 && days % 30 === 0) {
|
||||||
|
return { amount: days / 30, unit: "months" };
|
||||||
|
}
|
||||||
|
return { amount: days, unit: "days" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function retentionToDays(amount: number, unit: RetentionUnit): number {
|
||||||
|
if (unit === "years") return amount * 365;
|
||||||
|
if (unit === "months") return amount * 30;
|
||||||
|
return amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TrashRetention() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const hasAccess = useEnterpriseAccess();
|
||||||
|
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
||||||
|
|
||||||
|
const currentDays = workspace?.trashRetentionDays ?? DEFAULT_RETENTION_DAYS;
|
||||||
|
const parsed = daysToRetention(currentDays);
|
||||||
|
|
||||||
|
const [retentionAmount, setRetentionAmount] = useState<number | string>(parsed.amount);
|
||||||
|
const [retentionUnit, setRetentionUnit] = useState<RetentionUnit>(parsed.unit);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const days = workspace?.trashRetentionDays ?? DEFAULT_RETENTION_DAYS;
|
||||||
|
const { amount, unit } = daysToRetention(days);
|
||||||
|
setRetentionAmount(amount);
|
||||||
|
setRetentionUnit(unit);
|
||||||
|
}, [workspace?.trashRetentionDays]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
const num = typeof retentionAmount === "number" ? retentionAmount : 1;
|
||||||
|
const clamped = Math.max(1, num);
|
||||||
|
setRetentionAmount(clamped);
|
||||||
|
const days = retentionToDays(clamped, retentionUnit);
|
||||||
|
|
||||||
|
if (days === currentDays) return;
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const updatedWorkspace = await updateWorkspace({ trashRetentionDays: days });
|
||||||
|
setWorkspace(updatedWorkspace);
|
||||||
|
notifications.show({
|
||||||
|
message: t("Trash retention updated"),
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
notifications.show({
|
||||||
|
message: err?.response?.data?.message || t("Failed to update trash retention"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
const { amount, unit } = daysToRetention(currentDays);
|
||||||
|
setRetentionAmount(amount);
|
||||||
|
setRetentionUnit(unit);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDirty = retentionToDays(
|
||||||
|
typeof retentionAmount === "number" ? retentionAmount : 1,
|
||||||
|
retentionUnit,
|
||||||
|
) !== currentDays;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Text size="md">{t("Trash retention")}</Text>
|
||||||
|
<Text size="sm" c="dimmed" mb="sm">
|
||||||
|
{t("Pages in trash will be permanently deleted after this period.")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Tooltip
|
||||||
|
label={t("Requires an enterprise license")}
|
||||||
|
disabled={hasAccess}
|
||||||
|
>
|
||||||
|
<Group gap="xs" wrap="nowrap" maw={320}>
|
||||||
|
<NumberInput
|
||||||
|
value={retentionAmount}
|
||||||
|
onChange={(val) => setRetentionAmount(val)}
|
||||||
|
min={1}
|
||||||
|
hideControls
|
||||||
|
size="sm"
|
||||||
|
w={60}
|
||||||
|
disabled={!hasAccess}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
data={[
|
||||||
|
{ value: "days", label: t("days") },
|
||||||
|
{ value: "months", label: t("months") },
|
||||||
|
{ value: "years", label: t("years") },
|
||||||
|
]}
|
||||||
|
value={retentionUnit}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (value === "days" || value === "months" || value === "years") {
|
||||||
|
setRetentionUnit(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
disabled={!hasAccess}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSave}
|
||||||
|
loading={saving}
|
||||||
|
disabled={!hasAccess || !isDirty}
|
||||||
|
>
|
||||||
|
{t("Save")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import AllowedDomains from "@/ee/security/components/allowed-domains.tsx";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx";
|
import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx";
|
||||||
import DisablePublicSharing from "@/ee/security/components/disable-public-sharing.tsx";
|
import 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 useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx";
|
||||||
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx";
|
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx";
|
||||||
|
|
||||||
@@ -42,6 +43,13 @@ export default function Security() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!isCloud() && (
|
||||||
|
<>
|
||||||
|
<TrashRetention />
|
||||||
|
<Divider my="lg" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Title order={4} my="lg">
|
<Title order={4} my="lg">
|
||||||
Single sign-on (SSO)
|
Single sign-on (SSO)
|
||||||
</Title>
|
</Title>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import * as z from "zod";
|
import { z } from "zod/v4";
|
||||||
import { useForm, zodResolver } from "@mantine/form";
|
import { useForm } from "@mantine/form";
|
||||||
|
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||||
import useAuth from "@/features/auth/hooks/use-auth";
|
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 { Box, Button, Container, Text, TextInput, Title } from "@mantine/core";
|
||||||
import classes from "./auth.module.css";
|
import classes from "./auth.module.css";
|
||||||
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
|
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
|
||||||
@@ -10,10 +10,10 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
email: z
|
email: z
|
||||||
.string()
|
.email()
|
||||||
.min(1, { message: "Email is required" })
|
.min(1, { message: "Email is required" }),
|
||||||
.email({ message: "Invalid email address" }),
|
|
||||||
});
|
});
|
||||||
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
export function ForgotPasswordForm() {
|
export function ForgotPasswordForm() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -21,14 +21,14 @@ export function ForgotPasswordForm() {
|
|||||||
const [isTokenSent, setIsTokenSent] = useState<boolean>(false);
|
const [isTokenSent, setIsTokenSent] = useState<boolean>(false);
|
||||||
useRedirectIfAuthenticated();
|
useRedirectIfAuthenticated();
|
||||||
|
|
||||||
const form = useForm<IForgotPassword>({
|
const form = useForm<FormValues>({
|
||||||
validate: zodResolver(formSchema),
|
validate: zod4Resolver(formSchema),
|
||||||
initialValues: {
|
initialValues: {
|
||||||
email: "",
|
email: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
async function onSubmit(data: IForgotPassword) {
|
async function onSubmit(data: FormValues) {
|
||||||
if (await forgotPassword(data)) {
|
if (await forgotPassword(data)) {
|
||||||
setIsTokenSent(true);
|
setIsTokenSent(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import * as z from "zod";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
import { useForm } from "@mantine/form";
|
import { useForm } from "@mantine/form";
|
||||||
import {
|
import {
|
||||||
@@ -11,9 +11,8 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Stack,
|
Stack,
|
||||||
} from "@mantine/core";
|
} 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 { useParams, useSearchParams } from "react-router-dom";
|
||||||
import { IRegister } from "@/features/auth/types/auth.types";
|
|
||||||
import useAuth from "@/features/auth/hooks/use-auth";
|
import useAuth from "@/features/auth/hooks/use-auth";
|
||||||
import classes from "@/features/auth/components/auth.module.css";
|
import classes from "@/features/auth/components/auth.module.css";
|
||||||
import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-query.ts";
|
import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-query.ts";
|
||||||
@@ -40,14 +39,14 @@ export function InviteSignUpForm() {
|
|||||||
useRedirectIfAuthenticated();
|
useRedirectIfAuthenticated();
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
validate: zodResolver(formSchema),
|
validate: zod4Resolver(formSchema),
|
||||||
initialValues: {
|
initialValues: {
|
||||||
name: "",
|
name: "",
|
||||||
password: "",
|
password: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
async function onSubmit(data: IRegister) {
|
async function onSubmit(data: FormValues) {
|
||||||
const invitationToken = searchParams.get("token");
|
const invitationToken = searchParams.get("token");
|
||||||
|
|
||||||
await invitationSignup({
|
await invitationSignup({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as z from "zod";
|
import { z } from "zod/v4";
|
||||||
import { useForm, zodResolver } from "@mantine/form";
|
import { useForm } from "@mantine/form";
|
||||||
|
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||||
import useAuth from "@/features/auth/hooks/use-auth";
|
import useAuth from "@/features/auth/hooks/use-auth";
|
||||||
import { ILogin } from "@/features/auth/types/auth.types";
|
|
||||||
import {
|
import {
|
||||||
Container,
|
Container,
|
||||||
Title,
|
Title,
|
||||||
@@ -24,11 +24,11 @@ import React from "react";
|
|||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
email: z
|
email: z
|
||||||
.string()
|
.email()
|
||||||
.min(1, { message: "email is required" })
|
.min(1, { message: "email is required" }),
|
||||||
.email({ message: "Invalid email address" }),
|
|
||||||
password: z.string().min(1, { message: "Password is required" }),
|
password: z.string().min(1, { message: "Password is required" }),
|
||||||
});
|
});
|
||||||
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
export function LoginForm() {
|
export function LoginForm() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -41,15 +41,15 @@ export function LoginForm() {
|
|||||||
error,
|
error,
|
||||||
} = useWorkspacePublicDataQuery();
|
} = useWorkspacePublicDataQuery();
|
||||||
|
|
||||||
const form = useForm<ILogin>({
|
const form = useForm<FormValues>({
|
||||||
validate: zodResolver(formSchema),
|
validate: zod4Resolver(formSchema),
|
||||||
initialValues: {
|
initialValues: {
|
||||||
email: "",
|
email: "",
|
||||||
password: "",
|
password: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
async function onSubmit(data: ILogin) {
|
async function onSubmit(data: FormValues) {
|
||||||
await signIn(data);
|
await signIn(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as z from "zod";
|
import { z } from "zod/v4";
|
||||||
import { useForm, zodResolver } from "@mantine/form";
|
import { useForm } from "@mantine/form";
|
||||||
|
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||||
import useAuth from "@/features/auth/hooks/use-auth";
|
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 { Box, Button, Container, PasswordInput, Title } from "@mantine/core";
|
||||||
import classes from "./auth.module.css";
|
import classes from "./auth.module.css";
|
||||||
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
|
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
|
||||||
@@ -12,6 +12,7 @@ const formSchema = z.object({
|
|||||||
.string()
|
.string()
|
||||||
.min(8, { message: "Password must contain at least 8 characters" }),
|
.min(8, { message: "Password must contain at least 8 characters" }),
|
||||||
});
|
});
|
||||||
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
interface PasswordResetFormProps {
|
interface PasswordResetFormProps {
|
||||||
resetToken?: string;
|
resetToken?: string;
|
||||||
@@ -22,14 +23,14 @@ export function PasswordResetForm({ resetToken }: PasswordResetFormProps) {
|
|||||||
const { passwordReset, isLoading } = useAuth();
|
const { passwordReset, isLoading } = useAuth();
|
||||||
useRedirectIfAuthenticated();
|
useRedirectIfAuthenticated();
|
||||||
|
|
||||||
const form = useForm<IPasswordReset>({
|
const form = useForm<FormValues>({
|
||||||
validate: zodResolver(formSchema),
|
validate: zod4Resolver(formSchema),
|
||||||
initialValues: {
|
initialValues: {
|
||||||
newPassword: "",
|
newPassword: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
async function onSubmit(data: IPasswordReset) {
|
async function onSubmit(data: FormValues) {
|
||||||
await passwordReset({
|
await passwordReset({
|
||||||
token: resetToken,
|
token: resetToken,
|
||||||
newPassword: data.newPassword,
|
newPassword: data.newPassword,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import * as z from "zod";
|
import { z } from "zod/v4";
|
||||||
import { useForm, zodResolver } from "@mantine/form";
|
import { useForm } from "@mantine/form";
|
||||||
|
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||||
import {
|
import {
|
||||||
Container,
|
Container,
|
||||||
Title,
|
Title,
|
||||||
@@ -11,7 +12,6 @@ import {
|
|||||||
Anchor,
|
Anchor,
|
||||||
Text,
|
Text,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { ISetupWorkspace } from "@/features/auth/types/auth.types";
|
|
||||||
import useAuth from "@/features/auth/hooks/use-auth";
|
import useAuth from "@/features/auth/hooks/use-auth";
|
||||||
import classes from "@/features/auth/components/auth.module.css";
|
import classes from "@/features/auth/components/auth.module.css";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -24,19 +24,19 @@ const formSchema = z.object({
|
|||||||
workspaceName: z.string().trim().max(50).optional(),
|
workspaceName: z.string().trim().max(50).optional(),
|
||||||
name: z.string().min(1).max(50),
|
name: z.string().min(1).max(50),
|
||||||
email: z
|
email: z
|
||||||
.string()
|
.email()
|
||||||
.min(1, { message: "email is required" })
|
.min(1, { message: "email is required" }),
|
||||||
.email({ message: "Invalid email address" }),
|
|
||||||
password: z.string().min(8),
|
password: z.string().min(8),
|
||||||
});
|
});
|
||||||
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
export function SetupWorkspaceForm() {
|
export function SetupWorkspaceForm() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { setupWorkspace, isLoading } = useAuth();
|
const { setupWorkspace, isLoading } = useAuth();
|
||||||
// useRedirectIfAuthenticated();
|
// useRedirectIfAuthenticated();
|
||||||
|
|
||||||
const form = useForm<ISetupWorkspace>({
|
const form = useForm<FormValues>({
|
||||||
validate: zodResolver(formSchema),
|
validate: zod4Resolver(formSchema),
|
||||||
initialValues: {
|
initialValues: {
|
||||||
workspaceName: "",
|
workspaceName: "",
|
||||||
name: "",
|
name: "",
|
||||||
@@ -45,7 +45,7 @@ export function SetupWorkspaceForm() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
async function onSubmit(data: ISetupWorkspace) {
|
async function onSubmit(data: FormValues) {
|
||||||
await setupWorkspace(data);
|
await setupWorkspace(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-
|
|||||||
import { useEditor } from "@tiptap/react";
|
import { useEditor } from "@tiptap/react";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
|
||||||
|
|
||||||
interface CommentDialogProps {
|
interface CommentDialogProps {
|
||||||
editor: ReturnType<typeof useEditor>;
|
editor: ReturnType<typeof useEditor>;
|
||||||
@@ -37,8 +36,6 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
|||||||
const createCommentMutation = useCreateCommentMutation();
|
const createCommentMutation = useCreateCommentMutation();
|
||||||
const { isPending } = createCommentMutation;
|
const { isPending } = createCommentMutation;
|
||||||
|
|
||||||
const emit = useQueryEmit();
|
|
||||||
|
|
||||||
const handleDialogClose = () => {
|
const handleDialogClose = () => {
|
||||||
setShowCommentPopup(false);
|
setShowCommentPopup(false);
|
||||||
editor.chain().focus().unsetCommentDecoration().run();
|
editor.chain().focus().unsetCommentDecoration().run();
|
||||||
@@ -56,6 +53,7 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
|||||||
pageId: pageId,
|
pageId: pageId,
|
||||||
content: JSON.stringify(comment),
|
content: JSON.stringify(comment),
|
||||||
selection: selectedText,
|
selection: selectedText,
|
||||||
|
type: "inline",
|
||||||
};
|
};
|
||||||
|
|
||||||
const createdComment =
|
const createdComment =
|
||||||
@@ -81,10 +79,6 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
|||||||
);
|
);
|
||||||
}, 400);
|
}, 400);
|
||||||
|
|
||||||
emit({
|
|
||||||
operation: "invalidateComment",
|
|
||||||
pageId: pageId,
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
setShowCommentPopup(false);
|
setShowCommentPopup(false);
|
||||||
setDraftCommentId("");
|
setDraftCommentId("");
|
||||||
@@ -103,6 +97,7 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
|||||||
size="lg"
|
size="lg"
|
||||||
radius="md"
|
radius="md"
|
||||||
w={300}
|
w={300}
|
||||||
|
zIndex={180}
|
||||||
position={{ bottom: 500, right: 50 }}
|
position={{ bottom: 500, right: 50 }}
|
||||||
withCloseButton
|
withCloseButton
|
||||||
withBorder
|
withBorder
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Group, Text, Box, Badge } from "@mantine/core";
|
|||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import classes from "./comment.module.css";
|
import classes from "./comment.module.css";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { timeAgo } from "@/lib/time";
|
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||||
import CommentEditor from "@/features/comment/components/comment-editor";
|
import CommentEditor from "@/features/comment/components/comment-editor";
|
||||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
||||||
import CommentActions from "@/features/comment/components/comment-actions";
|
import CommentActions from "@/features/comment/components/comment-actions";
|
||||||
@@ -18,7 +18,6 @@ import { useResolveCommentMutation } from "@/ee/comment/queries/comment-query";
|
|||||||
import { IComment } from "@/features/comment/types/comment.types";
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface CommentListItemProps {
|
interface CommentListItemProps {
|
||||||
@@ -45,8 +44,8 @@ function CommentListItem({
|
|||||||
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
||||||
const resolveCommentMutation = useResolveCommentMutation();
|
const resolveCommentMutation = useResolveCommentMutation();
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
const emit = useQueryEmit();
|
|
||||||
const isCloudEE = useIsCloudEE();
|
const isCloudEE = useIsCloudEE();
|
||||||
|
const createdAtAgo = useTimeAgo(comment.createdAt);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setContent(comment.content);
|
setContent(comment.content);
|
||||||
@@ -65,11 +64,6 @@ function CommentListItem({
|
|||||||
editContentRef.current = null;
|
editContentRef.current = null;
|
||||||
}
|
}
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
|
|
||||||
emit({
|
|
||||||
operation: "invalidateComment",
|
|
||||||
pageId: pageId,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update comment:", error);
|
console.error("Failed to update comment:", error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -81,11 +75,6 @@ function CommentListItem({
|
|||||||
try {
|
try {
|
||||||
await deleteCommentMutation.mutateAsync(comment.id);
|
await deleteCommentMutation.mutateAsync(comment.id);
|
||||||
editor?.commands.unsetComment(comment.id);
|
editor?.commands.unsetComment(comment.id);
|
||||||
|
|
||||||
emit({
|
|
||||||
operation: "invalidateComment",
|
|
||||||
pageId: pageId,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to delete comment:", error);
|
console.error("Failed to delete comment:", error);
|
||||||
}
|
}
|
||||||
@@ -106,11 +95,6 @@ function CommentListItem({
|
|||||||
if (editor) {
|
if (editor) {
|
||||||
editor.commands.setCommentResolved(comment.id, !isResolved);
|
editor.commands.setCommentResolved(comment.id, !isResolved);
|
||||||
}
|
}
|
||||||
|
|
||||||
emit({
|
|
||||||
operation: "invalidateComment",
|
|
||||||
pageId: pageId,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to toggle resolved state:", error);
|
console.error("Failed to toggle resolved state:", error);
|
||||||
}
|
}
|
||||||
@@ -177,7 +161,7 @@ function CommentListItem({
|
|||||||
|
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<Text size="xs" fw={500} c="dimmed">
|
<Text size="xs" fw={500} c="dimmed">
|
||||||
{timeAgo(comment.createdAt)}
|
{createdAtAgo}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
import React, { useState, useRef, useCallback, memo, useMemo } from "react";
|
import React, { useState, useRef, useCallback, memo, useMemo } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
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 CommentListItem from "@/features/comment/components/comment-list-item";
|
||||||
import {
|
import {
|
||||||
useCommentsQuery,
|
useCommentsQuery,
|
||||||
@@ -14,14 +25,8 @@ import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
|||||||
import { IPagination } from "@/lib/types.ts";
|
import { IPagination } from "@/lib/types.ts";
|
||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId } from "@/lib";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||||
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
import { IconArrowUp, IconMessageOff } from "@tabler/icons-react";
|
||||||
import {
|
|
||||||
SpaceCaslAction,
|
|
||||||
SpaceCaslSubject,
|
|
||||||
} from "@/features/space/permissions/permissions.type.ts";
|
|
||||||
|
|
||||||
function CommentListWithTabs() {
|
function CommentListWithTabs() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -31,21 +36,12 @@ function CommentListWithTabs() {
|
|||||||
data: comments,
|
data: comments,
|
||||||
isLoading: isCommentsLoading,
|
isLoading: isCommentsLoading,
|
||||||
isError,
|
isError,
|
||||||
} = useCommentsQuery({ pageId: page?.id, limit: 100 });
|
} = useCommentsQuery({ pageId: page?.id });
|
||||||
const createCommentMutation = useCreateCommentMutation();
|
const createCommentMutation = useCreateCommentMutation();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const emit = useQueryEmit();
|
|
||||||
const isCloudEE = useIsCloudEE();
|
|
||||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||||
|
|
||||||
const spaceRules = space?.membership?.permissions;
|
const canComment = page?.permissions?.canEdit ?? false;
|
||||||
const spaceAbility = useSpaceAbility(spaceRules);
|
|
||||||
|
|
||||||
|
|
||||||
const canComment: boolean = spaceAbility.can(
|
|
||||||
SpaceCaslAction.Manage,
|
|
||||||
SpaceCaslSubject.Page
|
|
||||||
);
|
|
||||||
|
|
||||||
// Separate active and resolved comments
|
// Separate active and resolved comments
|
||||||
const { activeComments, resolvedComments } = useMemo(() => {
|
const { activeComments, resolvedComments } = useMemo(() => {
|
||||||
@@ -54,19 +50,47 @@ function CommentListWithTabs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const parentComments = comments.items.filter(
|
const parentComments = comments.items.filter(
|
||||||
(comment: IComment) => comment.parentCommentId === null
|
(comment: IComment) => comment.parentCommentId === null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const active = parentComments.filter(
|
const active = parentComments.filter(
|
||||||
(comment: IComment) => !comment.resolvedAt
|
(comment: IComment) => !comment.resolvedAt,
|
||||||
);
|
);
|
||||||
const resolved = parentComments.filter(
|
const resolved = parentComments.filter(
|
||||||
(comment: IComment) => comment.resolvedAt
|
(comment: IComment) => comment.resolvedAt,
|
||||||
);
|
);
|
||||||
|
|
||||||
return { activeComments: active, resolvedComments: resolved };
|
return { activeComments: active, resolvedComments: resolved };
|
||||||
}, [comments]);
|
}, [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(
|
const handleAddReply = useCallback(
|
||||||
async (commentId: string, content: string) => {
|
async (commentId: string, content: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -78,18 +102,13 @@ function CommentListWithTabs() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await createCommentMutation.mutateAsync(commentData);
|
await createCommentMutation.mutateAsync(commentData);
|
||||||
|
|
||||||
emit({
|
|
||||||
operation: "invalidateComment",
|
|
||||||
pageId: page?.id,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to post comment:", error);
|
console.error("Failed to post comment:", error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[createCommentMutation, page?.id]
|
[createCommentMutation, page?.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderComments = useCallback(
|
const renderComments = useCallback(
|
||||||
@@ -131,7 +150,7 @@ function CommentListWithTabs() {
|
|||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
),
|
),
|
||||||
[comments, handleAddReply, isLoading, space?.membership?.role]
|
[comments, handleAddReply, isLoading, space?.membership?.role],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isCommentsLoading) {
|
if (isCommentsLoading) {
|
||||||
@@ -144,63 +163,32 @@ function CommentListWithTabs() {
|
|||||||
|
|
||||||
const totalComments = activeComments.length + resolvedComments.length;
|
const totalComments = activeComments.length + resolvedComments.length;
|
||||||
|
|
||||||
// If not cloud/enterprise, show simple list without tabs
|
const pageCommentInput = canComment ? (
|
||||||
if (!isCloudEE) {
|
<PageCommentInput
|
||||||
if (totalComments === 0) {
|
onSave={handleAddPageComment}
|
||||||
return <>{t("No comments yet.")}</>;
|
isLoading={isPageCommentLoading}
|
||||||
}
|
/>
|
||||||
|
) : null;
|
||||||
return (
|
|
||||||
<ScrollArea style={{ height: "85vh" }} scrollbarSize={5} type="scroll">
|
|
||||||
<div style={{ paddingBottom: "200px" }}>
|
|
||||||
{comments?.items
|
|
||||||
.filter((comment: IComment) => comment.parentCommentId === null)
|
|
||||||
.map((comment) => (
|
|
||||||
<Paper
|
|
||||||
shadow="sm"
|
|
||||||
radius="md"
|
|
||||||
p="sm"
|
|
||||||
mb="sm"
|
|
||||||
withBorder
|
|
||||||
key={comment.id}
|
|
||||||
data-comment-id={comment.id}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<CommentListItem
|
|
||||||
comment={comment}
|
|
||||||
pageId={page?.id}
|
|
||||||
canComment={canComment}
|
|
||||||
userSpaceRole={space?.membership?.role}
|
|
||||||
/>
|
|
||||||
<MemoizedChildComments
|
|
||||||
comments={comments}
|
|
||||||
parentId={comment.id}
|
|
||||||
pageId={page?.id}
|
|
||||||
canComment={canComment}
|
|
||||||
userSpaceRole={space?.membership?.role}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{canComment && (
|
|
||||||
<>
|
|
||||||
<Divider my={4} />
|
|
||||||
<CommentEditorWithActions
|
|
||||||
commentId={comment.id}
|
|
||||||
onSave={handleAddReply}
|
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Paper>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ height: "85vh", display: "flex", flexDirection: "column", marginTop: '-15px' }}>
|
<div
|
||||||
<Tabs defaultValue="open" variant="default" style={{ flex: "0 0 auto" }}>
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tabs
|
||||||
|
defaultValue="open"
|
||||||
|
variant="default"
|
||||||
|
style={{
|
||||||
|
flex: "1 1 auto",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Tabs.List justify="center">
|
<Tabs.List justify="center">
|
||||||
<Tabs.Tab
|
<Tabs.Tab
|
||||||
value="open"
|
value="open"
|
||||||
@@ -225,16 +213,25 @@ function CommentListWithTabs() {
|
|||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
<ScrollArea
|
<ScrollArea
|
||||||
style={{ flex: "1 1 auto", height: "calc(85vh - 60px)" }}
|
style={{ flex: "1 1 auto" }}
|
||||||
scrollbarSize={5}
|
scrollbarSize={5}
|
||||||
type="scroll"
|
type="scroll"
|
||||||
>
|
>
|
||||||
<div style={{ paddingBottom: "200px" }}>
|
<div style={{ paddingBottom: "8px" }}>
|
||||||
<Tabs.Panel value="open" pt="xs">
|
<Tabs.Panel value="open" pt="xs">
|
||||||
{activeComments.length === 0 ? (
|
{activeComments.length === 0 ? (
|
||||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
<Center py="xl">
|
||||||
{t("No open comments.")}
|
<Stack align="center" gap="xs">
|
||||||
</Text>
|
<IconMessageOff
|
||||||
|
size={32}
|
||||||
|
stroke={1.5}
|
||||||
|
color="var(--mantine-color-dimmed)"
|
||||||
|
/>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t("No open comments.")}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
) : (
|
) : (
|
||||||
activeComments.map(renderComments)
|
activeComments.map(renderComments)
|
||||||
)}
|
)}
|
||||||
@@ -242,9 +239,18 @@ function CommentListWithTabs() {
|
|||||||
|
|
||||||
<Tabs.Panel value="resolved" pt="xs">
|
<Tabs.Panel value="resolved" pt="xs">
|
||||||
{resolvedComments.length === 0 ? (
|
{resolvedComments.length === 0 ? (
|
||||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
<Center py="xl">
|
||||||
{t("No resolved comments.")}
|
<Stack align="center" gap="xs">
|
||||||
</Text>
|
<IconMessageOff
|
||||||
|
size={32}
|
||||||
|
stroke={1.5}
|
||||||
|
color="var(--mantine-color-dimmed)"
|
||||||
|
/>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t("No resolved comments.")}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
) : (
|
) : (
|
||||||
resolvedComments.map(renderComments)
|
resolvedComments.map(renderComments)
|
||||||
)}
|
)}
|
||||||
@@ -252,6 +258,7 @@ function CommentListWithTabs() {
|
|||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
{pageCommentInput}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -273,9 +280,9 @@ const ChildComments = ({
|
|||||||
const getChildComments = useCallback(
|
const getChildComments = useCallback(
|
||||||
(parentId: string) =>
|
(parentId: string) =>
|
||||||
comments.items.filter(
|
comments.items.filter(
|
||||||
(comment: IComment) => comment.parentCommentId === parentId
|
(comment: IComment) => comment.parentCommentId === parentId,
|
||||||
),
|
),
|
||||||
[comments.items]
|
[comments.items],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -303,7 +310,12 @@ const ChildComments = ({
|
|||||||
|
|
||||||
const MemoizedChildComments = memo(ChildComments);
|
const MemoizedChildComments = memo(ChildComments);
|
||||||
|
|
||||||
const CommentEditorWithActions = ({ commentId, onSave, isLoading }) => {
|
const CommentEditorWithActions = ({
|
||||||
|
commentId,
|
||||||
|
onSave,
|
||||||
|
isLoading,
|
||||||
|
placeholder = undefined,
|
||||||
|
}) => {
|
||||||
const [content, setContent] = useState("");
|
const [content, setContent] = useState("");
|
||||||
const { ref, focused } = useFocusWithin();
|
const { ref, focused } = useFocusWithin();
|
||||||
const commentEditorRef = useRef(null);
|
const commentEditorRef = useRef(null);
|
||||||
@@ -321,10 +333,57 @@ const CommentEditorWithActions = ({ commentId, onSave, isLoading }) => {
|
|||||||
onUpdate={setContent}
|
onUpdate={setContent}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
editable={true}
|
editable={true}
|
||||||
|
placeholder={placeholder}
|
||||||
/>
|
/>
|
||||||
{focused && <CommentActions onSave={handleSave} isLoading={isLoading} />}
|
{focused && <CommentActions onSave={handleSave} isLoading={isLoading} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
style={{
|
||||||
|
flex: "0 0 auto",
|
||||||
|
borderTop: "1px solid var(--mantine-color-default-border)",
|
||||||
|
paddingTop: "var(--mantine-spacing-sm)",
|
||||||
|
paddingBottom: 25,
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CommentEditor
|
||||||
|
ref={commentEditorRef}
|
||||||
|
onUpdate={setContent}
|
||||||
|
onSave={handleSave}
|
||||||
|
editable={true}
|
||||||
|
placeholder={t("Add a comment...")}
|
||||||
|
/>
|
||||||
|
{focused && (
|
||||||
|
<ActionIcon
|
||||||
|
variant="filled"
|
||||||
|
radius="xl"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSave}
|
||||||
|
loading={isLoading}
|
||||||
|
style={{ position: "absolute", right: 8, bottom: 30 }}
|
||||||
|
>
|
||||||
|
<IconArrowUp size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default CommentListWithTabs;
|
export default CommentListWithTabs;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
|
useInfiniteQuery,
|
||||||
useMutation,
|
useMutation,
|
||||||
useQuery,
|
|
||||||
useQueryClient,
|
useQueryClient,
|
||||||
UseQueryResult,
|
InfiniteData,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
createComment,
|
createComment,
|
||||||
@@ -17,17 +17,40 @@ import {
|
|||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { IPagination } from "@/lib/types.ts";
|
import { IPagination } from "@/lib/types.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
|
||||||
export const RQ_KEY = (pageId: string) => ["comments", pageId];
|
export const RQ_KEY = (pageId: string) => ["comments", pageId];
|
||||||
|
|
||||||
export function useCommentsQuery(
|
export function useCommentsQuery(params: ICommentParams) {
|
||||||
params: ICommentParams,
|
const query = useInfiniteQuery({
|
||||||
): UseQueryResult<IPagination<IComment>, Error> {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: RQ_KEY(params.pageId),
|
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,
|
enabled: !!params.pageId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (query.hasNextPage && !query.isFetchingNextPage) {
|
||||||
|
query.fetchNextPage();
|
||||||
|
}
|
||||||
|
}, [query.hasNextPage, query.isFetchingNextPage, query.fetchNextPage]);
|
||||||
|
|
||||||
|
const data = useMemo<IPagination<IComment> | 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() {
|
export function useCreateCommentMutation() {
|
||||||
@@ -36,18 +59,26 @@ export function useCreateCommentMutation() {
|
|||||||
|
|
||||||
return useMutation<IComment, Error, Partial<IComment>>({
|
return useMutation<IComment, Error, Partial<IComment>>({
|
||||||
mutationFn: (data) => createComment(data),
|
mutationFn: (data) => createComment(data),
|
||||||
onSuccess: (data) => {
|
onSuccess: (newComment) => {
|
||||||
//const newComment = data;
|
const cache = queryClient.getQueryData(
|
||||||
// let comments = queryClient.getQueryData(RQ_KEY(data.pageId));
|
RQ_KEY(newComment.pageId),
|
||||||
// if (comments) {
|
) as InfiniteData<IPagination<IComment>> | undefined;
|
||||||
//comments = prevComments => [...prevComments, newComment];
|
|
||||||
//queryClient.setQueryData(RQ_KEY(data.pageId), comments);
|
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") });
|
notifications.show({ message: t("Comment created successfully") });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: () => {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: t("Error creating comment"),
|
message: t("Error creating comment"),
|
||||||
color: "red",
|
color: "red",
|
||||||
@@ -57,14 +88,31 @@ export function useCreateCommentMutation() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useUpdateCommentMutation() {
|
export function useUpdateCommentMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return useMutation<IComment, Error, Partial<IComment>>({
|
return useMutation<IComment, Error, Partial<IComment>>({
|
||||||
mutationFn: (data) => updateComment(data),
|
mutationFn: (data) => updateComment(data),
|
||||||
onSuccess: (data) => {
|
onSuccess: (updatedComment) => {
|
||||||
|
const cache = queryClient.getQueryData(
|
||||||
|
RQ_KEY(updatedComment.pageId),
|
||||||
|
) as InfiniteData<IPagination<IComment>> | 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") });
|
notifications.show({ message: t("Comment updated successfully") });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: () => {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: t("Failed to update comment"),
|
message: t("Failed to update comment"),
|
||||||
color: "red",
|
color: "red",
|
||||||
@@ -79,25 +127,24 @@ export function useDeleteCommentMutation(pageId?: string) {
|
|||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (commentId: string) => deleteComment(commentId),
|
mutationFn: (commentId: string) => deleteComment(commentId),
|
||||||
onSuccess: (data, variables) => {
|
onSuccess: (_data, commentId) => {
|
||||||
const comments = queryClient.getQueryData(
|
const cache = queryClient.getQueryData(
|
||||||
RQ_KEY(pageId),
|
RQ_KEY(pageId),
|
||||||
) as IPagination<IComment>;
|
) as InfiniteData<IPagination<IComment>> | undefined;
|
||||||
|
|
||||||
if (comments && comments.items) {
|
if (cache) {
|
||||||
const commentId = variables;
|
|
||||||
const newComments = comments.items.filter(
|
|
||||||
(comment) => comment.id !== commentId,
|
|
||||||
);
|
|
||||||
queryClient.setQueryData(RQ_KEY(pageId), {
|
queryClient.setQueryData(RQ_KEY(pageId), {
|
||||||
...comments,
|
...cache,
|
||||||
items: newComments,
|
pages: cache.pages.map((page) => ({
|
||||||
|
...page,
|
||||||
|
items: page.items.filter((comment) => comment.id !== commentId),
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
notifications.show({ message: t("Comment deleted successfully") });
|
notifications.show({ message: t("Comment deleted successfully") });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: () => {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: t("Failed to delete comment"),
|
message: t("Failed to delete comment"),
|
||||||
color: "red",
|
color: "red",
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
return (
|
return (
|
||||||
<BubbleMenu
|
<BubbleMenu
|
||||||
{...bubbleMenuProps}
|
{...bubbleMenuProps}
|
||||||
style={{ zIndex: 200, position: "relative" }}
|
style={{ zIndex: 199, position: "relative" }}
|
||||||
>
|
>
|
||||||
<div className={classes.bubbleMenu}>
|
<div className={classes.bubbleMenu}>
|
||||||
{isGenerativeAiEnabled && (
|
{isGenerativeAiEnabled && (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Dispatch, FC, SetStateAction, useCallback } from "react";
|
|||||||
import { IconLink } from "@tabler/icons-react";
|
import { IconLink } from "@tabler/icons-react";
|
||||||
import { ActionIcon, Popover, Tooltip } from "@mantine/core";
|
import { ActionIcon, Popover, Tooltip } from "@mantine/core";
|
||||||
import { useEditor } from "@tiptap/react";
|
import { useEditor } from "@tiptap/react";
|
||||||
|
import { TextSelection } from "@tiptap/pm/state";
|
||||||
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
|
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -20,7 +21,15 @@ export const LinkSelector: FC<LinkSelectorProps> = ({
|
|||||||
const onLink = useCallback(
|
const onLink = useCallback(
|
||||||
(url: string) => {
|
(url: string) => {
|
||||||
setIsOpen(false);
|
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],
|
[editor, setIsOpen],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
import React, { useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
import { Node as PMNode } from "prosemirror-model";
|
import { Node as PMNode } from "@tiptap/pm/model";
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
@@ -132,7 +132,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<div className={classes.toolbar}>
|
<div className={classes.toolbar}>
|
||||||
<Tooltip position="top" label={t("Info")}>
|
<Tooltip position="top" label={t("Info")} withinPortal={false}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={() => setCalloutType("info")}
|
onClick={() => setCalloutType("info")}
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -147,7 +147,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Note")}>
|
<Tooltip position="top" label={t("Note")} withinPortal={false}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={() => setCalloutType("note")}
|
onClick={() => setCalloutType("note")}
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -159,7 +159,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Success")}>
|
<Tooltip position="top" label={t("Success")} withinPortal={false}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={() => setCalloutType("success")}
|
onClick={() => setCalloutType("success")}
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -174,7 +174,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Warning")}>
|
<Tooltip position="top" label={t("Warning")} withinPortal={false}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={() => setCalloutType("warning")}
|
onClick={() => setCalloutType("warning")}
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -189,7 +189,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Danger")}>
|
<Tooltip position="top" label={t("Danger")} withinPortal={false}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={() => setCalloutType("danger")}
|
onClick={() => setCalloutType("danger")}
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
import React, { useCallback, useState } from "react";
|
import React, { useCallback, useRef, useState } from "react";
|
||||||
import { Node as PMNode } from "prosemirror-model";
|
import { DOMSerializer, Node as PMNode } from "@tiptap/pm/model";
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
IconLayoutSidebar,
|
IconLayoutSidebar,
|
||||||
IconLayoutSidebarRight,
|
IconLayoutSidebarRight,
|
||||||
IconLayoutAlignCenter,
|
IconLayoutAlignCenter,
|
||||||
|
IconCopy,
|
||||||
|
IconTrash,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { isTextSelected } from "@docmost/editor-ext";
|
import { isTextSelected } from "@docmost/editor-ext";
|
||||||
import type { WidthMode, ColumnsLayout } from "@docmost/editor-ext";
|
import type { WidthMode, ColumnsLayout } from "@docmost/editor-ext";
|
||||||
@@ -54,8 +56,7 @@ const threeColumnPresets: LayoutPreset[] = [
|
|||||||
label: "Left wide",
|
label: "Left wide",
|
||||||
icon: IconLayoutSidebarRight,
|
icon: IconLayoutSidebarRight,
|
||||||
},
|
},
|
||||||
{ layout: "three_right_wide", label: "Right wide", icon: IconLayoutSidebar
|
{ layout: "three_right_wide", label: "Right wide", icon: IconLayoutSidebar },
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function getPresetsForCount(count: number): LayoutPreset[] {
|
function getPresetsForCount(count: number): LayoutPreset[] {
|
||||||
@@ -67,6 +68,8 @@ function getPresetsForCount(count: number): LayoutPreset[] {
|
|||||||
export function ColumnsMenu({ editor }: EditorMenuProps) {
|
export function ColumnsMenu({ editor }: EditorMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isCountOpen, setIsCountOpen] = useState(false);
|
const [isCountOpen, setIsCountOpen] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
const nodesWithMenus = [
|
const nodesWithMenus = [
|
||||||
"callout",
|
"callout",
|
||||||
@@ -187,6 +190,65 @@ export function ColumnsMenu({ editor }: EditorMenuProps) {
|
|||||||
[editor],
|
[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 columnCount = editorState?.columnCount || 2;
|
||||||
const currentLayout = editorState?.layout || "two_equal";
|
const currentLayout = editorState?.layout || "two_equal";
|
||||||
const presets = getPresetsForCount(columnCount);
|
const presets = getPresetsForCount(columnCount);
|
||||||
@@ -259,6 +321,38 @@ export function ColumnsMenu({ editor }: EditorMenuProps) {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
<div className={classes.divider} />
|
||||||
|
|
||||||
|
<Tooltip
|
||||||
|
position="top"
|
||||||
|
label={copied ? t("Copied") : t("Copy")}
|
||||||
|
withinPortal={false}
|
||||||
|
>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={handleCopy}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Copy")}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<IconCheck size={18} color="var(--mantine-color-green-6)" />
|
||||||
|
) : (
|
||||||
|
<IconCopy size={18} />
|
||||||
|
)}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Delete")} withinPortal={false}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={handleDelete}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Delete")}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<IconTrash size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</BaseBubbleMenu>
|
</BaseBubbleMenu>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,20 @@ import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
|
|||||||
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
|
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
|
||||||
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
|
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
|
||||||
import { Editor } from "@tiptap/core";
|
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 = (
|
export const handlePaste = (
|
||||||
editor: Editor,
|
editor: Editor,
|
||||||
@@ -19,7 +33,6 @@ export const handlePaste = (
|
|||||||
const url = clipboardData.trim();
|
const url = clipboardData.trim();
|
||||||
const { from: pos, empty } = editor.state.selection;
|
const { from: pos, empty } = editor.state.selection;
|
||||||
const match = INTERNAL_LINK_REGEX.exec(url);
|
const match = INTERNAL_LINK_REGEX.exec(url);
|
||||||
const currentPageMatch = INTERNAL_LINK_REGEX.exec(window.location.href);
|
|
||||||
|
|
||||||
// pasted link must be from the same workspace/domain and must not be on a selection
|
// pasted link must be from the same workspace/domain and must not be on a selection
|
||||||
if (!empty || match[2] !== window.location.host) {
|
if (!empty || match[2] !== window.location.host) {
|
||||||
@@ -27,12 +40,6 @@ export const handlePaste = (
|
|||||||
return false;
|
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 anchorId = match[6] ? match[6].split("#")[0] : undefined;
|
||||||
const urlWithoutAnchor = anchorId
|
const urlWithoutAnchor = anchorId
|
||||||
? url.substring(0, url.indexOf("#"))
|
? url.substring(0, url.indexOf("#"))
|
||||||
@@ -47,7 +54,10 @@ export const handlePaste = (
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.clipboardData?.files.length) {
|
const htmlData = event.clipboardData?.getData("text/html");
|
||||||
|
const hasHtmlTable = htmlData && /<table[\s>]/i.test(htmlData);
|
||||||
|
|
||||||
|
if (event.clipboardData?.files.length && !hasHtmlTable) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
for (const file of event.clipboardData.files) {
|
for (const file of event.clipboardData.files) {
|
||||||
const pos = editor.state.selection.from;
|
const pos = editor.state.selection.from;
|
||||||
@@ -57,9 +67,151 @@ export const handlePaste = (
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (htmlData && ATTACHMENT_URL_RE.test(htmlData)) {
|
||||||
|
const pasteFrom = editor.state.selection.from;
|
||||||
|
setTimeout(() => {
|
||||||
|
reuploadPastedAttachments(editor, pageId, pasteFrom);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
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<string>();
|
||||||
|
|
||||||
|
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<string, string | null>();
|
||||||
|
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<string, (typeof nodesToReupload)[0]>();
|
||||||
|
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 = (
|
export const handleFileDrop = (
|
||||||
editor: Editor,
|
editor: Editor,
|
||||||
event: DragEvent,
|
event: DragEvent,
|
||||||
|
|||||||
@@ -44,6 +44,10 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.container:global(.ProseMirror-selectednode) .handle {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.resizing .handle {
|
.resizing .handle {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
@@ -63,3 +67,9 @@
|
|||||||
.resizing .handleBar {
|
.resizing .handleBar {
|
||||||
background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
|
background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
.handle {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
.wrapper {
|
.wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
overflow: visible;
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resizing {
|
.resizing {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
cursor: ns-resize;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay {
|
.overlay {
|
||||||
@@ -20,12 +18,118 @@
|
|||||||
background: transparent;
|
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 {
|
.resizeHandleBottom {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: -4px;
|
||||||
left: 0;
|
left: 20px;
|
||||||
right: 0;
|
right: 20px;
|
||||||
height: 24px;
|
height: 12px;
|
||||||
cursor: ns-resize;
|
cursor: ns-resize;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.2s ease;
|
transition: opacity 0.2s ease;
|
||||||
@@ -36,61 +140,53 @@
|
|||||||
touch-action: none;
|
touch-action: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
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 {
|
.resizeBar {
|
||||||
width: 50px;
|
width: 50px;
|
||||||
height: 4px;
|
height: 3px;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
transition: background-color 0.2s ease;
|
transition: background-color 0.15s ease;
|
||||||
|
background-color: light-dark(
|
||||||
@mixin light {
|
var(--mantine-color-blue-4),
|
||||||
background-color: var(--mantine-color-gray-5);
|
var(--mantine-color-blue-5)
|
||||||
}
|
);
|
||||||
|
}
|
||||||
@mixin dark {
|
|
||||||
background-color: var(--mantine-color-gray-6);
|
.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 {
|
.resizing .resizeBar {
|
||||||
@mixin light {
|
background-color: light-dark(
|
||||||
background-color: var(--mantine-color-gray-7);
|
var(--mantine-color-blue-6),
|
||||||
}
|
var(--mantine-color-blue-4)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@mixin dark {
|
@media print {
|
||||||
background-color: var(--mantine-color-gray-4);
|
.cornerHandle,
|
||||||
|
.resizeHandleBottom {
|
||||||
|
display: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,111 +2,163 @@ import React, { ReactNode, useCallback, useEffect, useRef, useState } from "reac
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import classes from "./resizable-wrapper.module.css";
|
import classes from "./resizable-wrapper.module.css";
|
||||||
|
|
||||||
|
type Handle = "tl" | "tr" | "bl" | "br" | "bottom";
|
||||||
|
|
||||||
|
const HANDLE_SIGN: Record<Handle, { x: number; y: number }> = {
|
||||||
|
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<Handle, string> = {
|
||||||
|
br: "nwse-resize",
|
||||||
|
tl: "nwse-resize",
|
||||||
|
bl: "nesw-resize",
|
||||||
|
tr: "nesw-resize",
|
||||||
|
bottom: "ns-resize",
|
||||||
|
};
|
||||||
|
|
||||||
|
const CORNER_CLASSES: Record<string, string> = {
|
||||||
|
tl: classes.cornerHandleTL,
|
||||||
|
tr: classes.cornerHandleTR,
|
||||||
|
bl: classes.cornerHandleBL,
|
||||||
|
br: classes.cornerHandleBR,
|
||||||
|
};
|
||||||
|
|
||||||
interface ResizableWrapperProps {
|
interface ResizableWrapperProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
initialWidth?: number;
|
||||||
initialHeight?: number;
|
initialHeight?: number;
|
||||||
|
minWidth?: number;
|
||||||
|
maxWidth?: number;
|
||||||
minHeight?: number;
|
minHeight?: number;
|
||||||
maxHeight?: number;
|
maxHeight?: number;
|
||||||
onResize?: (height: number) => void;
|
onResize?: (width: number, height: number) => void;
|
||||||
isEditable?: boolean;
|
isEditable?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
showHandles?: "always" | "hover";
|
selected?: boolean;
|
||||||
direction?: "vertical" | "horizontal" | "both";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DragState = {
|
||||||
|
handle: Handle;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
startWidth: number;
|
||||||
|
startHeight: number;
|
||||||
|
};
|
||||||
|
|
||||||
export const ResizableWrapper: React.FC<ResizableWrapperProps> = ({
|
export const ResizableWrapper: React.FC<ResizableWrapperProps> = ({
|
||||||
children,
|
children,
|
||||||
|
initialWidth = 640,
|
||||||
initialHeight = 480,
|
initialHeight = 480,
|
||||||
|
minWidth = 200,
|
||||||
|
maxWidth = 1200,
|
||||||
minHeight = 200,
|
minHeight = 200,
|
||||||
maxHeight = 1200,
|
maxHeight = 1200,
|
||||||
onResize,
|
onResize,
|
||||||
isEditable = true,
|
isEditable = true,
|
||||||
className,
|
className,
|
||||||
showHandles = "hover",
|
selected = false,
|
||||||
direction = "vertical",
|
|
||||||
}) => {
|
}) => {
|
||||||
const [resizeParams, setResizeParams] = useState<{
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
initialSize: number;
|
|
||||||
initialClientY: number;
|
|
||||||
initialClientX: number;
|
|
||||||
} | null>(null);
|
|
||||||
const [currentHeight, setCurrentHeight] = useState(initialHeight);
|
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const dragRef = useRef<DragState | null>(null);
|
||||||
if (!resizeParams) return;
|
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) => {
|
const handleMouseMove = useRef((e: MouseEvent) => {
|
||||||
if (!wrapperRef.current) return;
|
const drag = dragRef.current;
|
||||||
|
if (!drag || !wrapperRef.current) return;
|
||||||
|
|
||||||
if (direction === "vertical" || direction === "both") {
|
const sign = HANDLE_SIGN[drag.handle];
|
||||||
const deltaY = e.clientY - resizeParams.initialClientY;
|
const { minWidth, maxWidth, minHeight, maxHeight } = constraintsRef.current;
|
||||||
const newHeight = Math.min(
|
|
||||||
Math.max(resizeParams.initialSize + deltaY, minHeight),
|
const deltaY = e.clientY - drag.startY;
|
||||||
maxHeight
|
const newHeight = Math.min(Math.max(drag.startHeight + deltaY * sign.y, minHeight), maxHeight);
|
||||||
);
|
heightRef.current = newHeight;
|
||||||
setCurrentHeight(newHeight);
|
wrapperRef.current.style.height = `${newHeight}px`;
|
||||||
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,
|
||||||
};
|
};
|
||||||
|
setIsResizing(true);
|
||||||
const handleMouseUp = () => {
|
document.body.style.cursor = HANDLE_CURSOR[handle];
|
||||||
setResizeParams(null);
|
document.body.style.userSelect = "none";
|
||||||
if (onResize && currentHeight !== initialHeight) {
|
|
||||||
onResize(currentHeight);
|
|
||||||
}
|
|
||||||
document.body.style.cursor = "";
|
|
||||||
document.body.style.userSelect = "";
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("mousemove", handleMouseMove);
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
document.addEventListener("mouseup", handleMouseUp);
|
document.addEventListener("mouseup", handleMouseUp);
|
||||||
|
}, [handleMouseMove, handleMouseUp]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("mousemove", handleMouseMove);
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
document.removeEventListener("mouseup", handleMouseUp);
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
};
|
};
|
||||||
}, [resizeParams, currentHeight, initialHeight, onResize, minHeight, maxHeight, direction]);
|
}, [handleMouseMove, handleMouseUp]);
|
||||||
|
|
||||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
const shouldShowHandles = isEditable && (isHovered || isResizing || selected);
|
||||||
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)));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={wrapperRef}
|
ref={wrapperRef}
|
||||||
className={clsx(classes.wrapper, className, {
|
className={clsx(classes.wrapper, className, {
|
||||||
[classes.resizing]: !!resizeParams,
|
[classes.resizing]: isResizing,
|
||||||
})}
|
})}
|
||||||
style={{ height: currentHeight }}
|
style={{ width: widthRef.current, height: heightRef.current }}
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
{!!resizeParams && <div className={classes.overlay} />}
|
{isResizing && <div className={classes.overlay} />}
|
||||||
{shouldShowHandles && direction === "vertical" && (
|
{shouldShowHandles && (
|
||||||
<div
|
<>
|
||||||
className={classes.resizeHandleBottom}
|
{(["tl", "tr", "bl", "br"] as const).map((corner) => (
|
||||||
onMouseDown={handleResizeStart}
|
<div
|
||||||
>
|
key={corner}
|
||||||
<div className={classes.resizeBar} />
|
className={clsx(classes.cornerHandle, CORNER_CLASSES[corner])}
|
||||||
</div>
|
onMouseDown={(e) => handleResizeStart(e, corner)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<div
|
||||||
|
className={classes.resizeHandleBottom}
|
||||||
|
onMouseDown={(e) => handleResizeStart(e, "bottom")}
|
||||||
|
>
|
||||||
|
<div className={classes.resizeBar} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { Node as PMNode } from "prosemirror-model";
|
import { Node as PMNode } from "@tiptap/pm/model";
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
} from "@/features/editor/components/table/types/types.ts";
|
} from "@/features/editor/components/table/types/types.ts";
|
||||||
import { ActionIcon, Modal, Tooltip, useComputedColorScheme } from "@mantine/core";
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Modal,
|
||||||
|
Tooltip,
|
||||||
|
useComputedColorScheme,
|
||||||
|
} from "@mantine/core";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {
|
import {
|
||||||
@@ -194,7 +199,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
|||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<div className={classes.toolbar}>
|
<div className={classes.toolbar}>
|
||||||
<Tooltip position="top" label={t("Align left")}>
|
<Tooltip position="top" label={t("Align left")} withinPortal={false}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={alignLeft}
|
onClick={alignLeft}
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -206,7 +211,11 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Align center")}>
|
<Tooltip
|
||||||
|
position="top"
|
||||||
|
label={t("Align center")}
|
||||||
|
withinPortal={false}
|
||||||
|
>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={alignCenter}
|
onClick={alignCenter}
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -232,7 +241,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
|||||||
|
|
||||||
<div className={classes.divider} />
|
<div className={classes.divider} />
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Edit")}>
|
<Tooltip position="top" label={t("Edit")} withinPortal={false}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={handleOpen}
|
onClick={handleOpen}
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -243,7 +252,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Download")}>
|
<Tooltip position="top" label={t("Download")} withinPortal={false}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -254,7 +263,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Delete")}>
|
<Tooltip position="top" label={t("Delete")} withinPortal={false}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
|
:global(.ProseMirror .node-embed.ProseMirror-selectednode) {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embedContainer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.embedWrapper {
|
.embedWrapper {
|
||||||
@mixin light {
|
@mixin light {
|
||||||
background-color: var(--mantine-color-gray-0);
|
background-color: var(--mantine-color-gray-0);
|
||||||
@@ -13,4 +22,4 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconEdit } from "@tabler/icons-react";
|
import { IconEdit } from "@tabler/icons-react";
|
||||||
import { z } from "zod";
|
import { z } from "zod/v4";
|
||||||
import { useForm } from "@mantine/form";
|
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 { notifications } from "@mantine/notifications";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
@@ -27,16 +27,13 @@ import { ResizableWrapper } from "../common/resizable-wrapper";
|
|||||||
import classes from "./embed-view.module.css";
|
import classes from "./embed-view.module.css";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
url: z
|
url: z.url({ message: i18n.t("Please enter a valid url") }).trim(),
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.url({ message: i18n.t("Please enter a valid url") }),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function EmbedView(props: NodeViewProps) {
|
export default function EmbedView(props: NodeViewProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { node, selected, updateAttributes, editor } = props;
|
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(() => {
|
const embedUrl = useMemo(() => {
|
||||||
if (src) {
|
if (src) {
|
||||||
@@ -49,12 +46,12 @@ export default function EmbedView(props: NodeViewProps) {
|
|||||||
initialValues: {
|
initialValues: {
|
||||||
url: "",
|
url: "",
|
||||||
},
|
},
|
||||||
validate: zodResolver(schema),
|
validate: zod4Resolver(schema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleResize = useCallback(
|
const handleResize = useCallback(
|
||||||
(newHeight: number) => {
|
(newWidth: number, newHeight: number) => {
|
||||||
updateAttributes({ height: newHeight });
|
updateAttributes({ width: newWidth, height: newHeight });
|
||||||
},
|
},
|
||||||
[updateAttributes],
|
[updateAttributes],
|
||||||
);
|
);
|
||||||
@@ -85,27 +82,33 @@ export default function EmbedView(props: NodeViewProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper data-drag-handle>
|
<NodeViewWrapper data-drag-handle className={classes.embedNodeView}>
|
||||||
{embedUrl ? (
|
{embedUrl ? (
|
||||||
<ResizableWrapper
|
<div className={classes.embedContainer}>
|
||||||
initialHeight={nodeHeight || 480}
|
<ResizableWrapper
|
||||||
minHeight={200}
|
initialWidth={nodeWidth || 640}
|
||||||
maxHeight={1200}
|
initialHeight={nodeHeight || 480}
|
||||||
onResize={handleResize}
|
minWidth={200}
|
||||||
isEditable={editor.isEditable}
|
maxWidth={1200}
|
||||||
className={clsx(classes.embedWrapper, {
|
minHeight={200}
|
||||||
"ProseMirror-selectednode": selected,
|
maxHeight={1200}
|
||||||
})}
|
onResize={handleResize}
|
||||||
>
|
isEditable={editor.isEditable}
|
||||||
<iframe
|
selected={selected}
|
||||||
className={classes.embedIframe}
|
className={clsx(classes.embedWrapper, {
|
||||||
src={sanitizeUrl(embedUrl)}
|
"ProseMirror-selectednode": selected,
|
||||||
allow="encrypted-media"
|
})}
|
||||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
>
|
||||||
allowFullScreen
|
<iframe
|
||||||
frameBorder="0"
|
className={classes.embedIframe}
|
||||||
/>
|
src={sanitizeUrl(embedUrl)}
|
||||||
</ResizableWrapper>
|
allow="encrypted-media"
|
||||||
|
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||||
|
allowFullScreen
|
||||||
|
frameBorder="0"
|
||||||
|
/>
|
||||||
|
</ResizableWrapper>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Popover
|
<Popover
|
||||||
width={300}
|
width={300}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
import { lazy, Suspense, useCallback, useState } from "react";
|
import { lazy, Suspense, useCallback, useState } from "react";
|
||||||
import { Node as PMNode } from "prosemirror-model";
|
import { Node as PMNode } from "@tiptap/pm/model";
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
@@ -79,8 +79,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
editor.isActive("excalidraw") &&
|
editor.isActive("excalidraw") && editor.getAttributes("excalidraw")?.src
|
||||||
editor.getAttributes("excalidraw")?.src
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[editor],
|
[editor],
|
||||||
@@ -228,7 +227,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
|||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<div className={classes.toolbar}>
|
<div className={classes.toolbar}>
|
||||||
<Tooltip position="top" label={t("Align left")}>
|
<Tooltip position="top" label={t("Align left")} withinPortal={false}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={alignLeft}
|
onClick={alignLeft}
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -242,7 +241,11 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Align center")}>
|
<Tooltip
|
||||||
|
position="top"
|
||||||
|
label={t("Align center")}
|
||||||
|
withinPortal={false}
|
||||||
|
>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={alignCenter}
|
onClick={alignCenter}
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -256,7 +259,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Align right")}>
|
<Tooltip position="top" label={t("Align right")} withinPortal={false}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={alignRight}
|
onClick={alignRight}
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -272,7 +275,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
|||||||
|
|
||||||
<div className={classes.divider} />
|
<div className={classes.divider} />
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Edit")}>
|
<Tooltip position="top" label={t("Edit")} withinPortal={false}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={handleOpen}
|
onClick={handleOpen}
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -283,7 +286,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Download")}>
|
<Tooltip position="top" label={t("Download")} withinPortal={false}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -294,7 +297,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Delete")}>
|
<Tooltip position="top" label={t("Delete")} withinPortal={false}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
import React, { useCallback, useRef } from "react";
|
import React, { useCallback, useRef } from "react";
|
||||||
import { Node as PMNode } from "prosemirror-model";
|
import { Node as PMNode } from "@tiptap/pm/model";
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
@@ -149,7 +149,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<div className={classes.toolbar}>
|
<div className={classes.toolbar}>
|
||||||
<Tooltip position="top" label={t("Align left")}>
|
<Tooltip position="top" label={t("Align left")} withinPortal={false}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={alignImageLeft}
|
onClick={alignImageLeft}
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -161,7 +161,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Align center")}>
|
<Tooltip position="top" label={t("Align center")} withinPortal={false}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={alignImageCenter}
|
onClick={alignImageCenter}
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -173,7 +173,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Align right")}>
|
<Tooltip position="top" label={t("Align right")} withinPortal={false}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={alignImageRight}
|
onClick={alignImageRight}
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -187,7 +187,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
|
|
||||||
<div className={classes.divider} />
|
<div className={classes.divider} />
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Download")}>
|
<Tooltip position="top" label={t("Download")} withinPortal={false}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -198,7 +198,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Replace image")}>
|
<Tooltip position="top" label={t("Replace image")} withinPortal={false}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={handleReplace}
|
onClick={handleReplace}
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -209,7 +209,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Delete")}>
|
<Tooltip position="top" label={t("Delete")} withinPortal={false}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
max-width: 100%;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
animation: pulse 1.2s ease-in-out infinite;
|
animation: pulse 1.2s ease-in-out infinite;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||||
import React, { useCallback, useState } from "react";
|
import React, { useCallback, useState } from "react";
|
||||||
|
import { TextSelection } from "@tiptap/pm/state";
|
||||||
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
|
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
|
||||||
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
|
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
|
||||||
import { LinkPreviewPanel } from "@/features/editor/components/link/link-preview.tsx";
|
import { LinkPreviewPanel } from "@/features/editor/components/link/link-preview.tsx";
|
||||||
@@ -37,6 +38,10 @@ export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
|
|||||||
.focus()
|
.focus()
|
||||||
.extendMarkRange("link")
|
.extendMarkRange("link")
|
||||||
.setLink({ href: url })
|
.setLink({ href: url })
|
||||||
|
.command(({ tr }) => {
|
||||||
|
tr.setSelection(TextSelection.create(tr.doc, tr.selection.to));
|
||||||
|
return true;
|
||||||
|
})
|
||||||
.run();
|
.run();
|
||||||
setShowEdit(false);
|
setShowEdit(false);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -56,8 +56,11 @@ export default function MathBlockView(props: NodeViewProps) {
|
|||||||
}, [debouncedPreview]);
|
}, [debouncedPreview]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsEditing(!!props.selected);
|
const pos = getPos();
|
||||||
if (props.selected) setPreview(node.attrs.text);
|
const { from, to } = editor.state.selection;
|
||||||
|
const nodeSelected = props.selected && from === pos && to === pos + node.nodeSize;
|
||||||
|
setIsEditing(nodeSelected);
|
||||||
|
if (nodeSelected) setPreview(node.attrs.text);
|
||||||
}, [props.selected]);
|
}, [props.selected]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -46,8 +46,11 @@ export default function MathInlineView(props: NodeViewProps) {
|
|||||||
}, [preview, isEditing]);
|
}, [preview, isEditing]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsEditing(!!props.selected);
|
const pos = getPos();
|
||||||
if (props.selected) setPreview(node.attrs.text);
|
const { from, to } = editor.state.selection;
|
||||||
|
const nodeSelected = props.selected && from === pos && to === pos + node.nodeSize;
|
||||||
|
setIsEditing(nodeSelected);
|
||||||
|
if (nodeSelected) setPreview(node.attrs.text);
|
||||||
}, [props.selected]);
|
}, [props.selected]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -31,13 +31,17 @@ import {
|
|||||||
MentionSuggestionItem,
|
MentionSuggestionItem,
|
||||||
} from "@/features/editor/components/mention/mention.type.ts";
|
} from "@/features/editor/components/mention/mention.type.ts";
|
||||||
import { IPage } from "@/features/page/types/page.types";
|
import { IPage } from "@/features/page/types/page.types";
|
||||||
import { useCreatePageMutation, usePageQuery } from "@/features/page/queries/page-query";
|
import {
|
||||||
|
useCreatePageMutation,
|
||||||
|
usePageQuery,
|
||||||
|
} from "@/features/page/queries/page-query";
|
||||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
|
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
|
||||||
import { SimpleTree } from "react-arborist";
|
import { SimpleTree } from "react-arborist";
|
||||||
import { SpaceTreeNode } from "@/features/page/tree/types";
|
import { SpaceTreeNode } from "@/features/page/tree/types";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId } from "@/lib";
|
||||||
|
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
|
||||||
|
|
||||||
const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||||
const [selectedIndex, setSelectedIndex] = useState(1);
|
const [selectedIndex, setSelectedIndex] = useState(1);
|
||||||
@@ -59,11 +63,11 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
includeUsers: true,
|
includeUsers: true,
|
||||||
includePages: true,
|
includePages: true,
|
||||||
spaceId: space.id,
|
spaceId: space.id,
|
||||||
limit: 10,
|
limit: props.query ? 10 : 5,
|
||||||
preload: true,
|
preload: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const createPageItem = (label: string) : MentionSuggestionItem => {
|
const createPageItem = (label: string): MentionSuggestionItem => {
|
||||||
return {
|
return {
|
||||||
id: null,
|
id: null,
|
||||||
label: label,
|
label: label,
|
||||||
@@ -71,15 +75,15 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
entityId: null,
|
entityId: null,
|
||||||
slugId: null,
|
slugId: null,
|
||||||
icon: null,
|
icon: null,
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (suggestion && !isLoading) {
|
if (suggestion && !isLoading) {
|
||||||
let items: MentionSuggestionItem[] = [];
|
let items: MentionSuggestionItem[] = [];
|
||||||
|
|
||||||
if (suggestion?.users?.length > 0) {
|
if (suggestion?.users?.length > 0) {
|
||||||
items.push({ entityType: "header", label: t("Users") });
|
items.push({ entityType: "header", label: t("People") });
|
||||||
|
|
||||||
items = items.concat(
|
items = items.concat(
|
||||||
suggestion.users.map((user) => ({
|
suggestion.users.map((user) => ({
|
||||||
@@ -97,11 +101,13 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
items = items.concat(
|
items = items.concat(
|
||||||
suggestion.pages.map((page) => ({
|
suggestion.pages.map((page) => ({
|
||||||
id: uuid7(),
|
id: uuid7(),
|
||||||
label: page.title || "Untitled",
|
label: page.title || t("Untitled"),
|
||||||
entityType: "page",
|
entityType: "page",
|
||||||
entityId: page.id,
|
entityId: page.id,
|
||||||
slugId: page.slugId,
|
slugId: page.slugId,
|
||||||
icon: page.icon,
|
icon: page.icon,
|
||||||
|
spaceName: page.space?.name,
|
||||||
|
spaceSlug: page.space?.slug,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -129,17 +135,17 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
creatorId: currentUser?.user.id,
|
creatorId: currentUser?.user.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (item.entityType === "page" && item.id!==null) {
|
if (item.entityType === "page" && item.id !== null) {
|
||||||
props.command({
|
props.command({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
label: item.label || "Untitled",
|
label: item.label || t("Untitled"),
|
||||||
entityType: "page",
|
entityType: "page",
|
||||||
entityId: item.entityId,
|
entityId: item.entityId,
|
||||||
slugId: item.slugId,
|
slugId: item.slugId,
|
||||||
creatorId: currentUser?.user.id,
|
creatorId: currentUser?.user.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (item.entityType === "page" && item.id===null) {
|
if (item.entityType === "page" && item.id === null) {
|
||||||
createPage(item.label);
|
createPage(item.label);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -207,7 +213,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
const payload: { spaceId: string; parentPageId?: string; title: string } = {
|
const payload: { spaceId: string; parentPageId?: string; title: string } = {
|
||||||
spaceId: space.id,
|
spaceId: space.id,
|
||||||
parentPageId: page.id || null,
|
parentPageId: page.id || null,
|
||||||
title: title
|
title: title,
|
||||||
};
|
};
|
||||||
|
|
||||||
let createdPage: IPage;
|
let createdPage: IPage;
|
||||||
@@ -231,7 +237,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
|
|
||||||
props.command({
|
props.command({
|
||||||
id: uuid7(),
|
id: uuid7(),
|
||||||
label: createdPage.title || "Untitled",
|
label: createdPage.title || "Untitled",
|
||||||
entityType: "page",
|
entityType: "page",
|
||||||
entityId: createdPage.id,
|
entityId: createdPage.id,
|
||||||
slugId: createdPage.slugId,
|
slugId: createdPage.slugId,
|
||||||
@@ -239,21 +245,20 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
emit({
|
emit({
|
||||||
operation: "addTreeNode",
|
operation: "addTreeNode",
|
||||||
spaceId: space.id,
|
spaceId: space.id,
|
||||||
payload: {
|
payload: {
|
||||||
parentId,
|
parentId,
|
||||||
index: lastIndex,
|
index: lastIndex,
|
||||||
data,
|
data,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, 50);
|
}, 50);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error("Failed to create page");
|
throw new Error("Failed to create page");
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
viewportRef.current
|
viewportRef.current
|
||||||
@@ -267,15 +272,19 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
return (
|
return (
|
||||||
<Paper id="mention" shadow="md" py="xs" withBorder radius="md">
|
<Paper id="mention" shadow="md" py="xs" withBorder radius="md">
|
||||||
<Text c="dimmed" size="sm" px="sm">
|
<Text c="dimmed" size="sm" px="sm">
|
||||||
{ t("No results") }
|
{t("No results")}
|
||||||
</Text>
|
</Text>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasUsers = renderItems.some((item) => item.entityType === "user");
|
const hasUsers = renderItems.some((item) => item.entityType === "user");
|
||||||
const hasPages = renderItems.some((item) => item.entityType === "page" && item.id !== null);
|
const hasPages = renderItems.some(
|
||||||
const createPageItemData = renderItems.find((item) => item.entityType === "page" && item.id === null);
|
(item) => item.entityType === "page" && item.id !== null,
|
||||||
|
);
|
||||||
|
const createPageItemData = renderItems.find(
|
||||||
|
(item) => item.entityType === "page" && item.id === null,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper id="mention" shadow="md" withBorder radius="md" py={6}>
|
<Paper id="mention" shadow="md" withBorder radius="md" py={6}>
|
||||||
@@ -283,7 +292,9 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
viewportRef={viewportRef}
|
viewportRef={viewportRef}
|
||||||
mah={350}
|
mah={350}
|
||||||
w={popupWidth}
|
w={popupWidth}
|
||||||
|
scrollbars={"y"}
|
||||||
scrollbarSize={6}
|
scrollbarSize={6}
|
||||||
|
styles={{ content: { minWidth: 0 } }}
|
||||||
>
|
>
|
||||||
{renderItems?.map((item, index) => {
|
{renderItems?.map((item, index) => {
|
||||||
if (item.entityType === "header") {
|
if (item.entityType === "header") {
|
||||||
@@ -299,6 +310,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
pt={isFirst ? 2 : 4}
|
pt={isFirst ? 2 : 4}
|
||||||
pb={4}
|
pb={4}
|
||||||
tt="uppercase"
|
tt="uppercase"
|
||||||
|
style={{ userSelect: "none" }}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -323,9 +335,9 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<Text size="sm" fw={500}>
|
<AutoTooltipText size="sm" fw={500}>
|
||||||
{item.label}
|
{item.label}
|
||||||
</Text>
|
</AutoTooltipText>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
@@ -355,9 +367,14 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<Text size="sm" fw={500} truncate>
|
<AutoTooltipText size="sm" fw={500} truncate>
|
||||||
{item.label}
|
{item.label}
|
||||||
</Text>
|
</AutoTooltipText>
|
||||||
|
{item.spaceName && (
|
||||||
|
<Text size="xs" c="dimmed" truncate>
|
||||||
|
{item.spaceName}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
@@ -372,9 +389,12 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
{(hasUsers || hasPages) && <Divider my={6} />}
|
{(hasUsers || hasPages) && <Divider my={6} />}
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
data-item-index={renderItems.indexOf(createPageItemData)}
|
data-item-index={renderItems.indexOf(createPageItemData)}
|
||||||
onClick={() => selectItem(renderItems.indexOf(createPageItemData))}
|
onClick={() =>
|
||||||
|
selectItem(renderItems.indexOf(createPageItemData))
|
||||||
|
}
|
||||||
className={clsx(classes.menuBtn, {
|
className={clsx(classes.menuBtn, {
|
||||||
[classes.selectedItem]: renderItems.indexOf(createPageItemData) === selectedIndex,
|
[classes.selectedItem]:
|
||||||
|
renderItems.indexOf(createPageItemData) === selectedIndex,
|
||||||
})}
|
})}
|
||||||
px="sm"
|
px="sm"
|
||||||
>
|
>
|
||||||
@@ -388,7 +408,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
<IconPlus size={16} stroke={1.5} />
|
<IconPlus size={16} stroke={1.5} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0, overflow: "hidden" }}>
|
||||||
<Text size="sm" fw={500} truncate>
|
<Text size="sm" fw={500} truncate>
|
||||||
{t("Create page")}: {createPageItemData.label}
|
{t("Create page")}: {createPageItemData.label}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ const mentionRenderItems = () => {
|
|||||||
left: `${x}px`,
|
left: `${x}px`,
|
||||||
top: `${y}px`,
|
top: `${y}px`,
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
zIndex: "9999",
|
zIndex: "190",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -54,12 +54,20 @@ export default function MentionView(props: NodeViewProps) {
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{entityType === "page" && (
|
{entityType === "page" && isError && (
|
||||||
|
<Text component="span" c="dimmed" size="sm">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{entityType === "page" && !isError && (
|
||||||
<Anchor
|
<Anchor
|
||||||
component={Link}
|
component={Link}
|
||||||
fw={500}
|
fw={500}
|
||||||
to={
|
to={
|
||||||
isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label, anchorId)
|
isShareRoute
|
||||||
|
? shareSlugUrl
|
||||||
|
: buildPageUrl(page?.space?.slug || spaceSlug, slugId, page?.title || label, anchorId)
|
||||||
}
|
}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
underline="never"
|
underline="never"
|
||||||
|
|||||||
@@ -26,4 +26,6 @@ export type MentionSuggestionItem =
|
|||||||
entityId: string;
|
entityId: string;
|
||||||
slugId: string;
|
slugId: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
|
spaceName?: string;
|
||||||
|
spaceSlug?: string;
|
||||||
};
|
};
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
IconSitemap,
|
IconSitemap,
|
||||||
IconColumns3,
|
IconColumns3,
|
||||||
IconColumns2,
|
IconColumns2,
|
||||||
|
IconTag,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import {
|
import {
|
||||||
CommandProps,
|
CommandProps,
|
||||||
@@ -385,6 +386,20 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
.run();
|
.run();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Status",
|
||||||
|
description: "Insert inline status badge.",
|
||||||
|
searchTerms: ["status", "badge", "label", "lozenge"],
|
||||||
|
icon: IconTag,
|
||||||
|
command: ({ editor, range }: CommandProps) => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.setStatus({ text: "", color: "gray" })
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Subpages (Child pages)",
|
title: "Subpages (Child pages)",
|
||||||
description: "List all subpages of the current page",
|
description: "List all subpages of the current page",
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
|
import { Popover, TextInput, Group, Box } from "@mantine/core";
|
||||||
|
import { useDebouncedCallback } from "@mantine/hooks";
|
||||||
|
import { IconCheck } from "@tabler/icons-react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import classes from "./status.module.css";
|
||||||
|
import type { StatusColor } from "@docmost/editor-ext";
|
||||||
|
|
||||||
|
const STATUS_COLORS: { name: StatusColor; bg: string }[] = [
|
||||||
|
{ name: "gray", bg: "var(--mantine-color-gray-4)" },
|
||||||
|
{ name: "blue", bg: "var(--mantine-color-blue-4)" },
|
||||||
|
{ name: "green", bg: "var(--mantine-color-green-4)" },
|
||||||
|
{ name: "yellow", bg: "var(--mantine-color-yellow-4)" },
|
||||||
|
{ name: "red", bg: "var(--mantine-color-red-4)" },
|
||||||
|
{ name: "purple", bg: "var(--mantine-color-violet-4)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const colorClassMap: Record<StatusColor, string> = {
|
||||||
|
gray: classes.colorGray,
|
||||||
|
blue: classes.colorBlue,
|
||||||
|
green: classes.colorGreen,
|
||||||
|
yellow: classes.colorYellow,
|
||||||
|
red: classes.colorRed,
|
||||||
|
purple: classes.colorPurple,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function StatusView(props: NodeViewProps) {
|
||||||
|
const { node, updateAttributes, deleteNode, editor, getPos } = props;
|
||||||
|
const { text, color } = node.attrs as {
|
||||||
|
text: string;
|
||||||
|
color: StatusColor;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [opened, setOpened] = useState(false);
|
||||||
|
const [inputValue, setInputValue] = useState(text);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const storage = editor.storage?.status;
|
||||||
|
if (storage?.autoOpen) {
|
||||||
|
storage.autoOpen = false;
|
||||||
|
setOpened(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (opened) {
|
||||||
|
setInputValue(text);
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 0);
|
||||||
|
}
|
||||||
|
}, [opened]);
|
||||||
|
|
||||||
|
const debouncedUpdateAttributes = useDebouncedCallback(
|
||||||
|
(val: string) => updateAttributes({ text: val }),
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTextChange = (val: string) => {
|
||||||
|
setInputValue(val);
|
||||||
|
debouncedUpdateAttributes(val);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleColorChange = (newColor: StatusColor) => {
|
||||||
|
updateAttributes({ color: newColor });
|
||||||
|
};
|
||||||
|
|
||||||
|
const isEditable = editor.isEditable;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper style={{ display: "inline" }} data-drag-handle>
|
||||||
|
<Popover
|
||||||
|
opened={opened}
|
||||||
|
onChange={(open) => {
|
||||||
|
if (!open && !text) {
|
||||||
|
deleteNode();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOpened(open);
|
||||||
|
}}
|
||||||
|
width={220}
|
||||||
|
position="bottom"
|
||||||
|
withArrow
|
||||||
|
shadow="md"
|
||||||
|
trapFocus
|
||||||
|
>
|
||||||
|
<Popover.Target>
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"status-badge",
|
||||||
|
classes.status,
|
||||||
|
colorClassMap[color],
|
||||||
|
)}
|
||||||
|
onClick={() => isEditable && setOpened(true)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
{text || "SET STATUS"}
|
||||||
|
</span>
|
||||||
|
</Popover.Target>
|
||||||
|
|
||||||
|
<Popover.Dropdown>
|
||||||
|
<TextInput
|
||||||
|
ref={inputRef}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleTextChange(e.currentTarget.value.toUpperCase())
|
||||||
|
}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
setOpened(false);
|
||||||
|
editor.commands.focus(getPos() + node.nodeSize);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Status text"
|
||||||
|
size="sm"
|
||||||
|
mb="xs"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group gap={6} justify="center">
|
||||||
|
{STATUS_COLORS.map(({ name, bg }) => (
|
||||||
|
<Box
|
||||||
|
key={name}
|
||||||
|
className={clsx(
|
||||||
|
classes.swatch,
|
||||||
|
color === name && classes.swatchActive,
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: bg }}
|
||||||
|
onClick={() => handleColorChange(name)}
|
||||||
|
>
|
||||||
|
{color === name && <IconCheck size={14} />}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
.status {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 200px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
line-height: 1.6;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
vertical-align: middle;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colorGray {
|
||||||
|
background-color: light-dark(rgb(223 223 215), rgba(168, 162, 158, 0.4));
|
||||||
|
color: light-dark(#3d3d3d, var(--mantine-color-gray-3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.colorBlue {
|
||||||
|
background-color: light-dark(rgb(191 227 253), rgba(37, 99, 235, 0.4));
|
||||||
|
color: light-dark(#1a4d99, var(--mantine-color-blue-3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.colorGreen {
|
||||||
|
background-color: light-dark(rgb(187 240 173), rgba(0, 138, 0, 0.4));
|
||||||
|
color: light-dark(#135c13, var(--mantine-color-green-3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.colorYellow {
|
||||||
|
background-color: light-dark(rgb(249 238 148), rgba(234, 179, 8, 0.4));
|
||||||
|
color: light-dark(#6b5300, var(--mantine-color-yellow-3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.colorRed {
|
||||||
|
background-color: light-dark(rgb(255 200 195), rgba(224, 0, 0, 0.4));
|
||||||
|
color: light-dark(#a10000, var(--mantine-color-red-3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.colorPurple {
|
||||||
|
background-color: light-dark(rgb(225 207 245), rgba(147, 51, 234, 0.4));
|
||||||
|
color: light-dark(#5b21a6, var(--mantine-color-violet-3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatchActive {
|
||||||
|
border-color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-gray-4));
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { Node as PMNode } from "prosemirror-model";
|
import { Node as PMNode } from "@tiptap/pm/model";
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
@@ -125,7 +125,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<div className={classes.toolbar}>
|
<div className={classes.toolbar}>
|
||||||
<Tooltip position="top" label={t("Align left")}>
|
<Tooltip position="top" label={t("Align left")} withinPortal={false}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={alignLeft}
|
onClick={alignLeft}
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -137,7 +137,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Align center")}>
|
<Tooltip position="top" label={t("Align center")} withinPortal={false}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={alignCenter}
|
onClick={alignCenter}
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -149,7 +149,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Align right")}>
|
<Tooltip position="top" label={t("Align right")} withinPortal={false}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={alignRight}
|
onClick={alignRight}
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -163,7 +163,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
|
|
||||||
<div className={classes.divider} />
|
<div className={classes.divider} />
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Download")}>
|
<Tooltip position="top" label={t("Download")} withinPortal={false}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -174,7 +174,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Delete")}>
|
<Tooltip position="top" label={t("Delete")} withinPortal={false}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
max-width: 100%;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
animation: pulse 1.2s ease-in-out infinite;
|
animation: pulse 1.2s ease-in-out infinite;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user