mirror of
https://github.com/docmost/docmost.git
synced 2025-11-10 07:02:06 +10:00
Compare commits
119 Commits
c6bca6a602
...
sso-group-
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f57f887e5 | |||
| 9fc9a74f26 | |||
| 509622af54 | |||
| de48e36c7d | |||
| 937386e42b | |||
| 60a373f488 | |||
| 73ee6ee8c3 | |||
| 7d1e5bce0d | |||
| aa58e272d6 | |||
| 08135a2fba | |||
| d92a94244f | |||
| 5012a68d85 | |||
| 5a3377790e | |||
| 3b85f4b616 | |||
| cb2a0398c7 | |||
| 95b7be61df | |||
| b0c557272d | |||
| dddfd48934 | |||
| aa6eec754e | |||
| 97a7701f5d | |||
| b97eb85d05 | |||
| 1615e0f4ad | |||
| 1cb2535de3 | |||
| 83bc273cb0 | |||
| c7beaa3742 | |||
| 4a228e5a51 | |||
| edff375476 | |||
| 95016b2bfc | |||
| ca83712364 | |||
| 39550fe906 | |||
| e74ecb2604 | |||
| 992fb23160 | |||
| d58a3bba9b | |||
| 6ef47fc432 | |||
| 9e6765d83c | |||
| ec0ed5c630 | |||
| 77b334ea37 | |||
| 5da92a538a | |||
| f90c5a636b | |||
| 6db93ef0c7 | |||
| a3d058042f | |||
| 4ab9261cf5 | |||
| ca9558b246 | |||
| ec12e80423 | |||
| 28fcb11cb4 | |||
| 6b627d289c | |||
| 78bce0e29d | |||
| 0bd7ecb9b0 | |||
| 1f815880a4 | |||
| 37b9056070 | |||
| ad5cf1e18b | |||
| 32c7ecd9cf | |||
| b30bf61dc4 | |||
| 662460252f | |||
| 8522844673 | |||
| f8dc9845a7 | |||
| 4dfed2b2af | |||
| 44e592763d | |||
| 90488a95b1 | |||
| 9f39987404 | |||
| 16ec218ba7 | |||
| 608783b5cf | |||
| 5f5f1484db | |||
| f4082171ec | |||
| 6792a191b1 | |||
| e51a93221c | |||
| e856c8eb69 | |||
| c2c165528b | |||
| 9fa2b9636c | |||
| 837ab802a0 | |||
| 29388636bf | |||
| f80004817c | |||
| ac79a185de | |||
| 27a9c0ebe4 | |||
| 81ffa6f459 | |||
| 5364702b69 | |||
| 232cea8cc9 | |||
| b9643d3584 | |||
| 9f144d35fb | |||
| e44c170873 | |||
| 1be39d4353 | |||
| 36d028ef4d | |||
| f5a36c60e8 | |||
| d5b84ae0b8 | |||
| e775e4dd8c | |||
| 65b01038d7 | |||
| e07cb57b01 | |||
| 2b53e0a455 | |||
| b9b3406b28 | |||
| 728cac0a34 | |||
| d35e16010b | |||
| 15791d4e59 | |||
| 3318e13225 | |||
| 080900610d | |||
| d1dc6977ab | |||
| 5f62448894 | |||
| 44445fbf46 | |||
| 1c674efddd | |||
| ccf7e34e99 | |||
| f39d48d6ee | |||
| f584ea84b0 | |||
| bc0c4d6258 | |||
| d8da307a61 | |||
| 50b3f9ddd9 | |||
| 0029f84d50 | |||
| 6d024fc3de | |||
| ce1503af85 | |||
| 69447fc375 | |||
| 858ff9da06 | |||
| 343b2976c2 | |||
| 7491224d0f | |||
| 4a0b4040ed | |||
| e3ba817723 | |||
| b0491d5da4 | |||
| 1c200dbd0f | |||
| fb7e4a7956 | |||
| 1413033568 | |||
| 00f4588c21 | |||
| 3a75251e75 |
@ -43,4 +43,7 @@ POSTMARK_TOKEN=
|
||||
# for custom drawio server
|
||||
DRAWIO_URL=
|
||||
|
||||
DISABLE_TELEMETRY=false
|
||||
DISABLE_TELEMETRY=false
|
||||
|
||||
# Enable debug logging in production (default: false)
|
||||
DEBUG_MODE=false
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.20.4",
|
||||
"version": "0.22.2",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
@ -15,44 +15,48 @@
|
||||
"@docmost/editor-ext": "workspace:*",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@excalidraw/excalidraw": "^0.17.6",
|
||||
"@mantine/core": "^7.17.0",
|
||||
"@mantine/form": "^7.17.0",
|
||||
"@mantine/hooks": "^7.17.0",
|
||||
"@mantine/modals": "^7.17.0",
|
||||
"@mantine/notifications": "^7.17.0",
|
||||
"@mantine/spotlight": "^7.17.0",
|
||||
"@tabler/icons-react": "^3.22.0",
|
||||
"@tanstack/react-query": "^5.61.4",
|
||||
"@tiptap/extension-character-count": "^2.11.5",
|
||||
"axios": "^1.8.4",
|
||||
"@excalidraw/excalidraw": "0.18.0-864353b",
|
||||
"@mantine/core": "^8.1.3",
|
||||
"@mantine/form": "^8.1.3",
|
||||
"@mantine/hooks": "^8.1.3",
|
||||
"@mantine/modals": "^8.1.3",
|
||||
"@mantine/notifications": "^8.1.3",
|
||||
"@mantine/spotlight": "^8.1.3",
|
||||
"@tabler/icons-react": "^3.34.0",
|
||||
"@tanstack/react-query": "^5.80.6",
|
||||
"@tiptap/extension-character-count": "^2.10.3",
|
||||
"alfaaz": "^1.1.0",
|
||||
"axios": "^1.9.0",
|
||||
"clsx": "^2.1.1",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"highlightjs-sap-abap": "^0.3.0",
|
||||
"i18next": "^23.14.0",
|
||||
"i18next-http-backend": "^2.6.1",
|
||||
"jotai": "^2.12.1",
|
||||
"jotai": "^2.12.5",
|
||||
"jotai-optics": "^0.4.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"katex": "0.16.21",
|
||||
"lowlight": "^3.2.0",
|
||||
"mermaid": "^11.4.1",
|
||||
"katex": "0.16.22",
|
||||
"lowlight": "^3.3.0",
|
||||
"mantine-form-zod-resolver": "^1.3.0",
|
||||
"mermaid": "^11.6.0",
|
||||
"mitt": "^3.0.1",
|
||||
"posthog-js": "^1.255.1",
|
||||
"react": "^18.3.1",
|
||||
"react-arborist": "3.4.0",
|
||||
"react-clear-modal": "^2.0.11",
|
||||
"react-clear-modal": "^2.0.15",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-drawio": "^1.0.1",
|
||||
"react-error-boundary": "^4.1.2",
|
||||
"react-helmet-async": "^2.0.5",
|
||||
"react-i18next": "^15.0.1",
|
||||
"react-router-dom": "^7.0.1",
|
||||
"semver": "^7.7.1",
|
||||
"semver": "^7.7.2",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-extension-global-drag-handle": "^0.1.18",
|
||||
"zod": "^3.23.8"
|
||||
"zod": "^3.25.56"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.16.0",
|
||||
@ -76,6 +80,6 @@
|
||||
"prettier": "^3.4.1",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript-eslint": "^8.17.0",
|
||||
"vite": "^6.3.2"
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@ -104,7 +104,7 @@
|
||||
"Member": "Mitglied",
|
||||
"members": "Mitglieder",
|
||||
"Members": "Mitglieder",
|
||||
"My preferences": "Meine Vorlieben",
|
||||
"My preferences": "Meine Voreinstellungen",
|
||||
"My Profile": "Mein Profil",
|
||||
"My profile": "Mein Profil",
|
||||
"Name": "Name",
|
||||
@ -213,7 +213,18 @@
|
||||
"Comment deleted successfully": "Kommentar erfolgreich gelöscht",
|
||||
"Failed to delete comment": "Löschen des Kommentars fehlgeschlagen",
|
||||
"Comment resolved successfully": "Kommentar erfolgreich gelöst",
|
||||
"Comment re-opened successfully": "Kommentar erfolgreich wieder geöffnet",
|
||||
"Comment unresolved successfully": "Kommentar erfolgreich ungelöst",
|
||||
"Failed to resolve comment": "Lösen des Kommentars fehlgeschlagen",
|
||||
"Resolve comment": "Kommentar lösen",
|
||||
"Unresolve comment": "Kommentar nicht lösen",
|
||||
"Resolve Comment Thread": "Kommentarthread lösen",
|
||||
"Unresolve Comment Thread": "Kommentarthread nicht lösen",
|
||||
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "Sind Sie sicher, dass Sie diesen Kommentarthread lösen möchten? Dies wird als abgeschlossen markiert.",
|
||||
"Are you sure you want to unresolve this comment thread?": "Sind Sie sicher, dass Sie diesen Kommentarthread nicht lösen möchten?",
|
||||
"Resolved": "Gelöst",
|
||||
"No active comments.": "Keine aktiven Kommentare.",
|
||||
"No resolved comments.": "Keine gelösten Kommentare.",
|
||||
"Revoke invitation": "Einladung widerrufen",
|
||||
"Revoke": "Widerrufen",
|
||||
"Don't": "Nicht",
|
||||
@ -222,7 +233,9 @@
|
||||
"Anyone with this link can join this workspace.": "Jeder mit diesem Link kann dem Arbeitsbereich beitreten.",
|
||||
"Invite link": "Einladungslink",
|
||||
"Copy": "Kopieren",
|
||||
"Copy to space": "In Raum kopieren",
|
||||
"Copied": "Kopiert",
|
||||
"Duplicate": "Duplizieren",
|
||||
"Select a user": "Benutzer auswählen",
|
||||
"Select a group": "Gruppe auswählen",
|
||||
"Export all pages and attachments in this space.": "Alle Seiten und Anhänge in diesem Bereich exportieren.",
|
||||
@ -354,6 +367,9 @@
|
||||
"Character count: {{characterCount}}": "Zeichenzahl: {{characterCount}}",
|
||||
"New update": "Neues Update",
|
||||
"{{latestVersion}} is available": "{{latestVersion}} ist verfügbar",
|
||||
"Default page edit mode": "Standard-Seitenbearbeitungsmodus",
|
||||
"Choose your preferred page edit mode. Avoid accidental edits.": "Wählen Sie Ihren bevorzugten Seitenbearbeitungsmodus. Vermeiden Sie versehentliche Bearbeitungen.",
|
||||
"Reading": "Lesen",
|
||||
"Delete member": "Mitglied löschen",
|
||||
"Member deleted successfully": "Mitglied erfolgreich gelöscht",
|
||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "Sind Sie sicher, dass Sie dieses Arbeitsbereichsmitglied löschen möchten? Diese Aktion ist unwiderruflich.",
|
||||
@ -383,5 +399,101 @@
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "Öffentlich geteilte Seiten aus Bereichen, in denen Sie Mitglied sind, erscheinen hier",
|
||||
"Share deleted successfully": "Freigabe erfolgreich gelöscht",
|
||||
"Share not found": "Freigabe nicht gefunden",
|
||||
"Failed to share page": "Fehler beim Teilen der Seite"
|
||||
"Failed to share page": "Fehler beim Teilen der Seite",
|
||||
"Copy page": "Seite kopieren",
|
||||
"Copy page to a different space.": "Seite in einen anderen Bereich kopieren.",
|
||||
"Page copied successfully": "Seite erfolgreich kopiert",
|
||||
"Page duplicated successfully": "Seite erfolgreich dupliziert",
|
||||
"Find": "Finden",
|
||||
"Not found": "Nicht gefunden",
|
||||
"Previous Match (Shift+Enter)": "Vorheriger Treffer (Shift+Enter)",
|
||||
"Next match (Enter)": "Nächster Treffer (Enter)",
|
||||
"Match case (Alt+C)": "Groß-/Kleinschreibung beachten (Alt+C)",
|
||||
"Replace": "Ersetzen",
|
||||
"Close (Escape)": "Schließen (Escape)",
|
||||
"Replace (Enter)": "Ersetzen (Enter)",
|
||||
"Replace all (Ctrl+Alt+Enter)": "Alle ersetzen (Ctrl+Alt+Enter)",
|
||||
"Replace all": "Alle ersetzen",
|
||||
"View all spaces": "Alle Räume anzeigen",
|
||||
"Error": "Fehler",
|
||||
"Failed to disable MFA": "Deaktivierung der MFA fehlgeschlagen",
|
||||
"Disable two-factor authentication": "Zwei-Faktor-Authentifizierung deaktivieren",
|
||||
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "Die Deaktivierung der Zwei-Faktor-Authentifizierung macht Ihr Konto weniger sicher. Sie benötigen nur Ihr Passwort, um sich anzumelden.",
|
||||
"Please enter your password to disable two-factor authentication:": "Bitte geben Sie Ihr Passwort ein, um die Zwei-Faktor-Authentifizierung zu deaktivieren:",
|
||||
"Two-factor authentication has been enabled": "Zwei-Faktor-Authentifizierung wurde aktiviert",
|
||||
"Two-factor authentication has been disabled": "Zwei-Faktor-Authentifizierung wurde deaktiviert",
|
||||
"2-step verification": "2-Schritt-Verifizierung",
|
||||
"Protect your account with an additional verification layer when signing in.": "Schützen Sie Ihr Konto mit einer zusätzlichen Verifizierungsschicht beim Anmelden.",
|
||||
"Two-factor authentication is active on your account.": "Die Zwei-Faktor-Authentifizierung ist auf Ihrem Konto aktiv.",
|
||||
"Add 2FA method": "2FA-Methode hinzufügen",
|
||||
"Backup codes": "Sicherungscodes",
|
||||
"Disable": "Deaktivieren",
|
||||
"Invalid verification code": "Ungültiger Bestätigungscode",
|
||||
"New backup codes have been generated": "Neue Sicherungscodes wurden generiert",
|
||||
"Failed to regenerate backup codes": "Fehler beim Generieren neuer Sicherungscodes",
|
||||
"About backup codes": "Über Sicherungscodes",
|
||||
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Sicherungscodes können verwendet werden, um auf Ihr Konto zuzugreifen, wenn Sie den Zugang zu Ihrer Authenticator-App verlieren. Jeder Code kann nur einmal verwendet werden.",
|
||||
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "Sie können jederzeit neue Sicherungscodes generieren. Dies wird alle vorhandenen Codes ungültig machen.",
|
||||
"Confirm password": "Passwort bestätigen",
|
||||
"Generate new backup codes": "Neue Sicherungscodes generieren",
|
||||
"Save your new backup codes": "Speichern Sie Ihre neuen Sicherungscodes",
|
||||
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "Speichern Sie diese Codes an einem sicheren Ort. Ihre alten Sicherungscodes sind nicht mehr gültig.",
|
||||
"Your new backup codes": "Ihre neuen Sicherungscodes",
|
||||
"I've saved my backup codes": "Ich habe meine Sicherungscodes gespeichert",
|
||||
"Failed to setup MFA": "Fehler beim Einrichten der MFA",
|
||||
"Setup & Verify": "Einrichten & Überprüfen",
|
||||
"Add to authenticator": "Zum Authenticator hinzufügen",
|
||||
"1. Scan this QR code with your authenticator app": "1. Scannen Sie diesen QR-Code mit Ihrer Authenticator-App",
|
||||
"Can't scan the code?": "Code kann nicht gescannt werden?",
|
||||
"Enter this code manually in your authenticator app:": "Geben Sie diesen Code manuell in Ihrer Authenticator-App ein:",
|
||||
"2. Enter the 6-digit code from your authenticator": "2. Geben Sie den 6-stelligen Code aus Ihrem Authenticator ein",
|
||||
"Verify and enable": "Überprüfen und aktivieren",
|
||||
"Failed to generate QR code. Please try again.": "Fehler beim Generieren des QR-Codes. Bitte versuchen Sie es erneut.",
|
||||
"Backup": "Sicherung",
|
||||
"Save codes": "Codes speichern",
|
||||
"Save your backup codes": "Speichern Sie Ihre Sicherungscodes",
|
||||
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Diese Codes können verwendet werden, um auf Ihr Konto zuzugreifen, wenn Sie den Zugang zu Ihrer Authenticator-App verlieren. Jeder Code kann nur einmal verwendet werden.",
|
||||
"Print": "Drucken",
|
||||
"Two-factor authentication has been set up. Please log in again.": "Zwei-Faktor-Authentifizierung wurde eingerichtet. Bitte melden Sie sich erneut an.",
|
||||
"Two-Factor authentication required": "Zwei-Faktor-Authentifizierung erforderlich",
|
||||
"Your workspace requires two-factor authentication for all users": "Ihr Arbeitsbereich erfordert die Zwei-Faktor-Authentifizierung für alle Benutzer",
|
||||
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "Um weiterhin auf Ihren Arbeitsbereich zuzugreifen, müssen Sie die Zwei-Faktor-Authentifizierung einrichten. Dies fügt Ihrem Konto eine zusätzliche Sicherheitsebene hinzu.",
|
||||
"Set up two-factor authentication": "Zwei-Faktor-Authentifizierung einrichten",
|
||||
"Cancel and logout": "Abbrechen und abmelden",
|
||||
"Your workspace requires two-factor authentication. Please set it up to continue.": "Ihr Arbeitsbereich erfordert eine Zwei-Faktor-Authentifizierung. Bitte richten Sie diese ein, um fortzufahren.",
|
||||
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "Dadurch wird Ihrem Konto eine zusätzliche Sicherheitsebene hinzugefügt, indem ein Bestätigungscode von Ihrer Authenticator-App verlangt wird.",
|
||||
"Password is required": "Passwort erforderlich",
|
||||
"Password must be at least 8 characters": "Passwort muss mindestens 8 Zeichen lang sein",
|
||||
"Please enter a 6-digit code": "Bitte geben Sie einen 6-stelligen Code ein",
|
||||
"Code must be exactly 6 digits": "Code muss genau 6-stellig sein",
|
||||
"Enter the 6-digit code found in your authenticator app": "Geben Sie den 6-stelligen Code ein, der in Ihrer Authenticator-App zu finden ist",
|
||||
"Need help authenticating?": "Brauchen Sie Hilfe bei der Authentifizierung?",
|
||||
"MFA QR Code": "MFA QR-Code",
|
||||
"Account created successfully. Please log in to set up two-factor authentication.": "Konto erfolgreich erstellt. Bitte melden Sie sich an, um die Zwei-Faktor-Authentifizierung einzurichten.",
|
||||
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "Passwort erfolgreich zurückgesetzt. Bitte melden Sie sich mit Ihrem neuen Passwort an und führen Sie die Zwei-Faktor-Authentifizierung durch.",
|
||||
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "Passwort erfolgreich zurückgesetzt. Bitte melden Sie sich mit Ihrem neuen Passwort an, um die Zwei-Faktor-Authentifizierung einzurichten.",
|
||||
"Password reset was successful. Please log in with your new password.": "Passwort erfolgreich zurückgesetzt. Bitte melden Sie sich mit Ihrem neuen Passwort an.",
|
||||
"Two-factor authentication": "Zwei-Faktor-Authentifizierung",
|
||||
"Use authenticator app instead": "Stattdessen Authenticator-App verwenden",
|
||||
"Verify backup code": "Sicherungscode überprüfen",
|
||||
"Use backup code": "Sicherungscode verwenden",
|
||||
"Enter one of your backup codes": "Geben Sie einen Ihrer Sicherungscodes ein",
|
||||
"Backup code": "Sicherungscode",
|
||||
"Enter one of your backup codes. Each backup code can only be used once.": "Geben Sie einen Ihrer Sicherungscodes ein. Jeder Sicherungscode kann nur einmal verwendet werden.",
|
||||
"Verify": "Überprüfen",
|
||||
"Trash": "Papierkorb",
|
||||
"Pages in trash will be permanently deleted after 30 days.": "Seiten im Papierkorb werden nach 30 Tagen endgültig gelöscht.",
|
||||
"Deleted": "Gelöscht",
|
||||
"No pages in trash": "Keine Seiten im Papierkorb",
|
||||
"Permanently delete page?": "Seite endgültig löschen?",
|
||||
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Sind Sie sicher, dass Sie '{{title}}' endgültig löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"Restore '{{title}}' and its sub-pages?": "'{{title}}' und seine Unterseiten wiederherstellen?",
|
||||
"Move to trash": "In den Papierkorb verschieben",
|
||||
"Move this page to trash?": "Diese Seite in den Papierkorb verschieben?",
|
||||
"Restore page": "Seite wiederherstellen",
|
||||
"Page moved to trash": "Seite in den Papierkorb verschoben",
|
||||
"Page restored successfully": "Seite erfolgreich wiederhergestellt",
|
||||
"Deleted by": "Gelöscht von",
|
||||
"Deleted at": "Gelöscht am",
|
||||
"Preview": "Vorschau"
|
||||
}
|
||||
|
||||
@ -213,7 +213,18 @@
|
||||
"Comment deleted successfully": "Comment deleted successfully",
|
||||
"Failed to delete comment": "Failed to delete comment",
|
||||
"Comment resolved successfully": "Comment resolved successfully",
|
||||
"Comment re-opened successfully": "Comment re-opened successfully",
|
||||
"Comment unresolved successfully": "Comment unresolved successfully",
|
||||
"Failed to resolve comment": "Failed to resolve comment",
|
||||
"Resolve comment": "Resolve comment",
|
||||
"Unresolve comment": "Unresolve comment",
|
||||
"Resolve Comment Thread": "Resolve Comment Thread",
|
||||
"Unresolve Comment Thread": "Unresolve Comment Thread",
|
||||
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "Are you sure you want to resolve this comment thread? This will mark it as completed.",
|
||||
"Are you sure you want to unresolve this comment thread?": "Are you sure you want to unresolve this comment thread?",
|
||||
"Resolved": "Resolved",
|
||||
"No active comments.": "No active comments.",
|
||||
"No resolved comments.": "No resolved comments.",
|
||||
"Revoke invitation": "Revoke invitation",
|
||||
"Revoke": "Revoke",
|
||||
"Don't": "Don't",
|
||||
@ -222,7 +233,9 @@
|
||||
"Anyone with this link can join this workspace.": "Anyone with this link can join this workspace.",
|
||||
"Invite link": "Invite link",
|
||||
"Copy": "Copy",
|
||||
"Copy to space": "Copy to space",
|
||||
"Copied": "Copied",
|
||||
"Duplicate": "Duplicate",
|
||||
"Select a user": "Select a user",
|
||||
"Select a group": "Select a group",
|
||||
"Export all pages and attachments in this space.": "Export all pages and attachments in this space.",
|
||||
@ -354,6 +367,9 @@
|
||||
"Character count: {{characterCount}}": "Character count: {{characterCount}}",
|
||||
"New update": "New update",
|
||||
"{{latestVersion}} is available": "{{latestVersion}} is available",
|
||||
"Default page edit mode": "Default page edit mode",
|
||||
"Choose your preferred page edit mode. Avoid accidental edits.": "Choose your preferred page edit mode. Avoid accidental edits.",
|
||||
"Reading": "Reading",
|
||||
"Delete member": "Delete member",
|
||||
"Member deleted successfully": "Member deleted successfully",
|
||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "Are you sure you want to delete this workspace member? This action is irreversible.",
|
||||
@ -384,7 +400,105 @@
|
||||
"Share deleted successfully": "Share deleted successfully",
|
||||
"Share not found": "Share not found",
|
||||
"Failed to share page": "Failed to share page",
|
||||
"Copy page": "Copy page",
|
||||
"Copy page": "Copy page",
|
||||
"Copy page to a different space.": "Copy page to a different space.",
|
||||
"Page copied successfully": "Page copied successfully"
|
||||
"Page copied successfully": "Page copied successfully",
|
||||
"Page duplicated successfully": "Page duplicated successfully",
|
||||
"Find": "Find",
|
||||
"Not found": "Not found",
|
||||
"Previous Match (Shift+Enter)": "Previous Match (Shift+Enter)",
|
||||
"Next match (Enter)": "Next match (Enter)",
|
||||
"Match case (Alt+C)": "Match case (Alt+C)",
|
||||
"Replace": "Replace",
|
||||
"Close (Escape)": "Close (Escape)",
|
||||
"Replace (Enter)": "Replace (Enter)",
|
||||
"Replace all (Ctrl+Alt+Enter)": "Replace all (Ctrl+Alt+Enter)",
|
||||
"Replace all": "Replace all",
|
||||
"View all spaces": "View all spaces",
|
||||
"Error": "Error",
|
||||
"Failed to disable MFA": "Failed to disable MFA",
|
||||
"Disable two-factor authentication": "Disable two-factor authentication",
|
||||
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.",
|
||||
"Please enter your password to disable two-factor authentication:": "Please enter your password to disable two-factor authentication:",
|
||||
"Two-factor authentication has been enabled": "Two-factor authentication has been enabled",
|
||||
"Two-factor authentication has been disabled": "Two-factor authentication has been disabled",
|
||||
"2-step verification": "2-step verification",
|
||||
"Protect your account with an additional verification layer when signing in.": "Protect your account with an additional verification layer when signing in.",
|
||||
"Two-factor authentication is active on your account.": "Two-factor authentication is active on your account.",
|
||||
"Add 2FA method": "Add 2FA method",
|
||||
"Backup codes": "Backup codes",
|
||||
"Disable": "Disable",
|
||||
"Invalid verification code": "Invalid verification code",
|
||||
"New backup codes have been generated": "New backup codes have been generated",
|
||||
"Failed to regenerate backup codes": "Failed to regenerate backup codes",
|
||||
"About backup codes": "About backup codes",
|
||||
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.",
|
||||
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "You can regenerate new backup codes at any time. This will invalidate all existing codes.",
|
||||
"Confirm password": "Confirm password",
|
||||
"Generate new backup codes": "Generate new backup codes",
|
||||
"Save your new backup codes": "Save your new backup codes",
|
||||
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "Make sure to save these codes in a secure place. Your old backup codes are no longer valid.",
|
||||
"Your new backup codes": "Your new backup codes",
|
||||
"I've saved my backup codes": "I've saved my backup codes",
|
||||
"Failed to setup MFA": "Failed to setup MFA",
|
||||
"Setup & Verify": "Setup & Verify",
|
||||
"Add to authenticator": "Add to authenticator",
|
||||
"1. Scan this QR code with your authenticator app": "1. Scan this QR code with your authenticator app",
|
||||
"Can't scan the code?": "Can't scan the code?",
|
||||
"Enter this code manually in your authenticator app:": "Enter this code manually in your authenticator app:",
|
||||
"2. Enter the 6-digit code from your authenticator": "2. Enter the 6-digit code from your authenticator",
|
||||
"Verify and enable": "Verify and enable",
|
||||
"Failed to generate QR code. Please try again.": "Failed to generate QR code. Please try again.",
|
||||
"Backup": "Backup",
|
||||
"Save codes": "Save codes",
|
||||
"Save your backup codes": "Save your backup codes",
|
||||
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.",
|
||||
"Print": "Print",
|
||||
"Two-factor authentication has been set up. Please log in again.": "Two-factor authentication has been set up. Please log in again.",
|
||||
"Two-Factor authentication required": "Two-factor authentication required",
|
||||
"Your workspace requires two-factor authentication for all users": "Your workspace requires two-factor authentication for all users",
|
||||
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.",
|
||||
"Set up two-factor authentication": "Set up two-factor authentication",
|
||||
"Cancel and logout": "Cancel and logout",
|
||||
"Your workspace requires two-factor authentication. Please set it up to continue.": "Your workspace requires two-factor authentication. Please set it up to continue.",
|
||||
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "This adds an extra layer of security to your account by requiring a verification code from your authenticator app.",
|
||||
"Password is required": "Password is required",
|
||||
"Password must be at least 8 characters": "Password must be at least 8 characters",
|
||||
"Please enter a 6-digit code": "Please enter a 6-digit code",
|
||||
"Code must be exactly 6 digits": "Code must be exactly 6 digits",
|
||||
"Enter the 6-digit code found in your authenticator app": "Enter the 6-digit code found in your authenticator app",
|
||||
"Need help authenticating?": "Need help authenticating?",
|
||||
"MFA QR Code": "MFA QR Code",
|
||||
"Account created successfully. Please log in to set up two-factor authentication.": "Account created successfully. Please log in to set up two-factor authentication.",
|
||||
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "Password reset successful. Please log in with your new password and complete two-factor authentication.",
|
||||
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "Password reset successful. Please log in with your new password to set up two-factor authentication.",
|
||||
"Password reset was successful. Please log in with your new password.": "Password reset was successful. Please log in with your new password.",
|
||||
"Two-factor authentication": "Two-factor authentication",
|
||||
"Use authenticator app instead": "Use authenticator app instead",
|
||||
"Verify backup code": "Verify backup code",
|
||||
"Use backup code": "Use backup code",
|
||||
"Enter one of your backup codes": "Enter one of your backup codes",
|
||||
"Backup code": "Backup code",
|
||||
"Enter one of your backup codes. Each backup code can only be used once.": "Enter one of your backup codes. Each backup code can only be used once.",
|
||||
"Verify": "Verify",
|
||||
"Trash": "Trash",
|
||||
"Pages in trash will be permanently deleted after 30 days.": "Pages in trash will be permanently deleted after 30 days.",
|
||||
"Deleted": "Deleted",
|
||||
"No pages in trash": "No pages in trash",
|
||||
"Permanently delete page?": "Permanently delete page?",
|
||||
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.",
|
||||
"Restore '{{title}}' and its sub-pages?": "Restore '{{title}}' and its sub-pages?",
|
||||
"Move to trash": "Move to trash",
|
||||
"Move this page to trash?": "Move this page to trash?",
|
||||
"Restore page": "Restore page",
|
||||
"Page moved to trash": "Page moved to trash",
|
||||
"Page restored successfully": "Page restored successfully",
|
||||
"Deleted by": "Deleted by",
|
||||
"Deleted at": "Deleted at",
|
||||
"Preview": "Preview",
|
||||
"Subpages": "Subpages",
|
||||
"Failed to load subpages": "Failed to load subpages",
|
||||
"No subpages": "No subpages",
|
||||
"Subpages (Child pages)": "Subpages (Child pages)",
|
||||
"List all subpages of the current page": "List all subpages of the current page"
|
||||
}
|
||||
|
||||
@ -213,7 +213,18 @@
|
||||
"Comment deleted successfully": "Comentario eliminado con éxito",
|
||||
"Failed to delete comment": "No se pudo eliminar el comentario",
|
||||
"Comment resolved successfully": "Comentario resuelto con éxito",
|
||||
"Comment re-opened successfully": "Comentario reabierto con éxito",
|
||||
"Comment unresolved successfully": "Comentario no resuelto con éxito",
|
||||
"Failed to resolve comment": "No se pudo resolver el comentario",
|
||||
"Resolve comment": "Resolver comentario",
|
||||
"Unresolve comment": "No resolver comentario",
|
||||
"Resolve Comment Thread": "Resolver hilo de comentarios",
|
||||
"Unresolve Comment Thread": "No resolver hilo de comentarios",
|
||||
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "¿Está seguro de que desea resolver este hilo de comentarios? Esto lo marcará como completado.",
|
||||
"Are you sure you want to unresolve this comment thread?": "¿Está seguro de que desea no resolver este hilo de comentarios?",
|
||||
"Resolved": "Resuelto",
|
||||
"No active comments.": "No hay comentarios activos.",
|
||||
"No resolved comments.": "No hay comentarios resueltos.",
|
||||
"Revoke invitation": "Revocar invitación",
|
||||
"Revoke": "Revocar",
|
||||
"Don't": "No",
|
||||
@ -222,7 +233,9 @@
|
||||
"Anyone with this link can join this workspace.": "Cualquiera con este enlace puede unirse a este espacio de trabajo.",
|
||||
"Invite link": "Enlace de invitación",
|
||||
"Copy": "Copiar",
|
||||
"Copy to space": "Copiar al espacio",
|
||||
"Copied": "Copiado",
|
||||
"Duplicate": "Duplicar",
|
||||
"Select a user": "Seleccionar un usuario",
|
||||
"Select a group": "Seleccionar un grupo",
|
||||
"Export all pages and attachments in this space.": "Exportar todas las páginas y archivos adjuntos en este espacio.",
|
||||
@ -354,6 +367,9 @@
|
||||
"Character count: {{characterCount}}": "Recuento de caracteres: {{characterCount}}",
|
||||
"New update": "Nueva actualización",
|
||||
"{{latestVersion}} is available": "{{latestVersion}} está disponible",
|
||||
"Default page edit mode": "Modo de edición de página predeterminado",
|
||||
"Choose your preferred page edit mode. Avoid accidental edits.": "Elige tu modo de edición de página preferido. Evita ediciones accidentales.",
|
||||
"Reading": "Leyendo",
|
||||
"Delete member": "Eliminar miembro",
|
||||
"Member deleted successfully": "Miembro eliminado con éxito",
|
||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "¿Está seguro que desea eliminar este miembro del área de trabajo? Esta acción es irreversible.",
|
||||
@ -383,5 +399,101 @@
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "Las páginas compartidas públicamente de los espacios a los que pertenece aparecerán aquí",
|
||||
"Share deleted successfully": "Compartición eliminada con éxito",
|
||||
"Share not found": "Compartición no encontrada",
|
||||
"Failed to share page": "Error al compartir la página"
|
||||
"Failed to share page": "Error al compartir la página",
|
||||
"Copy page": "Copiar página",
|
||||
"Copy page to a different space.": "Copiar página en otro espacio",
|
||||
"Page copied successfully": "Página copiada exitosamente",
|
||||
"Page duplicated successfully": "Página duplicada con éxito",
|
||||
"Find": "Buscar",
|
||||
"Not found": "No encontrado",
|
||||
"Previous Match (Shift+Enter)": "Coincidencia anterior (Shift+Enter)",
|
||||
"Next match (Enter)": "Siguiente coincidencia (Enter)",
|
||||
"Match case (Alt+C)": "Distinguir mayúsculas y minúsculas (Alt+C)",
|
||||
"Replace": "Reemplazar",
|
||||
"Close (Escape)": "Cerrar (Escape)",
|
||||
"Replace (Enter)": "Reemplazar (Enter)",
|
||||
"Replace all (Ctrl+Alt+Enter)": "Reemplazar todo (Ctrl+Alt+Enter)",
|
||||
"Replace all": "Reemplazar todo",
|
||||
"View all spaces": "Ver todos los espacios",
|
||||
"Error": "Error",
|
||||
"Failed to disable MFA": "No se pudo desactivar MFA",
|
||||
"Disable two-factor authentication": "Desactivar la autenticación de dos factores",
|
||||
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "Desactivar la autenticación de dos factores hará que tu cuenta sea menos segura. Solo necesitarás tu contraseña para iniciar sesión.",
|
||||
"Please enter your password to disable two-factor authentication:": "Por favor ingresa tu contraseña para desactivar la autenticación de dos factores:",
|
||||
"Two-factor authentication has been enabled": "La autenticación de dos factores ha sido activada",
|
||||
"Two-factor authentication has been disabled": "La autenticación de dos factores ha sido desactivada",
|
||||
"2-step verification": "Verificación en 2 pasos",
|
||||
"Protect your account with an additional verification layer when signing in.": "Protege tu cuenta con una capa adicional de verificación al iniciar sesión.",
|
||||
"Two-factor authentication is active on your account.": "La autenticación de dos factores está activa en tu cuenta.",
|
||||
"Add 2FA method": "Agregar método 2FA",
|
||||
"Backup codes": "Códigos de seguridad",
|
||||
"Disable": "Desactivar",
|
||||
"Invalid verification code": "Código de verificación no válido",
|
||||
"New backup codes have been generated": "Nuevos códigos de seguridad han sido generados",
|
||||
"Failed to regenerate backup codes": "No se pudo regenerar los códigos de seguridad",
|
||||
"About backup codes": "Acerca de los códigos de seguridad",
|
||||
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Los códigos de seguridad pueden usarse para acceder a tu cuenta si pierdes acceso a tu aplicación autenticadora. Cada código solo puede ser usado una vez.",
|
||||
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "Puedes regenerar nuevos códigos de seguridad en cualquier momento. Esto invalidará todos los códigos existentes.",
|
||||
"Confirm password": "Confirmar contraseña",
|
||||
"Generate new backup codes": "Generar nuevos códigos de seguridad",
|
||||
"Save your new backup codes": "Guarda tus nuevos códigos de seguridad",
|
||||
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "Asegúrate de guardar estos códigos en un lugar seguro. Tus viejos códigos de seguridad ya no son válidos.",
|
||||
"Your new backup codes": "Tus nuevos códigos de seguridad",
|
||||
"I've saved my backup codes": "He guardado mis códigos de seguridad",
|
||||
"Failed to setup MFA": "No se pudo configurar MFA",
|
||||
"Setup & Verify": "Configurar y verificar",
|
||||
"Add to authenticator": "Agregar al autenticador",
|
||||
"1. Scan this QR code with your authenticator app": "1. Escanea este código QR con tu aplicación autenticadora",
|
||||
"Can't scan the code?": "¿No puedes escanear el código?",
|
||||
"Enter this code manually in your authenticator app:": "Introduce este código manualmente en tu aplicación autenticadora:",
|
||||
"2. Enter the 6-digit code from your authenticator": "2. Introduce el código de 6 dígitos de tu autenticador",
|
||||
"Verify and enable": "Verificar y activar",
|
||||
"Failed to generate QR code. Please try again.": "No se pudo generar el código QR. Por favor, intente de nuevo.",
|
||||
"Backup": "Respaldo",
|
||||
"Save codes": "Guardar códigos",
|
||||
"Save your backup codes": "Guarda tus códigos de seguridad",
|
||||
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Estos códigos pueden usarse para acceder a tu cuenta si pierdes acceso a tu aplicación autenticadora. Cada código solo puede ser usado una vez.",
|
||||
"Print": "Imprimir",
|
||||
"Two-factor authentication has been set up. Please log in again.": "La autenticación de dos factores ha sido configurada. Por favor, inicie sesión nuevamente.",
|
||||
"Two-Factor authentication required": "Se requiere autenticación de dos factores",
|
||||
"Your workspace requires two-factor authentication for all users": "Tu espacio de trabajo requiere autenticación de dos factores para todos los usuarios",
|
||||
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "Para continuar accediendo a tu espacio de trabajo, debes configurar la autenticación de dos factores. Esto añade una capa extra de seguridad a tu cuenta.",
|
||||
"Set up two-factor authentication": "Configurar la autenticación de dos factores",
|
||||
"Cancel and logout": "Cancelar y cerrar sesión",
|
||||
"Your workspace requires two-factor authentication. Please set it up to continue.": "Tu espacio de trabajo requiere autenticación de dos factores. Por favor, configúralo para continuar.",
|
||||
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "Esto añade una capa extra de seguridad a tu cuenta al requerir un código de verificación de tu aplicación autenticadora.",
|
||||
"Password is required": "Se requiere contraseña",
|
||||
"Password must be at least 8 characters": "La contraseña debe tener al menos 8 caracteres",
|
||||
"Please enter a 6-digit code": "Por favor, introduce un código de 6 dígitos",
|
||||
"Code must be exactly 6 digits": "El código debe ser exactamente de 6 dígitos",
|
||||
"Enter the 6-digit code found in your authenticator app": "Introduce el código de 6 dígitos que se encuentra en tu aplicación autenticadora",
|
||||
"Need help authenticating?": "¿Necesitas ayuda para autenticar?",
|
||||
"MFA QR Code": "Código QR MFA",
|
||||
"Account created successfully. Please log in to set up two-factor authentication.": "Cuenta creada exitosamente. Por favor, inicie sesión para configurar la autenticación de dos factores.",
|
||||
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "Restablecimiento de contraseña exitoso. Por favor, inicie sesión con su nueva contraseña y complete la autenticación de dos factores.",
|
||||
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "Restablecimiento de contraseña exitoso. Por favor, inicie sesión con su nueva contraseña para configurar la autenticación de dos factores.",
|
||||
"Password reset was successful. Please log in with your new password.": "El restablecimiento de contraseña fue exitoso. Por favor, inicie sesión con su nueva contraseña.",
|
||||
"Two-factor authentication": "Autenticación de dos factores",
|
||||
"Use authenticator app instead": "Usar la aplicación autenticadora en su lugar",
|
||||
"Verify backup code": "Verificar código de seguridad",
|
||||
"Use backup code": "Usar código de seguridad",
|
||||
"Enter one of your backup codes": "Introduce uno de tus códigos de seguridad",
|
||||
"Backup code": "Código de seguridad",
|
||||
"Enter one of your backup codes. Each backup code can only be used once.": "Introduce uno de tus códigos de seguridad. Cada código de seguridad solo puede ser usado una vez.",
|
||||
"Verify": "Verificar",
|
||||
"Trash": "Papelera",
|
||||
"Pages in trash will be permanently deleted after 30 days.": "Las páginas en la papelera serán eliminadas permanentemente después de 30 días.",
|
||||
"Deleted": "Eliminado",
|
||||
"No pages in trash": "No hay páginas en la papelera",
|
||||
"Permanently delete page?": "¿Eliminar página permanentemente?",
|
||||
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "¿Está seguro de que desea eliminar '{{title}}' permanentemente? Esta acción no se puede deshacer.",
|
||||
"Restore '{{title}}' and its sub-pages?": "¿Restaurar '{{title}}' y sus subpáginas?",
|
||||
"Move to trash": "Mover a la papelera",
|
||||
"Move this page to trash?": "¿Mover esta página a la papelera?",
|
||||
"Restore page": "Restaurar página",
|
||||
"Page moved to trash": "Página movida a la papelera",
|
||||
"Page restored successfully": "Página restaurada con éxito",
|
||||
"Deleted by": "Eliminado por",
|
||||
"Deleted at": "Eliminado en",
|
||||
"Preview": "Vista previa"
|
||||
}
|
||||
|
||||
@ -213,7 +213,18 @@
|
||||
"Comment deleted successfully": "Commentaire supprimé avec succès",
|
||||
"Failed to delete comment": "Échec de la suppression du commentaire",
|
||||
"Comment resolved successfully": "Commentaire résolu avec succès",
|
||||
"Comment re-opened successfully": "Commentaire rouvert avec succès",
|
||||
"Comment unresolved successfully": "Commentaire non résolu avec succès",
|
||||
"Failed to resolve comment": "Échec de la résolution du commentaire",
|
||||
"Resolve comment": "Résoudre le commentaire",
|
||||
"Unresolve comment": "Désorganiser le commentaire",
|
||||
"Resolve Comment Thread": "Résoudre le fil de commentaires",
|
||||
"Unresolve Comment Thread": "Désorganiser le fil de commentaires",
|
||||
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "Êtes-vous sûr de vouloir résoudre ce fil de commentaires ? Cela le marquera comme terminé.",
|
||||
"Are you sure you want to unresolve this comment thread?": "Êtes-vous sûr de vouloir désorganiser ce fil de commentaires ?",
|
||||
"Resolved": "Résolu",
|
||||
"No active comments.": "Aucun commentaire actif.",
|
||||
"No resolved comments.": "Aucun commentaire résolu.",
|
||||
"Revoke invitation": "Révoquer l'invitation",
|
||||
"Revoke": "Révoquer",
|
||||
"Don't": "Ne pas",
|
||||
@ -222,7 +233,9 @@
|
||||
"Anyone with this link can join this workspace.": "Toute personne ayant ce lien peut rejoindre cet espace de travail.",
|
||||
"Invite link": "Lien d'invitation",
|
||||
"Copy": "Copier",
|
||||
"Copy to space": "Copier dans l'espace",
|
||||
"Copied": "Copié",
|
||||
"Duplicate": "Dupliquer",
|
||||
"Select a user": "Sélectionner un utilisateur",
|
||||
"Select a group": "Sélectionner un groupe",
|
||||
"Export all pages and attachments in this space.": "Exporter toutes les pages et pièces jointes dans cet espace.",
|
||||
@ -354,6 +367,9 @@
|
||||
"Character count: {{characterCount}}": "Nombre de caractères : {{characterCount}}",
|
||||
"New update": "Nouvelle mise à jour",
|
||||
"{{latestVersion}} is available": "{{latestVersion}} est disponible",
|
||||
"Default page edit mode": "Mode d'édition de page par défaut",
|
||||
"Choose your preferred page edit mode. Avoid accidental edits.": "Choisissez votre mode d'édition de page préféré. Évitez les modifications accidentelles.",
|
||||
"Reading": "Lecture",
|
||||
"Delete member": "Supprimer le membre",
|
||||
"Member deleted successfully": "Membre supprimé avec succès",
|
||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "Êtes-vous sûr de vouloir supprimer ce membre de l'espace de travail? Cette action est irréversible.",
|
||||
@ -383,5 +399,101 @@
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "Les pages partagées publiquement des espaces dont vous êtes membre apparaîtront ici",
|
||||
"Share deleted successfully": "Partage supprimé avec succès",
|
||||
"Share not found": "Partage non trouvé",
|
||||
"Failed to share page": "Échec du partage de la page"
|
||||
"Failed to share page": "Échec du partage de la page",
|
||||
"Copy page": "Copier la page",
|
||||
"Copy page to a different space.": "Copier la page dans un autre espace.",
|
||||
"Page copied successfully": "Page copiée avec succès",
|
||||
"Page duplicated successfully": "Page dupliquée avec succès",
|
||||
"Find": "Trouver",
|
||||
"Not found": "Non trouvé",
|
||||
"Previous Match (Shift+Enter)": "Correspondance précédente (Shift+Entrée)",
|
||||
"Next match (Enter)": "Correspondance suivante (Entrée)",
|
||||
"Match case (Alt+C)": "Respecter la casse (Alt+C)",
|
||||
"Replace": "Remplacer",
|
||||
"Close (Escape)": "Fermer (Échapper)",
|
||||
"Replace (Enter)": "Remplacer (Entrée)",
|
||||
"Replace all (Ctrl+Alt+Enter)": "Tout remplacer (Ctrl+Alt+Entrée)",
|
||||
"Replace all": "Tout remplacer",
|
||||
"View all spaces": "Voir tous les espaces",
|
||||
"Error": "Erreur",
|
||||
"Failed to disable MFA": "Impossible de désactiver l'A2F",
|
||||
"Disable two-factor authentication": "Désactiver l'authentification à deux facteurs",
|
||||
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "La désactivation de l'authentification à deux facteurs rendra votre compte moins sécurisé. Vous n'aurez besoin que de votre mot de passe pour vous connecter.",
|
||||
"Please enter your password to disable two-factor authentication:": "Veuillez entrer votre mot de passe pour désactiver l'authentification à deux facteurs :",
|
||||
"Two-factor authentication has been enabled": "L'authentification à deux facteurs a été activée",
|
||||
"Two-factor authentication has been disabled": "L'authentification à deux facteurs a été désactivée",
|
||||
"2-step verification": "Vérification en 2 étapes",
|
||||
"Protect your account with an additional verification layer when signing in.": "Protégez votre compte avec une couche de vérification supplémentaire lors de la connexion.",
|
||||
"Two-factor authentication is active on your account.": "L'authentification à deux facteurs est active sur votre compte.",
|
||||
"Add 2FA method": "Ajouter une méthode A2F",
|
||||
"Backup codes": "Codes de sauvegarde",
|
||||
"Disable": "Désactiver",
|
||||
"Invalid verification code": "Code de vérification invalide",
|
||||
"New backup codes have been generated": "De nouveaux codes de sauvegarde ont été générés",
|
||||
"Failed to regenerate backup codes": "Échec de la régénération des codes de sauvegarde",
|
||||
"About backup codes": "À propos des codes de sauvegarde",
|
||||
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Les codes de sauvegarde peuvent être utilisés pour accéder à votre compte si vous perdez l'accès à votre application d'authentification. Chaque code ne peut être utilisé qu'une seule fois.",
|
||||
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "Vous pouvez régénérer de nouveaux codes de sauvegarde à tout moment. Cela invalidera tous les codes existants.",
|
||||
"Confirm password": "Confirmer le mot de passe",
|
||||
"Generate new backup codes": "Générer de nouveaux codes de sauvegarde",
|
||||
"Save your new backup codes": "Enregistrez vos nouveaux codes de sauvegarde",
|
||||
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "Assurez-vous d'enregistrer ces codes dans un endroit sécurisé. Vos anciens codes de sauvegarde ne sont plus valides.",
|
||||
"Your new backup codes": "Vos nouveaux codes de sauvegarde",
|
||||
"I've saved my backup codes": "J'ai enregistré mes codes de sauvegarde",
|
||||
"Failed to setup MFA": "Échec de la configuration de l'A2F",
|
||||
"Setup & Verify": "Configurer et vérifier",
|
||||
"Add to authenticator": "Ajouter à l'authentification",
|
||||
"1. Scan this QR code with your authenticator app": "1. Scannez ce code QR avec votre application d'authentification",
|
||||
"Can't scan the code?": "Impossible de scanner le code ?",
|
||||
"Enter this code manually in your authenticator app:": "Entrez ce code manuellement dans votre application d'authentification :",
|
||||
"2. Enter the 6-digit code from your authenticator": "2. Entrez le code à 6 chiffres de votre authentificateur",
|
||||
"Verify and enable": "Vérifier et activer",
|
||||
"Failed to generate QR code. Please try again.": "Échec de la génération du code QR. Veuillez réessayer.",
|
||||
"Backup": "Sauvegarde",
|
||||
"Save codes": "Enregistrer les codes",
|
||||
"Save your backup codes": "Enregistrez vos codes de sauvegarde",
|
||||
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Ces codes peuvent être utilisés pour accéder à votre compte si vous perdez l'accès à votre application d'authentification. Chaque code ne peut être utilisé qu'une seule fois.",
|
||||
"Print": "Imprimer",
|
||||
"Two-factor authentication has been set up. Please log in again.": "L'authentification à deux facteurs a été configurée. Veuillez vous reconnecter.",
|
||||
"Two-Factor authentication required": "Authentification à deux facteurs requise",
|
||||
"Your workspace requires two-factor authentication for all users": "Votre espace de travail nécessite l'authentification à deux facteurs pour tous les utilisateurs",
|
||||
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "Pour continuer à accéder à votre espace de travail, vous devez configurer l'authentification à deux facteurs. Cela ajoute une couche de sécurité supplémentaire à votre compte.",
|
||||
"Set up two-factor authentication": "Configurer l'authentification à deux facteurs",
|
||||
"Cancel and logout": "Annuler et se déconnecter",
|
||||
"Your workspace requires two-factor authentication. Please set it up to continue.": "Votre espace de travail nécessite l'authentification à deux facteurs. Veuillez le configurer pour continuer.",
|
||||
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "Cela ajoute une couche de sécurité supplémentaire à votre compte en exigeant un code de vérification provenant de votre application d'authentification.",
|
||||
"Password is required": "Mot de passe requis",
|
||||
"Password must be at least 8 characters": "Le mot de passe doit comporter au moins 8 caractères",
|
||||
"Please enter a 6-digit code": "Veuillez entrer un code à 6 chiffres",
|
||||
"Code must be exactly 6 digits": "Le code doit être exactement de 6 chiffres",
|
||||
"Enter the 6-digit code found in your authenticator app": "Entrez le code à 6 chiffres trouvé dans votre application d'authentification",
|
||||
"Need help authenticating?": "Besoin d'aide pour l'authentification ?",
|
||||
"MFA QR Code": "Code QR de l'A2F",
|
||||
"Account created successfully. Please log in to set up two-factor authentication.": "Compte créé avec succès. Veuillez vous connecter pour configurer l'authentification à deux facteurs.",
|
||||
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "Réinitialisation du mot de passe réussie. Veuillez vous connecter avec votre nouveau mot de passe et compléter l'authentification à deux facteurs.",
|
||||
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "Réinitialisation du mot de passe réussie. Veuillez vous connecter avec votre nouveau mot de passe pour configurer l'authentification à deux facteurs.",
|
||||
"Password reset was successful. Please log in with your new password.": "La réinitialisation du mot de passe a réussi. Veuillez vous connecter avec votre nouveau mot de passe.",
|
||||
"Two-factor authentication": "Authentification à deux facteurs",
|
||||
"Use authenticator app instead": "Utilisez l'application d'authentification à la place",
|
||||
"Verify backup code": "Vérifier le code de sauvegarde",
|
||||
"Use backup code": "Utiliser le code de sauvegarde",
|
||||
"Enter one of your backup codes": "Entrez un de vos codes de sauvegarde",
|
||||
"Backup code": "Code de sauvegarde",
|
||||
"Enter one of your backup codes. Each backup code can only be used once.": "Entrez un de vos codes de sauvegarde. Chaque code de sauvegarde ne peut être utilisé qu'une seule fois.",
|
||||
"Verify": "Vérifier",
|
||||
"Trash": "Corbeille",
|
||||
"Pages in trash will be permanently deleted after 30 days.": "Les pages dans la corbeille seront définitivement supprimées après 30 jours.",
|
||||
"Deleted": "Supprimé",
|
||||
"No pages in trash": "Aucune page dans la corbeille",
|
||||
"Permanently delete page?": "Supprimer définitivement la page ?",
|
||||
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Êtes-vous sûr de vouloir supprimer définitivement « {{title}} » ? Cette action ne peut pas être annulée.",
|
||||
"Restore '{{title}}' and its sub-pages?": "Restaurer « {{title}} » et ses sous-pages ?",
|
||||
"Move to trash": "Déplacer vers la corbeille",
|
||||
"Move this page to trash?": "Déplacer cette page vers la corbeille ?",
|
||||
"Restore page": "Restaurer la page",
|
||||
"Page moved to trash": "Page déplacée vers la corbeille",
|
||||
"Page restored successfully": "Page restaurée avec succès",
|
||||
"Deleted by": "Supprimé par",
|
||||
"Deleted at": "Supprimé à",
|
||||
"Preview": "Aperçu"
|
||||
}
|
||||
|
||||
@ -213,7 +213,18 @@
|
||||
"Comment deleted successfully": "Commento eliminato con successo",
|
||||
"Failed to delete comment": "Impossibile eliminare il commento",
|
||||
"Comment resolved successfully": "Commento risolto con successo",
|
||||
"Comment re-opened successfully": "Commento riaperto con successo",
|
||||
"Comment unresolved successfully": "Commento non risolto con successo",
|
||||
"Failed to resolve comment": "Impossibile risolvere il commento",
|
||||
"Resolve comment": "Risolvi commento",
|
||||
"Unresolve comment": "Annulla risoluzione commento",
|
||||
"Resolve Comment Thread": "Risolvi discussione commenti",
|
||||
"Unresolve Comment Thread": "Annulla risoluzione discussione commenti",
|
||||
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "Sei sicuro di voler risolvere questa discussione di commenti? Questo la contrassegnerà come completata.",
|
||||
"Are you sure you want to unresolve this comment thread?": "Sei sicuro di voler annullare la risoluzione di questa discussione di commenti?",
|
||||
"Resolved": "Risolto",
|
||||
"No active comments.": "Nessun commento attivo.",
|
||||
"No resolved comments.": "Nessun commento risolto.",
|
||||
"Revoke invitation": "Revoca invito",
|
||||
"Revoke": "Revoca",
|
||||
"Don't": "Non",
|
||||
@ -222,7 +233,9 @@
|
||||
"Anyone with this link can join this workspace.": "Chiunque con questo link può unirsi a questa area di lavoro.",
|
||||
"Invite link": "Link d'invito",
|
||||
"Copy": "Copia",
|
||||
"Copy to space": "Copia nello spazio",
|
||||
"Copied": "Copiato",
|
||||
"Duplicate": "Duplica",
|
||||
"Select a user": "Seleziona un utente",
|
||||
"Select a group": "Seleziona un gruppo",
|
||||
"Export all pages and attachments in this space.": "Esporta tutte le pagine e gli allegati di questo spazio.",
|
||||
@ -354,6 +367,9 @@
|
||||
"Character count: {{characterCount}}": "Conteggio caratteri: {{characterCount}}",
|
||||
"New update": "Nuovo aggiornamento",
|
||||
"{{latestVersion}} is available": "{{latestVersion}} è disponibile",
|
||||
"Default page edit mode": "Modalità di modifica pagina predefinita",
|
||||
"Choose your preferred page edit mode. Avoid accidental edits.": "Scegli la tua modalità di modifica della pagina preferita. Evita modifiche accidentali.",
|
||||
"Reading": "Lettura",
|
||||
"Delete member": "Elimina membro",
|
||||
"Member deleted successfully": "Membro eliminato con successo",
|
||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "Sei sicuro di voler eliminare questo membro del workspace? Questa azione è irreversibile.",
|
||||
@ -383,5 +399,101 @@
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "Le pagine condivise pubblicamente dagli spazi di cui sei membro appariranno qui",
|
||||
"Share deleted successfully": "Condivisione eliminata con successo",
|
||||
"Share not found": "Condivisione non trovata",
|
||||
"Failed to share page": "Condivisione della pagina fallita"
|
||||
"Failed to share page": "Condivisione della pagina fallita",
|
||||
"Copy page": "Copia pagina",
|
||||
"Copy page to a different space.": "Copia pagina in un altro spazio.",
|
||||
"Page copied successfully": "Pagina copiata con successo",
|
||||
"Page duplicated successfully": "Pagina duplicata con successo",
|
||||
"Find": "Trova",
|
||||
"Not found": "Non trovato",
|
||||
"Previous Match (Shift+Enter)": "Corrispondenza precedente (Shift+Invio)",
|
||||
"Next match (Enter)": "Corrispondenza successiva (Invio)",
|
||||
"Match case (Alt+C)": "Maiuscole/minuscole (Alt+C)",
|
||||
"Replace": "Sostituisci",
|
||||
"Close (Escape)": "Chiudi (Esc)",
|
||||
"Replace (Enter)": "Sostituisci (Invio)",
|
||||
"Replace all (Ctrl+Alt+Enter)": "Sostituisci tutto (Ctrl+Alt+Invio)",
|
||||
"Replace all": "Sostituisci tutto",
|
||||
"View all spaces": "Visualizza tutti gli spazi",
|
||||
"Error": "Errore",
|
||||
"Failed to disable MFA": "Disabilitazione MFA non riuscita",
|
||||
"Disable two-factor authentication": "Disabilita autenticazione a due fattori",
|
||||
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "Disabilitare l'autenticazione a due fattori renderà il tuo account meno sicuro. Avrai bisogno solo della tua password per accedere.",
|
||||
"Please enter your password to disable two-factor authentication:": "Inserisci la tua password per disabilitare l'autenticazione a due fattori:",
|
||||
"Two-factor authentication has been enabled": "Autenticazione a due fattori abilitata",
|
||||
"Two-factor authentication has been disabled": "Autenticazione a due fattori disabilitata",
|
||||
"2-step verification": "Verifica in 2 passaggi",
|
||||
"Protect your account with an additional verification layer when signing in.": "Proteggi il tuo account con un ulteriore livello di verifica durante l'accesso.",
|
||||
"Two-factor authentication is active on your account.": "L'autenticazione a due fattori è attiva sul tuo account.",
|
||||
"Add 2FA method": "Aggiungi metodo 2FA",
|
||||
"Backup codes": "Codici di backup",
|
||||
"Disable": "Disabilita",
|
||||
"Invalid verification code": "Codice di verifica non valido",
|
||||
"New backup codes have been generated": "Nuovi codici di backup generati",
|
||||
"Failed to regenerate backup codes": "Rigenerazione codici di backup non riuscita",
|
||||
"About backup codes": "Informazioni sui codici di backup",
|
||||
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "I codici di backup possono essere utilizzati per accedere al tuo account se perdi l'accesso alla tua app di autenticazione. Ogni codice può essere usato solo una volta.",
|
||||
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "Puoi rigenerare nuovi codici di backup in qualsiasi momento. Questo invaliderà tutti i codici esistenti.",
|
||||
"Confirm password": "Conferma password",
|
||||
"Generate new backup codes": "Genera nuovi codici di backup",
|
||||
"Save your new backup codes": "Salva i tuoi nuovi codici di backup",
|
||||
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "Assicurati di salvare questi codici in un luogo sicuro. I tuoi vecchi codici di backup non sono più validi.",
|
||||
"Your new backup codes": "I tuoi nuovi codici di backup",
|
||||
"I've saved my backup codes": "Ho salvato i miei codici di backup",
|
||||
"Failed to setup MFA": "Impostazione MFA non riuscita",
|
||||
"Setup & Verify": "Imposta e Verifica",
|
||||
"Add to authenticator": "Aggiungi ad authenticator",
|
||||
"1. Scan this QR code with your authenticator app": "1. Scansiona questo codice QR con la tua app di autenticazione",
|
||||
"Can't scan the code?": "Non riesci a scansionare il codice?",
|
||||
"Enter this code manually in your authenticator app:": "Inserisci questo codice manualmente nella tua app di autenticazione:",
|
||||
"2. Enter the 6-digit code from your authenticator": "2. Inserisci il codice a 6 cifre dal tuo autenticatore",
|
||||
"Verify and enable": "Verifica e abilita",
|
||||
"Failed to generate QR code. Please try again.": "Generazione del codice QR non riuscita. Si prega di riprovare.",
|
||||
"Backup": "Backup",
|
||||
"Save codes": "Salva codici",
|
||||
"Save your backup codes": "Salva i tuoi codici di backup",
|
||||
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Questi codici possono essere utilizzati per accedere al tuo account se perdi l'accesso alla tua app di autenticazione. Ogni codice può essere usato solo una volta.",
|
||||
"Print": "Stampa",
|
||||
"Two-factor authentication has been set up. Please log in again.": "L'autenticazione a due fattori è stata impostata. Effettua nuovamente l'accesso, per favore.",
|
||||
"Two-Factor authentication required": "Autenticazione a due fattori richiesta",
|
||||
"Your workspace requires two-factor authentication for all users": "Il tuo spazio di lavoro richiede l'autenticazione a due fattori per tutti gli utenti",
|
||||
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "Per continuare ad accedere al tuo spazio di lavoro, devi impostare l'autenticazione a due fattori. Questo aggiunge un ulteriore livello di sicurezza al tuo account.",
|
||||
"Set up two-factor authentication": "Imposta l'autenticazione a due fattori",
|
||||
"Cancel and logout": "Annulla e disconnetti",
|
||||
"Your workspace requires two-factor authentication. Please set it up to continue.": "Il tuo spazio di lavoro richiede l'autenticazione a due fattori. Impostala per continuare.",
|
||||
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "Questo aggiunge un ulteriore livello di sicurezza al tuo account richiedendo un codice di verifica dalla tua app di autenticazione.",
|
||||
"Password is required": "La password è richiesta",
|
||||
"Password must be at least 8 characters": "La password deve essere di almeno 8 caratteri",
|
||||
"Please enter a 6-digit code": "Inserisci un codice a 6 cifre",
|
||||
"Code must be exactly 6 digits": "Il codice deve essere esattamente di 6 cifre",
|
||||
"Enter the 6-digit code found in your authenticator app": "Inserisci il codice a 6 cifre trovato nella tua app di autenticazione",
|
||||
"Need help authenticating?": "Hai bisogno di aiuto per autenticarti?",
|
||||
"MFA QR Code": "Codice QR MFA",
|
||||
"Account created successfully. Please log in to set up two-factor authentication.": "Account creato con successo. Effettua l'accesso per impostare l'autenticazione a due fattori.",
|
||||
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "Reimpostazione della password riuscita. Accedi con la tua nuova password e completa l'autenticazione a due fattori.",
|
||||
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "Reimpostazione della password riuscita. Accedi con la tua nuova password per impostare l'autenticazione a due fattori.",
|
||||
"Password reset was successful. Please log in with your new password.": "Reimpostazione della password riuscita. Accedi con la tua nuova password.",
|
||||
"Two-factor authentication": "Autenticazione a due fattori",
|
||||
"Use authenticator app instead": "Usa l'app di autenticazione invece",
|
||||
"Verify backup code": "Verifica codice di backup",
|
||||
"Use backup code": "Usa codice di backup",
|
||||
"Enter one of your backup codes": "Inserisci uno dei tuoi codici di backup",
|
||||
"Backup code": "Codice di backup",
|
||||
"Enter one of your backup codes. Each backup code can only be used once.": "Inserisci uno dei tuoi codici di backup. Ogni codice di backup può essere utilizzato solo una volta.",
|
||||
"Verify": "Verifica",
|
||||
"Trash": "Cestino",
|
||||
"Pages in trash will be permanently deleted after 30 days.": "Le pagine nel cestino verranno eliminate definitivamente dopo 30 giorni.",
|
||||
"Deleted": "Eliminato",
|
||||
"No pages in trash": "Nessuna pagina nel cestino",
|
||||
"Permanently delete page?": "Eliminare definitivamente la pagina?",
|
||||
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Sei sicuro di voler eliminare definitivamente '{{title}}'? Questa azione non può essere annullata.",
|
||||
"Restore '{{title}}' and its sub-pages?": "Ripristinare '{{title}}' e le sue sottopagine?",
|
||||
"Move to trash": "Sposta nel cestino",
|
||||
"Move this page to trash?": "Spostare questa pagina nel cestino?",
|
||||
"Restore page": "Ripristina pagina",
|
||||
"Page moved to trash": "Pagina spostata nel cestino",
|
||||
"Page restored successfully": "Pagina ripristinata con successo",
|
||||
"Deleted by": "Eliminato da",
|
||||
"Deleted at": "Eliminato il",
|
||||
"Preview": "Anteprima"
|
||||
}
|
||||
|
||||
@ -213,7 +213,18 @@
|
||||
"Comment deleted successfully": "コメントが削除されました",
|
||||
"Failed to delete comment": "コメントの削除に失敗しました",
|
||||
"Comment resolved successfully": "コメントが解決されました",
|
||||
"Comment re-opened successfully": "コメントが再開されました",
|
||||
"Comment unresolved successfully": "コメントが再解決されました",
|
||||
"Failed to resolve comment": "コメントの解決に失敗しました",
|
||||
"Resolve comment": "コメントを解決",
|
||||
"Unresolve comment": "コメントを再解決",
|
||||
"Resolve Comment Thread": "コメントスレッドを解決",
|
||||
"Unresolve Comment Thread": "コメントスレッドを再解決",
|
||||
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "このコメントスレッドを解決しますか? これにより完了としてマークされます。",
|
||||
"Are you sure you want to unresolve this comment thread?": "このコメントスレッドを再解決しますか?",
|
||||
"Resolved": "解決済",
|
||||
"No active comments.": "アクティブなコメントはありません。",
|
||||
"No resolved comments.": "解決されたコメントはありません。",
|
||||
"Revoke invitation": "招待を取り消す",
|
||||
"Revoke": "取り消す",
|
||||
"Don't": "取り消さない",
|
||||
@ -222,7 +233,9 @@
|
||||
"Anyone with this link can join this workspace.": "このリンクを持っている人は誰でもこのワークスペースに参加できます。",
|
||||
"Invite link": "招待リンク",
|
||||
"Copy": "コピー",
|
||||
"Copy to space": "スペースにコピー",
|
||||
"Copied": "コピーしました",
|
||||
"Duplicate": "複製",
|
||||
"Select a user": "ユーザを選択",
|
||||
"Select a group": "グループを選択",
|
||||
"Export all pages and attachments in this space.": "このスペースのすべてのページと添付ファイルをエクスポートします。",
|
||||
@ -347,13 +360,16 @@
|
||||
"Members added successfully": "メンバーを追加しました",
|
||||
"Member removed successfully": "メンバーが削除されました",
|
||||
"Member role updated successfully": "メンバーのロールを更新しました",
|
||||
"Created by: <b>{{creatorName}}</b>": "作成者: <b>{{creatorName}}</b>",
|
||||
"Created by: <b>{{creatorName}}</b>": "作成者: <b>{{creatorName}}</b>",
|
||||
"Created at: {{time}}": "が作成しました:{{time}}",
|
||||
"Edited by {{name}} {{time}}": "最終編集: {{name}} {{time}}",
|
||||
"Word count: {{wordCount}}": "ワード数: {{wordCount}}",
|
||||
"Character count: {{characterCount}}": "文字数: {{characterCount}}",
|
||||
"New update": "新規更新",
|
||||
"{{latestVersion}} is available": "{{latestVersion}}は利用可能です",
|
||||
"Default page edit mode": "デフォルトのページ編集モード",
|
||||
"Choose your preferred page edit mode. Avoid accidental edits.": "希望のページ編集モードを選択してください。誤って編集を防ぎます。",
|
||||
"Reading": "読み取り",
|
||||
"Delete member": "メンバーを削除する",
|
||||
"Member deleted successfully": "メンバーが削除されました",
|
||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "ワークスペースメンバーを削除してもよろしいですか?この操作は元に戻せません。",
|
||||
@ -383,5 +399,101 @@
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "メンバーであるスペースからの公開ページがここに表示されます",
|
||||
"Share deleted successfully": "共有が正常に削除されました",
|
||||
"Share not found": "共有が見つかりません",
|
||||
"Failed to share page": "ページの共有に失敗しました"
|
||||
"Failed to share page": "ページの共有に失敗しました",
|
||||
"Copy page": "ページをコピー",
|
||||
"Copy page to a different space.": "ページを別のスペースにコピーします。",
|
||||
"Page copied successfully": "ページのコピーに成功しました",
|
||||
"Page duplicated successfully": "ページが正常に複製されました",
|
||||
"Find": "検索",
|
||||
"Not found": "見つかりません",
|
||||
"Previous Match (Shift+Enter)": "前の一致 (Shift+Enter)",
|
||||
"Next match (Enter)": "次の一致 (Enter)",
|
||||
"Match case (Alt+C)": "大文字小文字を区別 (Alt+C)",
|
||||
"Replace": "置換",
|
||||
"Close (Escape)": "閉じる (Escape)",
|
||||
"Replace (Enter)": "置換 (Enter)",
|
||||
"Replace all (Ctrl+Alt+Enter)": "すべて置換 (Ctrl+Alt+Enter)",
|
||||
"Replace all": "すべて置換",
|
||||
"View all spaces": "すべてのスペースを表示",
|
||||
"Error": "エラー",
|
||||
"Failed to disable MFA": "MFAの無効化に失敗しました",
|
||||
"Disable two-factor authentication": "二要素認証を無効化",
|
||||
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "二要素認証を無効化すると、アカウントのセキュリティが低下します。サインインにはパスワードのみが必要になります。",
|
||||
"Please enter your password to disable two-factor authentication:": "二要素認証を無効化するにはパスワードを入力してください:",
|
||||
"Two-factor authentication has been enabled": "二要素認証が有効になりました",
|
||||
"Two-factor authentication has been disabled": "二要素認証が無効になりました",
|
||||
"2-step verification": "2段階確認",
|
||||
"Protect your account with an additional verification layer when signing in.": "サインイン時に追加の認証レイヤーでアカウントを保護します。",
|
||||
"Two-factor authentication is active on your account.": "二要素認証がアカウントで有効です。",
|
||||
"Add 2FA method": "2FAメソッドを追加",
|
||||
"Backup codes": "バックアップコード",
|
||||
"Disable": "無効にする",
|
||||
"Invalid verification code": "無効な認証コード",
|
||||
"New backup codes have been generated": "新しいバックアップコードが生成されました",
|
||||
"Failed to regenerate backup codes": "バックアップコードの再生成に失敗しました",
|
||||
"About backup codes": "バックアップコードについて",
|
||||
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "バックアップコードは、認証アプリへのアクセスを失った場合にアカウントにアクセスするために使用できます。各コードは一度しか使用できません。",
|
||||
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "いつでも新しいバックアップコードを再生成できます。これにより、既存のすべてのコードが無効になります。",
|
||||
"Confirm password": "パスワードを確認",
|
||||
"Generate new backup codes": "新しいバックアップコードを生成",
|
||||
"Save your new backup codes": "新しいバックアップコードを保存",
|
||||
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "これらのコードを安全な場所に保存してください。古いバックアップコードは無効です。",
|
||||
"Your new backup codes": "新しいバックアップコード",
|
||||
"I've saved my backup codes": "バックアップコードを保存しました",
|
||||
"Failed to setup MFA": "MFAの設定に失敗しました",
|
||||
"Setup & Verify": "設定と確認",
|
||||
"Add to authenticator": "認証アプリに追加",
|
||||
"1. Scan this QR code with your authenticator app": "1. このQRコードを認証アプリでスキャンしてください",
|
||||
"Can't scan the code?": "コードをスキャンできませんか?",
|
||||
"Enter this code manually in your authenticator app:": "このコードを認証アプリに手動で入力してください:",
|
||||
"2. Enter the 6-digit code from your authenticator": "2. 認証アプリからの6桁のコードを入力してください",
|
||||
"Verify and enable": "確認と有効化",
|
||||
"Failed to generate QR code. Please try again.": "QRコードの生成に失敗しました。再試行してください。",
|
||||
"Backup": "バックアップ",
|
||||
"Save codes": "コードを保存",
|
||||
"Save your backup codes": "バックアップコードを保存",
|
||||
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "これらのコードは、認証アプリへのアクセスを失った場合にアカウントにアクセスするために使用できます。各コードは一度しか使用できません。",
|
||||
"Print": "印刷",
|
||||
"Two-factor authentication has been set up. Please log in again.": "二要素認証が設定されました。再度ログインしてください。",
|
||||
"Two-Factor authentication required": "二要素認証が必要です",
|
||||
"Your workspace requires two-factor authentication for all users": "ワークスペースでは、すべてのユーザーに二要素認証が必要です",
|
||||
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "ワークスペースへのアクセスを続けるには、二要素認証を設定する必要があります。これにより、アカウントに追加のセキュリティ層が追加されます。",
|
||||
"Set up two-factor authentication": "二要素認証を設定",
|
||||
"Cancel and logout": "キャンセルしてログアウト",
|
||||
"Your workspace requires two-factor authentication. Please set it up to continue.": "ワークスペースでは二要素認証が必要です。続行するには設定してください。",
|
||||
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "これにより、認証アプリからの確認コードが必要となり、アカウントに追加のセキュリティ層が追加されます。",
|
||||
"Password is required": "パスワードが必要です",
|
||||
"Password must be at least 8 characters": "パスワードは8文字以上必要です",
|
||||
"Please enter a 6-digit code": "6桁のコードを入力してください",
|
||||
"Code must be exactly 6 digits": "コードは正確に6桁である必要があります",
|
||||
"Enter the 6-digit code found in your authenticator app": "認証アプリに表示された6桁のコードを入力してください",
|
||||
"Need help authenticating?": "認証に関するヘルプが必要ですか?",
|
||||
"MFA QR Code": "MFA QRコード",
|
||||
"Account created successfully. Please log in to set up two-factor authentication.": "アカウントが正常に作成されました。二要素認証を設定するためにログインしてください。",
|
||||
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "パスワードのリセットが成功しました。新しいパスワードでログインし、二要素認証を完了してください。",
|
||||
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "パスワードのリセットが成功しました。二要素認証を設定するために新しいパスワードでログインしてください。",
|
||||
"Password reset was successful. Please log in with your new password.": "パスワードのリセットが成功しました。新しいパスワードでログインしてください。",
|
||||
"Two-factor authentication": "二要素認証",
|
||||
"Use authenticator app instead": "代わりに認証アプリを使用",
|
||||
"Verify backup code": "バックアップコードを確認",
|
||||
"Use backup code": "バックアップコードを使用",
|
||||
"Enter one of your backup codes": "バックアップコードのいずれかを入力してください",
|
||||
"Backup code": "バックアップコード",
|
||||
"Enter one of your backup codes. Each backup code can only be used once.": "バックアップコードのいずれかを入力してください。各バックアップコードは一度しか使用できません。",
|
||||
"Verify": "確認",
|
||||
"Trash": "ごみ箱",
|
||||
"Pages in trash will be permanently deleted after 30 days.": "ごみ箱内のページは30日後に完全に削除されます。",
|
||||
"Deleted": "削除",
|
||||
"No pages in trash": "ごみ箱にページがありません",
|
||||
"Permanently delete page?": "ページを完全に削除しますか?",
|
||||
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "{{title}}』を完全に削除しますか? この操作は元に戻せません。",
|
||||
"Restore '{{title}}' and its sub-pages?": "{{title}}』とそのサブページを復元しますか?",
|
||||
"Move to trash": "ごみ箱に移動",
|
||||
"Move this page to trash?": "このページをごみ箱に移動しますか?",
|
||||
"Restore page": "ページを復元",
|
||||
"Page moved to trash": "ページがごみ箱に移動されました",
|
||||
"Page restored successfully": "ページが正常に復元されました",
|
||||
"Deleted by": "削除者",
|
||||
"Deleted at": "削除日時",
|
||||
"Preview": "プレビュー"
|
||||
}
|
||||
|
||||
@ -58,7 +58,7 @@
|
||||
"Enter a strong password": "강력한 비밀번호를 입력하세요",
|
||||
"Enter valid email addresses separated by comma or space max_50": "유효한 이메일 주소를 쉼표나 공백으로 구분하여 입력하세요 [최대: 50]",
|
||||
"enter valid emails addresses": "유효한 이메일 주소를 입력하세요",
|
||||
"Enter your current password": "현재 비밀번호를 입력하세요",
|
||||
"Enter your current password": "기존 비밀번호를 입력하세요",
|
||||
"enter your full name": "전체 이름을 입력하세요",
|
||||
"Enter your new password": "새 비밀번호를 입력하세요",
|
||||
"Enter your new preferred email": "새로운 이메일을 입력하세요",
|
||||
@ -170,7 +170,7 @@
|
||||
"Successfully restored": "복원 완료",
|
||||
"System settings": "시스템 설정",
|
||||
"Theme": "배경",
|
||||
"To change your email, you have to enter your password and new email.": "이메일을 변경하려면 현재 비밀번호와 새 이메일을 입력해야 합니다.",
|
||||
"To change your email, you have to enter your password and new email.": "이메일을 변경하려면 기존 비밀번호와 새 이메일을 입력해야 합니다.",
|
||||
"Toggle full page width": "전체 페이지 너비 전환",
|
||||
"Unable to import pages. Please try again.": "페이지를 가져올 수 없습니다. 다시 시도해주세요.",
|
||||
"untitled": "제목 없음",
|
||||
@ -213,7 +213,18 @@
|
||||
"Comment deleted successfully": "댓글 삭제 완료",
|
||||
"Failed to delete comment": "댓글 삭제 실패",
|
||||
"Comment resolved successfully": "댓글 처리 완료",
|
||||
"Comment re-opened successfully": "댓글이 성공적으로 다시 열렸습니다",
|
||||
"Comment unresolved successfully": "댓글 미해결로 변경 완료",
|
||||
"Failed to resolve comment": "댓글 처리 실패",
|
||||
"Resolve comment": "댓글 해결하기",
|
||||
"Unresolve comment": "댓글 미해결로 변경하기",
|
||||
"Resolve Comment Thread": "댓글 스레드 해결하기",
|
||||
"Unresolve Comment Thread": "댓글 스레드 미해결로 변경하기",
|
||||
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "이 댓글 스레드를 해결하시겠습니까? 완료로 표시됩니다.",
|
||||
"Are you sure you want to unresolve this comment thread?": "이 댓글 스레드를 미해결로 변경하시겠습니까?",
|
||||
"Resolved": "해결됨",
|
||||
"No active comments.": "활성 댓글이 없습니다.",
|
||||
"No resolved comments.": "해결된 댓글이 없습니다.",
|
||||
"Revoke invitation": "초대 취소",
|
||||
"Revoke": "취소",
|
||||
"Don't": "하지 않음",
|
||||
@ -222,7 +233,9 @@
|
||||
"Anyone with this link can join this workspace.": "이 링크를 가진 모든 사용자가 이 Workspace에 참여할 수 있습니다.",
|
||||
"Invite link": "초대 링크",
|
||||
"Copy": "복사",
|
||||
"Copy to space": "공간에 복사하기",
|
||||
"Copied": "복사됨",
|
||||
"Duplicate": "중복",
|
||||
"Select a user": "사용자 선택",
|
||||
"Select a group": "팀 선택",
|
||||
"Export all pages and attachments in this space.": "이 Space의 모든 페이지와 첨부파일을 내보냅니다.",
|
||||
@ -354,6 +367,9 @@
|
||||
"Character count: {{characterCount}}": "문자 수: {{characterCount}}",
|
||||
"New update": "새로운 업데이트",
|
||||
"{{latestVersion}} is available": "{{latestVersion}}이 사용 가능합니다",
|
||||
"Default page edit mode": "기본 페이지 편집 모드",
|
||||
"Choose your preferred page edit mode. Avoid accidental edits.": "선호하는 페이지 편집 모드를 선택하세요. 실수로 인한 편집을 방지하세요.",
|
||||
"Reading": "읽기",
|
||||
"Delete member": "회원 삭제",
|
||||
"Member deleted successfully": "멤버가 성공적으로 제거되었습니다",
|
||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "이 워크스페이스 멤버를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
|
||||
@ -383,5 +399,101 @@
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "회원인 공간의 공개 공유된 페이지가 여기에 표시됩니다",
|
||||
"Share deleted successfully": "공유가 성공적으로 삭제되었습니다",
|
||||
"Share not found": "공유를 찾을 수 없습니다",
|
||||
"Failed to share page": "페이지 공유에 실패했습니다"
|
||||
"Failed to share page": "페이지 공유에 실패했습니다",
|
||||
"Copy page": "페이지 복사하기",
|
||||
"Copy page to a different space.": "다른 공간으로 페이지 복사하기.",
|
||||
"Page copied successfully": "페이지가 성공적으로 복사되었습니다",
|
||||
"Page duplicated successfully": "페이지가 성공적으로 복제되었습니다",
|
||||
"Find": "찾기",
|
||||
"Not found": "찾을 수 없음",
|
||||
"Previous Match (Shift+Enter)": "이전 일치 항목 (Shift+Enter)",
|
||||
"Next match (Enter)": "다음 일치 항목 (Enter)",
|
||||
"Match case (Alt+C)": "대소문자 구분 (Alt+C)",
|
||||
"Replace": "교체",
|
||||
"Close (Escape)": "닫기 (Escape)",
|
||||
"Replace (Enter)": "교체 (Enter)",
|
||||
"Replace all (Ctrl+Alt+Enter)": "모두 교체하기 (Ctrl+Alt+Enter)",
|
||||
"Replace all": "모두 교체하기",
|
||||
"View all spaces": "모든 공간 보기",
|
||||
"Error": "오류",
|
||||
"Failed to disable MFA": "MFA 비활성화 실패",
|
||||
"Disable two-factor authentication": "이중 인증 비활성화",
|
||||
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "이중 인증을 비활성화하면 계정의 보안이 낮아집니다. 로그인 시 비밀번호만 필요하게 됩니다.",
|
||||
"Please enter your password to disable two-factor authentication:": "이중 인증 비활성화를 위해 비밀번호를 입력하세요:",
|
||||
"Two-factor authentication has been enabled": "이중 인증이 활성화되었습니다",
|
||||
"Two-factor authentication has been disabled": "이중 인증이 비활성화되었습니다",
|
||||
"2-step verification": "2단계 인증",
|
||||
"Protect your account with an additional verification layer when signing in.": "로그인 시 추가 인증 단계를 통해 계정을 보호하세요.",
|
||||
"Two-factor authentication is active on your account.": "이중 인증이 계정에 활성화되어 있습니다.",
|
||||
"Add 2FA method": "2FA 방법 추가",
|
||||
"Backup codes": "백업 코드",
|
||||
"Disable": "비활성화",
|
||||
"Invalid verification code": "유효하지 않은 인증 코드",
|
||||
"New backup codes have been generated": "새 백업 코드가 생성되었습니다",
|
||||
"Failed to regenerate backup codes": "백업 코드 재생성 실패",
|
||||
"About backup codes": "백업 코드에 대하여",
|
||||
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "인증 앱에 접근할 수 없게 된 경우, 백업 코드를 사용하여 계정에 접근할 수 있습니다. 각 코드는 한 번만 사용할 수 있습니다.",
|
||||
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "언제든지 새 백업 코드를 재생성할 수 있습니다. 이 작업은 기존 모든 코드를 무효화합니다.",
|
||||
"Confirm password": "비밀번호 확인",
|
||||
"Generate new backup codes": "새 백업 코드 생성하기",
|
||||
"Save your new backup codes": "새 백업 코드 저장하기",
|
||||
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "이 코드를 안전한 장소에 저장하세요. 이전 백업 코드는 더 이상 유효하지 않습니다.",
|
||||
"Your new backup codes": "새 백업 코드",
|
||||
"I've saved my backup codes": "백업 코드를 저장했습니다",
|
||||
"Failed to setup MFA": "MFA 설정 실패",
|
||||
"Setup & Verify": "설정 및 확인",
|
||||
"Add to authenticator": "인증앱에 추가",
|
||||
"1. Scan this QR code with your authenticator app": "1. 인증앱으로 이 QR 코드를 스캔하십시오.",
|
||||
"Can't scan the code?": "코드를 스캔할 수 없습니까?",
|
||||
"Enter this code manually in your authenticator app:": "이 코드를 인증앱에 수동으로 입력해 주세요:",
|
||||
"2. Enter the 6-digit code from your authenticator": "2. 인증앱에서 6자리 코드를 입력하십시오",
|
||||
"Verify and enable": "확인 및 활성화",
|
||||
"Failed to generate QR code. Please try again.": "QR 코드 생성 실패. 다시 시도해 주세요.",
|
||||
"Backup": "백업",
|
||||
"Save codes": "코드 저장",
|
||||
"Save your backup codes": "백업 코드 저장하기",
|
||||
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "인증 앱에 대한 접근 권한을 잃은 경우, 이 코드를 사용하여 귀하의 계정에 접근할 수 있습니다. 각 코드는 한 번만 사용할 수 있습니다.",
|
||||
"Print": "인쇄",
|
||||
"Two-factor authentication has been set up. Please log in again.": "이중 인증이 설정되었습니다. 다시 로그인해 주세요.",
|
||||
"Two-Factor authentication required": "이중 인증 필요",
|
||||
"Your workspace requires two-factor authentication for all users": "워크스페이스에서는 모든 사용자에게 이중 인증이 필요합니다.",
|
||||
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "워크스페이스 접근을 계속하려면 이중 인증을 설정해야 합니다. 이는 계정에 추가 보안 계층을 추가합니다.",
|
||||
"Set up two-factor authentication": "이중 인증 설정하기",
|
||||
"Cancel and logout": "취소 및 로그아웃",
|
||||
"Your workspace requires two-factor authentication. Please set it up to continue.": "워크스페이스에서는 이중 인증이 필요합니다. 계속하려면 설정해 주세요.",
|
||||
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "인증앱에서 얻은 인증 코드를 요구하여 계정의 보안에 추가적인 계층을 추가합니다.",
|
||||
"Password is required": "비밀번호가 필요합니다",
|
||||
"Password must be at least 8 characters": "비밀번호는 최소 8자 이상이어야 합니다",
|
||||
"Please enter a 6-digit code": "6자리 코드를 입력해 주세요",
|
||||
"Code must be exactly 6 digits": "코드는 정확히 6자리여야 합니다",
|
||||
"Enter the 6-digit code found in your authenticator app": "인증앱에서 찾은 6자리 코드를 입력하십시오",
|
||||
"Need help authenticating?": "인증에 도움이 필요하십니까?",
|
||||
"MFA QR Code": "MFA QR 코드",
|
||||
"Account created successfully. Please log in to set up two-factor authentication.": "계정이 성공적으로 생성되었습니다. 이중 인증을 설정하려면 로그인해 주세요.",
|
||||
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "비밀번호 재설정 성공. 새 비밀번호로 로그인하여 이중 인증을 완료하세요.",
|
||||
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "비밀번호 재설정 성공. 새 비밀번호로 로그인하여 이중 인증을 설정하세요.",
|
||||
"Password reset was successful. Please log in with your new password.": "비밀번호 재설정이 성공적으로 완료되었습니다. 새 비밀번호로 로그인하세요.",
|
||||
"Two-factor authentication": "이중 인증",
|
||||
"Use authenticator app instead": "대신 인증 앱 사용",
|
||||
"Verify backup code": "백업 코드 확인",
|
||||
"Use backup code": "백업 코드 사용",
|
||||
"Enter one of your backup codes": "백업 코드 중 하나를 입력하세요",
|
||||
"Backup code": "백업 코드",
|
||||
"Enter one of your backup codes. Each backup code can only be used once.": "백업 코드 중 하나를 입력하세요. 각 백업 코드는 한 번만 사용할 수 있습니다.",
|
||||
"Verify": "확인",
|
||||
"Trash": "휴지통",
|
||||
"Pages in trash will be permanently deleted after 30 days.": "휴지통의 페이지는 30일 후에 영구적으로 삭제됩니다.",
|
||||
"Deleted": "삭제됨",
|
||||
"No pages in trash": "휴지통에 페이지가 없습니다",
|
||||
"Permanently delete page?": "페이지를 영구적으로 삭제하시겠습니까?",
|
||||
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "'{{title}}'을(를) 영구적으로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
|
||||
"Restore '{{title}}' and its sub-pages?": "'{{title}}' 및 하위 페이지를 복구하시겠습니까?",
|
||||
"Move to trash": "휴지통으로 이동",
|
||||
"Move this page to trash?": "이 페이지를 휴지통으로 이동하시겠습니까?",
|
||||
"Restore page": "페이지 복구",
|
||||
"Page moved to trash": "페이지가 휴지통으로 이동되었습니다",
|
||||
"Page restored successfully": "페이지가 성공적으로 복구되었습니다",
|
||||
"Deleted by": "삭제자",
|
||||
"Deleted at": "삭제 시간",
|
||||
"Preview": "미리보기"
|
||||
}
|
||||
|
||||
@ -213,7 +213,18 @@
|
||||
"Comment deleted successfully": "Reactie met succes verwijderd",
|
||||
"Failed to delete comment": "Verwijderen van reactie mislukt",
|
||||
"Comment resolved successfully": "Reactie succesvol opgelost",
|
||||
"Comment re-opened successfully": "Reactie succesvol heropend",
|
||||
"Comment unresolved successfully": "Reactie succesvol niet-opgelost gemaakt",
|
||||
"Failed to resolve comment": "Reactie oplossen mislukt",
|
||||
"Resolve comment": "Reactie oplossen",
|
||||
"Unresolve comment": "Reactie niet oplossen",
|
||||
"Resolve Comment Thread": "Reactiedraad oplossen",
|
||||
"Unresolve Comment Thread": "Reactiedraad niet oplossen",
|
||||
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "Weet u zeker dat u deze reactiedraad wilt oplossen? Dit zal het als voltooid markeren.",
|
||||
"Are you sure you want to unresolve this comment thread?": "Weet u zeker dat u deze reactiedraad niet wilt oplossen?",
|
||||
"Resolved": "Opgelost",
|
||||
"No active comments.": "Geen actieve reacties.",
|
||||
"No resolved comments.": "Geen opgeloste reacties.",
|
||||
"Revoke invitation": "Uitnodiging intrekken",
|
||||
"Revoke": "Intrekken",
|
||||
"Don't": "Niet doen",
|
||||
@ -222,7 +233,9 @@
|
||||
"Anyone with this link can join this workspace.": "Iedereen met deze link kan zich aansluiten bij deze werkruimte.",
|
||||
"Invite link": "Uitnodigingslink",
|
||||
"Copy": "Kopieer",
|
||||
"Copy to space": "Kopiëren naar ruimte",
|
||||
"Copied": "Gekopieerd",
|
||||
"Duplicate": "Dupliceren",
|
||||
"Select a user": "Selecteer een gebruiker",
|
||||
"Select a group": "Selecteer een groep",
|
||||
"Export all pages and attachments in this space.": "Exporteer alle pagina's en bijlagen in deze ruimte.",
|
||||
@ -354,6 +367,9 @@
|
||||
"Character count: {{characterCount}}": "Aantal tekens: {{characterCount}}",
|
||||
"New update": "Nieuwe update",
|
||||
"{{latestVersion}} is available": "{{latestVersion}} is beschikbaar",
|
||||
"Default page edit mode": "Standaard pagina bewerkmodus",
|
||||
"Choose your preferred page edit mode. Avoid accidental edits.": "Kies uw voorkeurs bewerkmodus voor pagina's. Vermijd per ongeluk bewerken.",
|
||||
"Reading": "Lezen",
|
||||
"Delete member": "Verwijder lid",
|
||||
"Member deleted successfully": "Lid succesvol verwijderd",
|
||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "Weet u zeker dat u dit lid van de werkruimte wilt verwijderen? Deze actie kan niet ongedaan gemaakt worden.",
|
||||
@ -383,5 +399,101 @@
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "Openbaar gedeelde pagina's van ruimtes waarvan u lid bent, verschijnen hier",
|
||||
"Share deleted successfully": "Delen succesvol verwijderd",
|
||||
"Share not found": "Delen niet gevonden",
|
||||
"Failed to share page": "Pagina delen mislukt"
|
||||
"Failed to share page": "Pagina delen mislukt",
|
||||
"Copy page": "Pagina kopiëren",
|
||||
"Copy page to a different space.": "Kopieer pagina naar een andere ruimte.",
|
||||
"Page copied successfully": "Pagina succesvol gekopieerd",
|
||||
"Page duplicated successfully": "Pagina succesvol gedupliceerd",
|
||||
"Find": "Zoeken",
|
||||
"Not found": "Niet gevonden",
|
||||
"Previous Match (Shift+Enter)": "Vorige overeenkomst (Shift+Enter)",
|
||||
"Next match (Enter)": "Volgende overeenkomst (Enter)",
|
||||
"Match case (Alt+C)": "Hoofdlettergevoeligheid (Alt+C)",
|
||||
"Replace": "Vervangen",
|
||||
"Close (Escape)": "Sluiten (Escape)",
|
||||
"Replace (Enter)": "Vervangen (Enter)",
|
||||
"Replace all (Ctrl+Alt+Enter)": "Alles vervangen (Ctrl+Alt+Enter)",
|
||||
"Replace all": "Alles vervangen",
|
||||
"View all spaces": "Bekijk alle ruimtes",
|
||||
"Error": "Fout",
|
||||
"Failed to disable MFA": "MFA uitschakelen mislukt",
|
||||
"Disable two-factor authentication": "Twee-factor authenticatie uitschakelen",
|
||||
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "Indien u twee-factor authenticatie uitschakelt, zal uw account minder veilig zijn. U heeft alleen uw wachtwoord nodig om in te loggen.",
|
||||
"Please enter your password to disable two-factor authentication:": "Voer uw wachtwoord in om twee-factor authenticatie uit te schakelen:",
|
||||
"Two-factor authentication has been enabled": "Twee-factor authenticatie is ingeschakeld",
|
||||
"Two-factor authentication has been disabled": "Twee-factor authenticatie is uitgeschakeld",
|
||||
"2-step verification": "2-staps verificatie",
|
||||
"Protect your account with an additional verification layer when signing in.": "Bescherm uw account met een extra verificatielaag tijdens het inloggen.",
|
||||
"Two-factor authentication is active on your account.": "Twee-factor authenticatie is actief op uw account.",
|
||||
"Add 2FA method": "2FA-methode toevoegen",
|
||||
"Backup codes": "Back-up codes",
|
||||
"Disable": "Uitschakelen",
|
||||
"Invalid verification code": "Ongeldige verificatiecode",
|
||||
"New backup codes have been generated": "Nieuwe back-up codes zijn gegenereerd",
|
||||
"Failed to regenerate backup codes": "Back-up codes opnieuw genereren mislukt",
|
||||
"About backup codes": "Over back-up codes",
|
||||
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Back-up codes kunnen worden gebruikt om uw account te bereiken als u toegang tot uw authenticator-app verliest. Elke code kan slechts één keer worden gebruikt.",
|
||||
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "U kunt te allen tijde nieuwe back-up codes genereren. Dit zal alle bestaande codes ongeldig maken.",
|
||||
"Confirm password": "Bevestig wachtwoord",
|
||||
"Generate new backup codes": "Genereer nieuwe back-up codes",
|
||||
"Save your new backup codes": "Sla uw nieuwe back-up codes op",
|
||||
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "Zorg ervoor dat u deze codes op een veilige plek opslaat. Uw oude back-up codes zijn niet langer geldig.",
|
||||
"Your new backup codes": "Uw nieuwe back-up codes",
|
||||
"I've saved my backup codes": "Ik heb mijn back-up codes opgeslagen",
|
||||
"Failed to setup MFA": "MFA instellen mislukt",
|
||||
"Setup & Verify": "Instellen & Verifiëren",
|
||||
"Add to authenticator": "Toevoegen aan de authenticator",
|
||||
"1. Scan this QR code with your authenticator app": "1. Scan deze QR-code met uw authenticator-app",
|
||||
"Can't scan the code?": "Kan de code niet scannen?",
|
||||
"Enter this code manually in your authenticator app:": "Voer deze code handmatig in uw authenticator-app in:",
|
||||
"2. Enter the 6-digit code from your authenticator": "2. Voer de 6-cijferige code van uw authenticator in",
|
||||
"Verify and enable": "Verifiëren en inschakelen",
|
||||
"Failed to generate QR code. Please try again.": "Het genereren van de QR-code is mislukt. Probeer het opnieuw.",
|
||||
"Backup": "Back-up",
|
||||
"Save codes": "Codes opslaan",
|
||||
"Save your backup codes": "Sla uw back-up codes op",
|
||||
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Deze codes kunnen worden gebruikt om toegang te krijgen tot uw account als u de toegang tot uw authenticator-app verliest. Elke code kan slechts één keer worden gebruikt.",
|
||||
"Print": "Afdrukken",
|
||||
"Two-factor authentication has been set up. Please log in again.": "Twee-factor authenticatie is ingesteld. Log alstublieft opnieuw in.",
|
||||
"Two-Factor authentication required": "Twee-factor authenticatie vereist",
|
||||
"Your workspace requires two-factor authentication for all users": "Uw werkruimte vereist twee-factor authenticatie voor alle gebruikers",
|
||||
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "Om toegang te blijven krijgen tot uw werkruimte, moet u twee-factor authenticatie instellen. Dit voegt een extra beveiligingslaag toe aan uw account.",
|
||||
"Set up two-factor authentication": "Stel twee-factor authenticatie in",
|
||||
"Cancel and logout": "Annuleren en uitloggen",
|
||||
"Your workspace requires two-factor authentication. Please set it up to continue.": "Uw werkruimte vereist twee-factor authenticatie. Stel het in om door te gaan.",
|
||||
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "Dit voegt een extra beveiligingslaag toe aan uw account door een verificatiecode van uw authenticator-app te vereisen.",
|
||||
"Password is required": "Wachtwoord is vereist",
|
||||
"Password must be at least 8 characters": "Wachtwoord moet minimaal 8 tekens zijn",
|
||||
"Please enter a 6-digit code": "Voer alstublieft een 6-cijferige code in",
|
||||
"Code must be exactly 6 digits": "Code moet exact 6 cijfers zijn",
|
||||
"Enter the 6-digit code found in your authenticator app": "Voer de 6-cijferige code in die in uw authenticator-app staat",
|
||||
"Need help authenticating?": "Hulp nodig bij het authenticeren?",
|
||||
"MFA QR Code": "MFA QR-code",
|
||||
"Account created successfully. Please log in to set up two-factor authentication.": "Account succesvol aangemaakt. Log alstublieft in om twee-factor authenticatie in te stellen.",
|
||||
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "Wachtwoord reset succesvol. Log in met uw nieuwe wachtwoord en voltooi twee-factor authenticatie.",
|
||||
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "Wachtwoord reset succesvol. Log in met uw nieuwe wachtwoord om twee-factor authenticatie in te stellen.",
|
||||
"Password reset was successful. Please log in with your new password.": "De wachtwoord reset was succesvol. Log in met uw nieuwe wachtwoord.",
|
||||
"Two-factor authentication": "Twee-factor authenticatie",
|
||||
"Use authenticator app instead": "Gebruik in plaats daarvan de authenticator-app",
|
||||
"Verify backup code": "Back-up code verifiëren",
|
||||
"Use backup code": "Gebruik back-up code",
|
||||
"Enter one of your backup codes": "Voer een van uw back-up codes in",
|
||||
"Backup code": "Back-up code",
|
||||
"Enter one of your backup codes. Each backup code can only be used once.": "Voer een van uw back-up codes in. Elke back-up code kan slechts één keer worden gebruikt.",
|
||||
"Verify": "Verifiëren",
|
||||
"Trash": "Prullenbak",
|
||||
"Pages in trash will be permanently deleted after 30 days.": "Pagina's in de prullenbak worden na 30 dagen permanent verwijderd.",
|
||||
"Deleted": "Verwijderd",
|
||||
"No pages in trash": "Geen pagina's in de prullenbak",
|
||||
"Permanently delete page?": "Pagina permanent verwijderen?",
|
||||
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Weet u zeker dat u '{{title}}' permanent wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
|
||||
"Restore '{{title}}' and its sub-pages?": "'{{title}}' en zijn subpagina's herstellen?",
|
||||
"Move to trash": "Naar de prullenbak verplaatsen",
|
||||
"Move this page to trash?": "Deze pagina naar de prullenbak verplaatsen?",
|
||||
"Restore page": "Pagina herstellen",
|
||||
"Page moved to trash": "Pagina verplaatst naar de prullenbak",
|
||||
"Page restored successfully": "Pagina succesvol hersteld",
|
||||
"Deleted by": "Verwijderd door",
|
||||
"Deleted at": "Verwijderd op",
|
||||
"Preview": "Voorbeeld"
|
||||
}
|
||||
|
||||
@ -213,7 +213,18 @@
|
||||
"Comment deleted successfully": "Comentário excluído com sucesso",
|
||||
"Failed to delete comment": "Falha ao excluir comentário",
|
||||
"Comment resolved successfully": "Comentário resolvido com sucesso",
|
||||
"Comment re-opened successfully": "Comentário reaberto com sucesso",
|
||||
"Comment unresolved successfully": "Comentário não resolvido com sucesso",
|
||||
"Failed to resolve comment": "Falha ao resolver comentário",
|
||||
"Resolve comment": "Resolver comentário",
|
||||
"Unresolve comment": "Não resolver comentário",
|
||||
"Resolve Comment Thread": "Resolver Fio de Comentários",
|
||||
"Unresolve Comment Thread": "Não resolver Fio de Comentários",
|
||||
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "Tem certeza de que deseja resolver este fio de comentários? Isso o marcará como concluído.",
|
||||
"Are you sure you want to unresolve this comment thread?": "Tem certeza de que deseja não resolver este fio de comentários?",
|
||||
"Resolved": "Resolvido",
|
||||
"No active comments.": "Sem comentários ativos.",
|
||||
"No resolved comments.": "Sem comentários resolvidos.",
|
||||
"Revoke invitation": "Cancelar o convite",
|
||||
"Revoke": "Anular",
|
||||
"Don't": "Não",
|
||||
@ -222,7 +233,9 @@
|
||||
"Anyone with this link can join this workspace.": "Qualquer um com este link pode participar deste espaço de trabalho.",
|
||||
"Invite link": "Link do convite",
|
||||
"Copy": "Copiar",
|
||||
"Copy to space": "Copiar para o espaço",
|
||||
"Copied": "Copiado",
|
||||
"Duplicate": "Duplicar",
|
||||
"Select a user": "Selecione um usuário",
|
||||
"Select a group": "Selecione um grupo",
|
||||
"Export all pages and attachments in this space.": "Exportar todas as páginas e anexos deste espaço.",
|
||||
@ -354,6 +367,9 @@
|
||||
"Character count: {{characterCount}}": "Contagem de caracteres: {{characterCount}}",
|
||||
"New update": "Nova atualização",
|
||||
"{{latestVersion}} is available": "{{latestVersion}} está disponível",
|
||||
"Default page edit mode": "Modo de edição de página padrão",
|
||||
"Choose your preferred page edit mode. Avoid accidental edits.": "Escolha o modo de edição de página preferido. Evite edições acidentais.",
|
||||
"Reading": "Leitura",
|
||||
"Delete member": "Excluir membro",
|
||||
"Member deleted successfully": "Membro removido com sucesso",
|
||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "Você tem certeza que deseja deletar este membro do workspace? Esta ação é irreversível.",
|
||||
@ -383,5 +399,101 @@
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "Páginas compartilhadas publicamente de espaços que você é membro aparecerão aqui",
|
||||
"Share deleted successfully": "Compartilhamento excluído com sucesso",
|
||||
"Share not found": "Compartilhamento não encontrado",
|
||||
"Failed to share page": "Falha ao compartilhar página"
|
||||
"Failed to share page": "Falha ao compartilhar página",
|
||||
"Copy page": "Copiar página",
|
||||
"Copy page to a different space.": "Copiar página para um espaço diferente.",
|
||||
"Page copied successfully": "Página copiada com sucesso",
|
||||
"Page duplicated successfully": "Página duplicada com sucesso",
|
||||
"Find": "Encontrar",
|
||||
"Not found": "Não encontrado",
|
||||
"Previous Match (Shift+Enter)": "Correspondência anterior (Shift+Enter)",
|
||||
"Next match (Enter)": "Próxima correspondência (Enter)",
|
||||
"Match case (Alt+C)": "Diferenciar maiúsculas de minúsculas (Alt+C)",
|
||||
"Replace": "Substituir",
|
||||
"Close (Escape)": "Fechar (Escape)",
|
||||
"Replace (Enter)": "Substituir (Enter)",
|
||||
"Replace all (Ctrl+Alt+Enter)": "Substituir tudo (Ctrl+Alt+Enter)",
|
||||
"Replace all": "Substituir tudo",
|
||||
"View all spaces": "Ver todos os espaços",
|
||||
"Error": "Erro",
|
||||
"Failed to disable MFA": "Falha ao desativar a MFA",
|
||||
"Disable two-factor authentication": "Desativar autenticação de dois fatores",
|
||||
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "Desativar a autenticação de dois fatores tornará sua conta menos segura. Você só precisará de sua senha para entrar.",
|
||||
"Please enter your password to disable two-factor authentication:": "Por favor, insira sua senha para desativar a autenticação de dois fatores:",
|
||||
"Two-factor authentication has been enabled": "Autenticação de dois fatores foi ativada",
|
||||
"Two-factor authentication has been disabled": "Autenticação de dois fatores foi desativada",
|
||||
"2-step verification": "Verificação em duas etapas",
|
||||
"Protect your account with an additional verification layer when signing in.": "Proteja sua conta com uma camada adicional de verificação ao entrar.",
|
||||
"Two-factor authentication is active on your account.": "Autenticação de dois fatores está ativa na sua conta.",
|
||||
"Add 2FA method": "Adicionar método de 2FA",
|
||||
"Backup codes": "Códigos de backup",
|
||||
"Disable": "Desativar",
|
||||
"Invalid verification code": "Código de verificação inválido",
|
||||
"New backup codes have been generated": "Novos códigos de backup foram gerados",
|
||||
"Failed to regenerate backup codes": "Falha ao regenerar códigos de backup",
|
||||
"About backup codes": "Sobre códigos de backup",
|
||||
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Códigos de backup podem ser usados para acessar sua conta se perder acesso ao aplicativo autenticador. Cada código só pode ser usado uma vez.",
|
||||
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "Você pode regenerar novos códigos de backup a qualquer momento. Isso invalidará todos os códigos existentes.",
|
||||
"Confirm password": "Confirmar senha",
|
||||
"Generate new backup codes": "Gerar novos códigos de backup",
|
||||
"Save your new backup codes": "Salvar seus novos códigos de backup",
|
||||
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "Certifique-se de salvar esses códigos em um local seguro. Seus códigos de backup antigos não são mais válidos.",
|
||||
"Your new backup codes": "Seus novos códigos de backup",
|
||||
"I've saved my backup codes": "Eu salvei meus códigos de backup",
|
||||
"Failed to setup MFA": "Falha ao configurar a MFA",
|
||||
"Setup & Verify": "Configurar & Verificar",
|
||||
"Add to authenticator": "Adicionar ao autenticador",
|
||||
"1. Scan this QR code with your authenticator app": "1. Escaneie este código QR com seu aplicativo autenticador",
|
||||
"Can't scan the code?": "Não consegue escanear o código?",
|
||||
"Enter this code manually in your authenticator app:": "Digite este código manualmente em seu aplicativo autenticador:",
|
||||
"2. Enter the 6-digit code from your authenticator": "2. Digite o código de 6 dígitos do seu autenticador",
|
||||
"Verify and enable": "Verificar e ativar",
|
||||
"Failed to generate QR code. Please try again.": "Falha ao gerar código QR. Por favor, tente novamente.",
|
||||
"Backup": "Backup",
|
||||
"Save codes": "Salvar códigos",
|
||||
"Save your backup codes": "Salvar seus códigos de backup",
|
||||
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Esses códigos podem ser usados para acessar sua conta se você perder o acesso ao aplicativo autenticador. Cada código só pode ser usado uma vez.",
|
||||
"Print": "Imprimir",
|
||||
"Two-factor authentication has been set up. Please log in again.": "A autenticação de dois fatores foi configurada. Por favor, faça login novamente.",
|
||||
"Two-Factor authentication required": "Autenticação de dois fatores necessária",
|
||||
"Your workspace requires two-factor authentication for all users": "Seu espaço de trabalho requer autenticação de dois fatores para todos os usuários",
|
||||
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "Para continuar acessando seu espaço de trabalho, você deve configurar a autenticação de dois fatores. Isso adiciona uma camada extra de segurança à sua conta.",
|
||||
"Set up two-factor authentication": "Configurar autenticação de dois fatores",
|
||||
"Cancel and logout": "Cancelar e sair",
|
||||
"Your workspace requires two-factor authentication. Please set it up to continue.": "Seu espaço de trabalho requer autenticação de dois fatores. Por favor, configure para continuar.",
|
||||
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "Isso adiciona uma camada extra de segurança à sua conta, exigindo um código de verificação de seu aplicativo autenticador.",
|
||||
"Password is required": "Senha é necessária",
|
||||
"Password must be at least 8 characters": "A senha deve ter pelo menos 8 caracteres",
|
||||
"Please enter a 6-digit code": "Por favor, insira um código de 6 dígitos",
|
||||
"Code must be exactly 6 digits": "O código deve ter exatamente 6 dígitos",
|
||||
"Enter the 6-digit code found in your authenticator app": "Insira o código de 6 dígitos encontrado em seu aplicativo autenticador",
|
||||
"Need help authenticating?": "Precisa de ajuda para autenticar?",
|
||||
"MFA QR Code": "Código QR de MFA",
|
||||
"Account created successfully. Please log in to set up two-factor authentication.": "Conta criada com sucesso. Por favor, faça login para configurar a autenticação de dois fatores.",
|
||||
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "Redefinição de senha bem-sucedida. Por favor, faça login com sua nova senha e complete a autenticação de dois fatores.",
|
||||
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "Redefinição de senha bem-sucedida. Por favor, faça login com sua nova senha para configurar a autenticação de dois fatores.",
|
||||
"Password reset was successful. Please log in with your new password.": "Redefinição de senha foi bem-sucedida. Por favor, faça login com sua nova senha.",
|
||||
"Two-factor authentication": "Autenticação de dois fatores",
|
||||
"Use authenticator app instead": "Use o aplicativo autenticador em vez disso",
|
||||
"Verify backup code": "Verificar código de backup",
|
||||
"Use backup code": "Usar código de backup",
|
||||
"Enter one of your backup codes": "Digite um de seus códigos de backup",
|
||||
"Backup code": "Código de backup",
|
||||
"Enter one of your backup codes. Each backup code can only be used once.": "Digite um de seus códigos de backup. Cada código de backup só pode ser usado uma vez.",
|
||||
"Verify": "Verificar",
|
||||
"Trash": "Lixeira",
|
||||
"Pages in trash will be permanently deleted after 30 days.": "Páginas na lixeira serão excluídas permanentemente após 30 dias.",
|
||||
"Deleted": "Excluído",
|
||||
"No pages in trash": "Sem páginas na lixeira",
|
||||
"Permanently delete page?": "Excluir página permanentemente?",
|
||||
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Tem certeza de que deseja excluir permanentemente '{{title}}'? Esta ação não pode ser desfeita.",
|
||||
"Restore '{{title}}' and its sub-pages?": "Restaurar '{{title}}' e suas subpáginas?",
|
||||
"Move to trash": "Mover para a lixeira",
|
||||
"Move this page to trash?": "Mover esta página para a lixeira?",
|
||||
"Restore page": "Restaurar página",
|
||||
"Page moved to trash": "Página movida para a lixeira",
|
||||
"Page restored successfully": "Página restaurada com sucesso",
|
||||
"Deleted by": "Excluído por",
|
||||
"Deleted at": "Excluído em",
|
||||
"Preview": "Visualização"
|
||||
}
|
||||
|
||||
@ -61,7 +61,7 @@
|
||||
"Enter your current password": "Введите ваш текущий пароль",
|
||||
"enter your full name": "введите ваше полное имя",
|
||||
"Enter your new password": "Введите ваш новый пароль",
|
||||
"Enter your new preferred email": "Введите ваш новый предпочитаемый адрес электронной почты",
|
||||
"Enter your new preferred email": "Введите ваш новый предпочтительный адрес электронной почты",
|
||||
"Enter your password": "Введите ваш пароль",
|
||||
"Error fetching page data.": "Ошибка при загрузке данных страницы.",
|
||||
"Error loading page history.": "Ошибка при загрузке истории страницы.",
|
||||
@ -213,7 +213,18 @@
|
||||
"Comment deleted successfully": "Комментарий успешно удалён",
|
||||
"Failed to delete comment": "Не удалось удалить комментарий",
|
||||
"Comment resolved successfully": "Комментарий успешно разрешён",
|
||||
"Comment re-opened successfully": "Комментарий успешно открыт заново",
|
||||
"Comment unresolved successfully": "Комментарий успешно размечен как нерешённый",
|
||||
"Failed to resolve comment": "Не удалось разрешить комментарий",
|
||||
"Resolve comment": "Разрешить комментарий",
|
||||
"Unresolve comment": "Отметить комментарий как нерешённый",
|
||||
"Resolve Comment Thread": "Закрыть цепочку комментариев",
|
||||
"Unresolve Comment Thread": "Отметить цепочку комментариев как нерешённую",
|
||||
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "Вы уверены, что хотите закрыть эту цепочку комментариев? Это пометит её как завершённую.",
|
||||
"Are you sure you want to unresolve this comment thread?": "Вы уверены, что хотите отметить эту цепочку комментариев как нерешённую?",
|
||||
"Resolved": "Решено",
|
||||
"No active comments.": "Нет активных комментариев.",
|
||||
"No resolved comments.": "Нет решённых комментариев.",
|
||||
"Revoke invitation": "Отозвать приглашение",
|
||||
"Revoke": "Отозвать",
|
||||
"Don't": "Нет",
|
||||
@ -222,7 +233,9 @@
|
||||
"Anyone with this link can join this workspace.": "Любой, у кого есть данная ссылка, может присоединиться к этой рабочей области.",
|
||||
"Invite link": "Ссылка для приглашения",
|
||||
"Copy": "Копировать",
|
||||
"Copy to space": "Копировать в пространство",
|
||||
"Copied": "Скопировано",
|
||||
"Duplicate": "Дублировать",
|
||||
"Select a user": "Выберите пользователя",
|
||||
"Select a group": "Выберите группу",
|
||||
"Export all pages and attachments in this space.": "Экспортировать все страницы и вложения в этом пространстве.",
|
||||
@ -354,6 +367,9 @@
|
||||
"Character count: {{characterCount}}": "Количество символов: {{characterCount}}",
|
||||
"New update": "Новое обновление",
|
||||
"{{latestVersion}} is available": "Доступна новая версия {{latestVersion}}",
|
||||
"Default page edit mode": "Режим редактирования страницы по умолчанию",
|
||||
"Choose your preferred page edit mode. Avoid accidental edits.": "Выберите предпочитаемый режим редактирования страницы. Избегайте случайных изменений.",
|
||||
"Reading": "Чтение",
|
||||
"Delete member": "Удалить участника",
|
||||
"Member deleted successfully": "Участник успешно удален",
|
||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "Вы уверены, что хотите удалить этого участника рабочей области? Это действие необратимо.",
|
||||
@ -383,5 +399,101 @@
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "Общие страницы из пространств, участником которых вы являетесь, появятся здесь",
|
||||
"Share deleted successfully": "Общий доступ успешно удален",
|
||||
"Share not found": "Общий доступ не найден",
|
||||
"Failed to share page": "Не удалось поделиться страницей"
|
||||
"Failed to share page": "Не удалось поделиться страницей",
|
||||
"Copy page": "Копировать страницу",
|
||||
"Copy page to a different space.": "Копировать страницу в другое пространство.",
|
||||
"Page copied successfully": "Страница успешно скопирована",
|
||||
"Page duplicated successfully": "Страница успешно дублирована",
|
||||
"Find": "Найти",
|
||||
"Not found": "Не найдено",
|
||||
"Previous Match (Shift+Enter)": "Предыдущее совпадение (Shift+Enter)",
|
||||
"Next match (Enter)": "Следующее совпадение (Enter)",
|
||||
"Match case (Alt+C)": "Учитывать регистр (Alt+C)",
|
||||
"Replace": "Заменить",
|
||||
"Close (Escape)": "Закрыть (Escape)",
|
||||
"Replace (Enter)": "Заменить (Enter)",
|
||||
"Replace all (Ctrl+Alt+Enter)": "Заменить все (Ctrl+Alt+Enter)",
|
||||
"Replace all": "Заменить все",
|
||||
"View all spaces": "Просмотреть все пространства",
|
||||
"Error": "Ошибка",
|
||||
"Failed to disable MFA": "Не удалось отключить двухфакторную аутентификацию",
|
||||
"Disable two-factor authentication": "Отключить двухфакторную аутентификацию",
|
||||
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "Отключение двухфакторной аутентификации сделает вашу учетную запись менее безопасной. Для входа потребуется только пароль.",
|
||||
"Please enter your password to disable two-factor authentication:": "Пожалуйста, введите ваш пароль, чтобы отключить двухфакторную аутентификацию:",
|
||||
"Two-factor authentication has been enabled": "Двухфакторная аутентификация включена",
|
||||
"Two-factor authentication has been disabled": "Двухфакторная аутентификация отключена",
|
||||
"2-step verification": "Двухэтапная проверка",
|
||||
"Protect your account with an additional verification layer when signing in.": "Защитите свою учетную запись дополнительным уровнем проверки при входе.",
|
||||
"Two-factor authentication is active on your account.": "Двухфакторная аутентификация активна на вашей учетной записи.",
|
||||
"Add 2FA method": "Добавить метод 2FA",
|
||||
"Backup codes": "Резервные коды",
|
||||
"Disable": "Отключить",
|
||||
"Invalid verification code": "Неверный код проверки",
|
||||
"New backup codes have been generated": "Созданы новые резервные коды",
|
||||
"Failed to regenerate backup codes": "Не удалось создать новые резервные коды",
|
||||
"About backup codes": "О резервных кодах",
|
||||
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Резервные коды можно использовать для доступа к вашей учетной записи, если вы потеряли доступ к приложению-аутентификатору. Каждый код можно использовать только один раз.",
|
||||
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "Вы можете создать новые резервные коды в любое время. Это аннулирует все существующие коды.",
|
||||
"Confirm password": "Подтвердите пароль",
|
||||
"Generate new backup codes": "Создать новые резервные коды",
|
||||
"Save your new backup codes": "Сохраните ваши новые резервные коды",
|
||||
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "Убедитесь, что сохранили эти коды в безопасном месте. Ваши старые резервные коды больше недействительны.",
|
||||
"Your new backup codes": "Ваши новые резервные коды",
|
||||
"I've saved my backup codes": "Я сохранил(а) свои резервные коды",
|
||||
"Failed to setup MFA": "Не удалось настроить многофакторную аутентификацию",
|
||||
"Setup & Verify": "Настроить и проверить",
|
||||
"Add to authenticator": "Добавить в аутентификатор",
|
||||
"1. Scan this QR code with your authenticator app": "1. Отсканируйте этот QR-код с помощью вашего приложения-аутентификатора",
|
||||
"Can't scan the code?": "Не удается сканировать код?",
|
||||
"Enter this code manually in your authenticator app:": "Введите этот код вручную в приложении-аутентификаторе:",
|
||||
"2. Enter the 6-digit code from your authenticator": "2. Введите 6-значный код из вашего аутентификатора",
|
||||
"Verify and enable": "Проверить и включить",
|
||||
"Failed to generate QR code. Please try again.": "Не удалось создать QR-код. Пожалуйста, попробуйте снова.",
|
||||
"Backup": "Резервное копирование",
|
||||
"Save codes": "Сохранить коды",
|
||||
"Save your backup codes": "Сохраните ваши резервные коды",
|
||||
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Эти коды можно использовать для доступа к вашей учетной записи, если вы потеряли доступ к приложению-аутентификатору. Каждый код можно использовать только один раз.",
|
||||
"Print": "Печать",
|
||||
"Two-factor authentication has been set up. Please log in again.": "Двухфакторная аутентификация настроена. Пожалуйста, войдите снова.",
|
||||
"Two-Factor authentication required": "Требуется двухфакторная аутентификация",
|
||||
"Your workspace requires two-factor authentication for all users": "Ваше рабочее пространство требует двухфакторной аутентификации для всех пользователей",
|
||||
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "Чтобы продолжать доступ к вашему рабочему пространству, вы должны настроить двухфакторную аутентификацию. Это добавляет дополнительный уровень безопасности к вашей учетной записи.",
|
||||
"Set up two-factor authentication": "Настройте двухфакторную аутентификацию",
|
||||
"Cancel and logout": "Отменить и выйти",
|
||||
"Your workspace requires two-factor authentication. Please set it up to continue.": "Ваше рабочее пространство требует двухфакторной аутентификации. Пожалуйста, настройте её, чтобы продолжить.",
|
||||
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "Это добавляет дополнительный уровень безопасности к вашей учетной записи, требуя код проверки из вашего приложения-аутентификатора.",
|
||||
"Password is required": "Требуется пароль",
|
||||
"Password must be at least 8 characters": "Пароль должен содержать как минимум 8 символов",
|
||||
"Please enter a 6-digit code": "Пожалуйста, введите 6-значный код",
|
||||
"Code must be exactly 6 digits": "Код должен содержать ровно 6 цифр",
|
||||
"Enter the 6-digit code found in your authenticator app": "Введите 6-значный код из вашего приложения-аутентификатора",
|
||||
"Need help authenticating?": "Нужна помощь с аутентификацией?",
|
||||
"MFA QR Code": "QR-код двухфакторной аутентификации",
|
||||
"Account created successfully. Please log in to set up two-factor authentication.": "Учетная запись успешно создана. Пожалуйста, войдите, чтобы настроить двухфакторную аутентификацию.",
|
||||
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "Сброс пароля выполнен успешно. Пожалуйста, войдите с вашим новым паролем и завершите настройку двухфакторной аутентификации.",
|
||||
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "Сброс пароля выполнен успешно. Пожалуйста, войдите с вашим новым паролем, чтобы настроить двухфакторную аутентификацию.",
|
||||
"Password reset was successful. Please log in with your new password.": "Сброс пароля выполнен успешно. Пожалуйста, войдите с вашим новым паролем.",
|
||||
"Two-factor authentication": "Двухфакторная аутентификация",
|
||||
"Use authenticator app instead": "Используйте приложение-аутентификатор вместо этого",
|
||||
"Verify backup code": "Проверка резервного кода",
|
||||
"Use backup code": "Использовать резервный код",
|
||||
"Enter one of your backup codes": "Введите один из ваших резервных кодов",
|
||||
"Backup code": "Резервный код",
|
||||
"Enter one of your backup codes. Each backup code can only be used once.": "Введите один из ваших резервных кодов. Каждый резервный код можно использовать только один раз.",
|
||||
"Verify": "Проверить",
|
||||
"Trash": "Корзина",
|
||||
"Pages in trash will be permanently deleted after 30 days.": "Страницы в корзине будут окончательно удалены через 30 дней.",
|
||||
"Deleted": "Удалено",
|
||||
"No pages in trash": "В корзине нет страниц",
|
||||
"Permanently delete page?": "Удалить страницу окончательно?",
|
||||
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Вы уверены, что хотите окончательно удалить '{{title}}'? Это действие невозможно отменить.",
|
||||
"Restore '{{title}}' and its sub-pages?": "Восстановить '{{title}}' и её подстраницы?",
|
||||
"Move to trash": "Переместить в корзину",
|
||||
"Move this page to trash?": "Переместить эту страницу в корзину?",
|
||||
"Restore page": "Восстановить страницу",
|
||||
"Page moved to trash": "Страница перемещена в корзину",
|
||||
"Page restored successfully": "Страница успешно восстановлена",
|
||||
"Deleted by": "Удалено пользователем",
|
||||
"Deleted at": "Удалено в",
|
||||
"Preview": "Предпросмотр"
|
||||
}
|
||||
|
||||
499
apps/client/public/locales/uk-UA/translation.json
Normal file
499
apps/client/public/locales/uk-UA/translation.json
Normal file
@ -0,0 +1,499 @@
|
||||
{
|
||||
"Account": "Обліковий запис",
|
||||
"Active": "Активний",
|
||||
"Add": "Додати",
|
||||
"Add group members": "Додати учасників групи",
|
||||
"Add groups": "Додати групи",
|
||||
"Add members": "Додати учасників",
|
||||
"Add to groups": "Додати до груп",
|
||||
"Add space members": "Додати учасників простору",
|
||||
"Admin": "Адміністратор",
|
||||
"Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "Ви впевнені, що хочете видалити цю групу? Учасники втратять доступ до матеріалів, до яких ця група має доступ.",
|
||||
"Are you sure you want to delete this page?": "Ви впевнені, що хочете видалити цю сторінку?",
|
||||
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Ви впевнені, що хочете видалити цього користувача з групи? Користувач втратить доступ до матеріалів, до яких ця група має доступ.",
|
||||
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Ви впевнені, що хочете видалити цього користувача з простору? Користувач втратить весь доступ до цього простору.",
|
||||
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Ви впевнені, що хочете відновити цю версію? Усі не збережені зміни будуть втрачені.",
|
||||
"Can become members of groups and spaces in workspace": "Можуть ставати учасниками груп та просторів у робочій області",
|
||||
"Can create and edit pages in space.": "Може створювати та редагувати сторінки в просторі.",
|
||||
"Can edit": "Може редагувати",
|
||||
"Can manage workspace": "Може керувати робочою областю",
|
||||
"Can manage workspace but cannot delete it": "Може керувати робочою областю, але не може її видалити",
|
||||
"Can view": "Може переглядати",
|
||||
"Can view pages in space but not edit.": "Може переглядати сторінки в просторі, але не може їх редагувати.",
|
||||
"Cancel": "Скасувати",
|
||||
"Change email": "Змінити електронну пошту",
|
||||
"Change password": "Змінити пароль",
|
||||
"Change photo": "Змінити фото",
|
||||
"Choose a role": "Оберіть роль",
|
||||
"Choose your preferred color scheme.": "Оберіть бажану кольорову схему.",
|
||||
"Choose your preferred interface language.": "Оберіть бажану мову інтерфейсу.",
|
||||
"Choose your preferred page width.": "Оберіть бажану ширину сторінки.",
|
||||
"Confirm": "Підтвердити",
|
||||
"Copy link": "Копіювати посилання",
|
||||
"Create": "Створити",
|
||||
"Create group": "Створити групу",
|
||||
"Create page": "Створити сторінку",
|
||||
"Create space": "Створити простір",
|
||||
"Create workspace": "Створити робочу область",
|
||||
"Current password": "Поточний пароль",
|
||||
"Dark": "Темна",
|
||||
"Date": "Дата",
|
||||
"Delete": "Видалити",
|
||||
"Delete group": "Видалити групу",
|
||||
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Ви впевнені, що хочете видалити цю сторінку? Це видалить її дочірні сторінки, а також історію сторінки. Ця дія необоротна.",
|
||||
"Description": "Опис",
|
||||
"Details": "Деталі",
|
||||
"e.g ACME": "наприклад, ACME",
|
||||
"e.g ACME Inc": "наприклад, ACME Inc",
|
||||
"e.g Developers": "наприклад, Розробники",
|
||||
"e.g Group for developers": "наприклад, Група для розробників",
|
||||
"e.g product": "наприклад, продукт",
|
||||
"e.g Product Team": "наприклад, Продуктова команда",
|
||||
"e.g Sales": "наприклад, Продажі",
|
||||
"e.g Space for product team": "наприклад, Простір для продуктової команди",
|
||||
"e.g Space for sales team to collaborate": "наприклад, Простір для спільної роботи команди продажів",
|
||||
"Edit": "Редагувати",
|
||||
"Edit group": "Редагувати групу",
|
||||
"Email": "Електронна пошта",
|
||||
"Enter a strong password": "Введіть надійний пароль",
|
||||
"Enter valid email addresses separated by comma or space max_50": "Введіть дійсні адреси електронної пошти, розділені комою або пробілом [макс: 50]",
|
||||
"enter valid emails addresses": "введіть дійсні адреси електронної пошти",
|
||||
"Enter your current password": "Введіть ваш поточний пароль",
|
||||
"enter your full name": "введіть ваше повне ім'я",
|
||||
"Enter your new password": "Введіть ваш новий пароль",
|
||||
"Enter your new preferred email": "Введіть вашу нову бажану електронну пошту",
|
||||
"Enter your password": "Введіть ваш пароль",
|
||||
"Error fetching page data.": "Помилка при завантаженні даних сторінки.",
|
||||
"Error loading page history.": "Помилка при завантаженні історії сторінки.",
|
||||
"Export": "Експорт",
|
||||
"Failed to create page": "Не вдалося створити сторінку",
|
||||
"Failed to delete page": "Не вдалося видалити сторінку",
|
||||
"Failed to fetch recent pages": "Не вдалося отримати нещодавні сторінки",
|
||||
"Failed to import pages": "Не вдалося імпортувати сторінки",
|
||||
"Failed to load page. An error occurred.": "Не вдалося завантажити сторінку. Сталася помилка.",
|
||||
"Failed to update data": "Не вдалося оновити дані",
|
||||
"Full access": "Повний доступ",
|
||||
"Full page width": "Ширина на всю сторінку",
|
||||
"Full width": "На всю ширину",
|
||||
"General": "Загальні",
|
||||
"Group": "Група",
|
||||
"Group description": "Опис групи",
|
||||
"Group name": "Назва групи",
|
||||
"Groups": "Групи",
|
||||
"Has full access to space settings and pages.": "Має повний доступ до налаштувань простору та сторінок.",
|
||||
"Home": "Головна",
|
||||
"Import pages": "Імпорт сторінок",
|
||||
"Import pages & space settings": "Імпорт сторінок і налаштування простору",
|
||||
"Importing pages": "Імпортування сторінок",
|
||||
"invalid invitation link": "посилання на запрошення недійсне",
|
||||
"Invitation signup": "Реєстрація за запрошенням",
|
||||
"Invite by email": "Запросити електронною поштою",
|
||||
"Invite members": "Запросити учасників",
|
||||
"Invite new members": "Запросити нових учасників",
|
||||
"Invited members who are yet to accept their invitation will appear here.": "Запрошені учасники, які ще не прийняли запрошення, з'являться тут.",
|
||||
"Invited members will be granted access to spaces the groups can access": "Запрошені учасники отримають доступ до просторів, доступ до яких має група",
|
||||
"Join the workspace": "Приєднатися до робочої області",
|
||||
"Language": "Мова",
|
||||
"Light": "Світла",
|
||||
"Link copied": "Посилання скопійовано",
|
||||
"Login": "Увійти",
|
||||
"Logout": "Вийти",
|
||||
"Manage Group": "Керування групою",
|
||||
"Manage members": "Керування учасниками",
|
||||
"member": "учасник",
|
||||
"Member": "Учасник",
|
||||
"members": "учасники",
|
||||
"Members": "Учасники",
|
||||
"My preferences": "Мої налаштування",
|
||||
"My Profile": "Мій профіль",
|
||||
"My profile": "Мій профіль",
|
||||
"Name": "Ім'я",
|
||||
"New email": "Нова електронна адреса",
|
||||
"New page": "Нова сторінка",
|
||||
"New password": "Новий пароль",
|
||||
"No group found": "Групу не знайдено",
|
||||
"No page history saved yet.": "Історія сторінок ще не збережена.",
|
||||
"No pages yet": "Сторінок поки немає",
|
||||
"No results found...": "Результати не знайдено...",
|
||||
"No user found": "Користувача не знайдено",
|
||||
"Overview": "Огляд",
|
||||
"Owner": "Власник",
|
||||
"page": "сторінка",
|
||||
"Page deleted successfully": "Сторінку успішно видалено",
|
||||
"Page history": "Історія сторінки",
|
||||
"Page import is in progress. Please do not close this tab.": "Імпорт сторінки в процесі. Будь ласка, не закривайте цю вкладку.",
|
||||
"Pages": "Сторінки",
|
||||
"pages": "сторінки",
|
||||
"Password": "Пароль",
|
||||
"Password changed successfully": "Пароль успішно змінено",
|
||||
"Pending": "В очікуванні",
|
||||
"Please confirm your action": "Будь ласка, підтвердіть вашу дію",
|
||||
"Preferences": "Налаштування",
|
||||
"Print PDF": "Друк PDF",
|
||||
"Profile": "Профіль",
|
||||
"Recently updated": "Нещодавно оновлено",
|
||||
"Remove": "Видалити",
|
||||
"Remove group member": "Видалити учасника групи",
|
||||
"Remove space member": "Видалити учасника простору",
|
||||
"Restore": "Відновити",
|
||||
"Role": "Роль",
|
||||
"Save": "Зберегти",
|
||||
"Search": "Пошук",
|
||||
"Search for groups": "Пошук груп",
|
||||
"Search for users": "Пошук користувачів",
|
||||
"Search for users and groups": "Пошук користувачів та груп",
|
||||
"Search...": "Пошук...",
|
||||
"Select language": "Оберіть мову",
|
||||
"Select role": "Оберіть роль",
|
||||
"Select role to assign to all invited members": "Оберіть роль для всіх запрошених учасників",
|
||||
"Select theme": "Оберіть тему",
|
||||
"Send invitation": "Надіслати запрошення",
|
||||
"Invitation sent": "Запрошення надіслано",
|
||||
"Settings": "Налаштування",
|
||||
"Setup workspace": "Налаштувати робочу область",
|
||||
"Sign In": "Вхід",
|
||||
"Sign Up": "Реєстрація",
|
||||
"Slug": "Slug",
|
||||
"Space": "Простір",
|
||||
"Space description": "Опис простору",
|
||||
"Space menu": "Меню простору",
|
||||
"Space name": "Назва простору",
|
||||
"Space settings": "Налаштування простору",
|
||||
"Space slug": "Slug простору",
|
||||
"Spaces": "Простори",
|
||||
"Spaces you belong to": "Простори, до яких ви належите",
|
||||
"No space found": "Простори не знайдено",
|
||||
"Search for spaces": "Пошук просторів",
|
||||
"Start typing to search...": "Почніть вводити для пошуку...",
|
||||
"Status": "Статус",
|
||||
"Successfully imported": "Успішно імпортовано",
|
||||
"Successfully restored": "Успішно відновлено",
|
||||
"System settings": "Системні налаштування",
|
||||
"Theme": "Тема",
|
||||
"To change your email, you have to enter your password and new email.": "Щоб змінити електронну пошту, вам потрібно ввести пароль і нову адресу.",
|
||||
"Toggle full page width": "Перемкнути ширину на всю сторінку",
|
||||
"Unable to import pages. Please try again.": "Не вдалося імпортувати сторінки. Будь ласка, спробуйте ще раз.",
|
||||
"untitled": "без назви",
|
||||
"Untitled": "Без назви",
|
||||
"Updated successfully": "Оновлено успішно",
|
||||
"User": "Користувач",
|
||||
"Workspace": "Робоча область",
|
||||
"Workspace Name": "Ім'я робочої області",
|
||||
"Workspace settings": "Налаштування робочої області",
|
||||
"You can change your password here.": "Ви можете змінити свій пароль тут.",
|
||||
"Your Email": "Ваша електронна пошта",
|
||||
"Your import is complete.": "Ваш імпорт завершено.",
|
||||
"Your name": "Ваше ім'я",
|
||||
"Your Name": "Ваше ім'я",
|
||||
"Your password": "Ваш пароль",
|
||||
"Your password must be a minimum of 8 characters.": "Ваш пароль повинен містити мінімум 8 символів.",
|
||||
"Sidebar toggle": "Перемкнути бічну панель",
|
||||
"Comments": "Коментарі",
|
||||
"404 page not found": "404 сторінку не знайдено",
|
||||
"Sorry, we can't find the page you are looking for.": "На жаль, ми не можемо знайти сторінку, яку ви шукаєте.",
|
||||
"Take me back to homepage": "Повернутися на головну сторінку",
|
||||
"Forgot password": "Забули пароль",
|
||||
"Forgot your password?": "Забули пароль?",
|
||||
"A password reset link has been sent to your email. Please check your inbox.": "Посилання для скидання пароля було надіслано на вашу електронну адресу. Будь ласка, перевірте вхідні повідомлення.",
|
||||
"Send reset link": "Надіслати посилання для скидання",
|
||||
"Password reset": "Скидання пароля",
|
||||
"Your new password": "Ваш новий пароль",
|
||||
"Set password": "Встановити пароль",
|
||||
"Write a comment": "Написати коментар",
|
||||
"Reply...": "Відповісти...",
|
||||
"Error loading comments.": "Помилка при завантаженні коментарів.",
|
||||
"No comments yet.": "Коментарів поки немає.",
|
||||
"Edit comment": "Редагувати коментар",
|
||||
"Delete comment": "Видалити коментар",
|
||||
"Are you sure you want to delete this comment?": "Ви впевнені, що хочете видалити цей коментар?",
|
||||
"Comment created successfully": "Коментар успішно створено",
|
||||
"Error creating comment": "Помилка при створенні коментаря",
|
||||
"Comment updated successfully": "Коментар успішно оновлено",
|
||||
"Failed to update comment": "Не вдалося оновити коментар",
|
||||
"Comment deleted successfully": "Коментар успішно видалено",
|
||||
"Failed to delete comment": "Не вдалося видалити коментар",
|
||||
"Comment resolved successfully": "Коментар успішно вирішено",
|
||||
"Comment re-opened successfully": "Коментар успішно відкрито повторно",
|
||||
"Comment unresolved successfully": "Коментар успішно розв'язано",
|
||||
"Failed to resolve comment": "Не вдалося вирішити коментар",
|
||||
"Resolve comment": "Вирішити коментар",
|
||||
"Unresolve comment": "Розв'язати коментар",
|
||||
"Resolve Comment Thread": "Вирішити ланцюжок коментарів",
|
||||
"Unresolve Comment Thread": "Розв'язати ланцюжок коментарів",
|
||||
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "Ви впевнені, що хочете вирішити цей ланцюжок коментарів? Це позначить його як завершений.",
|
||||
"Are you sure you want to unresolve this comment thread?": "Ви впевнені, що хочете розв'язати цей ланцюжок коментарів?",
|
||||
"Resolved": "Вирішено",
|
||||
"No active comments.": "Немає активних коментарів.",
|
||||
"No resolved comments.": "Немає вирішених коментарів.",
|
||||
"Revoke invitation": "Відкликати запрошення",
|
||||
"Revoke": "Відкликати",
|
||||
"Don't": "Ні",
|
||||
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "Ви впевнені, що хочете відкликати це запрошення? Користувач не зможе приєднатися до робочої області.",
|
||||
"Resend invitation": "Надіслати запрошення повторно",
|
||||
"Anyone with this link can join this workspace.": "Будь-хто, хто має це посилання, може приєднатися до цієї робочої області.",
|
||||
"Invite link": "Посилання для запрошення",
|
||||
"Copy": "Копіювати",
|
||||
"Copy to space": "Скопіювати в простір",
|
||||
"Copied": "Скопійовано",
|
||||
"Duplicate": "Дублювати",
|
||||
"Select a user": "Оберіть користувача",
|
||||
"Select a group": "Оберіть групу",
|
||||
"Export all pages and attachments in this space.": "Експортувати всі сторінки та вкладення в цьому просторі.",
|
||||
"Delete space": "Видалити простір",
|
||||
"Are you sure you want to delete this space?": "Ви впевнені, що хочете видалити цей простір?",
|
||||
"Delete this space with all its pages and data.": "Видалити цей простір з усіма його сторінками та даними.",
|
||||
"All pages, comments, attachments and permissions in this space will be deleted irreversibly.": "Усі сторінки, коментарі, вкладення та дозволи в цьому просторі будуть видалені безповоротно.",
|
||||
"Confirm space name": "Підтвердіть назву простору",
|
||||
"Type the space name <b>{{spaceName}}</b> to confirm your action.": "Введіть назву простору <b>{{spaceName}}</b>, щоб підтвердити вашу дію.",
|
||||
"Format": "Формат",
|
||||
"Include subpages": "Включити вкладені сторінки",
|
||||
"Include attachments": "Включити вкладення",
|
||||
"Select export format": "Виберіть формат експорту",
|
||||
"Export failed:": "Експортування не вдалося:",
|
||||
"export error": "помилка експорту",
|
||||
"Export page": "Експорт сторінки",
|
||||
"Export space": "Експорт простору",
|
||||
"Export {{type}}": "Експорт {{type}}",
|
||||
"File exceeds the {{limit}} attachment limit": "Файл перевищує ліміт вкладень {{limit}}",
|
||||
"Align left": "По лівому краю",
|
||||
"Align right": "По правому краю",
|
||||
"Align center": "По центру",
|
||||
"Justify": "По ширині",
|
||||
"Merge cells": "Об'єднати комірки",
|
||||
"Split cell": "Розділити комірку",
|
||||
"Delete column": "Видалити стовпець",
|
||||
"Delete row": "Видалити рядок",
|
||||
"Add left column": "Додати стовпець ліворуч",
|
||||
"Add right column": "Додати стовпець праворуч",
|
||||
"Add row above": "Додати рядок вище",
|
||||
"Add row below": "Додати рядок нижче",
|
||||
"Delete table": "Видалити таблицю",
|
||||
"Info": "Інформація",
|
||||
"Success": "Успішно",
|
||||
"Warning": "Попередження",
|
||||
"Danger": "Важливо",
|
||||
"Mermaid diagram error:": "Помилка діаграми Mermaid:",
|
||||
"Invalid Mermaid diagram": "Неприпустима діаграма Mermaid",
|
||||
"Double-click to edit Draw.io diagram": "Клацніть двічі для редагування діаграми Draw.io",
|
||||
"Exit": "Вийти",
|
||||
"Save & Exit": "Зберегти та вийти",
|
||||
"Double-click to edit Excalidraw diagram": "Клацніть двічі для редагування діаграми Excalidraw",
|
||||
"Paste link": "Вставити посилання",
|
||||
"Edit link": "Редагувати посилання",
|
||||
"Remove link": "Видалити посилання",
|
||||
"Add link": "Додати посилання",
|
||||
"Please enter a valid url": "Будь ласка, введіть коректний url",
|
||||
"Empty equation": "Порожнє рівняння",
|
||||
"Invalid equation": "Неприпустиме рівняння",
|
||||
"Color": "Колір",
|
||||
"Text color": "Колір тексту",
|
||||
"Default": "За замовчуванням",
|
||||
"Blue": "Синій",
|
||||
"Green": "Зелений",
|
||||
"Purple": "Фіолетовий",
|
||||
"Red": "Червоний",
|
||||
"Yellow": "Жовтий",
|
||||
"Orange": "Помаранчевий",
|
||||
"Pink": "Рожевий",
|
||||
"Gray": "Сірий",
|
||||
"Embed link": "Вбудоване посилання",
|
||||
"Invalid {{provider}} embed link": "Невірне посилання для вбудовування {{provider}}",
|
||||
"Embed {{provider}}": "Вбудувати {{provider}}",
|
||||
"Enter {{provider}} link to embed": "Введіть посилання для вбудовування {{provider}}",
|
||||
"Bold": "Жирний",
|
||||
"Italic": "Курсив",
|
||||
"Underline": "Підкреслений",
|
||||
"Strike": "Закреслений",
|
||||
"Code": "Код",
|
||||
"Comment": "Коментар",
|
||||
"Text": "Текст",
|
||||
"Heading 1": "Заголовок 1",
|
||||
"Heading 2": "Заголовок 2",
|
||||
"Heading 3": "Заголовок 3",
|
||||
"To-do List": "Список справ",
|
||||
"Bullet List": "Маркований список",
|
||||
"Numbered List": "Нумерований список",
|
||||
"Blockquote": "Блок цитування",
|
||||
"Just start typing with plain text.": "Просто почніть друкувати звичайний текст.",
|
||||
"Track tasks with a to-do list.": "Відстежуйте завдання за допомогою списку справ.",
|
||||
"Big section heading.": "Великий заголовок розділу.",
|
||||
"Medium section heading.": "Середній заголовок розділу.",
|
||||
"Small section heading.": "Малий заголовок розділу.",
|
||||
"Create a simple bullet list.": "Створити простий маркований список.",
|
||||
"Create a list with numbering.": "Створити нумерований список.",
|
||||
"Create block quote.": "Створити блок цитування.",
|
||||
"Insert code snippet.": "Вставити фрагмент коду.",
|
||||
"Insert horizontal rule divider": "Вставити горизонтальний роздільник",
|
||||
"Upload any image from your device.": "Завантажити будь-яке зображення з вашого пристрою.",
|
||||
"Upload any video from your device.": "Завантажити будь-яке відео з вашого пристрою.",
|
||||
"Upload any file from your device.": "Завантажити будь-який файл з вашого пристрою.",
|
||||
"Table": "Таблиця",
|
||||
"Insert a table.": "Вставити таблицю.",
|
||||
"Insert collapsible block.": "Вставити блок, що згортається.",
|
||||
"Video": "Відео",
|
||||
"Divider": "Роздільник",
|
||||
"Quote": "Цитата",
|
||||
"Image": "Зображення",
|
||||
"File attachment": "Прикріплений файл",
|
||||
"Toggle block": "Блок, що згортається",
|
||||
"Callout": "Виноска",
|
||||
"Insert callout notice.": "Вставити виноску з повідомленням.",
|
||||
"Math inline": "Формула",
|
||||
"Insert inline math equation.": "Вставити математичне рівняння в рядок.",
|
||||
"Math block": "Блок формул",
|
||||
"Insert math equation": "Вставити математичне рівняння",
|
||||
"Mermaid diagram": "Діаграма Mermaid",
|
||||
"Insert mermaid diagram": "Вставити діаграму Mermaid",
|
||||
"Insert and design Drawio diagrams": "Вставити та розробити діаграми Draw.io",
|
||||
"Insert current date": "Вставити поточну дату",
|
||||
"Draw and sketch excalidraw diagrams": "Вставити та малювати діаграми Excalidraw",
|
||||
"Multiple": "Декілька",
|
||||
"Heading {{level}}": "Заголовок {{level}}",
|
||||
"Toggle title": "Перемкнути заголовок",
|
||||
"Write anything. Enter \"/\" for commands": "Почніть писати. Введіть \"/\" для списку команд",
|
||||
"Names do not match": "Назви не співпадають",
|
||||
"Today, {{time}}": "Сьогодні, {{time}}",
|
||||
"Yesterday, {{time}}": "Вчора, {{time}}",
|
||||
"Space created successfully": "Простір успішно створено",
|
||||
"Space updated successfully": "Простір успішно оновлено",
|
||||
"Space deleted successfully": "Простір успішно видалено",
|
||||
"Members added successfully": "Учасників успішно додано",
|
||||
"Member removed successfully": "Учасника успішно видалено",
|
||||
"Member role updated successfully": "Роль учасника успішно оновлено",
|
||||
"Created by: <b>{{creatorName}}</b>": "Автор: <b>{{creatorName}}</b>",
|
||||
"Created at: {{time}}": "Дата створення: {{time}}",
|
||||
"Edited by {{name}} {{time}}": "Змінено {{name}} {{time}}",
|
||||
"Word count: {{wordCount}}": "Кількість слів: {{wordCount}}",
|
||||
"Character count: {{characterCount}}": "Кількість символів: {{characterCount}}",
|
||||
"New update": "Нове оновлення",
|
||||
"{{latestVersion}} is available": "Доступна нова версія {{latestVersion}}",
|
||||
"Default page edit mode": "Режим редагування сторінки за замовчуванням",
|
||||
"Choose your preferred page edit mode. Avoid accidental edits.": "Виберіть бажаний режим редагування сторінки. Уникайте випадкових редагувань.",
|
||||
"Reading": "Читання",
|
||||
"Delete member": "Видалити учасника",
|
||||
"Member deleted successfully": "Учасника успішно видалено",
|
||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "Ви впевнені, що хочете видалити цього учасника робочої області? Ця дія незворотна.",
|
||||
"Move": "Перемістити",
|
||||
"Move page": "Перемістити сторінку",
|
||||
"Move page to a different space.": "Перемістити сторінку в інший простір.",
|
||||
"Real-time editor connection lost. Retrying...": "З'єднання з редактором у реальному часі втрачено. Повторна спроба...",
|
||||
"Table of contents": "Зміст",
|
||||
"Add headings (H1, H2, H3) to generate a table of contents.": "Додайте заголовки (H1, H2, H3), щоб створити зміст.",
|
||||
"Share": "Поділитися",
|
||||
"Public sharing": "Публічний доступ",
|
||||
"Shared by": "Поділився",
|
||||
"Shared at": "Поділився в",
|
||||
"Inherits public sharing from": "Успадковує публічний доступ від",
|
||||
"Share to web": "Поділитися в інтернеті",
|
||||
"Shared to web": "Розміщено в інтернеті",
|
||||
"Anyone with the link can view this page": "Будь-хто, хто має посилання, може переглянути цю сторінку",
|
||||
"Make this page publicly accessible": "Зробити цю сторінку загальнодоступною",
|
||||
"Include sub-pages": "Включити підсторінки",
|
||||
"Make sub-pages public too": "Зробити підсторінки також загальнодоступними",
|
||||
"Allow search engines to index page": "Дозволити пошуковим системам індексувати сторінку",
|
||||
"Open page": "Відкрити сторінку",
|
||||
"Page": "Сторінка",
|
||||
"Delete public share link": "Видалити посилання на публічний доступ",
|
||||
"Delete share": "Видалити спільний доступ",
|
||||
"Are you sure you want to delete this shared link?": "Ви впевнені, що хочете видалити це посилання спільного доступу?",
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "Публічні сторінки з просторів, учасником яких ви є, з'являться тут",
|
||||
"Share deleted successfully": "Спільний доступ успішно видалено",
|
||||
"Share not found": "Спільний доступ не знайдено",
|
||||
"Failed to share page": "Не вдалося поділитися сторінкою",
|
||||
"Copy page": "Копіювати сторінки",
|
||||
"Copy page to a different space.": "Скопіювати сторінку в інший простір.",
|
||||
"Page copied successfully": "Сторінку успішно скопійовано",
|
||||
"Page duplicated successfully": "Сторінку успішно дубльовано",
|
||||
"Find": "Знайти",
|
||||
"Not found": "Не знайдено",
|
||||
"Previous Match (Shift+Enter)": "Попередній збіг (Shift+Enter)",
|
||||
"Next match (Enter)": "Наступний збіг (Enter)",
|
||||
"Match case (Alt+C)": "Враховувати регістр (Alt+C)",
|
||||
"Replace": "Замінити",
|
||||
"Close (Escape)": "Закрити (Escape)",
|
||||
"Replace (Enter)": "Замінити (Enter)",
|
||||
"Replace all (Ctrl+Alt+Enter)": "Замінити все (Ctrl+Alt+Enter)",
|
||||
"Replace all": "Замінити все",
|
||||
"View all spaces": "Переглянути всі простори",
|
||||
"Error": "Помилка",
|
||||
"Failed to disable MFA": "Не вдалося вимкнути MFA",
|
||||
"Disable two-factor authentication": "Вимкнути двоетапну аутентифікацію",
|
||||
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "Вимкнення двоетапної аутентифікації зробить ваш обліковий запис менш захищеним. Для входу потрібен лише пароль.",
|
||||
"Please enter your password to disable two-factor authentication:": "Будь ласка, введіть свій пароль, щоб вимкнути двоетапну аутентифікацію:",
|
||||
"Two-factor authentication has been enabled": "Двоетапну аутентифікацію включено",
|
||||
"Two-factor authentication has been disabled": "Двоетапну аутентифікацію вимкнено",
|
||||
"2-step verification": "Двоетапна перевірка",
|
||||
"Protect your account with an additional verification layer when signing in.": "Захистіть свій обліковий запис за допомогою додаткового шару перевірки при вході.",
|
||||
"Two-factor authentication is active on your account.": "Двоетапну аутентифікацію активовано у вашому обліковому записі.",
|
||||
"Add 2FA method": "Додати метод 2FA",
|
||||
"Backup codes": "Резервні коди",
|
||||
"Disable": "Вимкнути",
|
||||
"Invalid verification code": "Невірний код перевірки",
|
||||
"New backup codes have been generated": "Нові резервні коди створено",
|
||||
"Failed to regenerate backup codes": "Не вдалося повторно створити резервні коди",
|
||||
"About backup codes": "Про резервні коди",
|
||||
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Резервні коди можуть бути використані для доступу до вашого облікового запису, якщо ви втратите доступ до додатку аутентифікатора. Кожен код можна використовувати лише один раз.",
|
||||
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "Ви можете повторно створити нові резервні коди в будь-який час. Це зробить усі існуючі коди недійсними.",
|
||||
"Confirm password": "Підтвердити пароль",
|
||||
"Generate new backup codes": "Створити нові резервні коди",
|
||||
"Save your new backup codes": "Збережіть нові резервні коди",
|
||||
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "Обов'язково збережіть ці коди у безпечному місці. Ваші старі резервні коди більше не дійсні.",
|
||||
"Your new backup codes": "Ваші нові резервні коди",
|
||||
"I've saved my backup codes": "Я зберіг резервні коди",
|
||||
"Failed to setup MFA": "Не вдалося налаштувати MFA",
|
||||
"Setup & Verify": "Налаштувати та перевірити",
|
||||
"Add to authenticator": "Додати до аутентифікатора",
|
||||
"1. Scan this QR code with your authenticator app": "1. Скануйте цей QR-код за допомогою додатку аутентифікатора",
|
||||
"Can't scan the code?": "Не можете відсканувати код?",
|
||||
"Enter this code manually in your authenticator app:": "Введіть цей код вручну у додатку аутентифікатора:",
|
||||
"2. Enter the 6-digit code from your authenticator": "2. Введіть 6-значний код із аутентифікатора",
|
||||
"Verify and enable": "Перевірити та увімкнути",
|
||||
"Failed to generate QR code. Please try again.": "Не вдалося створити QR-код. Будь ласка, спробуйте ще раз.",
|
||||
"Backup": "Резервне копіювання",
|
||||
"Save codes": "Зберегти коди",
|
||||
"Save your backup codes": "Зберегти резервні коди",
|
||||
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Ці коди можуть бути використані для доступу до вашого облікового запису, якщо ви втратите доступ до додатку аутентифікатора. Кожен код можна використовувати лише один раз.",
|
||||
"Print": "Друкувати",
|
||||
"Two-factor authentication has been set up. Please log in again.": "Двоетапну аутентифікацію налаштовано. Будь ласка, увійдіть знову.",
|
||||
"Two-Factor authentication required": "Потрібна двоетапна аутентифікація",
|
||||
"Your workspace requires two-factor authentication for all users": "Ваш робочий простір вимагає двоетапної аутентифікації для всіх користувачів",
|
||||
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "Щоб продовжити доступ до робочого простору, вам потрібно налаштувати двоетапну аутентифікацію. Це додає додатковий шар захисту до вашого облікового запису.",
|
||||
"Set up two-factor authentication": "Налаштувати двоетапну аутентифікацію",
|
||||
"Cancel and logout": "Скасувати та вийти",
|
||||
"Your workspace requires two-factor authentication. Please set it up to continue.": "Ваш робочий простір вимагає двоетапної аутентифікації. Будь ласка, налаштуйте це щоб продовжити.",
|
||||
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "Це додає додатковий шар захисту до вашого облікового запису, вимагаючи код підтвердження з вашого додатку аутентифікатора.",
|
||||
"Password is required": "Вимагається пароль",
|
||||
"Password must be at least 8 characters": "Пароль повинен містити щонайменше 8 символів",
|
||||
"Please enter a 6-digit code": "Будь ласка, введіть 6-значний код",
|
||||
"Code must be exactly 6 digits": "Код повинен мати точно 6 цифр",
|
||||
"Enter the 6-digit code found in your authenticator app": "Введіть 6-значний код з вашого додатку аутентифікатора",
|
||||
"Need help authenticating?": "Потрібна допомога з аутентифікацією?",
|
||||
"MFA QR Code": "MFA QR-код",
|
||||
"Account created successfully. Please log in to set up two-factor authentication.": "Обліковий запис успішно створено. Будь ласка, увійдіть, щоб налаштувати двоетапну аутентифікацію.",
|
||||
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "Скидання паролю успішне. Будь ласка, увійдіть за допомогою нового паролю та завершіть двоетапну аутентифікацію.",
|
||||
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "Скидання паролю успішне. Будь ласка, увійдіть за допомогою нового паролю, щоб налаштувати двоетапну аутентифікацію.",
|
||||
"Password reset was successful. Please log in with your new password.": "Скидання паролю успішне. Будь ласка, увійдіть за допомогою нового паролю.",
|
||||
"Two-factor authentication": "Двоетапна аутентифікація",
|
||||
"Use authenticator app instead": "Використовуйте додаток аутентифікатора замість цього",
|
||||
"Verify backup code": "Перевірити резервний код",
|
||||
"Use backup code": "Використовуйте резервний код",
|
||||
"Enter one of your backup codes": "Введіть один з ваших резервних кодів",
|
||||
"Backup code": "Резервний код",
|
||||
"Enter one of your backup codes. Each backup code can only be used once.": "Введіть один з ваших резервних кодів. Кожен резервний код можна використовувати лише один раз.",
|
||||
"Verify": "Перевірити",
|
||||
"Trash": "Кошик",
|
||||
"Pages in trash will be permanently deleted after 30 days.": "Сторінки у кошику будуть остаточно видалені через 30 днів.",
|
||||
"Deleted": "Видалено",
|
||||
"No pages in trash": "Немає сторінок у кошику",
|
||||
"Permanently delete page?": "Остаточно видалити сторінку?",
|
||||
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Ви впевнені, що хочете остаточно видалити '{{title}}'? Цю дію не можна скасувати.",
|
||||
"Restore '{{title}}' and its sub-pages?": "Відновити '{{title}}' та її підсторінки?",
|
||||
"Move to trash": "Перемістити до кошика",
|
||||
"Move this page to trash?": "Перемістити цю сторінку до кошика?",
|
||||
"Restore page": "Відновити сторінку",
|
||||
"Page moved to trash": "Сторінка переміщена до кошика",
|
||||
"Page restored successfully": "Сторінку успішно відновлено",
|
||||
"Deleted by": "Видалено",
|
||||
"Deleted at": "Видалено о",
|
||||
"Preview": "Попередній перегляд"
|
||||
}
|
||||
@ -213,7 +213,18 @@
|
||||
"Comment deleted successfully": "成功删除评论",
|
||||
"Failed to delete comment": "删除评论失败",
|
||||
"Comment resolved successfully": "成功标记评论为解决",
|
||||
"Comment re-opened successfully": "成功重新打开评论",
|
||||
"Comment unresolved successfully": "成功标记评论为未解决",
|
||||
"Failed to resolve comment": "标记评论为解决失败",
|
||||
"Resolve comment": "解决评论",
|
||||
"Unresolve comment": "取消解决评论",
|
||||
"Resolve Comment Thread": "解决评论线程",
|
||||
"Unresolve Comment Thread": "取消解决评论线程",
|
||||
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "确定要解决此评论线程吗?这将标记为已完成。",
|
||||
"Are you sure you want to unresolve this comment thread?": "确定要取消解决此评论线程吗?",
|
||||
"Resolved": "已解决",
|
||||
"No active comments.": "没有活跃的评论。",
|
||||
"No resolved comments.": "没有已解决的评论。",
|
||||
"Revoke invitation": "撤回邀请",
|
||||
"Revoke": "撤销",
|
||||
"Don't": "不要",
|
||||
@ -222,7 +233,9 @@
|
||||
"Anyone with this link can join this workspace.": "任何拥有此连接的人都可以加入此工作区",
|
||||
"Invite link": "邀请链接",
|
||||
"Copy": "复制",
|
||||
"Copy to space": "复制到空间",
|
||||
"Copied": "已复制",
|
||||
"Duplicate": "重复",
|
||||
"Select a user": "选择一个用户",
|
||||
"Select a group": "选择一个组",
|
||||
"Export all pages and attachments in this space.": "导出当前空间的所有页面和附件",
|
||||
@ -354,6 +367,9 @@
|
||||
"Character count: {{characterCount}}": "字符数:{{characterCount}}",
|
||||
"New update": "新更新",
|
||||
"{{latestVersion}} is available": "{{latestVersion}} 已经可以使用",
|
||||
"Default page edit mode": "默认页面编辑模式",
|
||||
"Choose your preferred page edit mode. Avoid accidental edits.": "选择您偏好的页面编辑模式。避免意外编辑。",
|
||||
"Reading": "阅读",
|
||||
"Delete member": "删除成员",
|
||||
"Member deleted successfully": "成员删除成功",
|
||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "您确定要删除此工作区成员吗?此操作不可逆。",
|
||||
@ -383,5 +399,101 @@
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "您所在空间的公开共享页面会显示在此处",
|
||||
"Share deleted successfully": "分享已成功删除",
|
||||
"Share not found": "未找到分享",
|
||||
"Failed to share page": "页面分享失败"
|
||||
"Failed to share page": "页面分享失败",
|
||||
"Copy page": "复制页面",
|
||||
"Copy page to a different space.": "将页面复制到不同的空间。",
|
||||
"Page copied successfully": "页面复制成功",
|
||||
"Page duplicated successfully": "页面复制成功",
|
||||
"Find": "查找",
|
||||
"Not found": "未找到",
|
||||
"Previous Match (Shift+Enter)": "上一个匹配 (Shift+Enter)",
|
||||
"Next match (Enter)": "下一个匹配 (Enter)",
|
||||
"Match case (Alt+C)": "区分大小写 (Alt+C)",
|
||||
"Replace": "替换",
|
||||
"Close (Escape)": "关闭 (Escape)",
|
||||
"Replace (Enter)": "替换 (Enter)",
|
||||
"Replace all (Ctrl+Alt+Enter)": "全部替换 (Ctrl+Alt+Enter)",
|
||||
"Replace all": "全部替换",
|
||||
"View all spaces": "查看所有空间",
|
||||
"Error": "错误",
|
||||
"Failed to disable MFA": "停用 MFA 失败",
|
||||
"Disable two-factor authentication": "停用双因素认证",
|
||||
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "停用双因素认证会降低账户安全性。您只需密码即可登录。",
|
||||
"Please enter your password to disable two-factor authentication:": "请输入您的密码以停用双因素认证:",
|
||||
"Two-factor authentication has been enabled": "双因素认证已启用",
|
||||
"Two-factor authentication has been disabled": "双因素认证已停用",
|
||||
"2-step verification": "两步验证",
|
||||
"Protect your account with an additional verification layer when signing in.": "通过额外的验证层保护您的账户安全。",
|
||||
"Two-factor authentication is active on your account.": "您的账户已激活双因素认证。",
|
||||
"Add 2FA method": "添加 2FA 方法",
|
||||
"Backup codes": "备份代码",
|
||||
"Disable": "停用",
|
||||
"Invalid verification code": "无效的验证码",
|
||||
"New backup codes have been generated": "已生成新的备份代码",
|
||||
"Failed to regenerate backup codes": "重新生成备份代码失败",
|
||||
"About backup codes": "关于备份代码",
|
||||
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "如果您无法访问身份验证器应用,可使用备份代码访问账户。每个代码仅可使用一次。",
|
||||
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "您可以随时重新生成新的备份代码。这将使所有现有代码失效。",
|
||||
"Confirm password": "确认密码",
|
||||
"Generate new backup codes": "生成新的备份代码",
|
||||
"Save your new backup codes": "保存您的新备份代码",
|
||||
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "请确保将这些代码保存在安全的地方。您的旧备份代码不再有效。",
|
||||
"Your new backup codes": "您的新备份代码",
|
||||
"I've saved my backup codes": "我已经保存了我的备份代码",
|
||||
"Failed to setup MFA": "设置 MFA 失败",
|
||||
"Setup & Verify": "设置并验证",
|
||||
"Add to authenticator": "添加到身份验证器",
|
||||
"1. Scan this QR code with your authenticator app": "1. 用身份验证器应用扫描此二维码",
|
||||
"Can't scan the code?": "无法扫描代码?",
|
||||
"Enter this code manually in your authenticator app:": "在您的身份验证器应用中手动输入此代码:",
|
||||
"2. Enter the 6-digit code from your authenticator": "2. 输入来自身份验证器的6位代码",
|
||||
"Verify and enable": "验证并启用",
|
||||
"Failed to generate QR code. Please try again.": "生成二维码失败。请重试。",
|
||||
"Backup": "备份",
|
||||
"Save codes": "保存代码",
|
||||
"Save your backup codes": "保存您的备份代码",
|
||||
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "如果无法访问身份验证器应用,可以使用这些代码访问账户。每个代码仅可使用一次。",
|
||||
"Print": "打印",
|
||||
"Two-factor authentication has been set up. Please log in again.": "双因素认证已设置。请重新登录。",
|
||||
"Two-Factor authentication required": "需要双因素认证",
|
||||
"Your workspace requires two-factor authentication for all users": "您的工作区要求所有用户启用双因素认证",
|
||||
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "要继续访问工作区,必须设置双因素认证。此操作为您的账户添加一层额外的安全保障。",
|
||||
"Set up two-factor authentication": "设置双因素认证",
|
||||
"Cancel and logout": "取消并退出登录",
|
||||
"Your workspace requires two-factor authentication. Please set it up to continue.": "您的工作区需要双因素认证。请设置以继续。",
|
||||
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "通过要求您的身份验证器应用提供验证码,此操作为您的账户增加了一层额外的安全保障。",
|
||||
"Password is required": "需要密码",
|
||||
"Password must be at least 8 characters": "密码必须至少包含8个字符",
|
||||
"Please enter a 6-digit code": "请输入6位代码",
|
||||
"Code must be exactly 6 digits": "代码必须正好是6位",
|
||||
"Enter the 6-digit code found in your authenticator app": "输入在您的身份验证器应用中找到的6位代码",
|
||||
"Need help authenticating?": "需要帮助进行身份验证吗?",
|
||||
"MFA QR Code": "MFA二维码",
|
||||
"Account created successfully. Please log in to set up two-factor authentication.": "账户创建成功。请登录以设置双因素认证。",
|
||||
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "密码重置成功。请使用新密码登录并完成双因素认证。",
|
||||
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "密码重置成功。请使用新密码登录以设置双因素认证。",
|
||||
"Password reset was successful. Please log in with your new password.": "密码重置成功。请使用新密码登录。",
|
||||
"Two-factor authentication": "双因素认证",
|
||||
"Use authenticator app instead": "改用身份验证器应用",
|
||||
"Verify backup code": "验证备份代码",
|
||||
"Use backup code": "使用备份代码",
|
||||
"Enter one of your backup codes": "输入您的一个备份代码",
|
||||
"Backup code": "备份代码",
|
||||
"Enter one of your backup codes. Each backup code can only be used once.": "输入您的一个备份代码。每个备份代码只能使用一次。",
|
||||
"Verify": "验证",
|
||||
"Trash": "垃圾箱",
|
||||
"Pages in trash will be permanently deleted after 30 days.": "垃圾箱中的页面将在30天后被永久删除。",
|
||||
"Deleted": "已删除",
|
||||
"No pages in trash": "垃圾箱中没有页面",
|
||||
"Permanently delete page?": "永久删除页面?",
|
||||
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "确定要永久删除“{{title}}”吗?此操作无法撤销。",
|
||||
"Restore '{{title}}' and its sub-pages?": "恢复“{{title}}”及其子页面?",
|
||||
"Move to trash": "移至垃圾箱",
|
||||
"Move this page to trash?": "将此页面移至垃圾箱?",
|
||||
"Restore page": "恢复页面",
|
||||
"Page moved to trash": "页面已移至垃圾箱",
|
||||
"Page restored successfully": "页面恢复成功",
|
||||
"Deleted by": "删除人",
|
||||
"Deleted at": "删除时间",
|
||||
"Preview": "预览"
|
||||
}
|
||||
|
||||
@ -29,8 +29,12 @@ import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-selec
|
||||
import SharedPage from "@/pages/share/shared-page.tsx";
|
||||
import Shares from "@/pages/settings/shares/shares.tsx";
|
||||
import ShareLayout from "@/features/share/components/share-layout.tsx";
|
||||
import ShareRedirect from '@/pages/share/share-redirect.tsx';
|
||||
import ShareRedirect from "@/pages/share/share-redirect.tsx";
|
||||
import { useTrackOrigin } from "@/hooks/use-track-origin";
|
||||
import SpacesPage from "@/pages/spaces/spaces.tsx";
|
||||
import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page";
|
||||
import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page";
|
||||
import SpaceTrash from "@/pages/space/space-trash.tsx";
|
||||
|
||||
export default function App() {
|
||||
const { t } = useTranslation();
|
||||
@ -45,6 +49,8 @@ export default function App() {
|
||||
<Route path={"/invites/:invitationId"} element={<InviteSignup />} />
|
||||
<Route path={"/forgot-password"} element={<ForgotPassword />} />
|
||||
<Route path={"/password-reset"} element={<PasswordReset />} />
|
||||
<Route path={"/login/mfa"} element={<MfaChallengePage />} />
|
||||
<Route path={"/login/mfa/setup"} element={<MfaSetupRequiredPage />} />
|
||||
|
||||
{!isCloud() && (
|
||||
<Route path={"/setup/register"} element={<SetupWorkspace />} />
|
||||
@ -58,7 +64,10 @@ export default function App() {
|
||||
)}
|
||||
|
||||
<Route element={<ShareLayout />}>
|
||||
<Route path={"/share/:shareId/p/:pageSlug"} element={<SharedPage />} />
|
||||
<Route
|
||||
path={"/share/:shareId/p/:pageSlug"}
|
||||
element={<SharedPage />}
|
||||
/>
|
||||
<Route path={"/share/p/:pageSlug"} element={<SharedPage />} />
|
||||
</Route>
|
||||
|
||||
@ -67,7 +76,9 @@ export default function App() {
|
||||
|
||||
<Route element={<Layout />}>
|
||||
<Route path={"/home"} element={<Home />} />
|
||||
<Route path={"/spaces"} element={<SpacesPage />} />
|
||||
<Route path={"/s/:spaceSlug"} element={<SpaceHome />} />
|
||||
<Route path={"/s/:spaceSlug/trash"} element={<SpaceTrash />} />
|
||||
<Route
|
||||
path={"/s/:spaceSlug/p/:pageSlug"}
|
||||
element={
|
||||
|
||||
@ -29,19 +29,22 @@ export default function ExportModal({
|
||||
}: ExportModalProps) {
|
||||
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
|
||||
const [includeChildren, setIncludeChildren] = useState<boolean>(false);
|
||||
const [includeAttachments, setIncludeAttachments] = useState<boolean>(true);
|
||||
const [includeAttachments, setIncludeAttachments] = useState<boolean>(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
if (type === "page") {
|
||||
await exportPage({ pageId: id, format, includeChildren });
|
||||
await exportPage({
|
||||
pageId: id,
|
||||
format,
|
||||
includeChildren,
|
||||
includeAttachments,
|
||||
});
|
||||
}
|
||||
if (type === "space") {
|
||||
await exportSpace({ spaceId: id, format, includeAttachments });
|
||||
}
|
||||
setIncludeChildren(false);
|
||||
setIncludeAttachments(true);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
@ -96,6 +99,18 @@ export default function ExportModal({
|
||||
checked={includeChildren}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between" wrap="nowrap" mt="md">
|
||||
<div>
|
||||
<Text size="md">{t("Include attachments")}</Text>
|
||||
</div>
|
||||
<Switch
|
||||
onChange={(event) =>
|
||||
setIncludeAttachments(event.currentTarget.checked)
|
||||
}
|
||||
checked={includeAttachments}
|
||||
/>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
24
apps/client/src/components/common/user-info.tsx
Normal file
24
apps/client/src/components/common/user-info.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { Group, Text } from "@mantine/core";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import React from "react";
|
||||
import { IUser } from '@/features/user/types/user.types.ts';
|
||||
|
||||
interface UserInfoProps {
|
||||
user: Partial<IUser>;
|
||||
size?: string;
|
||||
}
|
||||
export function UserInfo({ user, size }: UserInfoProps) {
|
||||
return (
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<CustomAvatar avatarUrl={user?.avatarUrl} name={user?.name} size={size} />
|
||||
<div>
|
||||
<Text fz="sm" fw={500} lineClamp={1}>
|
||||
{user?.name}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{user?.email}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
20
apps/client/src/components/icons/confluence-icon.tsx
Normal file
20
apps/client/src/components/icons/confluence-icon.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { rem } from "@mantine/core";
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
export function ConfluenceIcon({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<path d="M.87 18.257c-.248.382-.53.875-.763 1.245a.764.764 0 0 0 .255 1.04l4.965 3.054a.764.764 0 0 0 1.058-.26c.199-.332.454-.763.733-1.221 1.967-3.247 3.945-2.853 7.508-1.146l4.957 2.337a.764.764 0 0 0 1.028-.382l2.364-5.346a.764.764 0 0 0-.382-1 599.851 599.851 0 0 1-4.965-2.361C10.911 10.97 5.224 11.185.87 18.257zM23.131 5.743c.249-.405.531-.875.764-1.25a.764.764 0 0 0-.256-1.034L18.675.404a.764.764 0 0 0-1.058.26c-.195.335-.451.763-.734 1.225-1.966 3.246-3.945 2.85-7.508 1.146L4.437.694a.764.764 0 0 0-1.027.382L1.046 6.422a.764.764 0 0 0 .382 1c1.039.49 3.105 1.467 4.965 2.361 6.698 3.246 12.392 3.029 16.738-4.04z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@ -27,6 +27,8 @@ export function AppHeader() {
|
||||
const { isTrial, trialDaysLeft } = useTrial();
|
||||
|
||||
const isHomeRoute = location.pathname.startsWith("/home");
|
||||
const isSpacesRoute = location.pathname === "/spaces";
|
||||
const hideSidebar = isHomeRoute || isSpacesRoute;
|
||||
|
||||
const items = links.map((link) => (
|
||||
<Link key={link.label} to={link.link} className={classes.link}>
|
||||
@ -38,7 +40,7 @@ export function AppHeader() {
|
||||
<>
|
||||
<Group h="100%" px="md" justify="space-between" wrap={"nowrap"}>
|
||||
<Group wrap="nowrap">
|
||||
{!isHomeRoute && (
|
||||
{!hideSidebar && (
|
||||
<>
|
||||
<Tooltip label={t("Sidebar toggle")}>
|
||||
<SidebarToggle
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Box, ScrollArea, Text } from "@mantine/core";
|
||||
import CommentList from "@/features/comment/components/comment-list.tsx";
|
||||
import CommentListWithTabs from "@/features/comment/components/comment-list-with-tabs.tsx";
|
||||
import { useAtom } from "jotai";
|
||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
import React, { ReactNode } from "react";
|
||||
@ -18,7 +18,7 @@ export default function Aside() {
|
||||
|
||||
switch (tab) {
|
||||
case "comments":
|
||||
component = <CommentList />;
|
||||
component = <CommentListWithTabs />;
|
||||
title = "Comments";
|
||||
break;
|
||||
case "toc":
|
||||
@ -38,13 +38,17 @@ export default function Aside() {
|
||||
{t(title)}
|
||||
</Text>
|
||||
|
||||
<ScrollArea
|
||||
style={{ height: "85vh" }}
|
||||
scrollbarSize={5}
|
||||
type="scroll"
|
||||
>
|
||||
<div style={{ paddingBottom: "200px" }}>{component}</div>
|
||||
</ScrollArea>
|
||||
{tab === "comments" ? (
|
||||
<CommentListWithTabs />
|
||||
) : (
|
||||
<ScrollArea
|
||||
style={{ height: "85vh" }}
|
||||
scrollbarSize={5}
|
||||
type="scroll"
|
||||
>
|
||||
<div style={{ paddingBottom: "200px" }}>{component}</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@ -73,13 +73,15 @@ export default function GlobalAppShell({
|
||||
const isSettingsRoute = location.pathname.startsWith("/settings");
|
||||
const isSpaceRoute = location.pathname.startsWith("/s/");
|
||||
const isHomeRoute = location.pathname.startsWith("/home");
|
||||
const isSpacesRoute = location.pathname === "/spaces";
|
||||
const isPageRoute = location.pathname.includes("/p/");
|
||||
const hideSidebar = isHomeRoute || isSpacesRoute;
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
header={{ height: 45 }}
|
||||
navbar={
|
||||
!isHomeRoute && {
|
||||
!hideSidebar && {
|
||||
width: isSpaceRoute ? sidebarWidth : 300,
|
||||
breakpoint: "sm",
|
||||
collapsed: {
|
||||
@ -100,7 +102,7 @@ export default function GlobalAppShell({
|
||||
<AppShell.Header px="md" className={classes.header}>
|
||||
<AppHeader />
|
||||
</AppShell.Header>
|
||||
{!isHomeRoute && (
|
||||
{!hideSidebar && (
|
||||
<AppShell.Navbar
|
||||
className={classes.navbar}
|
||||
withBorder={false}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { UserProvider } from "@/features/user/user-provider.tsx";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
|
||||
import { PosthogUser } from "@/ee/components/posthog-user.tsx";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
@ -8,6 +10,7 @@ export default function Layout() {
|
||||
<GlobalAppShell>
|
||||
<Outlet />
|
||||
</GlobalAppShell>
|
||||
{isCloud() && <PosthogUser />}
|
||||
</UserProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,9 +1,21 @@
|
||||
import { Group, Menu, UnstyledButton, Text } from "@mantine/core";
|
||||
import {
|
||||
Group,
|
||||
Menu,
|
||||
UnstyledButton,
|
||||
Text,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconBrightnessFilled,
|
||||
IconBrush,
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconDeviceDesktop,
|
||||
IconLogout,
|
||||
IconMoon,
|
||||
IconSettings,
|
||||
IconSun,
|
||||
IconUserCircle,
|
||||
IconUsers,
|
||||
} from "@tabler/icons-react";
|
||||
@ -19,6 +31,7 @@ export default function TopMenu() {
|
||||
const { t } = useTranslation();
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const { logout } = useAuth();
|
||||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||
|
||||
const user = currentUser?.user;
|
||||
const workspace = currentUser?.workspace;
|
||||
@ -75,7 +88,7 @@ export default function TopMenu() {
|
||||
name={user.name}
|
||||
/>
|
||||
|
||||
<div style={{width: 190}}>
|
||||
<div style={{ width: 190 }}>
|
||||
<Text size="sm" fw={500} lineClamp={1}>
|
||||
{user.name}
|
||||
</Text>
|
||||
@ -101,6 +114,44 @@ export default function TopMenu() {
|
||||
{t("My preferences")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Sub>
|
||||
<Menu.Sub.Target>
|
||||
<Menu.Sub.Item leftSection={<IconBrightnessFilled size={16} />}>
|
||||
{t("Theme")}
|
||||
</Menu.Sub.Item>
|
||||
</Menu.Sub.Target>
|
||||
|
||||
<Menu.Sub.Dropdown>
|
||||
<Menu.Item
|
||||
onClick={() => setColorScheme("light")}
|
||||
leftSection={<IconSun size={16} />}
|
||||
rightSection={
|
||||
colorScheme === "light" ? <IconCheck size={16} /> : null
|
||||
}
|
||||
>
|
||||
{t("Light")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() => setColorScheme("dark")}
|
||||
leftSection={<IconMoon size={16} />}
|
||||
rightSection={
|
||||
colorScheme === "dark" ? <IconCheck size={16} /> : null
|
||||
}
|
||||
>
|
||||
{t("Dark")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() => setColorScheme("auto")}
|
||||
leftSection={<IconDeviceDesktop size={16} />}
|
||||
rightSection={
|
||||
colorScheme === "auto" ? <IconCheck size={16} /> : null
|
||||
}
|
||||
>
|
||||
{t("System settings")}
|
||||
</Menu.Item>
|
||||
</Menu.Sub.Dropdown>
|
||||
</Menu.Sub>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item onClick={logout} leftSection={<IconLogout size={16} />}>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Group, Text, ScrollArea, ActionIcon } from "@mantine/core";
|
||||
import { Group, Text, ScrollArea, ActionIcon, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconUser,
|
||||
IconSettings,
|
||||
@ -42,6 +42,7 @@ interface DataItem {
|
||||
isEnterprise?: boolean;
|
||||
isAdmin?: boolean;
|
||||
isSelfhosted?: boolean;
|
||||
showDisabledInNonEE?: boolean;
|
||||
}
|
||||
|
||||
interface DataGroup {
|
||||
@ -84,6 +85,7 @@ const groupedData: DataGroup[] = [
|
||||
isCloud: true,
|
||||
isEnterprise: true,
|
||||
isAdmin: true,
|
||||
showDisabledInNonEE: true,
|
||||
},
|
||||
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
|
||||
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
|
||||
@ -117,6 +119,11 @@ export default function SettingsSidebar() {
|
||||
}, [location.pathname]);
|
||||
|
||||
const canShowItem = (item: DataItem) => {
|
||||
if (item.showDisabledInNonEE && item.isEnterprise) {
|
||||
// Check admin permission regardless of license
|
||||
return item.isAdmin ? isAdmin : true;
|
||||
}
|
||||
|
||||
if (item.isCloud && item.isEnterprise) {
|
||||
if (!(isCloud() || workspace?.hasLicenseKey)) return false;
|
||||
return item.isAdmin ? isAdmin : true;
|
||||
@ -141,6 +148,13 @@ export default function SettingsSidebar() {
|
||||
return true;
|
||||
};
|
||||
|
||||
const isItemDisabled = (item: DataItem) => {
|
||||
if (item.showDisabledInNonEE && item.isEnterprise) {
|
||||
return !(isCloud() || workspace?.hasLicenseKey);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const menuItems = groupedData.map((group) => {
|
||||
if (group.heading === "System" && (!isAdmin || isCloud())) {
|
||||
return null;
|
||||
@ -185,23 +199,48 @@ export default function SettingsSidebar() {
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
const isDisabled = isItemDisabled(item);
|
||||
const linkElement = (
|
||||
<Link
|
||||
onMouseEnter={prefetchHandler}
|
||||
onMouseEnter={!isDisabled ? prefetchHandler : undefined}
|
||||
className={classes.link}
|
||||
data-active={active.startsWith(item.path) || undefined}
|
||||
data-disabled={isDisabled || undefined}
|
||||
key={item.label}
|
||||
to={item.path}
|
||||
onClick={() => {
|
||||
to={isDisabled ? "#" : item.path}
|
||||
onClick={(e) => {
|
||||
if (isDisabled) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (mobileSidebarOpened) {
|
||||
toggleMobileSidebar();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
opacity: isDisabled ? 0.5 : 1,
|
||||
cursor: isDisabled ? "not-allowed" : "pointer",
|
||||
}}
|
||||
>
|
||||
<item.icon className={classes.linkIcon} stroke={2} />
|
||||
<span>{t(item.label)}</span>
|
||||
</Link>
|
||||
);
|
||||
|
||||
if (isDisabled) {
|
||||
return (
|
||||
<Tooltip
|
||||
key={item.label}
|
||||
label={t("Available in enterprise edition")}
|
||||
position="right"
|
||||
withArrow
|
||||
>
|
||||
{linkElement}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return linkElement;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -30,12 +30,12 @@ export default function BillingDetails() {
|
||||
>
|
||||
Plan
|
||||
</Text>
|
||||
<Text fw={700} fz="lg">
|
||||
{
|
||||
plans.find(
|
||||
(plan) => plan.productId === billing.stripeProductId,
|
||||
)?.name
|
||||
}
|
||||
<Text fw={700} fz="lg" tt="capitalize">
|
||||
{plans.find(
|
||||
(plan) => plan.productId === billing.stripeProductId,
|
||||
)?.name ||
|
||||
billing.planName ||
|
||||
"Standard"}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
@ -112,18 +112,59 @@ export default function BillingDetails() {
|
||||
fz="xs"
|
||||
className={classes.label}
|
||||
>
|
||||
Total
|
||||
</Text>
|
||||
<Text fw={700} fz="lg">
|
||||
{(billing.amount / 100) * billing.quantity}{" "}
|
||||
{billing.currency.toUpperCase()}
|
||||
</Text>
|
||||
<Text c="dimmed" fz="sm">
|
||||
${billing.amount / 100} /user/{billing.interval}
|
||||
Cost
|
||||
</Text>
|
||||
{billing.billingScheme === "tiered" && (
|
||||
<>
|
||||
<Text fw={700} fz="lg">
|
||||
${billing.amount / 100} {billing.currency.toUpperCase()} /{" "}
|
||||
{billing.interval}
|
||||
</Text>
|
||||
<Text c="dimmed" fz="sm">
|
||||
per {billing.interval}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{billing.billingScheme !== "tiered" && (
|
||||
<>
|
||||
<Text fw={700} fz="lg">
|
||||
{(billing.amount / 100) * billing.quantity}{" "}
|
||||
{billing.currency.toUpperCase()} / {billing.interval}
|
||||
</Text>
|
||||
<Text c="dimmed" fz="sm">
|
||||
${billing.amount / 100} /user/{billing.interval}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{billing.billingScheme === "tiered" && billing.tieredUpTo && (
|
||||
<Paper p="md" radius="md">
|
||||
<Group justify="apart">
|
||||
<div>
|
||||
<Text
|
||||
c="dimmed"
|
||||
tt="uppercase"
|
||||
fw={700}
|
||||
fz="xs"
|
||||
className={classes.label}
|
||||
>
|
||||
Current Tier
|
||||
</Text>
|
||||
<Text fw={700} fz="lg">
|
||||
For {billing.tieredUpTo} users
|
||||
</Text>
|
||||
{/*billing.tieredFlatAmount && (
|
||||
<Text c="dimmed" fz="sm">
|
||||
</Text>
|
||||
)*/}
|
||||
</div>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -2,24 +2,32 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
List,
|
||||
SegmentedControl,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
Text,
|
||||
Group,
|
||||
Select,
|
||||
Container,
|
||||
Stack,
|
||||
Badge,
|
||||
Flex,
|
||||
Switch,
|
||||
Alert,
|
||||
} from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
import { useBillingPlans } from "@/ee/billing/queries/billing-query.ts";
|
||||
import { IconCheck, IconInfoCircle } from "@tabler/icons-react";
|
||||
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
|
||||
import { useBillingPlans } from "@/ee/billing/queries/billing-query.ts";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
|
||||
|
||||
export default function BillingPlans() {
|
||||
const { data: plans } = useBillingPlans();
|
||||
const [interval, setInterval] = useState("yearly");
|
||||
|
||||
if (!plans) {
|
||||
return null;
|
||||
}
|
||||
const workspace = useAtomValue(workspaceAtom);
|
||||
const [isAnnual, setIsAnnual] = useState(true);
|
||||
const [selectedTierValue, setSelectedTierValue] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const handleCheckout = async (priceId: string) => {
|
||||
try {
|
||||
@ -32,84 +40,194 @@ export default function BillingPlans() {
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: remove by July 30.
|
||||
// Check if workspace was created between June 28 and July 14, 2025
|
||||
const showTieredPricingNotice = (() => {
|
||||
if (!workspace?.createdAt) return false;
|
||||
const createdDate = new Date(workspace.createdAt);
|
||||
const startDate = new Date('2025-06-20');
|
||||
const endDate = new Date('2025-07-14');
|
||||
return createdDate >= startDate && createdDate <= endDate;
|
||||
})();
|
||||
|
||||
if (!plans || plans.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if any plan is tiered
|
||||
const hasTieredPlans = plans.some(plan => plan.billingScheme === 'tiered' && plan.pricingTiers?.length > 0);
|
||||
const firstTieredPlan = plans.find(plan => plan.billingScheme === 'tiered' && plan.pricingTiers?.length > 0);
|
||||
|
||||
// Set initial tier value if not set and we have tiered plans
|
||||
if (hasTieredPlans && !selectedTierValue && firstTieredPlan) {
|
||||
setSelectedTierValue(firstTieredPlan.pricingTiers[0].upTo.toString());
|
||||
return null;
|
||||
}
|
||||
|
||||
// For tiered plans, ensure we have a selected tier
|
||||
if (hasTieredPlans && !selectedTierValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectData = firstTieredPlan?.pricingTiers
|
||||
?.filter((tier) => !tier.custom)
|
||||
.map((tier, index) => {
|
||||
const prevMaxUsers =
|
||||
index > 0 ? firstTieredPlan.pricingTiers[index - 1].upTo : 0;
|
||||
return {
|
||||
value: tier.upTo.toString(),
|
||||
label: `${prevMaxUsers + 1}-${tier.upTo} users`,
|
||||
};
|
||||
}) || [];
|
||||
|
||||
return (
|
||||
<Group justify="center" p="xl">
|
||||
{plans.map((plan) => {
|
||||
const price =
|
||||
interval === "monthly" ? plan.price.monthly : plan.price.yearly;
|
||||
const priceId = interval === "monthly" ? plan.monthlyId : plan.yearlyId;
|
||||
const yearlyMonthPrice = parseInt(plan.price.yearly) / 12;
|
||||
<Container size="xl" py="xl">
|
||||
{/* Tiered pricing notice for eligible workspaces */}
|
||||
{showTieredPricingNotice && !hasTieredPlans && (
|
||||
<Alert
|
||||
icon={<IconInfoCircle size={16} />}
|
||||
title="Want the old tiered pricing?"
|
||||
color="blue"
|
||||
mb="lg"
|
||||
>
|
||||
Contact support to switch back to our tiered pricing model.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={plan.name}
|
||||
withBorder
|
||||
radius="md"
|
||||
shadow="sm"
|
||||
p="xl"
|
||||
w={300}
|
||||
>
|
||||
<SegmentedControl
|
||||
value={interval}
|
||||
onChange={setInterval}
|
||||
fullWidth
|
||||
data={[
|
||||
{ label: "Monthly", value: "monthly" },
|
||||
{ label: "Yearly (25% OFF)", value: "yearly" },
|
||||
]}
|
||||
{/* Controls Section */}
|
||||
<Stack gap="xl" mb="md">
|
||||
{/* Team Size and Billing Controls */}
|
||||
<Group justify="center" align="center" gap="sm">
|
||||
{hasTieredPlans && (
|
||||
<Select
|
||||
label="Team size"
|
||||
description="Select the number of users"
|
||||
value={selectedTierValue}
|
||||
onChange={setSelectedTierValue}
|
||||
data={selectData}
|
||||
w={250}
|
||||
size="md"
|
||||
allowDeselect={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Title order={3} ta="center" mt="sm" mb="xs">
|
||||
{plan.name}
|
||||
</Title>
|
||||
<Text ta="center" size="lg" fw={700}>
|
||||
{interval === "monthly" && (
|
||||
<>
|
||||
${price}{" "}
|
||||
<Text span size="sm" fw={500} c="dimmed">
|
||||
/user/month
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{interval === "yearly" && (
|
||||
<>
|
||||
${yearlyMonthPrice}{" "}
|
||||
<Text span size="sm" fw={500} c="dimmed">
|
||||
/user/month
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<br/>
|
||||
<Text span ta="center" size="md" fw={500} c="dimmed">
|
||||
billed {interval}
|
||||
</Text>
|
||||
</Text>
|
||||
|
||||
<Card.Section mt="lg">
|
||||
<Button onClick={() => handleCheckout(priceId)} fullWidth>
|
||||
Subscribe
|
||||
</Button>
|
||||
</Card.Section>
|
||||
|
||||
<Card.Section mt="md">
|
||||
<List
|
||||
spacing="xs"
|
||||
<Group justify="center" align="start">
|
||||
<Flex justify="center" gap="md" align="center">
|
||||
<Text size="md">Monthly</Text>
|
||||
<Switch
|
||||
defaultChecked={isAnnual}
|
||||
onChange={(event) => setIsAnnual(event.target.checked)}
|
||||
size="sm"
|
||||
center
|
||||
icon={
|
||||
<ThemeIcon variant="light" size={24} radius="xl">
|
||||
<IconCheck size={16} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
>
|
||||
{plan.features.map((feature, index) => (
|
||||
<List.Item key={index}>{feature}</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
/>
|
||||
<Text size="md">
|
||||
Annually
|
||||
<Badge component="span" variant="light" color="blue">
|
||||
15% OFF
|
||||
</Badge>
|
||||
</Text>
|
||||
</Flex>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
{/* Plans Grid */}
|
||||
<Group justify="center" gap="lg" align="stretch">
|
||||
{plans.map((plan, index) => {
|
||||
let price;
|
||||
let displayPrice;
|
||||
const priceId = isAnnual ? plan.yearlyId : plan.monthlyId;
|
||||
|
||||
if (plan.billingScheme === 'tiered' && plan.pricingTiers?.length > 0) {
|
||||
// Tiered billing logic
|
||||
const planSelectedTier =
|
||||
plan.pricingTiers.find(
|
||||
(tier) => tier.upTo.toString() === selectedTierValue,
|
||||
) || plan.pricingTiers[0];
|
||||
|
||||
price = isAnnual
|
||||
? planSelectedTier.yearly
|
||||
: planSelectedTier.monthly;
|
||||
displayPrice = isAnnual ? (price / 12).toFixed(0) : price;
|
||||
} else {
|
||||
// Per-unit billing logic
|
||||
const monthlyPrice = parseFloat(plan.price?.monthly || '0');
|
||||
const yearlyPrice = parseFloat(plan.price?.yearly || '0');
|
||||
price = isAnnual ? yearlyPrice : monthlyPrice;
|
||||
displayPrice = isAnnual ? (yearlyPrice / 12).toFixed(0) : monthlyPrice;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={plan.name}
|
||||
withBorder
|
||||
radius="lg"
|
||||
shadow="sm"
|
||||
p="xl"
|
||||
w={350}
|
||||
miw={300}
|
||||
style={{
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<Stack gap="lg">
|
||||
{/* Plan Header */}
|
||||
<Stack gap="xs">
|
||||
<Title order={3} size="h4">
|
||||
{plan.name}
|
||||
</Title>
|
||||
{plan.description && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{plan.description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Pricing */}
|
||||
<Stack gap="xs">
|
||||
<Group align="baseline" gap="xs">
|
||||
<Title order={1} size="h1">
|
||||
${displayPrice}
|
||||
</Title>
|
||||
<Text size="lg" c="dimmed">
|
||||
{plan.billingScheme === 'per_unit'
|
||||
? `per user/month`
|
||||
: `per month`}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{isAnnual ? "Billed annually" : "Billed monthly"}
|
||||
</Text>
|
||||
{plan.billingScheme === 'tiered' && plan.pricingTiers && (
|
||||
<Text size="md" fw={500}>
|
||||
For {plan.pricingTiers.find(tier => tier.upTo.toString() === selectedTierValue)?.upTo || plan.pricingTiers[0].upTo} users
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* CTA Button */}
|
||||
<Button onClick={() => handleCheckout(priceId)} fullWidth>
|
||||
Subscribe
|
||||
</Button>
|
||||
|
||||
{/* Features */}
|
||||
<List
|
||||
spacing="xs"
|
||||
size="sm"
|
||||
icon={
|
||||
<ThemeIcon size={20} radius="xl">
|
||||
<IconCheck size={14} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
>
|
||||
{plan.features.map((feature, featureIndex) => (
|
||||
<List.Item key={featureIndex}>{feature}</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
export enum BillingPlan {
|
||||
STANDARD = "standard",
|
||||
BUSINESS = "business",
|
||||
}
|
||||
|
||||
export interface IBilling {
|
||||
@ -24,6 +25,11 @@ export interface IBilling {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
deletedAt: Date;
|
||||
billingScheme: string | null;
|
||||
tieredUpTo: string | null;
|
||||
tieredFlatAmount: number | null;
|
||||
tieredUnitAmount: number | null;
|
||||
planName: string | null;
|
||||
}
|
||||
|
||||
export interface ICheckoutLink {
|
||||
@ -41,9 +47,18 @@ export interface IBillingPlan {
|
||||
monthlyId: string;
|
||||
yearlyId: string;
|
||||
currency: string;
|
||||
price: {
|
||||
price?: {
|
||||
monthly: string;
|
||||
yearly: string;
|
||||
};
|
||||
features: string[];
|
||||
billingScheme: string | null;
|
||||
pricingTiers?: PricingTier[];
|
||||
}
|
||||
|
||||
interface PricingTier {
|
||||
upTo: number;
|
||||
monthly?: number;
|
||||
yearly?: number;
|
||||
custom?: boolean;
|
||||
}
|
||||
67
apps/client/src/ee/comment/components/resolve-comment.tsx
Normal file
67
apps/client/src/ee/comment/components/resolve-comment.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { IconCircleCheck, IconCircleCheckFilled } from "@tabler/icons-react";
|
||||
import { useResolveCommentMutation } from "@/ee/comment/queries/comment-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Editor } from "@tiptap/react";
|
||||
|
||||
interface ResolveCommentProps {
|
||||
editor: Editor;
|
||||
commentId: string;
|
||||
pageId: string;
|
||||
resolvedAt?: Date;
|
||||
}
|
||||
|
||||
function ResolveComment({
|
||||
editor,
|
||||
commentId,
|
||||
pageId,
|
||||
resolvedAt,
|
||||
}: ResolveCommentProps) {
|
||||
const { t } = useTranslation();
|
||||
const resolveCommentMutation = useResolveCommentMutation();
|
||||
|
||||
const isResolved = resolvedAt != null;
|
||||
const iconColor = isResolved ? "green" : "gray";
|
||||
|
||||
const handleResolveToggle = async () => {
|
||||
try {
|
||||
await resolveCommentMutation.mutateAsync({
|
||||
commentId,
|
||||
pageId,
|
||||
resolved: !isResolved,
|
||||
});
|
||||
|
||||
if (editor) {
|
||||
editor.commands.setCommentResolved(commentId, !isResolved);
|
||||
}
|
||||
|
||||
//
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle resolved state:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label={isResolved ? t("Re-Open comment") : t("Resolve comment")}
|
||||
position="top"
|
||||
>
|
||||
<ActionIcon
|
||||
onClick={handleResolveToggle}
|
||||
variant="subtle"
|
||||
color={isResolved ? "green" : "gray"}
|
||||
size="sm"
|
||||
loading={resolveCommentMutation.isPending}
|
||||
disabled={resolveCommentMutation.isPending}
|
||||
>
|
||||
{isResolved ? (
|
||||
<IconCircleCheckFilled size={18} />
|
||||
) : (
|
||||
<IconCircleCheck size={18} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default ResolveComment;
|
||||
87
apps/client/src/ee/comment/queries/comment-query.ts
Normal file
87
apps/client/src/ee/comment/queries/comment-query.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import {
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { resolveComment } from "@/features/comment/services/comment-service";
|
||||
import {
|
||||
IComment,
|
||||
IResolveComment,
|
||||
} from "@/features/comment/types/comment.types";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||
import { RQ_KEY } from "@/features/comment/queries/comment-query";
|
||||
|
||||
export function useResolveCommentMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const emit = useQueryEmit();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: IResolveComment) => resolveComment(data),
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({ queryKey: RQ_KEY(variables.pageId) });
|
||||
const previousComments = queryClient.getQueryData(RQ_KEY(variables.pageId));
|
||||
queryClient.setQueryData(RQ_KEY(variables.pageId), (old: IPagination<IComment>) => {
|
||||
if (!old || !old.items) return old;
|
||||
const updatedItems = old.items.map((comment) =>
|
||||
comment.id === variables.commentId
|
||||
? {
|
||||
...comment,
|
||||
resolvedAt: variables.resolved ? new Date() : null,
|
||||
resolvedById: variables.resolved ? 'optimistic-user' : null,
|
||||
resolvedBy: variables.resolved ? { id: 'optimistic-user', name: 'Resolving...', avatarUrl: null } : null
|
||||
}
|
||||
: comment,
|
||||
);
|
||||
return {
|
||||
...old,
|
||||
items: updatedItems,
|
||||
};
|
||||
});
|
||||
return { previousComments };
|
||||
},
|
||||
onError: (err, variables, context) => {
|
||||
if (context?.previousComments) {
|
||||
queryClient.setQueryData(RQ_KEY(variables.pageId), context.previousComments);
|
||||
}
|
||||
notifications.show({
|
||||
message: t("Failed to resolve comment"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
onSuccess: (data: IComment, variables) => {
|
||||
const pageId = data.pageId;
|
||||
const currentComments = queryClient.getQueryData(
|
||||
RQ_KEY(pageId),
|
||||
) as IPagination<IComment>;
|
||||
if (currentComments && currentComments.items) {
|
||||
const updatedComments = currentComments.items.map((comment) =>
|
||||
comment.id === variables.commentId
|
||||
? { ...comment, resolvedAt: data.resolvedAt, resolvedById: data.resolvedById, resolvedBy: data.resolvedBy }
|
||||
: comment,
|
||||
);
|
||||
queryClient.setQueryData(RQ_KEY(pageId), {
|
||||
...currentComments,
|
||||
items: updatedComments,
|
||||
});
|
||||
}
|
||||
emit({
|
||||
operation: "resolveComment",
|
||||
pageId: pageId,
|
||||
commentId: variables.commentId,
|
||||
resolved: variables.resolved,
|
||||
resolvedAt: data.resolvedAt,
|
||||
resolvedById: data.resolvedById,
|
||||
resolvedBy: data.resolvedBy,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: RQ_KEY(pageId) });
|
||||
notifications.show({
|
||||
message: variables.resolved
|
||||
? t("Comment resolved successfully")
|
||||
: t("Comment re-opened successfully")
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
41
apps/client/src/ee/components/posthog-user.tsx
Normal file
41
apps/client/src/ee/components/posthog-user.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { useEffect } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
|
||||
export function PosthogUser() {
|
||||
const posthog = usePostHog();
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser) {
|
||||
const user = currentUser?.user;
|
||||
const workspace = currentUser?.workspace;
|
||||
if (!user || !workspace) return;
|
||||
|
||||
posthog?.identify(user.id, {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
workspaceId: user.workspaceId,
|
||||
workspaceHostname: workspace.hostname,
|
||||
lastActiveAt: new Date().toISOString(),
|
||||
createdAt: user.createdAt,
|
||||
source: "docmost-app",
|
||||
});
|
||||
posthog?.group("workspace", workspace.id, {
|
||||
name: workspace.name,
|
||||
hostname: workspace.hostname,
|
||||
plan: workspace?.plan,
|
||||
status: workspace.status,
|
||||
isOnTrial: !!workspace.trialEndAt,
|
||||
hasStripeCustomerId: !!workspace.stripeCustomerId,
|
||||
memberCount: workspace.memberCount,
|
||||
lastActiveAt: new Date().toISOString(),
|
||||
createdAt: workspace.createdAt,
|
||||
source: "docmost-app",
|
||||
});
|
||||
}
|
||||
}, [posthog, currentUser]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@ -2,14 +2,18 @@ import { useAtom } from "jotai";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { BillingPlan } from "@/ee/billing/types/billing.types.ts";
|
||||
|
||||
export const usePlan = () => {
|
||||
const usePlan = () => {
|
||||
const [workspace] = useAtom(workspaceAtom);
|
||||
|
||||
const isStandard =
|
||||
typeof workspace?.plan === "string" &&
|
||||
workspace?.plan.toLowerCase() === BillingPlan.STANDARD.toLowerCase();
|
||||
|
||||
return { isStandard };
|
||||
const isBusiness =
|
||||
typeof workspace?.plan === "string" &&
|
||||
workspace?.plan.toLowerCase() === BillingPlan.BUSINESS.toLowerCase();
|
||||
|
||||
return { isStandard, isBusiness };
|
||||
};
|
||||
|
||||
export default usePlan;
|
||||
|
||||
@ -11,7 +11,7 @@ export default function OssDetails() {
|
||||
withTableBorder
|
||||
>
|
||||
<Table.Caption>
|
||||
To unlock enterprise features like SSO, contact sales@docmost.com.
|
||||
To unlock enterprise features like SSO, MFA, Resolve comments, contact sales@docmost.com.
|
||||
</Table.Caption>
|
||||
<Table.Tbody>
|
||||
<Table.Tr>
|
||||
|
||||
81
apps/client/src/ee/mfa/components/mfa-backup-code-input.tsx
Normal file
81
apps/client/src/ee/mfa/components/mfa-backup-code-input.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import React from "react";
|
||||
import {
|
||||
TextInput,
|
||||
Button,
|
||||
Stack,
|
||||
Text,
|
||||
Alert,
|
||||
} from "@mantine/core";
|
||||
import { IconKey, IconAlertCircle } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface MfaBackupCodeInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
error?: string;
|
||||
onSubmit: () => void;
|
||||
onCancel: () => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function MfaBackupCodeInput({
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
isLoading,
|
||||
}: MfaBackupCodeInputProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Alert icon={<IconAlertCircle size={16} />} color="blue" variant="light">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"Enter one of your backup codes. Each backup code can only be used once.",
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<TextInput
|
||||
label={t("Backup code")}
|
||||
placeholder="XXXXXXXX"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.currentTarget.value.toUpperCase())}
|
||||
error={error}
|
||||
autoFocus
|
||||
maxLength={8}
|
||||
styles={{
|
||||
input: {
|
||||
fontFamily: "monospace",
|
||||
letterSpacing: "0.1em",
|
||||
fontSize: "1rem",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Stack>
|
||||
<Button
|
||||
fullWidth
|
||||
size="md"
|
||||
loading={isLoading}
|
||||
onClick={onSubmit}
|
||||
leftSection={<IconKey size={18} />}
|
||||
>
|
||||
{t("Verify backup code")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={onCancel}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t("Use authenticator app instead")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
193
apps/client/src/ee/mfa/components/mfa-backup-codes-modal.tsx
Normal file
193
apps/client/src/ee/mfa/components/mfa-backup-codes-modal.tsx
Normal file
@ -0,0 +1,193 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
Button,
|
||||
Paper,
|
||||
Group,
|
||||
List,
|
||||
Code,
|
||||
CopyButton,
|
||||
Alert,
|
||||
PasswordInput,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconRefresh,
|
||||
IconCopy,
|
||||
IconCheck,
|
||||
IconAlertCircle,
|
||||
} from "@tabler/icons-react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { regenerateBackupCodes } from "@/ee/mfa";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zodResolver } from "mantine-form-zod-resolver";
|
||||
import { z } from "zod";
|
||||
|
||||
interface MfaBackupCodesModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const formSchema = z.object({
|
||||
confirmPassword: z.string().min(1, { message: "Password is required" }),
|
||||
});
|
||||
|
||||
export function MfaBackupCodesModal({
|
||||
opened,
|
||||
onClose,
|
||||
}: MfaBackupCodesModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
const [showNewCodes, setShowNewCodes] = useState(false);
|
||||
|
||||
const form = useForm({
|
||||
validate: zodResolver(formSchema),
|
||||
initialValues: {
|
||||
confirmPassword: "",
|
||||
},
|
||||
});
|
||||
|
||||
const regenerateMutation = useMutation({
|
||||
mutationFn: (data: { confirmPassword: string }) =>
|
||||
regenerateBackupCodes(data),
|
||||
onSuccess: (data) => {
|
||||
setBackupCodes(data.backupCodes);
|
||||
setShowNewCodes(true);
|
||||
form.reset();
|
||||
notifications.show({
|
||||
title: t("Success"),
|
||||
message: t("New backup codes have been generated"),
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
notifications.show({
|
||||
title: t("Error"),
|
||||
message:
|
||||
error.response?.data?.message ||
|
||||
t("Failed to regenerate backup codes"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleRegenerate = (values: { confirmPassword: string }) => {
|
||||
regenerateMutation.mutate(values);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setShowNewCodes(false);
|
||||
setBackupCodes([]);
|
||||
form.reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
title={t("Backup codes")}
|
||||
size="md"
|
||||
>
|
||||
<Stack gap="md">
|
||||
{!showNewCodes ? (
|
||||
<form onSubmit={form.onSubmit(handleRegenerate)}>
|
||||
<Stack gap="md">
|
||||
<Alert
|
||||
icon={<IconAlertCircle size={20} />}
|
||||
title={t("About backup codes")}
|
||||
color="blue"
|
||||
variant="light"
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.",
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"You can regenerate new backup codes at any time. This will invalidate all existing codes.",
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<PasswordInput
|
||||
label={t("Confirm password")}
|
||||
placeholder={t("Enter your password")}
|
||||
variant="filled"
|
||||
{...form.getInputProps("confirmPassword")}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
loading={regenerateMutation.isPending}
|
||||
leftSection={<IconRefresh size={18} />}
|
||||
>
|
||||
{t("Generate new backup codes")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<Alert
|
||||
icon={<IconAlertCircle size={20} />}
|
||||
title={t("Save your new backup codes")}
|
||||
color="yellow"
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.",
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Paper p="md" withBorder>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text size="sm" fw={600}>
|
||||
{t("Your new backup codes")}
|
||||
</Text>
|
||||
<CopyButton value={backupCodes.join("\n")}>
|
||||
{({ copied, copy }) => (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
onClick={copy}
|
||||
leftSection={
|
||||
copied ? (
|
||||
<IconCheck size={14} />
|
||||
) : (
|
||||
<IconCopy size={14} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{copied ? t("Copied") : t("Copy")}
|
||||
</Button>
|
||||
)}
|
||||
</CopyButton>
|
||||
</Group>
|
||||
<List size="sm" spacing="xs">
|
||||
{backupCodes.map((code, index) => (
|
||||
<List.Item key={index}>
|
||||
<Code>{code}</Code>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
onClick={handleClose}
|
||||
leftSection={<IconCheck size={18} />}
|
||||
>
|
||||
{t("I've saved my backup codes")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
12
apps/client/src/ee/mfa/components/mfa-challenge.module.css
Normal file
12
apps/client/src/ee/mfa/components/mfa-challenge.module.css
Normal file
@ -0,0 +1,12 @@
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.paper {
|
||||
width: 100%;
|
||||
box-shadow: var(--mantine-shadow-lg);
|
||||
}
|
||||
160
apps/client/src/ee/mfa/components/mfa-challenge.tsx
Normal file
160
apps/client/src/ee/mfa/components/mfa-challenge.tsx
Normal file
@ -0,0 +1,160 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Container,
|
||||
Title,
|
||||
Text,
|
||||
PinInput,
|
||||
Button,
|
||||
Stack,
|
||||
Anchor,
|
||||
Paper,
|
||||
Center,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zodResolver } from "mantine-form-zod-resolver";
|
||||
import { IconDeviceMobile, IconLock } from "@tabler/icons-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import classes from "./mfa-challenge.module.css";
|
||||
import { verifyMfa } from "@/ee/mfa";
|
||||
import APP_ROUTE from "@/lib/app-route";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as z from "zod";
|
||||
import { MfaBackupCodeInput } from "./mfa-backup-code-input";
|
||||
|
||||
const formSchema = z.object({
|
||||
code: z
|
||||
.string()
|
||||
.refine(
|
||||
(val) => (val.length === 6 && /^\d{6}$/.test(val)) || val.length === 8,
|
||||
{
|
||||
message: "Enter a 6-digit code or 8-character backup code",
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
type MfaChallengeFormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function MfaChallenge() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [useBackupCode, setUseBackupCode] = useState(false);
|
||||
|
||||
const form = useForm<MfaChallengeFormValues>({
|
||||
validate: zodResolver(formSchema),
|
||||
initialValues: {
|
||||
code: "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: MfaChallengeFormValues) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await verifyMfa(values.code);
|
||||
navigate(APP_ROUTE.HOME);
|
||||
} catch (error: any) {
|
||||
setIsLoading(false);
|
||||
notifications.show({
|
||||
message:
|
||||
error.response?.data?.message || t("Invalid verification code"),
|
||||
color: "red",
|
||||
});
|
||||
form.setFieldValue("code", "");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size={420} className={classes.container}>
|
||||
<Paper radius="lg" p={40} className={classes.paper}>
|
||||
<Stack align="center" gap="xl">
|
||||
<Center>
|
||||
<ThemeIcon size={80} radius="xl" variant="light" color="blue">
|
||||
<IconDeviceMobile size={40} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
</Center>
|
||||
|
||||
<Stack align="center" gap="xs">
|
||||
<Title order={2} ta="center" fw={600}>
|
||||
{t("Two-factor authentication")}
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{useBackupCode
|
||||
? t("Enter one of your backup codes")
|
||||
: t("Enter the 6-digit code found in your authenticator app")}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{!useBackupCode ? (
|
||||
<form
|
||||
onSubmit={form.onSubmit(handleSubmit)}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<Center>
|
||||
<PinInput
|
||||
length={6}
|
||||
type="number"
|
||||
autoFocus
|
||||
oneTimeCode
|
||||
{...form.getInputProps("code")}
|
||||
error={!!form.errors.code}
|
||||
styles={{
|
||||
input: {
|
||||
fontSize: "1.2rem",
|
||||
textAlign: "center",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Center>
|
||||
{form.errors.code && (
|
||||
<Text c="red" size="sm" ta="center">
|
||||
{form.errors.code}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
size="md"
|
||||
loading={isLoading}
|
||||
leftSection={<IconLock size={18} />}
|
||||
>
|
||||
{t("Verify")}
|
||||
</Button>
|
||||
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
onClick={() => {
|
||||
setUseBackupCode(true);
|
||||
form.setFieldValue("code", "");
|
||||
form.clearErrors();
|
||||
}}
|
||||
>
|
||||
{t("Use backup code")}
|
||||
</Anchor>
|
||||
</Stack>
|
||||
</form>
|
||||
) : (
|
||||
<MfaBackupCodeInput
|
||||
value={form.values.code}
|
||||
onChange={(value) => form.setFieldValue("code", value)}
|
||||
error={form.errors.code?.toString()}
|
||||
onSubmit={() => handleSubmit(form.values)}
|
||||
onCancel={() => {
|
||||
setUseBackupCode(false);
|
||||
form.setFieldValue("code", "");
|
||||
form.clearErrors();
|
||||
}}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
124
apps/client/src/ee/mfa/components/mfa-disable-modal.tsx
Normal file
124
apps/client/src/ee/mfa/components/mfa-disable-modal.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
Button,
|
||||
PasswordInput,
|
||||
Alert,
|
||||
} from "@mantine/core";
|
||||
import { IconShieldOff, IconAlertTriangle } from "@tabler/icons-react";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zodResolver } from "mantine-form-zod-resolver";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { disableMfa } from "@/ee/mfa";
|
||||
|
||||
interface MfaDisableModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
const formSchema = z.object({
|
||||
confirmPassword: z.string().min(1, { message: "Password is required" }),
|
||||
});
|
||||
|
||||
export function MfaDisableModal({
|
||||
opened,
|
||||
onClose,
|
||||
onComplete,
|
||||
}: MfaDisableModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const form = useForm({
|
||||
validate: zodResolver(formSchema),
|
||||
initialValues: {
|
||||
confirmPassword: "",
|
||||
},
|
||||
});
|
||||
|
||||
const disableMutation = useMutation({
|
||||
mutationFn: disableMfa,
|
||||
onSuccess: () => {
|
||||
onComplete();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
notifications.show({
|
||||
title: t("Error"),
|
||||
message: error.response?.data?.message || t("Failed to disable MFA"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: { confirmPassword: string }) => {
|
||||
await disableMutation.mutateAsync(values);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
form.reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
title={t("Disable two-factor authentication")}
|
||||
size="md"
|
||||
>
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack gap="md">
|
||||
<Alert
|
||||
icon={<IconAlertTriangle size={20} />}
|
||||
title={t("Warning")}
|
||||
color="red"
|
||||
variant="light"
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.",
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"Please enter your password to disable two-factor authentication:",
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<PasswordInput
|
||||
label={t("Password")}
|
||||
placeholder={t("Enter your password")}
|
||||
{...form.getInputProps("confirmPassword")}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<Stack gap="sm">
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
color="red"
|
||||
loading={disableMutation.isPending}
|
||||
leftSection={<IconShieldOff size={18} />}
|
||||
>
|
||||
{t("Disable two-factor authentication")}
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="default"
|
||||
onClick={handleClose}
|
||||
disabled={disableMutation.isPending}
|
||||
>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
123
apps/client/src/ee/mfa/components/mfa-settings.tsx
Normal file
123
apps/client/src/ee/mfa/components/mfa-settings.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
import React, { useState } from "react";
|
||||
import { Group, Text, Button, Tooltip } from "@mantine/core";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getMfaStatus } from "@/ee/mfa";
|
||||
import { MfaSetupModal } from "@/ee/mfa";
|
||||
import { MfaDisableModal } from "@/ee/mfa";
|
||||
import { MfaBackupCodesModal } from "@/ee/mfa";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
import useLicense from "@/ee/hooks/use-license.tsx";
|
||||
|
||||
export function MfaSettings() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [setupModalOpen, setSetupModalOpen] = useState(false);
|
||||
const [disableModalOpen, setDisableModalOpen] = useState(false);
|
||||
const [backupCodesModalOpen, setBackupCodesModalOpen] = useState(false);
|
||||
const { hasLicenseKey } = useLicense();
|
||||
|
||||
const { data: mfaStatus, isLoading } = useQuery({
|
||||
queryKey: ["mfa-status"],
|
||||
queryFn: getMfaStatus,
|
||||
});
|
||||
|
||||
if (isLoading || !mfaStatus) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const canUseMfa = isCloud() || hasLicenseKey;
|
||||
|
||||
// Check if MFA is truly enabled
|
||||
const isMfaEnabled = mfaStatus?.isEnabled === true;
|
||||
|
||||
const handleSetupComplete = () => {
|
||||
setSetupModalOpen(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["mfa-status"] });
|
||||
notifications.show({
|
||||
title: t("Success"),
|
||||
message: t("Two-factor authentication has been enabled"),
|
||||
});
|
||||
};
|
||||
|
||||
const handleDisableComplete = () => {
|
||||
setDisableModalOpen(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["mfa-status"] });
|
||||
notifications.show({
|
||||
title: t("Success"),
|
||||
message: t("Two-factor authentication has been disabled"),
|
||||
color: "blue",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text size="md">{t("2-step verification")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{!isMfaEnabled
|
||||
? t(
|
||||
"Protect your account with an additional verification layer when signing in.",
|
||||
)
|
||||
: t("Two-factor authentication is active on your account.")}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{!isMfaEnabled ? (
|
||||
<Tooltip
|
||||
label={t("Available in enterprise edition")}
|
||||
disabled={canUseMfa}
|
||||
>
|
||||
<Button
|
||||
disabled={!canUseMfa}
|
||||
variant="default"
|
||||
onClick={() => setSetupModalOpen(true)}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{t("Add 2FA method")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => setBackupCodesModalOpen(true)}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{t("Backup codes")} ({mfaStatus?.backupCodesCount || 0})
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
color="red"
|
||||
onClick={() => setDisableModalOpen(true)}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{t("Disable")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<MfaSetupModal
|
||||
opened={setupModalOpen}
|
||||
onClose={() => setSetupModalOpen(false)}
|
||||
onComplete={handleSetupComplete}
|
||||
/>
|
||||
|
||||
<MfaDisableModal
|
||||
opened={disableModalOpen}
|
||||
onClose={() => setDisableModalOpen(false)}
|
||||
onComplete={handleDisableComplete}
|
||||
/>
|
||||
|
||||
<MfaBackupCodesModal
|
||||
opened={backupCodesModalOpen}
|
||||
onClose={() => setBackupCodesModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
347
apps/client/src/ee/mfa/components/mfa-setup-modal.tsx
Normal file
347
apps/client/src/ee/mfa/components/mfa-setup-modal.tsx
Normal file
@ -0,0 +1,347 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
Button,
|
||||
Group,
|
||||
Stepper,
|
||||
Center,
|
||||
Image,
|
||||
PinInput,
|
||||
Alert,
|
||||
List,
|
||||
CopyButton,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
Paper,
|
||||
Code,
|
||||
Loader,
|
||||
Collapse,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconQrcode,
|
||||
IconShieldCheck,
|
||||
IconKey,
|
||||
IconCopy,
|
||||
IconCheck,
|
||||
IconAlertCircle,
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconPrinter,
|
||||
} from "@tabler/icons-react";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { setupMfa, enableMfa } from "@/ee/mfa";
|
||||
import { zodResolver } from "mantine-form-zod-resolver";
|
||||
import { z } from "zod";
|
||||
|
||||
interface MfaSetupModalProps {
|
||||
opened: boolean;
|
||||
onClose?: () => void;
|
||||
onComplete: () => void;
|
||||
isRequired?: boolean;
|
||||
}
|
||||
|
||||
interface SetupData {
|
||||
secret: string;
|
||||
qrCode: string;
|
||||
manualKey: string;
|
||||
}
|
||||
|
||||
const formSchema = z.object({
|
||||
verificationCode: z
|
||||
.string()
|
||||
.length(6, { message: "Please enter a 6-digit code" }),
|
||||
});
|
||||
|
||||
export function MfaSetupModal({
|
||||
opened,
|
||||
onClose,
|
||||
onComplete,
|
||||
isRequired = false,
|
||||
}: MfaSetupModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [active, setActive] = useState(0);
|
||||
const [setupData, setSetupData] = useState<SetupData | null>(null);
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
const [manualEntryOpen, setManualEntryOpen] = useState(false);
|
||||
|
||||
const form = useForm({
|
||||
validate: zodResolver(formSchema),
|
||||
initialValues: {
|
||||
verificationCode: "",
|
||||
},
|
||||
});
|
||||
|
||||
const setupMutation = useMutation({
|
||||
mutationFn: () => setupMfa({ method: "totp" }),
|
||||
onSuccess: (data) => {
|
||||
setSetupData(data);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
notifications.show({
|
||||
title: t("Error"),
|
||||
message: error.response?.data?.message || t("Failed to setup MFA"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Generate QR code when modal opens
|
||||
React.useEffect(() => {
|
||||
if (opened && !setupData && !setupMutation.isPending) {
|
||||
setupMutation.mutate();
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
const enableMutation = useMutation({
|
||||
mutationFn: (verificationCode: string) =>
|
||||
enableMfa({
|
||||
secret: setupData!.secret,
|
||||
verificationCode,
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
setBackupCodes(data.backupCodes);
|
||||
setActive(1); // Move to backup codes step
|
||||
},
|
||||
onError: (error: any) => {
|
||||
notifications.show({
|
||||
title: t("Error"),
|
||||
message:
|
||||
error.response?.data?.message || t("Invalid verification code"),
|
||||
color: "red",
|
||||
});
|
||||
form.setFieldValue("verificationCode", "");
|
||||
},
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
if (active === 1 && backupCodes.length > 0) {
|
||||
onComplete();
|
||||
}
|
||||
onClose();
|
||||
// Reset state
|
||||
setTimeout(() => {
|
||||
setActive(0);
|
||||
setSetupData(null);
|
||||
setBackupCodes([]);
|
||||
setManualEntryOpen(false);
|
||||
form.reset();
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const handleVerify = async (values: { verificationCode: string }) => {
|
||||
await enableMutation.mutateAsync(values.verificationCode);
|
||||
};
|
||||
|
||||
const handlePrintBackupCodes = () => {
|
||||
window.print();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
title={t("Set up two-factor authentication")}
|
||||
size="md"
|
||||
>
|
||||
<Stepper active={active} size="sm">
|
||||
<Stepper.Step
|
||||
label={t("Setup & Verify")}
|
||||
description={t("Add to authenticator")}
|
||||
icon={<IconQrcode size={18} />}
|
||||
>
|
||||
<form onSubmit={form.onSubmit(handleVerify)}>
|
||||
<Stack gap="md" mt="xl">
|
||||
{setupMutation.isPending ? (
|
||||
<Center py="xl">
|
||||
<Loader size="lg" />
|
||||
</Center>
|
||||
) : setupData ? (
|
||||
<>
|
||||
<Text size="sm">
|
||||
{t("1. Scan this QR code with your authenticator app")}
|
||||
</Text>
|
||||
|
||||
<Center>
|
||||
<Paper p="md" withBorder>
|
||||
<Image
|
||||
src={setupData.qrCode}
|
||||
alt="MFA QR Code"
|
||||
width={200}
|
||||
height={200}
|
||||
/>
|
||||
</Paper>
|
||||
</Center>
|
||||
|
||||
<UnstyledButton
|
||||
onClick={() => setManualEntryOpen(!manualEntryOpen)}
|
||||
>
|
||||
<Group gap="xs">
|
||||
{manualEntryOpen ? (
|
||||
<IconChevronDown size={16} />
|
||||
) : (
|
||||
<IconChevronRight size={16} />
|
||||
)}
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("Can't scan the code?")}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
|
||||
<Collapse in={manualEntryOpen}>
|
||||
<Alert
|
||||
icon={<IconAlertCircle size={20} />}
|
||||
color="gray"
|
||||
variant="light"
|
||||
>
|
||||
<Text size="sm" mb="sm">
|
||||
{t(
|
||||
"Enter this code manually in your authenticator app:",
|
||||
)}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Code block>{setupData.manualKey}</Code>
|
||||
<CopyButton value={setupData.manualKey}>
|
||||
{({ copied, copy }) => (
|
||||
<Tooltip label={copied ? t("Copied") : t("Copy")}>
|
||||
<ActionIcon
|
||||
color={copied ? "green" : "gray"}
|
||||
onClick={copy}
|
||||
>
|
||||
{copied ? (
|
||||
<IconCheck size={16} />
|
||||
) : (
|
||||
<IconCopy size={16} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</CopyButton>
|
||||
</Group>
|
||||
</Alert>
|
||||
</Collapse>
|
||||
|
||||
<Text size="sm" mt="md">
|
||||
{t("2. Enter the 6-digit code from your authenticator")}
|
||||
</Text>
|
||||
|
||||
<Stack align="center">
|
||||
<PinInput
|
||||
length={6}
|
||||
type="number"
|
||||
autoFocus
|
||||
oneTimeCode
|
||||
{...form.getInputProps("verificationCode")}
|
||||
styles={{
|
||||
input: {
|
||||
fontSize: "1.2rem",
|
||||
textAlign: "center",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{form.errors.verificationCode && (
|
||||
<Text c="red" size="sm">
|
||||
{form.errors.verificationCode}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
loading={enableMutation.isPending}
|
||||
leftSection={<IconShieldCheck size={18} />}
|
||||
>
|
||||
{t("Verify and enable")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Center py="xl">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("Failed to generate QR code. Please try again.")}
|
||||
</Text>
|
||||
</Center>
|
||||
)}
|
||||
</Stack>
|
||||
</form>
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step
|
||||
label={t("Backup")}
|
||||
description={t("Save codes")}
|
||||
icon={<IconKey size={18} />}
|
||||
>
|
||||
<Stack gap="md" mt="xl">
|
||||
<Alert
|
||||
icon={<IconAlertCircle size={20} />}
|
||||
title={t("Save your backup codes")}
|
||||
color="yellow"
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.",
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Paper p="md" withBorder>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text size="sm" fw={600}>
|
||||
{t("Backup codes")}
|
||||
</Text>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<CopyButton value={backupCodes.join("\n")}>
|
||||
{({ copied, copy }) => (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
onClick={copy}
|
||||
leftSection={
|
||||
copied ? (
|
||||
<IconCheck size={14} />
|
||||
) : (
|
||||
<IconCopy size={14} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{copied ? t("Copied") : t("Copy")}
|
||||
</Button>
|
||||
)}
|
||||
</CopyButton>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
onClick={handlePrintBackupCodes}
|
||||
leftSection={<IconPrinter size={14} />}
|
||||
>
|
||||
{t("Print")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
<List size="sm" spacing="xs">
|
||||
{backupCodes.map((code, index) => (
|
||||
<List.Item key={index}>
|
||||
<Code>{code}</Code>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
onClick={handleClose}
|
||||
leftSection={<IconCheck size={18} />}
|
||||
>
|
||||
{t("I've saved my backup codes")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
</Stepper>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
48
apps/client/src/ee/mfa/components/mfa-setup-required.tsx
Normal file
48
apps/client/src/ee/mfa/components/mfa-setup-required.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import React from "react";
|
||||
import { Container, Paper, Title, Text, Alert, Stack } from "@mantine/core";
|
||||
import { IconAlertCircle } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MfaSetupModal } from "@/ee/mfa";
|
||||
import APP_ROUTE from "@/lib/app-route.ts";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export default function MfaSetupRequired() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSetupComplete = () => {
|
||||
navigate(APP_ROUTE.HOME);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size="sm" py="xl">
|
||||
<Paper shadow="sm" p="xl" radius="md" withBorder>
|
||||
<Stack>
|
||||
<Title order={2} ta="center">
|
||||
{t("Two-factor authentication required")}
|
||||
</Title>
|
||||
|
||||
<Alert icon={<IconAlertCircle size="1rem" />} color="yellow">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"Your workspace requires two-factor authentication. Please set it up to continue.",
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Text c="dimmed" size="sm" ta="center">
|
||||
{t(
|
||||
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.",
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<MfaSetupModal
|
||||
opened={true}
|
||||
onComplete={handleSetupComplete}
|
||||
isRequired={true}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
31
apps/client/src/ee/mfa/components/mfa.module.css
Normal file
31
apps/client/src/ee/mfa/components/mfa.module.css
Normal file
@ -0,0 +1,31 @@
|
||||
.qrCodeContainer {
|
||||
background-color: white;
|
||||
padding: 1rem;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.backupCodesList {
|
||||
font-family: var(--mantine-font-family-monospace);
|
||||
background-color: var(--mantine-color-gray-0);
|
||||
padding: 1rem;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
|
||||
@mixin dark {
|
||||
background-color: var(--mantine-color-dark-7);
|
||||
}
|
||||
}
|
||||
|
||||
.codeItem {
|
||||
padding: 0.25rem 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.setupStep {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.verificationInput {
|
||||
max-width: 320px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
51
apps/client/src/ee/mfa/hooks/use-mfa-page-protection.ts
Normal file
51
apps/client/src/ee/mfa/hooks/use-mfa-page-protection.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import APP_ROUTE from "@/lib/app-route";
|
||||
import { validateMfaAccess } from "@/ee/mfa";
|
||||
|
||||
export function useMfaPageProtection() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [isValidating, setIsValidating] = useState(true);
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkAccess = async () => {
|
||||
const result = await validateMfaAccess();
|
||||
|
||||
if (!result.valid) {
|
||||
navigate(APP_ROUTE.AUTH.LOGIN);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user is on the correct page based on their MFA state
|
||||
const isOnChallengePage =
|
||||
location.pathname === APP_ROUTE.AUTH.MFA_CHALLENGE;
|
||||
const isOnSetupPage =
|
||||
location.pathname === APP_ROUTE.AUTH.MFA_SETUP_REQUIRED;
|
||||
|
||||
if (result.requiresMfaSetup && !isOnSetupPage) {
|
||||
// User needs to set up MFA but is on challenge page
|
||||
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED);
|
||||
} else if (
|
||||
!result.requiresMfaSetup &&
|
||||
result.userHasMfa &&
|
||||
!isOnChallengePage
|
||||
) {
|
||||
// User has MFA and should be on challenge page
|
||||
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE);
|
||||
} else if (!result.isTransferToken) {
|
||||
// User has a regular auth token, shouldn't be on MFA pages
|
||||
navigate(APP_ROUTE.HOME);
|
||||
} else {
|
||||
setIsValid(true);
|
||||
}
|
||||
|
||||
setIsValidating(false);
|
||||
};
|
||||
|
||||
checkAccess();
|
||||
}, [navigate, location.pathname]);
|
||||
|
||||
return { isValidating, isValid };
|
||||
}
|
||||
19
apps/client/src/ee/mfa/index.ts
Normal file
19
apps/client/src/ee/mfa/index.ts
Normal file
@ -0,0 +1,19 @@
|
||||
// Components
|
||||
export { MfaChallenge } from "./components/mfa-challenge";
|
||||
export { MfaSettings } from "./components/mfa-settings";
|
||||
export { MfaSetupModal } from "./components/mfa-setup-modal";
|
||||
export { MfaDisableModal } from "./components/mfa-disable-modal";
|
||||
export { MfaBackupCodesModal } from "./components/mfa-backup-codes-modal";
|
||||
|
||||
// Pages
|
||||
export { MfaChallengePage } from "./pages/mfa-challenge-page";
|
||||
export { MfaSetupRequiredPage } from "./pages/mfa-setup-required-page";
|
||||
|
||||
// Services
|
||||
export * from "./services/mfa-service";
|
||||
|
||||
// Types
|
||||
export * from "./types/mfa.types";
|
||||
|
||||
// Hooks
|
||||
export { useMfaPageProtection } from "./hooks/use-mfa-page-protection.ts";
|
||||
13
apps/client/src/ee/mfa/pages/mfa-challenge-page.tsx
Normal file
13
apps/client/src/ee/mfa/pages/mfa-challenge-page.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
import { MfaChallenge } from "@/ee/mfa";
|
||||
import { useMfaPageProtection } from "@/ee/mfa";
|
||||
|
||||
export function MfaChallengePage() {
|
||||
const { isValid } = useMfaPageProtection();
|
||||
|
||||
if (!isValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <MfaChallenge />;
|
||||
}
|
||||
113
apps/client/src/ee/mfa/pages/mfa-setup-required-page.tsx
Normal file
113
apps/client/src/ee/mfa/pages/mfa-setup-required-page.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
import React, { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Container,
|
||||
Title,
|
||||
Text,
|
||||
Button,
|
||||
Stack,
|
||||
Paper,
|
||||
Alert,
|
||||
Center,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { IconShieldCheck, IconAlertCircle } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import APP_ROUTE from "@/lib/app-route";
|
||||
import { MfaSetupModal } from "@/ee/mfa";
|
||||
import classes from "@/features/auth/components/auth.module.css";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useMfaPageProtection } from "@/ee/mfa";
|
||||
|
||||
export function MfaSetupRequiredPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [setupModalOpen, setSetupModalOpen] = useState(false);
|
||||
const { isValid } = useMfaPageProtection();
|
||||
|
||||
const handleSetupComplete = async () => {
|
||||
setSetupModalOpen(false);
|
||||
|
||||
notifications.show({
|
||||
title: t("Success"),
|
||||
message: t(
|
||||
"Two-factor authentication has been set up. Please log in again.",
|
||||
),
|
||||
});
|
||||
|
||||
navigate(APP_ROUTE.AUTH.LOGIN);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
navigate(APP_ROUTE.AUTH.LOGIN);
|
||||
};
|
||||
|
||||
if (!isValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size={480} className={classes.container}>
|
||||
<Paper radius="lg" p={40}>
|
||||
<Stack align="center" gap="xl">
|
||||
<Center>
|
||||
<ThemeIcon size={80} radius="xl" variant="light" color="blue">
|
||||
<IconShieldCheck size={40} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
</Center>
|
||||
|
||||
<Stack align="center" gap="xs">
|
||||
<Title order={2} ta="center" fw={600}>
|
||||
{t("Two-factor authentication required")}
|
||||
</Title>
|
||||
<Text size="md" c="dimmed" ta="center">
|
||||
{t(
|
||||
"Your workspace requires two-factor authentication for all users",
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Alert
|
||||
icon={<IconAlertCircle size={20} />}
|
||||
color="blue"
|
||||
variant="light"
|
||||
w="100%"
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.",
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Stack w="100%" gap="sm">
|
||||
<Button
|
||||
fullWidth
|
||||
size="md"
|
||||
onClick={() => setSetupModalOpen(true)}
|
||||
leftSection={<IconShieldCheck size={18} />}
|
||||
>
|
||||
{t("Set up two-factor authentication")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
{t("Cancel and logout")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<MfaSetupModal
|
||||
opened={setupModalOpen}
|
||||
onClose={() => setSetupModalOpen(false)}
|
||||
onComplete={handleSetupComplete}
|
||||
isRequired={true}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
61
apps/client/src/ee/mfa/services/mfa-service.ts
Normal file
61
apps/client/src/ee/mfa/services/mfa-service.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import api from "@/lib/api-client";
|
||||
import {
|
||||
MfaBackupCodesResponse,
|
||||
MfaDisableRequest,
|
||||
MfaEnableRequest,
|
||||
MfaEnableResponse,
|
||||
MfaSetupRequest,
|
||||
MfaSetupResponse,
|
||||
MfaStatusResponse,
|
||||
MfaAccessValidationResponse,
|
||||
} from "@/ee/mfa";
|
||||
|
||||
export async function getMfaStatus(): Promise<MfaStatusResponse> {
|
||||
const req = await api.post("/mfa/status");
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function setupMfa(
|
||||
data: MfaSetupRequest,
|
||||
): Promise<MfaSetupResponse> {
|
||||
const req = await api.post<MfaSetupResponse>("/mfa/setup", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function enableMfa(
|
||||
data: MfaEnableRequest,
|
||||
): Promise<MfaEnableResponse> {
|
||||
const req = await api.post<MfaEnableResponse>("/mfa/enable", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function disableMfa(
|
||||
data: MfaDisableRequest,
|
||||
): Promise<{ success: boolean }> {
|
||||
const req = await api.post<{ success: boolean }>("/mfa/disable", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function regenerateBackupCodes(data: {
|
||||
confirmPassword: string;
|
||||
}): Promise<MfaBackupCodesResponse> {
|
||||
const req = await api.post<MfaBackupCodesResponse>(
|
||||
"/mfa/generate-backup-codes",
|
||||
data,
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function verifyMfa(code: string): Promise<any> {
|
||||
const req = await api.post("/mfa/verify", { code });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function validateMfaAccess(): Promise<MfaAccessValidationResponse> {
|
||||
try {
|
||||
const res = await api.post("/mfa/validate-access");
|
||||
return res.data;
|
||||
} catch {
|
||||
return { valid: false };
|
||||
}
|
||||
}
|
||||
62
apps/client/src/ee/mfa/types/mfa.types.ts
Normal file
62
apps/client/src/ee/mfa/types/mfa.types.ts
Normal file
@ -0,0 +1,62 @@
|
||||
export interface MfaMethod {
|
||||
type: 'totp' | 'email';
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface MfaSettings {
|
||||
isEnabled: boolean;
|
||||
methods: MfaMethod[];
|
||||
backupCodesCount: number;
|
||||
lastUpdated?: string;
|
||||
}
|
||||
|
||||
export interface MfaSetupState {
|
||||
method: 'totp' | 'email';
|
||||
secret?: string;
|
||||
qrCode?: string;
|
||||
manualEntry?: string;
|
||||
backupCodes?: string[];
|
||||
}
|
||||
|
||||
export interface MfaStatusResponse {
|
||||
isEnabled?: boolean;
|
||||
method?: string | null;
|
||||
backupCodesCount?: number;
|
||||
}
|
||||
|
||||
export interface MfaSetupRequest {
|
||||
method: 'totp';
|
||||
}
|
||||
|
||||
export interface MfaSetupResponse {
|
||||
method: string;
|
||||
qrCode: string;
|
||||
secret: string;
|
||||
manualKey: string;
|
||||
}
|
||||
|
||||
export interface MfaEnableRequest {
|
||||
secret: string;
|
||||
verificationCode: string;
|
||||
}
|
||||
|
||||
export interface MfaEnableResponse {
|
||||
success: boolean;
|
||||
backupCodes: string[];
|
||||
}
|
||||
|
||||
export interface MfaDisableRequest {
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
export interface MfaBackupCodesResponse {
|
||||
backupCodes: string[];
|
||||
}
|
||||
|
||||
export interface MfaAccessValidationResponse {
|
||||
valid: boolean;
|
||||
isTransferToken?: boolean;
|
||||
requiresMfaSetup?: boolean;
|
||||
userHasMfa?: boolean;
|
||||
isMfaEnforced?: boolean;
|
||||
}
|
||||
66
apps/client/src/ee/security/components/enforce-mfa.tsx
Normal file
66
apps/client/src/ee/security/components/enforce-mfa.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import { Group, Text, Switch, MantineSize, Title } from "@mantine/core";
|
||||
import { useAtom } from "jotai";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
|
||||
export default function EnforceMfa() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title order={4} my="sm">
|
||||
MFA
|
||||
</Title>
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||
<div>
|
||||
<Text size="md">{t("Enforce two-factor authentication")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"Once enforced, all members must enable two-factor authentication to access the workspace.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<EnforceMfaToggle />
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface EnforceMfaToggleProps {
|
||||
size?: MantineSize;
|
||||
label?: string;
|
||||
}
|
||||
export function EnforceMfaToggle({ size, label }: EnforceMfaToggleProps) {
|
||||
const { t } = useTranslation();
|
||||
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
||||
const [checked, setChecked] = useState(workspace?.enforceMfa);
|
||||
|
||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.currentTarget.checked;
|
||||
try {
|
||||
const updatedWorkspace = await updateWorkspace({ enforceMfa: value });
|
||||
setChecked(value);
|
||||
setWorkspace(updatedWorkspace);
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
message: err?.response?.data?.message,
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Switch
|
||||
size={size}
|
||||
label={label}
|
||||
labelPosition="left"
|
||||
defaultChecked={checked}
|
||||
onChange={handleChange}
|
||||
aria-label={t("Toggle MFA enforcement")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -16,6 +16,7 @@ const ssoSchema = z.object({
|
||||
oidcClientSecret: z.string().min(1, "Client secret is required"),
|
||||
isEnabled: z.boolean(),
|
||||
allowSignup: z.boolean(),
|
||||
groupSync: z.boolean(),
|
||||
});
|
||||
|
||||
type SSOFormValues = z.infer<typeof ssoSchema>;
|
||||
@ -36,6 +37,7 @@ export function SsoOIDCForm({ provider, onClose }: SsoFormProps) {
|
||||
oidcClientSecret: provider.oidcClientSecret || "",
|
||||
isEnabled: provider.isEnabled,
|
||||
allowSignup: provider.allowSignup,
|
||||
groupSync: provider.groupSync || false,
|
||||
},
|
||||
validate: zodResolver(ssoSchema),
|
||||
});
|
||||
@ -67,6 +69,9 @@ export function SsoOIDCForm({ provider, onClose }: SsoFormProps) {
|
||||
if (form.isDirty("allowSignup")) {
|
||||
ssoData.allowSignup = values.allowSignup;
|
||||
}
|
||||
if (form.isDirty("groupSync")) {
|
||||
ssoData.groupSync = values.groupSync;
|
||||
}
|
||||
|
||||
await updateSsoProviderMutation.mutateAsync(ssoData);
|
||||
form.resetDirty();
|
||||
@ -119,6 +124,15 @@ export function SsoOIDCForm({ provider, onClose }: SsoFormProps) {
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between">
|
||||
<div>{t("Group sync")}</div>
|
||||
<Switch
|
||||
className={classes.switch}
|
||||
checked={form.values.groupSync}
|
||||
{...form.getInputProps("groupSync")}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between">
|
||||
<div>{t("Enabled")}</div>
|
||||
<Switch
|
||||
|
||||
@ -26,6 +26,7 @@ const ssoSchema = z.object({
|
||||
samlCertificate: z.string().min(1, "SAML Idp Certificate is required"),
|
||||
isEnabled: z.boolean(),
|
||||
allowSignup: z.boolean(),
|
||||
groupSync: z.boolean(),
|
||||
});
|
||||
|
||||
type SSOFormValues = z.infer<typeof ssoSchema>;
|
||||
@ -45,6 +46,7 @@ export function SsoSamlForm({ provider, onClose }: SsoFormProps) {
|
||||
samlCertificate: provider.samlCertificate || "",
|
||||
isEnabled: provider.isEnabled,
|
||||
allowSignup: provider.allowSignup,
|
||||
groupSync: provider.groupSync || false,
|
||||
},
|
||||
validate: zodResolver(ssoSchema),
|
||||
});
|
||||
@ -75,6 +77,9 @@ export function SsoSamlForm({ provider, onClose }: SsoFormProps) {
|
||||
if (form.isDirty("allowSignup")) {
|
||||
ssoData.allowSignup = values.allowSignup;
|
||||
}
|
||||
if (form.isDirty("groupSync")) {
|
||||
ssoData.groupSync = values.groupSync;
|
||||
}
|
||||
|
||||
await updateSsoProviderMutation.mutateAsync(ssoData);
|
||||
form.resetDirty();
|
||||
@ -132,6 +137,15 @@ export function SsoSamlForm({ provider, onClose }: SsoFormProps) {
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between">
|
||||
<div>{t("Group sync")}</div>
|
||||
<Switch
|
||||
className={classes.switch}
|
||||
checked={form.values.groupSync}
|
||||
{...form.getInputProps("groupSync")}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between">
|
||||
<div>{t("Enabled")}</div>
|
||||
<Switch
|
||||
|
||||
@ -10,11 +10,14 @@ import EnforceSso from "@/ee/security/components/enforce-sso.tsx";
|
||||
import AllowedDomains from "@/ee/security/components/allowed-domains.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useLicense from "@/ee/hooks/use-license.tsx";
|
||||
import usePlan from "@/ee/hooks/use-plan.tsx";
|
||||
import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx";
|
||||
|
||||
export default function Security() {
|
||||
const { t } = useTranslation();
|
||||
const { isAdmin } = useUserRole();
|
||||
const { hasLicenseKey } = useLicense();
|
||||
const { isBusiness } = usePlan();
|
||||
|
||||
if (!isAdmin) {
|
||||
return null;
|
||||
@ -31,12 +34,15 @@ export default function Security() {
|
||||
|
||||
<Divider my="lg" />
|
||||
|
||||
<EnforceMfa />
|
||||
|
||||
<Divider my="lg" />
|
||||
|
||||
<Title order={4} my="lg">
|
||||
Single sign-on (SSO)
|
||||
</Title>
|
||||
|
||||
{/*TODO: revisit when we add a second plan */}
|
||||
{!isCloud() && hasLicenseKey ? (
|
||||
{(isCloud() && isBusiness) || (!isCloud() && hasLicenseKey) ? (
|
||||
<>
|
||||
<EnforceSso />
|
||||
<Divider my="lg" />
|
||||
|
||||
@ -11,6 +11,7 @@ export interface IAuthProvider {
|
||||
oidcClientSecret: string;
|
||||
allowSignup: boolean;
|
||||
isEnabled: boolean;
|
||||
groupSync: boolean;
|
||||
creatorId: string;
|
||||
workspaceId: string;
|
||||
createdAt: Date;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import * as React from "react";
|
||||
import * as z from "zod";
|
||||
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
import { useForm } from "@mantine/form";
|
||||
import {
|
||||
Container,
|
||||
Title,
|
||||
@ -11,6 +11,7 @@ import {
|
||||
Box,
|
||||
Stack,
|
||||
} from "@mantine/core";
|
||||
import { zodResolver } from "mantine-form-zod-resolver";
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import { IRegister } from "@/features/auth/types/auth.types";
|
||||
import useAuth from "@/features/auth/hooks/use-auth";
|
||||
@ -18,6 +19,7 @@ import classes from "@/features/auth/components/auth.module.css";
|
||||
import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-query.ts";
|
||||
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SsoLogin from "@/ee/components/sso-login.tsx";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().trim().min(1),
|
||||
@ -71,39 +73,43 @@ export function InviteSignUpForm() {
|
||||
{t("Join the workspace")}
|
||||
</Title>
|
||||
|
||||
<Stack align="stretch" justify="center" gap="xl">
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<TextInput
|
||||
id="name"
|
||||
type="text"
|
||||
label={t("Name")}
|
||||
placeholder={t("enter your full name")}
|
||||
variant="filled"
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
<SsoLogin />
|
||||
|
||||
<TextInput
|
||||
id="email"
|
||||
type="email"
|
||||
label={t("Email")}
|
||||
value={invitation.email}
|
||||
disabled
|
||||
variant="filled"
|
||||
mt="md"
|
||||
/>
|
||||
{!invitation.enforceSso && (
|
||||
<Stack align="stretch" justify="center" gap="xl">
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<TextInput
|
||||
id="name"
|
||||
type="text"
|
||||
label={t("Name")}
|
||||
placeholder={t("enter your full name")}
|
||||
variant="filled"
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label={t("Password")}
|
||||
placeholder={t("Your password")}
|
||||
variant="filled"
|
||||
mt="md"
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
|
||||
{t("Sign Up")}
|
||||
</Button>
|
||||
</form>
|
||||
</Stack>
|
||||
<TextInput
|
||||
id="email"
|
||||
type="email"
|
||||
label={t("Email")}
|
||||
value={invitation.email}
|
||||
disabled
|
||||
variant="filled"
|
||||
mt="md"
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label={t("Password")}
|
||||
placeholder={t("Your password")}
|
||||
variant="filled"
|
||||
mt="md"
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
|
||||
{t("Sign Up")}
|
||||
</Button>
|
||||
</form>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@ -21,7 +21,7 @@ import { Link } from "react-router-dom";
|
||||
import APP_ROUTE from "@/lib/app-route.ts";
|
||||
|
||||
const formSchema = z.object({
|
||||
workspaceName: z.string().trim().min(3).max(50),
|
||||
workspaceName: z.string().trim().max(50).optional(),
|
||||
name: z.string().min(1).max(50),
|
||||
email: z
|
||||
.string()
|
||||
@ -60,15 +60,17 @@ export function SetupWorkspaceForm() {
|
||||
{isCloud() && <SsoCloudSignup />}
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<TextInput
|
||||
id="workspaceName"
|
||||
type="text"
|
||||
label={t("Workspace Name")}
|
||||
placeholder={t("e.g ACME Inc")}
|
||||
variant="filled"
|
||||
mt="md"
|
||||
{...form.getInputProps("workspaceName")}
|
||||
/>
|
||||
{!isCloud() && (
|
||||
<TextInput
|
||||
id="workspaceName"
|
||||
type="text"
|
||||
label={t("Workspace Name")}
|
||||
placeholder={t("e.g ACME Inc")}
|
||||
variant="filled"
|
||||
mt="md"
|
||||
{...form.getInputProps("workspaceName")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
id="name"
|
||||
|
||||
@ -27,7 +27,7 @@ import APP_ROUTE from "@/lib/app-route.ts";
|
||||
import { RESET } from "jotai/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
import { exchangeTokenRedirectUrl, getHostnameUrl } from "@/ee/utils.ts";
|
||||
import { exchangeTokenRedirectUrl } from "@/ee/utils.ts";
|
||||
|
||||
export default function useAuth() {
|
||||
const { t } = useTranslation();
|
||||
@ -39,9 +39,17 @@ export default function useAuth() {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await login(data);
|
||||
const response = await login(data);
|
||||
setIsLoading(false);
|
||||
navigate(APP_ROUTE.HOME);
|
||||
|
||||
// Check if MFA is required
|
||||
if (response?.userHasMfa) {
|
||||
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE);
|
||||
} else if (response?.requiresMfaSetup) {
|
||||
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED);
|
||||
} else {
|
||||
navigate(APP_ROUTE.HOME);
|
||||
}
|
||||
} catch (err) {
|
||||
setIsLoading(false);
|
||||
console.log(err);
|
||||
@ -56,9 +64,19 @@ export default function useAuth() {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await acceptInvitation(data);
|
||||
const response = await acceptInvitation(data);
|
||||
setIsLoading(false);
|
||||
navigate(APP_ROUTE.HOME);
|
||||
|
||||
if (response?.requiresLogin) {
|
||||
notifications.show({
|
||||
message: t(
|
||||
"Account created successfully. Please log in to set up two-factor authentication.",
|
||||
),
|
||||
});
|
||||
navigate(APP_ROUTE.AUTH.LOGIN);
|
||||
} else {
|
||||
navigate(APP_ROUTE.HOME);
|
||||
}
|
||||
} catch (err) {
|
||||
setIsLoading(false);
|
||||
notifications.show({
|
||||
@ -100,12 +118,22 @@ export default function useAuth() {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await passwordReset(data);
|
||||
const response = await passwordReset(data);
|
||||
setIsLoading(false);
|
||||
navigate(APP_ROUTE.HOME);
|
||||
notifications.show({
|
||||
message: t("Password reset was successful"),
|
||||
});
|
||||
|
||||
if (response?.requiresLogin) {
|
||||
notifications.show({
|
||||
message: t(
|
||||
"Password reset was successful. Please log in with your new password.",
|
||||
),
|
||||
});
|
||||
navigate(APP_ROUTE.AUTH.LOGIN);
|
||||
} else {
|
||||
navigate(APP_ROUTE.HOME);
|
||||
notifications.show({
|
||||
message: t("Password reset was successful"),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
setIsLoading(false);
|
||||
notifications.show({
|
||||
|
||||
@ -4,14 +4,16 @@ import {
|
||||
ICollabToken,
|
||||
IForgotPassword,
|
||||
ILogin,
|
||||
ILoginResponse,
|
||||
IPasswordReset,
|
||||
ISetupWorkspace,
|
||||
IVerifyUserToken,
|
||||
} from "@/features/auth/types/auth.types";
|
||||
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
|
||||
|
||||
export async function login(data: ILogin): Promise<void> {
|
||||
await api.post<void>("/auth/login", data);
|
||||
export async function login(data: ILogin): Promise<ILoginResponse> {
|
||||
const response = await api.post<ILoginResponse>("/auth/login", data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
@ -36,8 +38,9 @@ export async function forgotPassword(data: IForgotPassword): Promise<void> {
|
||||
await api.post<void>("/auth/forgot-password", data);
|
||||
}
|
||||
|
||||
export async function passwordReset(data: IPasswordReset): Promise<void> {
|
||||
await api.post<void>("/auth/password-reset", data);
|
||||
export async function passwordReset(data: IPasswordReset): Promise<{ requiresLogin?: boolean; }> {
|
||||
const req = await api.post("/auth/password-reset", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function verifyUserToken(data: IVerifyUserToken): Promise<any> {
|
||||
@ -47,4 +50,4 @@ export async function verifyUserToken(data: IVerifyUserToken): Promise<any> {
|
||||
export async function getCollabToken(): Promise<ICollabToken> {
|
||||
const req = await api.post<ICollabToken>("/auth/collab-token");
|
||||
return req.data;
|
||||
}
|
||||
}
|
||||
@ -10,7 +10,7 @@ export interface IRegister {
|
||||
}
|
||||
|
||||
export interface ISetupWorkspace {
|
||||
workspaceName: string;
|
||||
workspaceName?: string;
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
@ -36,5 +36,12 @@ export interface IVerifyUserToken {
|
||||
}
|
||||
|
||||
export interface ICollabToken {
|
||||
token: string;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export interface ILoginResponse {
|
||||
userHasMfa?: boolean;
|
||||
requiresMfaSetup?: boolean;
|
||||
mfaToken?: string;
|
||||
isMfaEnforced?: boolean;
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Button, Group } from "@mantine/core";
|
||||
import { Button, Group, Tooltip } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type CommentActionsProps = {
|
||||
@ -15,7 +15,7 @@ function CommentActions({
|
||||
isCommentEditor,
|
||||
}: CommentActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
||||
return (
|
||||
<Group justify="flex-end" pt="sm" wrap="nowrap">
|
||||
{isCommentEditor && (
|
||||
|
||||
@ -15,6 +15,7 @@ import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-
|
||||
import { useEditor } from "@tiptap/react";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||
|
||||
interface CommentDialogProps {
|
||||
editor: ReturnType<typeof useEditor>;
|
||||
@ -35,6 +36,8 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
||||
const createCommentMutation = useCreateCommentMutation();
|
||||
const { isPending } = createCommentMutation;
|
||||
|
||||
const emit = useQueryEmit();
|
||||
|
||||
const handleDialogClose = () => {
|
||||
setShowCommentPopup(false);
|
||||
editor.chain().focus().unsetCommentDecoration().run();
|
||||
@ -63,11 +66,23 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
||||
.run();
|
||||
setActiveCommentId(createdComment.id);
|
||||
|
||||
//unselect text to close bubble menu
|
||||
editor.commands.setTextSelection({ from: editor.view.state.selection.from, to: editor.view.state.selection.from });
|
||||
|
||||
setAsideState({ tab: "comments", isAsideOpen: true });
|
||||
setTimeout(() => {
|
||||
const selector = `div[data-comment-id="${createdComment.id}"]`;
|
||||
const commentElement = document.querySelector(selector);
|
||||
commentElement?.scrollIntoView();
|
||||
commentElement?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
|
||||
editor.view.dispatch(
|
||||
editor.state.tr.scrollIntoView()
|
||||
);
|
||||
}, 400);
|
||||
|
||||
emit({
|
||||
operation: "invalidateComment",
|
||||
pageId: pageId,
|
||||
});
|
||||
} finally {
|
||||
setShowCommentPopup(false);
|
||||
@ -109,6 +124,7 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
||||
|
||||
<CommentEditor
|
||||
onUpdate={handleCommentEditorChange}
|
||||
onSave={handleAddComment}
|
||||
placeholder={t("Write a comment")}
|
||||
editable={true}
|
||||
autofocus={true}
|
||||
|
||||
@ -8,10 +8,12 @@ import { useFocusWithin } from "@mantine/hooks";
|
||||
import clsx from "clsx";
|
||||
import { forwardRef, useEffect, useImperativeHandle } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import EmojiCommand from "@/features/editor/extensions/emoji-command";
|
||||
|
||||
interface CommentEditorProps {
|
||||
defaultContent?: any;
|
||||
onUpdate?: any;
|
||||
onSave?: any;
|
||||
editable: boolean;
|
||||
placeholder?: string;
|
||||
autofocus?: boolean;
|
||||
@ -22,6 +24,7 @@ const CommentEditor = forwardRef(
|
||||
{
|
||||
defaultContent,
|
||||
onUpdate,
|
||||
onSave,
|
||||
editable,
|
||||
placeholder,
|
||||
autofocus,
|
||||
@ -42,7 +45,35 @@ const CommentEditor = forwardRef(
|
||||
}),
|
||||
Underline,
|
||||
Link,
|
||||
EmojiCommand,
|
||||
],
|
||||
editorProps: {
|
||||
handleDOMEvents: {
|
||||
keydown: (_view, event) => {
|
||||
if (
|
||||
[
|
||||
"ArrowUp",
|
||||
"ArrowDown",
|
||||
"ArrowLeft",
|
||||
"ArrowRight",
|
||||
"Enter",
|
||||
].includes(event.key)
|
||||
) {
|
||||
const emojiCommand = document.querySelector("#emoji-command");
|
||||
if (emojiCommand) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (onSave) onSave();
|
||||
|
||||
return true;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
onUpdate({ editor }) {
|
||||
if (onUpdate) onUpdate(editor.getJSON());
|
||||
},
|
||||
@ -53,6 +84,10 @@ const CommentEditor = forwardRef(
|
||||
autofocus: (autofocus && "end") || false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
commentEditor.commands.setContent(defaultContent);
|
||||
}, [defaultContent]);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
if (autofocus) {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Group, Text, Box } from "@mantine/core";
|
||||
import React, { useState } from "react";
|
||||
import { Group, Text, Box, Badge } from "@mantine/core";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import classes from "./comment.module.css";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { timeAgo } from "@/lib/time";
|
||||
@ -7,20 +7,34 @@ import CommentEditor from "@/features/comment/components/comment-editor";
|
||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
||||
import CommentActions from "@/features/comment/components/comment-actions";
|
||||
import CommentMenu from "@/features/comment/components/comment-menu";
|
||||
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
|
||||
import ResolveComment from "@/ee/comment/components/resolve-comment";
|
||||
import { useHover } from "@mantine/hooks";
|
||||
import {
|
||||
useDeleteCommentMutation,
|
||||
useUpdateCommentMutation,
|
||||
} from "@/features/comment/queries/comment-query";
|
||||
import { useResolveCommentMutation } from "@/ee/comment/queries/comment-query";
|
||||
import { IComment } from "@/features/comment/types/comment.types";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface CommentListItemProps {
|
||||
comment: IComment;
|
||||
pageId: string;
|
||||
canComment: boolean;
|
||||
userSpaceRole?: string;
|
||||
}
|
||||
|
||||
function CommentListItem({ comment }: CommentListItemProps) {
|
||||
function CommentListItem({
|
||||
comment,
|
||||
pageId,
|
||||
canComment,
|
||||
userSpaceRole,
|
||||
}: CommentListItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const { hovered, ref } = useHover();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@ -28,7 +42,14 @@ function CommentListItem({ comment }: CommentListItemProps) {
|
||||
const [content, setContent] = useState<string>(comment.content);
|
||||
const updateCommentMutation = useUpdateCommentMutation();
|
||||
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
||||
const resolveCommentMutation = useResolveCommentMutation();
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const emit = useQueryEmit();
|
||||
const isCloudEE = useIsCloudEE();
|
||||
|
||||
useEffect(() => {
|
||||
setContent(comment.content);
|
||||
}, [comment]);
|
||||
|
||||
async function handleUpdateComment() {
|
||||
try {
|
||||
@ -39,6 +60,11 @@ function CommentListItem({ comment }: CommentListItemProps) {
|
||||
};
|
||||
await updateCommentMutation.mutateAsync(commentToUpdate);
|
||||
setIsEditing(false);
|
||||
|
||||
emit({
|
||||
operation: "invalidateComment",
|
||||
pageId: pageId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update comment:", error);
|
||||
} finally {
|
||||
@ -50,11 +76,54 @@ function CommentListItem({ comment }: CommentListItemProps) {
|
||||
try {
|
||||
await deleteCommentMutation.mutateAsync(comment.id);
|
||||
editor?.commands.unsetComment(comment.id);
|
||||
|
||||
emit({
|
||||
operation: "invalidateComment",
|
||||
pageId: pageId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to delete comment:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResolveComment() {
|
||||
if (!isCloudEE) return;
|
||||
|
||||
try {
|
||||
const isResolved = comment.resolvedAt != null;
|
||||
|
||||
await resolveCommentMutation.mutateAsync({
|
||||
commentId: comment.id,
|
||||
pageId: comment.pageId,
|
||||
resolved: !isResolved,
|
||||
});
|
||||
|
||||
if (editor) {
|
||||
editor.commands.setCommentResolved(comment.id, !isResolved);
|
||||
}
|
||||
|
||||
emit({
|
||||
operation: "invalidateComment",
|
||||
pageId: pageId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle resolved state:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCommentClick(comment: IComment) {
|
||||
const el = document.querySelector(
|
||||
`.comment-mark[data-comment-id="${comment.id}"]`,
|
||||
);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
el.classList.add("comment-highlight");
|
||||
setTimeout(() => {
|
||||
el.classList.remove("comment-highlight");
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
function handleEditToggle() {
|
||||
setIsEditing(true);
|
||||
}
|
||||
@ -78,28 +147,42 @@ function CommentListItem({ comment }: CommentListItemProps) {
|
||||
</Text>
|
||||
|
||||
<div style={{ visibility: hovered ? "visible" : "hidden" }}>
|
||||
{/*!comment.parentCommentId && (
|
||||
<ResolveComment commentId={comment.id} pageId={comment.pageId} resolvedAt={comment.resolvedAt} />
|
||||
)*/}
|
||||
{!comment.parentCommentId && canComment && isCloudEE && (
|
||||
<ResolveComment
|
||||
editor={editor}
|
||||
commentId={comment.id}
|
||||
pageId={comment.pageId}
|
||||
resolvedAt={comment.resolvedAt}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentUser?.user?.id === comment.creatorId && (
|
||||
{(currentUser?.user?.id === comment.creatorId || userSpaceRole === 'admin') && (
|
||||
<CommentMenu
|
||||
onEditComment={handleEditToggle}
|
||||
onDeleteComment={handleDeleteComment}
|
||||
onResolveComment={handleResolveComment}
|
||||
canEdit={currentUser?.user?.id === comment.creatorId}
|
||||
isResolved={comment.resolvedAt != null}
|
||||
isParentComment={!comment.parentCommentId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Text size="xs" fw={500} c="dimmed">
|
||||
{timeAgo(comment.createdAt)}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Text size="xs" fw={500} c="dimmed">
|
||||
{timeAgo(comment.createdAt)}
|
||||
</Text>
|
||||
</Group>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<div>
|
||||
{!comment.parentCommentId && comment?.selection && (
|
||||
<Box className={classes.textSelection}>
|
||||
<Box
|
||||
className={classes.textSelection}
|
||||
onClick={() => handleCommentClick(comment)}
|
||||
>
|
||||
<Text size="sm">{comment?.selection}</Text>
|
||||
</Box>
|
||||
)}
|
||||
@ -112,6 +195,7 @@ function CommentListItem({ comment }: CommentListItemProps) {
|
||||
defaultContent={content}
|
||||
editable={true}
|
||||
onUpdate={(newContent: any) => setContent(newContent)}
|
||||
onSave={handleUpdateComment}
|
||||
autofocus={true}
|
||||
/>
|
||||
|
||||
|
||||
@ -0,0 +1,330 @@
|
||||
import React, { useState, useRef, useCallback, memo, useMemo } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Divider, Paper, Tabs, Badge, Text, ScrollArea } from "@mantine/core";
|
||||
import CommentListItem from "@/features/comment/components/comment-list-item";
|
||||
import {
|
||||
useCommentsQuery,
|
||||
useCreateCommentMutation,
|
||||
} from "@/features/comment/queries/comment-query";
|
||||
import CommentEditor from "@/features/comment/components/comment-editor";
|
||||
import CommentActions from "@/features/comment/components/comment-actions";
|
||||
import { useFocusWithin } from "@mantine/hooks";
|
||||
import { IComment } from "@/features/comment/types/comment.types.ts";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
|
||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from "@/features/space/permissions/permissions.type.ts";
|
||||
|
||||
function CommentListWithTabs() {
|
||||
const { t } = useTranslation();
|
||||
const { pageSlug } = useParams();
|
||||
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
||||
const {
|
||||
data: comments,
|
||||
isLoading: isCommentsLoading,
|
||||
isError,
|
||||
} = useCommentsQuery({ pageId: page?.id, limit: 100 });
|
||||
const createCommentMutation = useCreateCommentMutation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const emit = useQueryEmit();
|
||||
const isCloudEE = useIsCloudEE();
|
||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||
|
||||
const spaceRules = space?.membership?.permissions;
|
||||
const spaceAbility = useSpaceAbility(spaceRules);
|
||||
|
||||
|
||||
const canComment: boolean = spaceAbility.can(
|
||||
SpaceCaslAction.Manage,
|
||||
SpaceCaslSubject.Page
|
||||
);
|
||||
|
||||
// Separate active and resolved comments
|
||||
const { activeComments, resolvedComments } = useMemo(() => {
|
||||
if (!comments?.items) {
|
||||
return { activeComments: [], resolvedComments: [] };
|
||||
}
|
||||
|
||||
const parentComments = comments.items.filter(
|
||||
(comment: IComment) => comment.parentCommentId === null
|
||||
);
|
||||
|
||||
const active = parentComments.filter(
|
||||
(comment: IComment) => !comment.resolvedAt
|
||||
);
|
||||
const resolved = parentComments.filter(
|
||||
(comment: IComment) => comment.resolvedAt
|
||||
);
|
||||
|
||||
return { activeComments: active, resolvedComments: resolved };
|
||||
}, [comments]);
|
||||
|
||||
const handleAddReply = useCallback(
|
||||
async (commentId: string, content: string) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const commentData = {
|
||||
pageId: page?.id,
|
||||
parentCommentId: commentId,
|
||||
content: JSON.stringify(content),
|
||||
};
|
||||
|
||||
await createCommentMutation.mutateAsync(commentData);
|
||||
|
||||
emit({
|
||||
operation: "invalidateComment",
|
||||
pageId: page?.id,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to post comment:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[createCommentMutation, page?.id]
|
||||
);
|
||||
|
||||
const renderComments = useCallback(
|
||||
(comment: IComment) => (
|
||||
<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>
|
||||
|
||||
{!comment.resolvedAt && canComment && (
|
||||
<>
|
||||
<Divider my={4} />
|
||||
<CommentEditorWithActions
|
||||
commentId={comment.id}
|
||||
onSave={handleAddReply}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
),
|
||||
[comments, handleAddReply, isLoading, space?.membership?.role]
|
||||
);
|
||||
|
||||
if (isCommentsLoading) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <div>{t("Error loading comments.")}</div>;
|
||||
}
|
||||
|
||||
const totalComments = activeComments.length + resolvedComments.length;
|
||||
|
||||
// If not cloud/enterprise, show simple list without tabs
|
||||
if (!isCloudEE) {
|
||||
if (totalComments === 0) {
|
||||
return <>{t("No comments yet.")}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<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 (
|
||||
<div style={{ height: "85vh", display: "flex", flexDirection: "column", marginTop: '-15px' }}>
|
||||
<Tabs defaultValue="open" variant="default" style={{ flex: "0 0 auto" }}>
|
||||
<Tabs.List justify="center">
|
||||
<Tabs.Tab
|
||||
value="open"
|
||||
leftSection={
|
||||
<Badge size="sm" variant="light" color="blue">
|
||||
{activeComments.length}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
{t("Open")}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value="resolved"
|
||||
leftSection={
|
||||
<Badge size="sm" variant="light" color="green">
|
||||
{resolvedComments.length}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
{t("Resolved")}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<ScrollArea
|
||||
style={{ flex: "1 1 auto", height: "calc(85vh - 60px)" }}
|
||||
scrollbarSize={5}
|
||||
type="scroll"
|
||||
>
|
||||
<div style={{ paddingBottom: "200px" }}>
|
||||
<Tabs.Panel value="open" pt="xs">
|
||||
{activeComments.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||
{t("No open comments.")}
|
||||
</Text>
|
||||
) : (
|
||||
activeComments.map(renderComments)
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="resolved" pt="xs">
|
||||
{resolvedComments.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||
{t("No resolved comments.")}
|
||||
</Text>
|
||||
) : (
|
||||
resolvedComments.map(renderComments)
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChildCommentsProps {
|
||||
comments: IPagination<IComment>;
|
||||
parentId: string;
|
||||
pageId: string;
|
||||
canComment: boolean;
|
||||
userSpaceRole?: string;
|
||||
}
|
||||
const ChildComments = ({
|
||||
comments,
|
||||
parentId,
|
||||
pageId,
|
||||
canComment,
|
||||
userSpaceRole,
|
||||
}: ChildCommentsProps) => {
|
||||
const getChildComments = useCallback(
|
||||
(parentId: string) =>
|
||||
comments.items.filter(
|
||||
(comment: IComment) => comment.parentCommentId === parentId
|
||||
),
|
||||
[comments.items]
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{getChildComments(parentId).map((childComment) => (
|
||||
<div key={childComment.id}>
|
||||
<CommentListItem
|
||||
comment={childComment}
|
||||
pageId={pageId}
|
||||
canComment={canComment}
|
||||
userSpaceRole={userSpaceRole}
|
||||
/>
|
||||
<MemoizedChildComments
|
||||
comments={comments}
|
||||
parentId={childComment.id}
|
||||
pageId={pageId}
|
||||
canComment={canComment}
|
||||
userSpaceRole={userSpaceRole}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MemoizedChildComments = memo(ChildComments);
|
||||
|
||||
const CommentEditorWithActions = ({ commentId, onSave, isLoading }) => {
|
||||
const [content, setContent] = useState("");
|
||||
const { ref, focused } = useFocusWithin();
|
||||
const commentEditorRef = useRef(null);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
onSave(commentId, content);
|
||||
setContent("");
|
||||
commentEditorRef.current?.clearContent();
|
||||
}, [commentId, content, onSave]);
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<CommentEditor
|
||||
ref={commentEditorRef}
|
||||
onUpdate={setContent}
|
||||
onSave={handleSave}
|
||||
editable={true}
|
||||
/>
|
||||
{focused && <CommentActions onSave={handleSave} isLoading={isLoading} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentListWithTabs;
|
||||
@ -1,152 +0,0 @@
|
||||
import React, { useState, useRef, useCallback, memo } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Divider, Paper } from "@mantine/core";
|
||||
import CommentListItem from "@/features/comment/components/comment-list-item";
|
||||
import {
|
||||
useCommentsQuery,
|
||||
useCreateCommentMutation,
|
||||
} from "@/features/comment/queries/comment-query";
|
||||
import CommentEditor from "@/features/comment/components/comment-editor";
|
||||
import CommentActions from "@/features/comment/components/comment-actions";
|
||||
import { useFocusWithin } from "@mantine/hooks";
|
||||
import { IComment } from "@/features/comment/types/comment.types.ts";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function CommentList() {
|
||||
const { t } = useTranslation();
|
||||
const { pageSlug } = useParams();
|
||||
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
||||
const {
|
||||
data: comments,
|
||||
isLoading: isCommentsLoading,
|
||||
isError,
|
||||
} = useCommentsQuery({ pageId: page?.id, limit: 100 });
|
||||
const createCommentMutation = useCreateCommentMutation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleAddReply = useCallback(
|
||||
async (commentId: string, content: string) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const commentData = {
|
||||
pageId: page?.id,
|
||||
parentCommentId: commentId,
|
||||
content: JSON.stringify(content),
|
||||
};
|
||||
|
||||
await createCommentMutation.mutateAsync(commentData);
|
||||
} catch (error) {
|
||||
console.error("Failed to post comment:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[createCommentMutation, page?.id],
|
||||
);
|
||||
|
||||
const renderComments = useCallback(
|
||||
(comment: IComment) => (
|
||||
<Paper
|
||||
shadow="sm"
|
||||
radius="md"
|
||||
p="sm"
|
||||
mb="sm"
|
||||
withBorder
|
||||
key={comment.id}
|
||||
data-comment-id={comment.id}
|
||||
>
|
||||
<div>
|
||||
<CommentListItem comment={comment} />
|
||||
<MemoizedChildComments comments={comments} parentId={comment.id} />
|
||||
</div>
|
||||
|
||||
<Divider my={4} />
|
||||
|
||||
<CommentEditorWithActions
|
||||
commentId={comment.id}
|
||||
onSave={handleAddReply}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</Paper>
|
||||
),
|
||||
[comments, handleAddReply, isLoading],
|
||||
);
|
||||
|
||||
if (isCommentsLoading) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <div>{t("Error loading comments.")}</div>;
|
||||
}
|
||||
|
||||
if (!comments || comments.items.length === 0) {
|
||||
return <>{t("No comments yet.")}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{comments.items
|
||||
.filter((comment) => comment.parentCommentId === null)
|
||||
.map(renderComments)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChildCommentsProps {
|
||||
comments: IPagination<IComment>;
|
||||
parentId: string;
|
||||
}
|
||||
const ChildComments = ({ comments, parentId }: ChildCommentsProps) => {
|
||||
const getChildComments = useCallback(
|
||||
(parentId: string) =>
|
||||
comments.items.filter(
|
||||
(comment: IComment) => comment.parentCommentId === parentId,
|
||||
),
|
||||
[comments.items],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{getChildComments(parentId).map((childComment) => (
|
||||
<div key={childComment.id}>
|
||||
<CommentListItem comment={childComment} />
|
||||
<MemoizedChildComments
|
||||
comments={comments}
|
||||
parentId={childComment.id}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MemoizedChildComments = memo(ChildComments);
|
||||
|
||||
const CommentEditorWithActions = ({ commentId, onSave, isLoading }) => {
|
||||
const [content, setContent] = useState("");
|
||||
const { ref, focused } = useFocusWithin();
|
||||
const commentEditorRef = useRef(null);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
onSave(commentId, content);
|
||||
setContent("");
|
||||
commentEditorRef.current?.clearContent();
|
||||
}, [commentId, content, onSave]);
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<CommentEditor
|
||||
ref={commentEditorRef}
|
||||
onUpdate={setContent}
|
||||
editable={true}
|
||||
/>
|
||||
{focused && <CommentActions onSave={handleSave} isLoading={isLoading} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentList;
|
||||
@ -1,15 +1,28 @@
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react";
|
||||
import { ActionIcon, Menu, Tooltip } from "@mantine/core";
|
||||
import { IconDots, IconEdit, IconTrash, IconCircleCheck, IconCircleCheckFilled } from "@tabler/icons-react";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
|
||||
|
||||
type CommentMenuProps = {
|
||||
onEditComment: () => void;
|
||||
onDeleteComment: () => void;
|
||||
onResolveComment?: () => void;
|
||||
canEdit?: boolean;
|
||||
isResolved?: boolean;
|
||||
isParentComment?: boolean;
|
||||
};
|
||||
|
||||
function CommentMenu({ onEditComment, onDeleteComment }: CommentMenuProps) {
|
||||
function CommentMenu({
|
||||
onEditComment,
|
||||
onDeleteComment,
|
||||
onResolveComment,
|
||||
canEdit = true,
|
||||
isResolved = false,
|
||||
isParentComment = false
|
||||
}: CommentMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const isCloudEE = useIsCloudEE();
|
||||
|
||||
//@ts-ignore
|
||||
const openDeleteModal = () =>
|
||||
@ -30,9 +43,34 @@ function CommentMenu({ onEditComment, onDeleteComment }: CommentMenuProps) {
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={onEditComment} leftSection={<IconEdit size={14} />}>
|
||||
{t("Edit comment")}
|
||||
</Menu.Item>
|
||||
{canEdit && (
|
||||
<Menu.Item onClick={onEditComment} leftSection={<IconEdit size={14} />}>
|
||||
{t("Edit comment")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{isParentComment && (
|
||||
isCloudEE ? (
|
||||
<Menu.Item
|
||||
onClick={onResolveComment}
|
||||
leftSection={
|
||||
isResolved ?
|
||||
<IconCircleCheckFilled size={14} /> :
|
||||
<IconCircleCheck size={14} />
|
||||
}
|
||||
>
|
||||
{isResolved ? t("Re-open comment") : t("Resolve comment")}
|
||||
</Menu.Item>
|
||||
) : (
|
||||
<Tooltip label={t("Available in enterprise edition")} position="left">
|
||||
<Menu.Item
|
||||
disabled
|
||||
leftSection={<IconCircleCheck size={14} />}
|
||||
>
|
||||
{t("Resolve comment")}
|
||||
</Menu.Item>
|
||||
</Tooltip>
|
||||
)
|
||||
)}
|
||||
<Menu.Item
|
||||
leftSection={<IconTrash size={14} />}
|
||||
onClick={openDeleteModal}
|
||||
|
||||
@ -11,22 +11,31 @@
|
||||
border-left: 2px solid var(--mantine-color-gray-6);
|
||||
padding: 8px;
|
||||
background: var(--mantine-color-gray-light);
|
||||
cursor: pointer;
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
-ms-word-break: break-word;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.commentEditor {
|
||||
|
||||
.focused {
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
box-shadow: 0 0 0 2px var(--mantine-color-blue-3);
|
||||
}
|
||||
|
||||
.ProseMirror :global(.ProseMirror){
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
max-width: 100%;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 20vh;
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
margin-top: 2px;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 2px;
|
||||
overflow: hidden auto;
|
||||
}
|
||||
|
||||
@ -1,47 +0,0 @@
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
import { IconCircleCheck } from "@tabler/icons-react";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { useResolveCommentMutation } from "@/features/comment/queries/comment-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function ResolveComment({ commentId, pageId, resolvedAt }) {
|
||||
const { t } = useTranslation();
|
||||
const resolveCommentMutation = useResolveCommentMutation();
|
||||
|
||||
const isResolved = resolvedAt != null;
|
||||
const iconColor = isResolved ? "green" : "gray";
|
||||
|
||||
//@ts-ignore
|
||||
const openConfirmModal = () =>
|
||||
modals.openConfirmModal({
|
||||
title: t("Are you sure you want to resolve this comment thread?"),
|
||||
centered: true,
|
||||
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
|
||||
onConfirm: handleResolveToggle,
|
||||
});
|
||||
|
||||
const handleResolveToggle = async () => {
|
||||
try {
|
||||
await resolveCommentMutation.mutateAsync({
|
||||
commentId,
|
||||
resolved: !isResolved,
|
||||
});
|
||||
//TODO: remove comment mark
|
||||
// Remove comment thread from state on resolve
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle resolved state:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ActionIcon
|
||||
onClick={openConfirmModal}
|
||||
variant="default"
|
||||
style={{ border: "none" }}
|
||||
>
|
||||
<IconCircleCheck size={20} stroke={2} color={iconColor} />
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
|
||||
export default ResolveComment;
|
||||
@ -8,13 +8,11 @@ import {
|
||||
createComment,
|
||||
deleteComment,
|
||||
getPageComments,
|
||||
resolveComment,
|
||||
updateComment,
|
||||
} from "@/features/comment/services/comment-service";
|
||||
import {
|
||||
ICommentParams,
|
||||
IComment,
|
||||
IResolveComment,
|
||||
} from "@/features/comment/types/comment.types";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
@ -108,34 +106,4 @@ export function useDeleteCommentMutation(pageId?: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useResolveCommentMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: IResolveComment) => resolveComment(data),
|
||||
onSuccess: (data: IComment, variables) => {
|
||||
const currentComments = queryClient.getQueryData(
|
||||
RQ_KEY(data.pageId),
|
||||
) as IComment[];
|
||||
|
||||
/*
|
||||
if (currentComments) {
|
||||
const updatedComments = currentComments.map((comment) =>
|
||||
comment.id === variables.commentId
|
||||
? { ...comment, ...data }
|
||||
: comment,
|
||||
);
|
||||
queryClient.setQueryData(RQ_KEY(data.pageId), updatedComments);
|
||||
}*/
|
||||
|
||||
notifications.show({ message: t("Comment resolved successfully") });
|
||||
},
|
||||
onError: (error) => {
|
||||
notifications.show({
|
||||
message: t("Failed to resolve comment"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
// EE: useResolveCommentMutation has been moved to @/ee/comment/queries/comment-query
|
||||
|
||||
@ -16,6 +16,7 @@ export interface IComment {
|
||||
editedAt?: Date;
|
||||
deletedAt?: Date;
|
||||
creator: IUser;
|
||||
resolvedBy?: IUser;
|
||||
}
|
||||
|
||||
export interface ICommentData {
|
||||
@ -28,6 +29,7 @@ export interface ICommentData {
|
||||
|
||||
export interface IResolveComment {
|
||||
commentId: string;
|
||||
pageId: string;
|
||||
resolved: boolean;
|
||||
}
|
||||
|
||||
|
||||
@ -116,6 +116,12 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
},
|
||||
tippyOptions: {
|
||||
moveTransition: "transform 0.15s ease-out",
|
||||
onCreate: (instance) => {
|
||||
instance.popper.firstChild?.addEventListener("blur", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
});
|
||||
},
|
||||
onHide: () => {
|
||||
setIsNodeSelectorOpen(false);
|
||||
setIsTextAlignmentOpen(false);
|
||||
@ -177,8 +183,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
<LinkSelector
|
||||
editor={props.editor}
|
||||
isOpen={isLinkSelectorOpen}
|
||||
setIsOpen={() => {
|
||||
setIsLinkSelectorOpen(!isLinkSelectorOpen);
|
||||
setIsOpen={(value) => {
|
||||
setIsLinkSelectorOpen(value);
|
||||
setIsNodeSelectorOpen(false);
|
||||
setIsTextAlignmentOpen(false);
|
||||
setIsColorSelectorOpen(false);
|
||||
|
||||
@ -156,13 +156,11 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
||||
)
|
||||
}
|
||||
onClick={() => {
|
||||
editor.commands.unsetColor();
|
||||
name !== "Default" &&
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setColor(color || "")
|
||||
.run();
|
||||
if (name === "Default") {
|
||||
editor.commands.unsetColor();
|
||||
} else {
|
||||
editor.chain().focus().setColor(color || "").run();
|
||||
}
|
||||
setIsOpen(false);
|
||||
}}
|
||||
style={{ border: "none" }}
|
||||
|
||||
@ -0,0 +1,96 @@
|
||||
.wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.resizing {
|
||||
user-select: none;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 10;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.resizeHandleBottom {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 24px;
|
||||
cursor: ns-resize;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
touch-action: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
|
||||
@mixin light {
|
||||
background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.05));
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.05)
|
||||
);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@mixin light {
|
||||
background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.1)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper:hover .resizeHandleBottom,
|
||||
.resizing .resizeHandleBottom {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.resizeBar {
|
||||
width: 50px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
@mixin light {
|
||||
background-color: var(--mantine-color-gray-5);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
background-color: var(--mantine-color-gray-6);
|
||||
}
|
||||
}
|
||||
|
||||
.resizeHandleBottom:hover .resizeBar,
|
||||
.resizing .resizeBar {
|
||||
@mixin light {
|
||||
background-color: var(--mantine-color-gray-7);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
background-color: var(--mantine-color-gray-4);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,112 @@
|
||||
import React, { ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import classes from "./resizable-wrapper.module.css";
|
||||
|
||||
interface ResizableWrapperProps {
|
||||
children: ReactNode;
|
||||
initialHeight?: number;
|
||||
minHeight?: number;
|
||||
maxHeight?: number;
|
||||
onResize?: (height: number) => void;
|
||||
isEditable?: boolean;
|
||||
className?: string;
|
||||
showHandles?: "always" | "hover";
|
||||
direction?: "vertical" | "horizontal" | "both";
|
||||
}
|
||||
|
||||
export const ResizableWrapper: React.FC<ResizableWrapperProps> = ({
|
||||
children,
|
||||
initialHeight = 480,
|
||||
minHeight = 200,
|
||||
maxHeight = 1200,
|
||||
onResize,
|
||||
isEditable = true,
|
||||
className,
|
||||
showHandles = "hover",
|
||||
direction = "vertical",
|
||||
}) => {
|
||||
const [resizeParams, setResizeParams] = useState<{
|
||||
initialSize: number;
|
||||
initialClientY: number;
|
||||
initialClientX: number;
|
||||
} | null>(null);
|
||||
const [currentHeight, setCurrentHeight] = useState(initialHeight);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resizeParams) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!wrapperRef.current) return;
|
||||
|
||||
if (direction === "vertical" || direction === "both") {
|
||||
const deltaY = e.clientY - resizeParams.initialClientY;
|
||||
const newHeight = Math.min(
|
||||
Math.max(resizeParams.initialSize + deltaY, minHeight),
|
||||
maxHeight
|
||||
);
|
||||
setCurrentHeight(newHeight);
|
||||
wrapperRef.current.style.height = `${newHeight}px`;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setResizeParams(null);
|
||||
if (onResize && currentHeight !== initialHeight) {
|
||||
onResize(currentHeight);
|
||||
}
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [resizeParams, currentHeight, initialHeight, onResize, minHeight, maxHeight, direction]);
|
||||
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setResizeParams({
|
||||
initialSize: currentHeight,
|
||||
initialClientY: e.clientY,
|
||||
initialClientX: e.clientX,
|
||||
});
|
||||
|
||||
document.body.style.cursor = "ns-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
}, [currentHeight]);
|
||||
|
||||
const shouldShowHandles =
|
||||
isEditable &&
|
||||
(showHandles === "always" || (showHandles === "hover" && (isHovered || resizeParams)));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={clsx(classes.wrapper, className, {
|
||||
[classes.resizing]: !!resizeParams,
|
||||
})}
|
||||
style={{ height: currentHeight }}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{children}
|
||||
{!!resizeParams && <div className={classes.overlay} />}
|
||||
{shouldShowHandles && direction === "vertical" && (
|
||||
<div
|
||||
className={classes.resizeHandleBottom}
|
||||
onMouseDown={handleResizeStart}
|
||||
>
|
||||
<div className={classes.resizeBar} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,16 @@
|
||||
.embedWrapper {
|
||||
@mixin light {
|
||||
background-color: var(--mantine-color-gray-0);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
background-color: var(--mantine-color-dark-7);
|
||||
}
|
||||
}
|
||||
|
||||
.embedIframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
}
|
||||
@ -1,9 +1,8 @@
|
||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { useMemo } from "react";
|
||||
import React, { useMemo, useCallback } from "react";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
ActionIcon,
|
||||
AspectRatio,
|
||||
Button,
|
||||
Card,
|
||||
FocusTrap,
|
||||
@ -14,14 +13,18 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { IconEdit } from "@tabler/icons-react";
|
||||
import { z } from "zod";
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
import {
|
||||
getEmbedProviderById,
|
||||
getEmbedUrlAndProvider,
|
||||
} from "@/features/editor/components/embed/providers.ts";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zodResolver } from "mantine-form-zod-resolver";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n from "i18next";
|
||||
import {
|
||||
getEmbedProviderById,
|
||||
getEmbedUrlAndProvider,
|
||||
sanitizeUrl,
|
||||
} from "@docmost/editor-ext";
|
||||
import { ResizableWrapper } from "../common/resizable-wrapper";
|
||||
import classes from "./embed-view.module.css";
|
||||
|
||||
const schema = z.object({
|
||||
url: z
|
||||
@ -32,8 +35,8 @@ const schema = z.object({
|
||||
|
||||
export default function EmbedView(props: NodeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { node, selected, updateAttributes } = props;
|
||||
const { src, provider } = node.attrs;
|
||||
const { node, selected, updateAttributes, editor } = props;
|
||||
const { src, provider, height: nodeHeight } = node.attrs;
|
||||
|
||||
const embedUrl = useMemo(() => {
|
||||
if (src) {
|
||||
@ -49,11 +52,26 @@ export default function EmbedView(props: NodeViewProps) {
|
||||
validate: zodResolver(schema),
|
||||
});
|
||||
|
||||
const handleResize = useCallback(
|
||||
(newHeight: number) => {
|
||||
updateAttributes({ height: newHeight });
|
||||
},
|
||||
[updateAttributes],
|
||||
);
|
||||
|
||||
async function onSubmit(data: { url: string }) {
|
||||
if (!editor.isEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider) {
|
||||
const embedProvider = getEmbedProviderById(provider);
|
||||
if (embedProvider.id === "iframe") {
|
||||
updateAttributes({ src: sanitizeUrl(data.url) });
|
||||
return;
|
||||
}
|
||||
if (embedProvider.regex.test(data.url)) {
|
||||
updateAttributes({ src: data.url });
|
||||
updateAttributes({ src: sanitizeUrl(data.url) });
|
||||
} else {
|
||||
notifications.show({
|
||||
message: t("Invalid {{provider}} embed link", {
|
||||
@ -69,19 +87,33 @@ export default function EmbedView(props: NodeViewProps) {
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
{embedUrl ? (
|
||||
<>
|
||||
<AspectRatio ratio={16 / 9}>
|
||||
<iframe
|
||||
src={embedUrl}
|
||||
allow="encrypted-media"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
allowFullScreen
|
||||
frameBorder="0"
|
||||
></iframe>
|
||||
</AspectRatio>
|
||||
</>
|
||||
<ResizableWrapper
|
||||
initialHeight={nodeHeight || 480}
|
||||
minHeight={200}
|
||||
maxHeight={1200}
|
||||
onResize={handleResize}
|
||||
isEditable={editor.isEditable}
|
||||
className={clsx(classes.embedWrapper, {
|
||||
"ProseMirror-selectednode": selected,
|
||||
})}
|
||||
>
|
||||
<iframe
|
||||
className={classes.embedIframe}
|
||||
src={sanitizeUrl(embedUrl)}
|
||||
allow="encrypted-media"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
allowFullScreen
|
||||
frameBorder="0"
|
||||
/>
|
||||
</ResizableWrapper>
|
||||
) : (
|
||||
<Popover width={300} position="bottom" withArrow shadow="md">
|
||||
<Popover
|
||||
width={300}
|
||||
position="bottom"
|
||||
withArrow
|
||||
shadow="md"
|
||||
disabled={!editor.isEditable}
|
||||
>
|
||||
<Popover.Target>
|
||||
<Card
|
||||
radius="md"
|
||||
@ -101,7 +133,7 @@ export default function EmbedView(props: NodeViewProps) {
|
||||
|
||||
<Text component="span" size="lg" c="dimmed">
|
||||
{t("Embed {{provider}}", {
|
||||
provider: getEmbedProviderById(provider).name,
|
||||
provider: getEmbedProviderById(provider)?.name,
|
||||
})}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
@ -33,7 +33,7 @@ const renderEmojiItems = () => {
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
placement: "bottom",
|
||||
});
|
||||
},
|
||||
onStart: (props: {
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
type LibraryItems = any;
|
||||
|
||||
type LibraryPersistedData = {
|
||||
libraryItems: LibraryItems;
|
||||
};
|
||||
|
||||
export interface LibraryPersistenceAdapter {
|
||||
load(metadata: { source: "load" | "save" }):
|
||||
| Promise<{ libraryItems: LibraryItems } | null>
|
||||
| {
|
||||
libraryItems: LibraryItems;
|
||||
}
|
||||
| null;
|
||||
|
||||
save(libraryData: LibraryPersistedData): Promise<void> | void;
|
||||
}
|
||||
|
||||
const LOCAL_STORAGE_KEY = "excalidrawLibrary";
|
||||
|
||||
export const localStorageLibraryAdapter: LibraryPersistenceAdapter = {
|
||||
async load() {
|
||||
try {
|
||||
const data = localStorage.getItem(LOCAL_STORAGE_KEY);
|
||||
if (data) {
|
||||
return JSON.parse(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error downloading Excalidraw library from localStorage", e);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
async save(libraryData) {
|
||||
try {
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(libraryData));
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"Error while saving library from Excalidraw to localStorage",
|
||||
e,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -13,7 +13,8 @@ import { uploadFile } from "@/features/page/services/page-service.ts";
|
||||
import { svgStringToFile } from "@/lib";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { getFileUrl } from "@/lib/config.ts";
|
||||
import { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types/types";
|
||||
import "@excalidraw/excalidraw/index.css";
|
||||
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
|
||||
import { IAttachment } from "@/lib/types";
|
||||
import ReactClearModal from "react-clear-modal";
|
||||
import clsx from "clsx";
|
||||
@ -21,6 +22,8 @@ import { IconEdit } from "@tabler/icons-react";
|
||||
import { lazy } from "react";
|
||||
import { Suspense } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHandleLibrary } from "@excalidraw/excalidraw";
|
||||
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
|
||||
|
||||
const Excalidraw = lazy(() =>
|
||||
import("@excalidraw/excalidraw").then((module) => ({
|
||||
@ -35,6 +38,10 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
||||
|
||||
const [excalidrawAPI, setExcalidrawAPI] =
|
||||
useState<ExcalidrawImperativeAPI>(null);
|
||||
useHandleLibrary({
|
||||
excalidrawAPI,
|
||||
adapter: localStorageLibraryAdapter,
|
||||
});
|
||||
const [excalidrawData, setExcalidrawData] = useState<any>(null);
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const computedColorScheme = useComputedColorScheme();
|
||||
|
||||
@ -3,6 +3,7 @@ import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
@ -18,7 +19,7 @@ import {
|
||||
import clsx from "clsx";
|
||||
import classes from "./mention.module.css";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { IconFileDescription } from "@tabler/icons-react";
|
||||
import { IconFileDescription, IconPlus } from "@tabler/icons-react";
|
||||
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { v7 as uuid7 } from "uuid";
|
||||
@ -28,14 +29,28 @@ import {
|
||||
MentionListProps,
|
||||
MentionSuggestionItem,
|
||||
} from "@/features/editor/components/mention/mention.type.ts";
|
||||
import { IPage } from "@/features/page/types/page.types";
|
||||
import { useCreatePageMutation, usePageQuery } from "@/features/page/queries/page-query";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
|
||||
import { SimpleTree } from "react-arborist";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
|
||||
const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(1);
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const { spaceSlug } = useParams();
|
||||
const { pageSlug, spaceSlug } = useParams();
|
||||
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
||||
const { data: space } = useSpaceQuery(spaceSlug);
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const [renderItems, setRenderItems] = useState<MentionSuggestionItem[]>([]);
|
||||
const { t } = useTranslation();
|
||||
const [data, setData] = useAtom(treeDataAtom);
|
||||
const tree = useMemo(() => new SimpleTree<SpaceTreeNode>(data), [data]);
|
||||
const createPageMutation = useCreatePageMutation();
|
||||
const emit = useQueryEmit();
|
||||
|
||||
const { data: suggestion, isLoading } = useSearchSuggestionsQuery({
|
||||
query: props.query,
|
||||
@ -45,12 +60,23 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
const createPageItem = (label: string) : MentionSuggestionItem => {
|
||||
return {
|
||||
id: null,
|
||||
label: label,
|
||||
entityType: "page",
|
||||
entityId: null,
|
||||
slugId: null,
|
||||
icon: null,
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (suggestion && !isLoading) {
|
||||
let items: MentionSuggestionItem[] = [];
|
||||
|
||||
if (suggestion?.users?.length > 0) {
|
||||
items.push({ entityType: "header", label: "Users" });
|
||||
items.push({ entityType: "header", label: t("Users") });
|
||||
|
||||
items = items.concat(
|
||||
suggestion.users.map((user) => ({
|
||||
@ -64,7 +90,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
}
|
||||
|
||||
if (suggestion?.pages?.length > 0) {
|
||||
items.push({ entityType: "header", label: "Pages" });
|
||||
items.push({ entityType: "header", label: t("Pages") });
|
||||
items = items.concat(
|
||||
suggestion.pages.map((page) => ({
|
||||
id: uuid7(),
|
||||
@ -76,6 +102,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
})),
|
||||
);
|
||||
}
|
||||
items.push(createPageItem(props.query));
|
||||
|
||||
setRenderItems(items);
|
||||
// update editor storage
|
||||
@ -96,7 +123,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
creatorId: currentUser?.user.id,
|
||||
});
|
||||
}
|
||||
if (item.entityType === "page") {
|
||||
if (item.entityType === "page" && item.id!==null) {
|
||||
props.command({
|
||||
id: item.id,
|
||||
label: item.label || "Untitled",
|
||||
@ -106,6 +133,9 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
creatorId: currentUser?.user.id,
|
||||
});
|
||||
}
|
||||
if (item.entityType === "page" && item.id===null) {
|
||||
createPage(item.label);
|
||||
}
|
||||
}
|
||||
},
|
||||
[renderItems],
|
||||
@ -167,6 +197,58 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
},
|
||||
}));
|
||||
|
||||
const createPage = async (title: string) => {
|
||||
const payload: { spaceId: string; parentPageId?: string; title: string } = {
|
||||
spaceId: space.id,
|
||||
parentPageId: page.id || null,
|
||||
title: title
|
||||
};
|
||||
|
||||
let createdPage: IPage;
|
||||
try {
|
||||
createdPage = await createPageMutation.mutateAsync(payload);
|
||||
const parentId = page.id || null;
|
||||
const data = {
|
||||
id: createdPage.id,
|
||||
slugId: createdPage.slugId,
|
||||
name: createdPage.title,
|
||||
position: createdPage.position,
|
||||
spaceId: createdPage.spaceId,
|
||||
parentPageId: createdPage.parentPageId,
|
||||
children: [],
|
||||
} as any;
|
||||
|
||||
const lastIndex = tree.data.length;
|
||||
|
||||
tree.create({ parentId, index: lastIndex, data });
|
||||
setData(tree.data);
|
||||
|
||||
props.command({
|
||||
id: uuid7(),
|
||||
label: createdPage.title || "Untitled",
|
||||
entityType: "page",
|
||||
entityId: createdPage.id,
|
||||
slugId: createdPage.slugId,
|
||||
creatorId: currentUser?.user.id,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
emit({
|
||||
operation: "addTreeNode",
|
||||
spaceId: space.id,
|
||||
payload: {
|
||||
parentId,
|
||||
index: lastIndex,
|
||||
data,
|
||||
},
|
||||
});
|
||||
}, 50);
|
||||
|
||||
} catch (err) {
|
||||
throw new Error("Failed to create page");
|
||||
}
|
||||
}
|
||||
|
||||
// if no results and enter what to do?
|
||||
|
||||
useEffect(() => {
|
||||
@ -178,7 +260,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
if (renderItems.length === 0) {
|
||||
return (
|
||||
<Paper shadow="md" p="xs" withBorder>
|
||||
No results
|
||||
{ t("No results") }
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@ -248,14 +330,14 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
color="gray"
|
||||
size={18}
|
||||
>
|
||||
<IconFileDescription size={18} />
|
||||
{ (item.id) ? <IconFileDescription size={18} /> : <IconPlus size={18} /> }
|
||||
</ActionIcon>
|
||||
)}
|
||||
</ActionIcon>
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
{item.label}
|
||||
{ (item.id) ? item.label : t("Create page") + ': ' + item.label }
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
import { atom } from "jotai";
|
||||
|
||||
type SearchAndReplaceAtomType = {
|
||||
isOpen: boolean;
|
||||
};
|
||||
|
||||
export const searchAndReplaceStateAtom = atom<SearchAndReplaceAtomType>({
|
||||
isOpen: false,
|
||||
});
|
||||
@ -0,0 +1,312 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Dialog,
|
||||
Flex,
|
||||
Input,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconArrowNarrowDown,
|
||||
IconArrowNarrowUp,
|
||||
IconLetterCase,
|
||||
IconReplace,
|
||||
IconSearch,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { useEditor } from "@tiptap/react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { searchAndReplaceStateAtom } from "@/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts";
|
||||
import { useAtom } from "jotai";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getHotkeyHandler, useToggle } from "@mantine/hooks";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import classes from "./search-replace.module.css";
|
||||
|
||||
interface PageFindDialogDialogProps {
|
||||
editor: ReturnType<typeof useEditor>;
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [replaceText, setReplaceText] = useState("");
|
||||
const [pageFindState, setPageFindState] = useAtom(searchAndReplaceStateAtom);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const [replaceButton, replaceButtonToggle] = useToggle([
|
||||
{ isReplaceShow: false, color: "gray" },
|
||||
{ isReplaceShow: true, color: "blue" },
|
||||
]);
|
||||
|
||||
const [caseSensitive, caseSensitiveToggle] = useToggle([
|
||||
{ isCaseSensitive: false, color: "gray" },
|
||||
{ isCaseSensitive: true, color: "blue" },
|
||||
]);
|
||||
|
||||
const searchInputEvent = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchText(event.target.value);
|
||||
};
|
||||
|
||||
const replaceInputEvent = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setReplaceText(event.target.value);
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setSearchText("");
|
||||
setReplaceText("");
|
||||
setPageFindState({ isOpen: false });
|
||||
// Reset replace button state when closing
|
||||
if (replaceButton.isReplaceShow) {
|
||||
replaceButtonToggle();
|
||||
}
|
||||
// Clear search term in editor
|
||||
if (editor) {
|
||||
editor.commands.setSearchTerm("");
|
||||
}
|
||||
};
|
||||
|
||||
const goToSelection = () => {
|
||||
if (!editor) return;
|
||||
|
||||
const { results, resultIndex } = editor.storage.searchAndReplace;
|
||||
const position: Range = results[resultIndex];
|
||||
|
||||
if (!position) return;
|
||||
|
||||
// @ts-ignore
|
||||
editor.commands.setTextSelection(position);
|
||||
|
||||
const element = document.querySelector(".search-result-current");
|
||||
if (element)
|
||||
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
|
||||
editor.commands.setTextSelection(0);
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
editor.commands.nextSearchResult();
|
||||
goToSelection();
|
||||
};
|
||||
|
||||
const previous = () => {
|
||||
editor.commands.previousSearchResult();
|
||||
goToSelection();
|
||||
};
|
||||
|
||||
const replace = () => {
|
||||
editor.commands.setReplaceTerm(replaceText);
|
||||
editor.commands.replace();
|
||||
goToSelection();
|
||||
};
|
||||
|
||||
const replaceAll = () => {
|
||||
editor.commands.setReplaceTerm(replaceText);
|
||||
editor.commands.replaceAll();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
editor.commands.setSearchTerm(searchText);
|
||||
editor.commands.resetIndex();
|
||||
editor.commands.selectCurrentItem();
|
||||
}, [searchText]);
|
||||
|
||||
const handleOpenEvent = (e) => {
|
||||
setPageFindState({ isOpen: true });
|
||||
const selectedText = editor.state.doc.textBetween(
|
||||
editor.state.selection.from,
|
||||
editor.state.selection.to,
|
||||
);
|
||||
if (selectedText !== "") {
|
||||
setSearchText(selectedText);
|
||||
}
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
};
|
||||
|
||||
const handleCloseEvent = (e) => {
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
!pageFindState.isOpen && closeDialog();
|
||||
|
||||
document.addEventListener("openFindDialogFromEditor", handleOpenEvent);
|
||||
document.addEventListener("closeFindDialogFromEditor", handleCloseEvent);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("openFindDialogFromEditor", handleOpenEvent);
|
||||
document.removeEventListener(
|
||||
"closeFindDialogFromEditor",
|
||||
handleCloseEvent,
|
||||
);
|
||||
};
|
||||
}, [pageFindState.isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
editor.commands.setCaseSensitive(caseSensitive.isCaseSensitive);
|
||||
editor.commands.resetIndex();
|
||||
goToSelection();
|
||||
}, [caseSensitive]);
|
||||
|
||||
const resultsCount = useMemo(
|
||||
() =>
|
||||
searchText.trim() === ""
|
||||
? ""
|
||||
: editor?.storage?.searchAndReplace?.results.length > 0
|
||||
? editor?.storage?.searchAndReplace?.resultIndex +
|
||||
1 +
|
||||
"/" +
|
||||
editor?.storage?.searchAndReplace?.results.length
|
||||
: t("Not found"),
|
||||
[
|
||||
searchText,
|
||||
editor?.storage?.searchAndReplace?.resultIndex,
|
||||
editor?.storage?.searchAndReplace?.results.length,
|
||||
],
|
||||
);
|
||||
|
||||
const location = useLocation();
|
||||
useEffect(() => {
|
||||
closeDialog();
|
||||
}, [location]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
className={classes.findDialog}
|
||||
opened={pageFindState.isOpen}
|
||||
|
||||
size="lg"
|
||||
radius="md"
|
||||
w={"auto"}
|
||||
position={{ top: 90, right: 50 }}
|
||||
withBorder
|
||||
transitionProps={{ transition: "slide-down" }}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Flex align="center" gap="xs">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder={t("Find")}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
rightSection={
|
||||
<Text size="xs" ta="right">
|
||||
{resultsCount}
|
||||
</Text>
|
||||
}
|
||||
rightSectionWidth="70"
|
||||
rightSectionPointerEvents="all"
|
||||
size="xs"
|
||||
w={220}
|
||||
onChange={searchInputEvent}
|
||||
value={searchText}
|
||||
autoFocus
|
||||
onKeyDown={getHotkeyHandler([
|
||||
["Enter", next],
|
||||
["shift+Enter", previous],
|
||||
["alt+C", caseSensitiveToggle],
|
||||
//@ts-ignore
|
||||
...(editable ? [["alt+R", replaceButtonToggle]] : []),
|
||||
])}
|
||||
/>
|
||||
|
||||
<ActionIcon.Group>
|
||||
<Tooltip label={t("Previous match (Shift+Enter)")}>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={previous}>
|
||||
<IconArrowNarrowUp
|
||||
style={{ width: "70%", height: "70%" }}
|
||||
stroke={1.5}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("Next match (Enter)")}>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={next}>
|
||||
<IconArrowNarrowDown
|
||||
style={{ width: "70%", height: "70%" }}
|
||||
stroke={1.5}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("Match case (Alt+C)")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color={caseSensitive.color}
|
||||
onClick={() => caseSensitiveToggle()}
|
||||
>
|
||||
<IconLetterCase
|
||||
style={{ width: "70%", height: "70%" }}
|
||||
stroke={1.5}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{editable && (
|
||||
<Tooltip label={t("Replace")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color={replaceButton.color}
|
||||
onClick={() => replaceButtonToggle()}
|
||||
>
|
||||
<IconReplace
|
||||
style={{ width: "70%", height: "70%" }}
|
||||
stroke={1.5}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={t("Close (Escape)")}>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={closeDialog}>
|
||||
<IconX style={{ width: "70%", height: "70%" }} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</ActionIcon.Group>
|
||||
</Flex>
|
||||
{replaceButton.isReplaceShow && editable && (
|
||||
<Flex align="center" gap="xs">
|
||||
<Input
|
||||
placeholder={t("Replace")}
|
||||
leftSection={<IconReplace size={16} />}
|
||||
rightSection={<div></div>}
|
||||
rightSectionPointerEvents="all"
|
||||
size="xs"
|
||||
w={180}
|
||||
autoFocus
|
||||
onChange={replaceInputEvent}
|
||||
value={replaceText}
|
||||
onKeyDown={getHotkeyHandler([
|
||||
["Enter", replace],
|
||||
["ctrl+alt+Enter", replaceAll],
|
||||
])}
|
||||
/>
|
||||
<ActionIcon.Group>
|
||||
<Tooltip label={t("Replace (Enter)")}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={replace}
|
||||
>
|
||||
{t("Replace")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("Replace all (Ctrl+Alt+Enter)")}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={replaceAll}
|
||||
>
|
||||
{t("Replace all")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</ActionIcon.Group>
|
||||
</Flex>
|
||||
)}
|
||||
</Stack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default SearchAndReplaceDialog;
|
||||
@ -0,0 +1,10 @@
|
||||
.findDialog{
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.findDialog div[data-position="right"].mantine-Input-section {
|
||||
justify-content: right;
|
||||
padding-right: 8px;
|
||||
}
|
||||
@ -18,6 +18,8 @@ import {
|
||||
IconTypography,
|
||||
IconMenu4,
|
||||
IconCalendar,
|
||||
IconAppWindow,
|
||||
IconSitemap,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
CommandProps,
|
||||
@ -357,6 +359,29 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Subpages (Child pages)",
|
||||
description: "List all subpages of the current page",
|
||||
searchTerms: ["subpages", "child", "children", "nested", "hierarchy"],
|
||||
icon: IconSitemap,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).insertSubpages().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Iframe embed",
|
||||
description: "Embed any Iframe",
|
||||
searchTerms: ["iframe"],
|
||||
icon: IconAppWindow,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.setEmbed({ provider: "iframe" })
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Airtable",
|
||||
description: "Embed Airtable",
|
||||
|
||||
@ -0,0 +1,95 @@
|
||||
import {
|
||||
BubbleMenu as BaseBubbleMenu,
|
||||
posToDOMRect,
|
||||
findParentNode,
|
||||
} from "@tiptap/react";
|
||||
import { Node as PMNode } from "@tiptap/pm/model";
|
||||
import React, { useCallback } from "react";
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { IconTrash } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { sticky } from "tippy.js";
|
||||
|
||||
interface SubpagesMenuProps {
|
||||
editor: Editor;
|
||||
}
|
||||
|
||||
interface ShouldShowProps {
|
||||
state: any;
|
||||
from?: number;
|
||||
to?: number;
|
||||
}
|
||||
|
||||
export const SubpagesMenu = React.memo(
|
||||
({ editor }: SubpagesMenuProps): JSX.Element => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const shouldShow = useCallback(
|
||||
({ state }: ShouldShowProps) => {
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return editor.isActive("subpages");
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const getReferenceClientRect = useCallback(() => {
|
||||
const { selection } = editor.state;
|
||||
const predicate = (node: PMNode) => node.type.name === "subpages";
|
||||
const parent = findParentNode(predicate)(selection);
|
||||
|
||||
if (parent) {
|
||||
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||
return dom.getBoundingClientRect();
|
||||
}
|
||||
|
||||
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||
}, [editor]);
|
||||
|
||||
const deleteNode = useCallback(() => {
|
||||
const { selection } = editor.state;
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setNodeSelection(selection.from)
|
||||
.deleteSelection()
|
||||
.run();
|
||||
}, [editor]);
|
||||
|
||||
return (
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
pluginKey={`subpages-menu}`}
|
||||
updateDelay={0}
|
||||
tippyOptions={{
|
||||
getReferenceClientRect,
|
||||
offset: [0, 8],
|
||||
zIndex: 99,
|
||||
popperOptions: {
|
||||
modifiers: [{ name: "flip", enabled: false }],
|
||||
},
|
||||
plugins: [sticky],
|
||||
sticky: "popper",
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<Tooltip position="top" label={t("Delete")}>
|
||||
<ActionIcon
|
||||
onClick={deleteNode}
|
||||
variant="default"
|
||||
size="lg"
|
||||
color="red"
|
||||
aria-label={t("Delete")}
|
||||
>
|
||||
<IconTrash size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</BaseBubbleMenu>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default SubpagesMenu;
|
||||
@ -0,0 +1,120 @@
|
||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { Stack, Text, Anchor, ActionIcon } from "@mantine/core";
|
||||
import { IconFileDescription } from "@tabler/icons-react";
|
||||
import { useGetSidebarPagesQuery } from "@/features/page/queries/page-query";
|
||||
import { useMemo } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import classes from "./subpages.module.css";
|
||||
import styles from "../mention/mention.module.css";
|
||||
import {
|
||||
buildPageUrl,
|
||||
buildSharedPageUrl,
|
||||
} from "@/features/page/page.utils.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { sortPositionKeys } from "@/features/page/tree/utils/utils";
|
||||
import { useSharedPageSubpages } from "@/features/share/hooks/use-shared-page-subpages";
|
||||
|
||||
export default function SubpagesView(props: NodeViewProps) {
|
||||
const { editor } = props;
|
||||
const { spaceSlug, shareId } = useParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const currentPageId = editor.storage.pageId;
|
||||
|
||||
// Get subpages from shared tree if we're in a shared context
|
||||
const sharedSubpages = useSharedPageSubpages(currentPageId);
|
||||
|
||||
const { data, isLoading, error } = useGetSidebarPagesQuery({
|
||||
pageId: currentPageId,
|
||||
});
|
||||
|
||||
const subpages = useMemo(() => {
|
||||
// If we're in a shared context, use the shared subpages
|
||||
if (shareId && sharedSubpages) {
|
||||
return sharedSubpages.map((node) => ({
|
||||
id: node.value,
|
||||
slugId: node.slugId,
|
||||
title: node.name,
|
||||
icon: node.icon,
|
||||
position: node.position,
|
||||
}));
|
||||
}
|
||||
|
||||
// Otherwise use the API data
|
||||
if (!data?.pages) return [];
|
||||
const allPages = data.pages.flatMap((page) => page.items);
|
||||
return sortPositionKeys(allPages);
|
||||
}, [data, shareId, sharedSubpages]);
|
||||
|
||||
if (isLoading && !shareId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (error && !shareId) {
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<Text c="dimmed" size="md" py="md">
|
||||
{t("Failed to load subpages")}
|
||||
</Text>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (subpages.length === 0) {
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<div className={classes.container}>
|
||||
<Text c="dimmed" size="md" py="md">
|
||||
{t("No subpages")}
|
||||
</Text>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<div className={classes.container}>
|
||||
<Stack gap={5}>
|
||||
{subpages.map((page) => (
|
||||
<Anchor
|
||||
key={page.id}
|
||||
component={Link}
|
||||
fw={500}
|
||||
to={
|
||||
shareId
|
||||
? buildSharedPageUrl({
|
||||
shareId,
|
||||
pageSlugId: page.slugId,
|
||||
pageTitle: page.title,
|
||||
})
|
||||
: buildPageUrl(spaceSlug, page.slugId, page.title)
|
||||
}
|
||||
underline="never"
|
||||
className={styles.pageMentionLink}
|
||||
draggable={false}
|
||||
>
|
||||
{page?.icon ? (
|
||||
<span style={{ marginRight: "4px" }}>{page.icon}</span>
|
||||
) : (
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
component="span"
|
||||
size={18}
|
||||
style={{ verticalAlign: "text-bottom" }}
|
||||
>
|
||||
<IconFileDescription size={18} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
|
||||
<span className={styles.pageMentionText}>
|
||||
{page?.title || t("untitled")}
|
||||
</span>
|
||||
</Anchor>
|
||||
))}
|
||||
</Stack>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
.container {
|
||||
margin: 0;
|
||||
padding-left: 4px;
|
||||
user-select: none;
|
||||
|
||||
a {
|
||||
border: none !important;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,145 @@
|
||||
import React, { FC } from "react";
|
||||
import { IconCheck, IconPalette } from "@tabler/icons-react";
|
||||
import {
|
||||
ActionIcon,
|
||||
ColorSwatch,
|
||||
Popover,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { useEditor } from "@tiptap/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface TableColorItem {
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface TableBackgroundColorProps {
|
||||
editor: ReturnType<typeof useEditor>;
|
||||
}
|
||||
|
||||
const TABLE_COLORS: TableColorItem[] = [
|
||||
{ name: "Default", color: "" },
|
||||
{ name: "Blue", color: "#b4d5ff" },
|
||||
{ name: "Green", color: "#acf5d2" },
|
||||
{ name: "Yellow", color: "#fef1b4" },
|
||||
{ name: "Red", color: "#ffbead" },
|
||||
{ name: "Pink", color: "#ffc7fe" },
|
||||
{ name: "Gray", color: "#eaecef" },
|
||||
{ name: "Purple", color: "#c1b7f2" },
|
||||
];
|
||||
|
||||
export const TableBackgroundColor: FC<TableBackgroundColorProps> = ({
|
||||
editor,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [opened, setOpened] = React.useState(false);
|
||||
|
||||
const setTableCellBackground = (color: string, colorName: string) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.updateAttributes("tableCell", {
|
||||
backgroundColor: color || null,
|
||||
backgroundColorName: color ? colorName : null
|
||||
})
|
||||
.updateAttributes("tableHeader", {
|
||||
backgroundColor: color || null,
|
||||
backgroundColorName: color ? colorName : null
|
||||
})
|
||||
.run();
|
||||
setOpened(false);
|
||||
};
|
||||
|
||||
// Get current cell's background color
|
||||
const getCurrentColor = () => {
|
||||
if (editor.isActive("tableCell")) {
|
||||
const attrs = editor.getAttributes("tableCell");
|
||||
return attrs.backgroundColor || "";
|
||||
}
|
||||
if (editor.isActive("tableHeader")) {
|
||||
const attrs = editor.getAttributes("tableHeader");
|
||||
return attrs.backgroundColor || "";
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const currentColor = getCurrentColor();
|
||||
|
||||
return (
|
||||
<Popover
|
||||
width={200}
|
||||
position="bottom"
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
withArrow
|
||||
transitionProps={{ transition: "pop" }}
|
||||
>
|
||||
<Popover.Target>
|
||||
<Tooltip label={t("Background color")} withArrow>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
aria-label={t("Background color")}
|
||||
onClick={() => setOpened(!opened)}
|
||||
>
|
||||
<IconPalette size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Popover.Target>
|
||||
|
||||
<Popover.Dropdown>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("Background color")}
|
||||
</Text>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(4, 1fr)",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
{TABLE_COLORS.map((item, index) => (
|
||||
<UnstyledButton
|
||||
key={index}
|
||||
onClick={() => setTableCellBackground(item.color, item.name)}
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
}}
|
||||
title={t(item.name)}
|
||||
>
|
||||
<ColorSwatch
|
||||
color={item.color || "#ffffff"}
|
||||
size={24}
|
||||
style={{
|
||||
border: item.color === "" ? "1px solid #e5e7eb" : undefined,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{currentColor === item.color && (
|
||||
<IconCheck
|
||||
size={18}
|
||||
style={{
|
||||
color:
|
||||
item.color === "" || item.color.startsWith("#F")
|
||||
? "#000000"
|
||||
: "#ffffff",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ColorSwatch>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
</div>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@ -12,8 +12,11 @@ import {
|
||||
IconColumnRemove,
|
||||
IconRowRemove,
|
||||
IconSquareToggle,
|
||||
IconTableRow,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TableBackgroundColor } from "./table-background-color";
|
||||
import { TableTextAlignment } from "./table-text-alignment";
|
||||
|
||||
export const TableCellMenu = React.memo(
|
||||
({ editor, appendTo }: EditorMenuProps): JSX.Element => {
|
||||
@ -45,6 +48,10 @@ export const TableCellMenu = React.memo(
|
||||
editor.chain().focus().deleteRow().run();
|
||||
}, [editor]);
|
||||
|
||||
const toggleHeaderCell = useCallback(() => {
|
||||
editor.chain().focus().toggleHeaderCell().run();
|
||||
}, [editor]);
|
||||
|
||||
return (
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
@ -60,6 +67,9 @@ export const TableCellMenu = React.memo(
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<ActionIcon.Group>
|
||||
<TableBackgroundColor editor={editor} />
|
||||
<TableTextAlignment editor={editor} />
|
||||
|
||||
<Tooltip position="top" label={t("Merge cells")}>
|
||||
<ActionIcon
|
||||
onClick={mergeCells}
|
||||
@ -103,6 +113,17 @@ export const TableCellMenu = React.memo(
|
||||
<IconRowRemove size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Toggle header cell")}>
|
||||
<ActionIcon
|
||||
onClick={toggleHeaderCell}
|
||||
variant="default"
|
||||
size="lg"
|
||||
aria-label={t("Toggle header cell")}
|
||||
>
|
||||
<IconTableRow size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</ActionIcon.Group>
|
||||
</BaseBubbleMenu>
|
||||
);
|
||||
|
||||
@ -17,9 +17,9 @@ import {
|
||||
IconColumnRemove,
|
||||
IconRowInsertBottom,
|
||||
IconRowInsertTop,
|
||||
IconRowRemove,
|
||||
IconRowRemove, IconTableColumn, IconTableRow,
|
||||
IconTrashX,
|
||||
} from "@tabler/icons-react";
|
||||
} from '@tabler/icons-react';
|
||||
import { isCellSelection } from "@docmost/editor-ext";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -50,6 +50,14 @@ export const TableMenu = React.memo(
|
||||
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||
}, [editor]);
|
||||
|
||||
const toggleHeaderColumn = useCallback(() => {
|
||||
editor.chain().focus().toggleHeaderColumn().run();
|
||||
}, [editor]);
|
||||
|
||||
const toggleHeaderRow = useCallback(() => {
|
||||
editor.chain().focus().toggleHeaderRow().run();
|
||||
}, [editor]);
|
||||
|
||||
const addColumnLeft = useCallback(() => {
|
||||
editor.chain().focus().addColumnBefore().run();
|
||||
}, [editor]);
|
||||
@ -180,6 +188,30 @@ export const TableMenu = React.memo(
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Toggle header row")}
|
||||
>
|
||||
<ActionIcon
|
||||
onClick={toggleHeaderRow}
|
||||
variant="default"
|
||||
size="lg"
|
||||
aria-label={t("Toggle header row")}
|
||||
>
|
||||
<IconTableRow size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Toggle header column")}
|
||||
>
|
||||
<ActionIcon
|
||||
onClick={toggleHeaderColumn}
|
||||
variant="default"
|
||||
size="lg"
|
||||
aria-label={t("Toggle header column")}
|
||||
>
|
||||
<IconTableColumn size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Delete table")}>
|
||||
<ActionIcon
|
||||
onClick={deleteTable}
|
||||
|
||||
@ -0,0 +1,109 @@
|
||||
import React, { FC } from "react";
|
||||
import {
|
||||
IconAlignCenter,
|
||||
IconAlignLeft,
|
||||
IconAlignRight,
|
||||
IconCheck,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Popover,
|
||||
rem,
|
||||
ScrollArea,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useEditor } from "@tiptap/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface TableTextAlignmentProps {
|
||||
editor: ReturnType<typeof useEditor>;
|
||||
}
|
||||
|
||||
interface AlignmentItem {
|
||||
name: string;
|
||||
icon: React.ElementType;
|
||||
command: () => void;
|
||||
isActive: () => boolean;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
|
||||
const { t } = useTranslation();
|
||||
const [opened, setOpened] = React.useState(false);
|
||||
|
||||
const items: AlignmentItem[] = [
|
||||
{
|
||||
name: "Align left",
|
||||
value: "left",
|
||||
isActive: () => editor.isActive({ textAlign: "left" }),
|
||||
command: () => editor.chain().focus().setTextAlign("left").run(),
|
||||
icon: IconAlignLeft,
|
||||
},
|
||||
{
|
||||
name: "Align center",
|
||||
value: "center",
|
||||
isActive: () => editor.isActive({ textAlign: "center" }),
|
||||
command: () => editor.chain().focus().setTextAlign("center").run(),
|
||||
icon: IconAlignCenter,
|
||||
},
|
||||
{
|
||||
name: "Align right",
|
||||
value: "right",
|
||||
isActive: () => editor.isActive({ textAlign: "right" }),
|
||||
command: () => editor.chain().focus().setTextAlign("right").run(),
|
||||
icon: IconAlignRight,
|
||||
},
|
||||
];
|
||||
|
||||
const activeItem = items.find((item) => item.isActive()) || items[0];
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
position="bottom"
|
||||
withArrow
|
||||
transitionProps={{ transition: 'pop' }}
|
||||
>
|
||||
<Popover.Target>
|
||||
<Tooltip label={t("Text alignment")} withArrow>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
aria-label={t("Text alignment")}
|
||||
onClick={() => setOpened(!opened)}
|
||||
>
|
||||
<activeItem.icon size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Popover.Target>
|
||||
|
||||
<Popover.Dropdown>
|
||||
<ScrollArea.Autosize type="scroll" mah={300}>
|
||||
<Button.Group orientation="vertical">
|
||||
{items.map((item, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant="default"
|
||||
leftSection={<item.icon size={16} />}
|
||||
rightSection={
|
||||
item.isActive() && <IconCheck size={16} />
|
||||
}
|
||||
justify="left"
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
item.command();
|
||||
setOpened(false);
|
||||
}}
|
||||
style={{ border: "none" }}
|
||||
>
|
||||
{t(item.name)}
|
||||
</Button>
|
||||
))}
|
||||
</Button.Group>
|
||||
</ScrollArea.Autosize>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@ -10,8 +10,6 @@ import { Highlight } from "@tiptap/extension-highlight";
|
||||
import { Typography } from "@tiptap/extension-typography";
|
||||
import { TextStyle } from "@tiptap/extension-text-style";
|
||||
import { Color } from "@tiptap/extension-color";
|
||||
import Table from "@tiptap/extension-table";
|
||||
import TableHeader from "@tiptap/extension-table-header";
|
||||
import SlashCommand from "@/features/editor/extensions/slash-command";
|
||||
import { Collaboration } from "@tiptap/extension-collaboration";
|
||||
import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor";
|
||||
@ -25,6 +23,8 @@ import {
|
||||
MathInline,
|
||||
TableCell,
|
||||
TableRow,
|
||||
TableHeader,
|
||||
CustomTable,
|
||||
TrailingNode,
|
||||
TiptapImage,
|
||||
Callout,
|
||||
@ -36,7 +36,10 @@ import {
|
||||
Drawio,
|
||||
Excalidraw,
|
||||
Embed,
|
||||
SearchAndReplace,
|
||||
Mention,
|
||||
Subpages,
|
||||
TableDndExtension,
|
||||
} from "@docmost/editor-ext";
|
||||
import {
|
||||
randomElement,
|
||||
@ -56,8 +59,10 @@ import CodeBlockView from "@/features/editor/components/code-block/code-block-vi
|
||||
import DrawioView from "../components/drawio/drawio-view";
|
||||
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
|
||||
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
||||
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
|
||||
import plaintext from "highlight.js/lib/languages/plaintext";
|
||||
import powershell from "highlight.js/lib/languages/powershell";
|
||||
import abap from "highlightjs-sap-abap";
|
||||
import elixir from "highlight.js/lib/languages/elixir";
|
||||
import erlang from "highlight.js/lib/languages/erlang";
|
||||
import dockerfile from "highlight.js/lib/languages/dockerfile";
|
||||
@ -72,11 +77,12 @@ import i18n from "@/i18n.ts";
|
||||
import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts";
|
||||
import EmojiCommand from "./emoji-command";
|
||||
import { CharacterCount } from "@tiptap/extension-character-count";
|
||||
import { countWords } from "alfaaz";
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
lowlight.register("mermaid", plaintext);
|
||||
lowlight.register("powershell", powershell);
|
||||
lowlight.register("powershell", powershell);
|
||||
lowlight.register("abap", abap);
|
||||
lowlight.register("erlang", erlang);
|
||||
lowlight.register("elixir", elixir);
|
||||
lowlight.register("dockerfile", dockerfile);
|
||||
@ -157,7 +163,7 @@ export const mainExtensions = [
|
||||
return ReactNodeViewRenderer(MentionView);
|
||||
},
|
||||
}),
|
||||
Table.configure({
|
||||
CustomTable.configure({
|
||||
resizable: true,
|
||||
lastColumnResizable: false,
|
||||
allowTableNodeSelection: true,
|
||||
@ -165,6 +171,7 @@ export const mainExtensions = [
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableDndExtension,
|
||||
MathInline.configure({
|
||||
view: MathInlineView,
|
||||
}),
|
||||
@ -209,10 +216,31 @@ export const mainExtensions = [
|
||||
Embed.configure({
|
||||
view: EmbedView,
|
||||
}),
|
||||
Subpages.configure({
|
||||
view: SubpagesView,
|
||||
}),
|
||||
MarkdownClipboard.configure({
|
||||
transformPastedText: true,
|
||||
}),
|
||||
CharacterCount
|
||||
CharacterCount.configure({
|
||||
wordCounter: (text) => countWords(text),
|
||||
}),
|
||||
SearchAndReplace.extend({
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
'Mod-f': () => {
|
||||
const event = new CustomEvent("openFindDialogFromEditor", {});
|
||||
document.dispatchEvent(event);
|
||||
return true;
|
||||
},
|
||||
'Escape': () => {
|
||||
const event = new CustomEvent("closeFindDialogFromEditor", {});
|
||||
document.dispatchEvent(event);
|
||||
return true;
|
||||
},
|
||||
}
|
||||
},
|
||||
}).configure(),
|
||||
] as any;
|
||||
|
||||
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
||||
@ -228,4 +256,4 @@ export const collabExtensions: CollabExtensions = (provider, user) => [
|
||||
color: randomElement(userColors),
|
||||
},
|
||||
}),
|
||||
];
|
||||
];
|
||||
|
||||
@ -42,7 +42,11 @@ export function FullEditor({
|
||||
spaceSlug={spaceSlug}
|
||||
editable={editable}
|
||||
/>
|
||||
<MemoizedPageEditor pageId={pageId} editable={editable} content={content} />
|
||||
<MemoizedPageEditor
|
||||
pageId={pageId}
|
||||
editable={editable}
|
||||
content={content}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,11 +1,5 @@
|
||||
import "@/features/editor/styles/index.css";
|
||||
import React, {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { IndexeddbPersistence } from "y-indexeddb";
|
||||
import * as Y from "yjs";
|
||||
import {
|
||||
@ -37,6 +31,7 @@ import TableMenu from "@/features/editor/components/table/table-menu.tsx";
|
||||
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
|
||||
import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
|
||||
import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
|
||||
import SubpagesMenu from "@/features/editor/components/subpages/subpages-menu.tsx";
|
||||
import {
|
||||
handleFileDrop,
|
||||
handlePaste,
|
||||
@ -45,6 +40,7 @@ import LinkMenu from "@/features/editor/components/link/link-menu.tsx";
|
||||
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
|
||||
import DrawioMenu from "./components/drawio/drawio-menu";
|
||||
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
|
||||
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
|
||||
import { useIdle } from "@/hooks/use-idle.ts";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
@ -52,6 +48,7 @@ import { IPage } from "@/features/page/types/page.types.ts";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
|
||||
interface PageEditorProps {
|
||||
@ -71,11 +68,15 @@ export default function PageEditor({
|
||||
const [, setAsideState] = useAtom(asideStateAtom);
|
||||
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||
const ydoc = useMemo(() => new Y.Doc(), [pageId]);
|
||||
const ydocRef = useRef<Y.Doc | null>(null);
|
||||
if (!ydocRef.current) {
|
||||
ydocRef.current = new Y.Doc();
|
||||
}
|
||||
const ydoc = ydocRef.current;
|
||||
const [isLocalSynced, setLocalSynced] = useState(false);
|
||||
const [isRemoteSynced, setRemoteSynced] = useState(false);
|
||||
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
||||
yjsConnectionStatusAtom,
|
||||
yjsConnectionStatusAtom
|
||||
);
|
||||
const menuContainerRef = useRef(null);
|
||||
const documentName = `page.${pageId}`;
|
||||
@ -85,67 +86,126 @@ export default function PageEditor({
|
||||
const [isCollabReady, setIsCollabReady] = useState(false);
|
||||
const { pageSlug } = useParams();
|
||||
const slugId = extractPageSlugId(pageSlug);
|
||||
const userPageEditMode =
|
||||
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
||||
|
||||
const localProvider = useMemo(() => {
|
||||
const provider = new IndexeddbPersistence(documentName, ydoc);
|
||||
// Providers only created once per pageId
|
||||
const providersRef = useRef<{
|
||||
local: IndexeddbPersistence;
|
||||
remote: HocuspocusProvider;
|
||||
} | null>(null);
|
||||
const [providersReady, setProvidersReady] = useState(false);
|
||||
|
||||
provider.on("synced", () => {
|
||||
setLocalSynced(true);
|
||||
});
|
||||
const localProvider = providersRef.current?.local;
|
||||
const remoteProvider = providersRef.current?.remote;
|
||||
|
||||
return provider;
|
||||
}, [pageId, ydoc]);
|
||||
// Track when collaborative provider is ready and synced
|
||||
const [collabReady, setCollabReady] = useState(false);
|
||||
useEffect(() => {
|
||||
if (
|
||||
remoteProvider?.status === WebSocketStatus.Connected &&
|
||||
isLocalSynced &&
|
||||
isRemoteSynced
|
||||
) {
|
||||
setCollabReady(true);
|
||||
}
|
||||
}, [remoteProvider?.status, isLocalSynced, isRemoteSynced]);
|
||||
|
||||
const remoteProvider = useMemo(() => {
|
||||
const provider = new HocuspocusProvider({
|
||||
name: documentName,
|
||||
url: collaborationURL,
|
||||
document: ydoc,
|
||||
token: collabQuery?.token,
|
||||
connect: false,
|
||||
preserveConnection: false,
|
||||
onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => {
|
||||
const payload = jwtDecode(collabQuery?.token);
|
||||
const now = Date.now().valueOf() / 1000;
|
||||
const isTokenExpired = now >= payload.exp;
|
||||
if (isTokenExpired) {
|
||||
refetchCollabToken();
|
||||
}
|
||||
},
|
||||
onStatus: (status) => {
|
||||
if (status.status === "connected") {
|
||||
setYjsConnectionStatus(status.status);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
provider.on("synced", () => {
|
||||
setRemoteSynced(true);
|
||||
});
|
||||
|
||||
provider.on("disconnect", () => {
|
||||
setYjsConnectionStatus(WebSocketStatus.Disconnected);
|
||||
});
|
||||
|
||||
return provider;
|
||||
}, [ydoc, pageId, collabQuery?.token]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
remoteProvider.connect();
|
||||
useEffect(() => {
|
||||
if (!providersRef.current) {
|
||||
const local = new IndexeddbPersistence(documentName, ydoc);
|
||||
local.on("synced", () => setLocalSynced(true));
|
||||
const remote = new HocuspocusProvider({
|
||||
name: documentName,
|
||||
url: collaborationURL,
|
||||
document: ydoc,
|
||||
token: collabQuery?.token,
|
||||
connect: true,
|
||||
preserveConnection: false,
|
||||
onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => {
|
||||
const payload = jwtDecode(collabQuery?.token);
|
||||
const now = Date.now().valueOf() / 1000;
|
||||
const isTokenExpired = now >= payload.exp;
|
||||
if (isTokenExpired) {
|
||||
refetchCollabToken().then((result) => {
|
||||
if (result.data?.token) {
|
||||
remote.disconnect();
|
||||
setTimeout(() => {
|
||||
remote.configuration.token = result.data.token;
|
||||
remote.connect();
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
onStatus: (status) => {
|
||||
if (status.status === "connected") {
|
||||
setYjsConnectionStatus(status.status);
|
||||
}
|
||||
},
|
||||
});
|
||||
remote.on("synced", () => setRemoteSynced(true));
|
||||
remote.on("disconnect", () => {
|
||||
setYjsConnectionStatus(WebSocketStatus.Disconnected);
|
||||
});
|
||||
providersRef.current = { local, remote };
|
||||
setProvidersReady(true);
|
||||
} else {
|
||||
setProvidersReady(true);
|
||||
}
|
||||
// Only destroy on final unmount
|
||||
return () => {
|
||||
setRemoteSynced(false);
|
||||
setLocalSynced(false);
|
||||
remoteProvider.destroy();
|
||||
localProvider.destroy();
|
||||
providersRef.current?.remote.destroy();
|
||||
providersRef.current?.local.destroy();
|
||||
providersRef.current = null;
|
||||
};
|
||||
}, [remoteProvider, localProvider]);
|
||||
}, [pageId]);
|
||||
|
||||
/*
|
||||
useEffect(() => {
|
||||
// Handle token updates by reconnecting with new token
|
||||
if (providersRef.current?.remote && collabQuery?.token) {
|
||||
const currentToken = providersRef.current.remote.configuration.token;
|
||||
if (currentToken !== collabQuery.token) {
|
||||
// Token has changed, need to reconnect with new token
|
||||
providersRef.current.remote.disconnect();
|
||||
providersRef.current.remote.configuration.token = collabQuery.token;
|
||||
providersRef.current.remote.connect();
|
||||
}
|
||||
}
|
||||
}, [collabQuery?.token]);
|
||||
*/
|
||||
|
||||
// Only connect/disconnect on tab/idle, not destroy
|
||||
useEffect(() => {
|
||||
if (!providersReady || !providersRef.current) return;
|
||||
const remoteProvider = providersRef.current.remote;
|
||||
if (
|
||||
isIdle &&
|
||||
documentState === "hidden" &&
|
||||
remoteProvider.status === WebSocketStatus.Connected
|
||||
) {
|
||||
remoteProvider.disconnect();
|
||||
setIsCollabReady(false);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
documentState === "visible" &&
|
||||
remoteProvider.status === WebSocketStatus.Disconnected
|
||||
) {
|
||||
resetIdle();
|
||||
remoteProvider.connect();
|
||||
setTimeout(() => setIsCollabReady(true), 500);
|
||||
}
|
||||
}, [isIdle, documentState, providersReady, resetIdle]);
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
if (!remoteProvider || !currentUser?.user) return mainExtensions;
|
||||
return [
|
||||
...mainExtensions,
|
||||
...collabExtensions(remoteProvider, currentUser?.user),
|
||||
];
|
||||
}, [ydoc, pageId, remoteProvider, currentUser?.user]);
|
||||
}, [remoteProvider, currentUser?.user]);
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
@ -158,6 +218,10 @@ export default function PageEditor({
|
||||
scrollMargin: 80,
|
||||
handleDOMEvents: {
|
||||
keydown: (_view, event) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyS') {
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
|
||||
const slashCommand = document.querySelector("#slash-command");
|
||||
if (slashCommand) {
|
||||
@ -199,7 +263,7 @@ export default function PageEditor({
|
||||
debouncedUpdateContent(editorJson);
|
||||
},
|
||||
},
|
||||
[pageId, editable, remoteProvider?.status],
|
||||
[pageId, editable, remoteProvider]
|
||||
);
|
||||
|
||||
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
|
||||
@ -215,13 +279,21 @@ export default function PageEditor({
|
||||
}, 3000);
|
||||
|
||||
const handleActiveCommentEvent = (event) => {
|
||||
const { commentId } = event.detail;
|
||||
const { commentId, resolved } = event.detail;
|
||||
|
||||
if (resolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveCommentId(commentId);
|
||||
setAsideState({ tab: "comments", isAsideOpen: true });
|
||||
|
||||
const selector = `div[data-comment-id="${commentId}"]`;
|
||||
const commentElement = document.querySelector(selector);
|
||||
commentElement?.scrollIntoView();
|
||||
//wait if aside is closed
|
||||
setTimeout(() => {
|
||||
const selector = `div[data-comment-id="${commentId}"]`;
|
||||
const commentElement = document.querySelector(selector);
|
||||
commentElement?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}, 400);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@ -229,7 +301,7 @@ export default function PageEditor({
|
||||
return () => {
|
||||
document.removeEventListener(
|
||||
"ACTIVE_COMMENT_EVENT",
|
||||
handleActiveCommentEvent,
|
||||
handleActiveCommentEvent
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
@ -249,29 +321,6 @@ export default function PageEditor({
|
||||
}
|
||||
}, [remoteProvider?.status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isIdle &&
|
||||
documentState === "hidden" &&
|
||||
remoteProvider?.status === WebSocketStatus.Connected
|
||||
) {
|
||||
remoteProvider.disconnect();
|
||||
setIsCollabReady(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
documentState === "visible" &&
|
||||
remoteProvider?.status === WebSocketStatus.Disconnected
|
||||
) {
|
||||
resetIdle();
|
||||
remoteProvider.connect();
|
||||
setTimeout(() => {
|
||||
setIsCollabReady(true);
|
||||
}, 600);
|
||||
}
|
||||
}, [isIdle, documentState, remoteProvider]);
|
||||
|
||||
const isSynced = isLocalSynced && isRemoteSynced;
|
||||
|
||||
useEffect(() => {
|
||||
@ -287,11 +336,54 @@ export default function PageEditor({
|
||||
return () => clearTimeout(collabReadyTimeout);
|
||||
}, [isRemoteSynced, isLocalSynced, remoteProvider?.status]);
|
||||
|
||||
return isCollabReady ? (
|
||||
<div>
|
||||
useEffect(() => {
|
||||
// Only honor user default page edit mode preference and permissions
|
||||
if (editor) {
|
||||
if (userPageEditMode && editable) {
|
||||
if (userPageEditMode === PageEditMode.Edit) {
|
||||
editor.setEditable(true);
|
||||
} else if (userPageEditMode === PageEditMode.Read) {
|
||||
editor.setEditable(false);
|
||||
}
|
||||
} else {
|
||||
editor.setEditable(false);
|
||||
}
|
||||
}
|
||||
}, [userPageEditMode, editor, editable]);
|
||||
|
||||
const hasConnectedOnceRef = useRef(false);
|
||||
const [showStatic, setShowStatic] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!hasConnectedOnceRef.current &&
|
||||
remoteProvider?.status === WebSocketStatus.Connected
|
||||
) {
|
||||
hasConnectedOnceRef.current = true;
|
||||
setShowStatic(false);
|
||||
}
|
||||
}, [remoteProvider?.status]);
|
||||
|
||||
if (showStatic) {
|
||||
return (
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
immediatelyRender={true}
|
||||
extensions={mainExtensions}
|
||||
content={content}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="editor-container" style={{ position: "relative" }}>
|
||||
<div ref={menuContainerRef}>
|
||||
<EditorContent editor={editor} />
|
||||
|
||||
{editor && (
|
||||
<SearchAndReplaceDialog editor={editor} editable={editable} />
|
||||
)}
|
||||
|
||||
{editor && editor.isEditable && (
|
||||
<div>
|
||||
<EditorBubbleMenu editor={editor} />
|
||||
@ -300,26 +392,18 @@ export default function PageEditor({
|
||||
<ImageMenu editor={editor} />
|
||||
<VideoMenu editor={editor} />
|
||||
<CalloutMenu editor={editor} />
|
||||
<SubpagesMenu editor={editor} />
|
||||
<ExcalidrawMenu editor={editor} />
|
||||
<DrawioMenu editor={editor} />
|
||||
<LinkMenu editor={editor} appendTo={menuContainerRef} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
|
||||
</div>
|
||||
|
||||
<div
|
||||
onClick={() => editor.commands.focus("end")}
|
||||
style={{ paddingBottom: "20vh" }}
|
||||
></div>
|
||||
</div>
|
||||
) : (
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
immediatelyRender={true}
|
||||
extensions={mainExtensions}
|
||||
content={content}
|
||||
></EditorProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -6,21 +6,19 @@ import { Document } from "@tiptap/extension-document";
|
||||
import { Heading } from "@tiptap/extension-heading";
|
||||
import { Text } from "@tiptap/extension-text";
|
||||
import { Placeholder } from "@tiptap/extension-placeholder";
|
||||
import { useAtom } from "jotai/index";
|
||||
import {
|
||||
pageEditorAtom,
|
||||
readOnlyEditorAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { useAtom } from "jotai";
|
||||
import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||
|
||||
interface PageEditorProps {
|
||||
title: string;
|
||||
content: any;
|
||||
pageId?: string;
|
||||
}
|
||||
|
||||
export default function ReadonlyPageEditor({
|
||||
title,
|
||||
content,
|
||||
pageId,
|
||||
}: PageEditorProps) {
|
||||
const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom);
|
||||
|
||||
@ -56,6 +54,9 @@ export default function ReadonlyPageEditor({
|
||||
content={content}
|
||||
onCreate={({ editor }) => {
|
||||
if (editor) {
|
||||
if (pageId) {
|
||||
editor.storage.pageId = pageId;
|
||||
}
|
||||
// @ts-ignore
|
||||
setReadOnlyEditor(editor);
|
||||
}
|
||||
|
||||
@ -142,6 +142,24 @@
|
||||
.comment-mark {
|
||||
background: rgba(255, 215, 0, 0.14);
|
||||
border-bottom: 2px solid rgb(166, 158, 12);
|
||||
|
||||
&.resolved {
|
||||
background: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.comment-highlight {
|
||||
animation: flash-highlight 3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes flash-highlight {
|
||||
0% {
|
||||
background-color: #ff4d4d;
|
||||
}
|
||||
100% {
|
||||
background-color: rgba(255, 215, 0, 0.14);
|
||||
}
|
||||
}
|
||||
|
||||
.resize-cursor {
|
||||
@ -174,7 +192,7 @@
|
||||
mask-size: 100% 100%;
|
||||
background-color: currentColor;
|
||||
|
||||
& -open {
|
||||
&-open {
|
||||
--svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M10 3v2H5v14h14v-5h2v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zm7.586 2H13V3h8v8h-2V6.414l-7 7L10.586 12z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
|
||||
@ -71,4 +71,12 @@
|
||||
[data-type="details"][open] > [data-type="detailsButton"] .ProseMirror-icon{
|
||||
transform: rotateZ(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
[data-type="details"]:has(.search-result) > [data-type="detailsContainer"] > [data-type="detailsContent"]{
|
||||
display: block;
|
||||
}
|
||||
|
||||
[data-type="details"]:has(.search-result) > [data-type="detailsButton"] .ProseMirror-icon{
|
||||
transform: rotateZ(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
@ -45,6 +45,10 @@
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&[data-direction='horizontal'] {
|
||||
rotate: 90deg;
|
||||
}
|
||||
}
|
||||
|
||||
.dark .drag-handle {
|
||||
|
||||
9
apps/client/src/features/editor/styles/find.css
Normal file
9
apps/client/src/features/editor/styles/find.css
Normal file
@ -0,0 +1,9 @@
|
||||
.search-result{
|
||||
background: #ffff65;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.search-result-current{
|
||||
background: #ffc266 !important;
|
||||
color: #212529;
|
||||
}
|
||||
@ -9,5 +9,6 @@
|
||||
@import "./media.css";
|
||||
@import "./code.css";
|
||||
@import "./print.css";
|
||||
@import "./find.css";
|
||||
@import "./mention.css";
|
||||
|
||||
@import "./ordered-list.css";
|
||||
|
||||
34
apps/client/src/features/editor/styles/ordered-list.css
Normal file
34
apps/client/src/features/editor/styles/ordered-list.css
Normal file
@ -0,0 +1,34 @@
|
||||
/* Ordered list type cycling based on nesting depth */
|
||||
ol,
|
||||
ol ol ol ol,
|
||||
ol ol ol ol ol ol ol,
|
||||
ol ol ol ol ol ol ol ol ol ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
ol ol,
|
||||
ol ol ol ol ol,
|
||||
ol ol ol ol ol ol ol ol,
|
||||
ol ol ol ol ol ol ol ol ol ol ol {
|
||||
list-style-type: lower-alpha;
|
||||
}
|
||||
|
||||
ol ol ol,
|
||||
ol ol ol ol ol ol,
|
||||
ol ol ol ol ol ol ol ol ol,
|
||||
ol ol ol ol ol ol ol ol ol ol ol ol {
|
||||
list-style-type: lower-roman;
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style-position: outside;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
/* Nested list spacing */
|
||||
ol ol,
|
||||
ol ul,
|
||||
ul ol {
|
||||
margin-top: 0.1rem;
|
||||
margin-bottom: 0.1rem;
|
||||
}
|
||||
@ -4,9 +4,20 @@
|
||||
overflow-x: auto;
|
||||
& table {
|
||||
overflow-x: hidden;
|
||||
min-width: 700px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.table-dnd-preview {
|
||||
padding: 0;
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.table-dnd-drop-indicator {
|
||||
background-color: #adf;
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
@ -38,8 +49,8 @@
|
||||
|
||||
th {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-5)
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-5)
|
||||
);
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
@ -47,7 +58,7 @@
|
||||
|
||||
.column-resize-handle {
|
||||
background-color: #adf;
|
||||
bottom: -2px;
|
||||
bottom: -1px;
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
pointer-events: none;
|
||||
@ -66,8 +77,64 @@
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/* Table cell background colors with dark mode support */
|
||||
.ProseMirror {
|
||||
table {
|
||||
@mixin dark {
|
||||
/* Blue */
|
||||
td[data-background-color="#b4d5ff"],
|
||||
th[data-background-color="#b4d5ff"] {
|
||||
background-color: #1a3a5c !important;
|
||||
}
|
||||
|
||||
/* Green */
|
||||
td[data-background-color="#acf5d2"],
|
||||
th[data-background-color="#acf5d2"] {
|
||||
background-color: #1a4d3a !important;
|
||||
}
|
||||
|
||||
/* Yellow */
|
||||
td[data-background-color="#fef1b4"],
|
||||
th[data-background-color="#fef1b4"] {
|
||||
background-color: #7c5014 !important;
|
||||
}
|
||||
|
||||
/* Red */
|
||||
td[data-background-color="#ffbead"],
|
||||
th[data-background-color="#ffbead"] {
|
||||
background-color: #5c2a23 !important;
|
||||
}
|
||||
|
||||
/* Pink */
|
||||
td[data-background-color="#ffc7fe"],
|
||||
th[data-background-color="#ffc7fe"] {
|
||||
background-color: #4d2a4d !important;
|
||||
}
|
||||
|
||||
/* Gray */
|
||||
td[data-background-color="#eaecef"],
|
||||
th[data-background-color="#eaecef"] {
|
||||
background-color: #2a2e33 !important;
|
||||
}
|
||||
|
||||
/* Purple */
|
||||
td[data-background-color="#c1b7f2"],
|
||||
th[data-background-color="#c1b7f2"] {
|
||||
background-color: #3a2f5c !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.editor-container:has(.table-dnd-drop-indicator[data-dragging="true"]) {
|
||||
.prosemirror-dropcursor-block {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.prosemirror-dropcursor-inline {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user