mirror of
https://github.com/docmost/docmost.git
synced 2026-05-09 07:43:06 +08:00
Compare commits
8 Commits
ai
..
feat/mfa-reset
| Author | SHA1 | Date | |
|---|---|---|---|
| f5246d544d | |||
| d789ba3ffa | |||
| 2857d86dae | |||
| b2deed843a | |||
| 2375ffe4fe | |||
| 109365c432 | |||
| c17e4f7ff6 | |||
| 8cd4b25dbc |
@@ -44,6 +44,3 @@ POSTMARK_TOKEN=
|
|||||||
DRAWIO_URL=
|
DRAWIO_URL=
|
||||||
|
|
||||||
DISABLE_TELEMETRY=false
|
DISABLE_TELEMETRY=false
|
||||||
|
|
||||||
# Enable debug logging in production (default: false)
|
|
||||||
DEBUG_MODE=false
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.22.2",
|
"version": "0.21.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
|
|||||||
@@ -104,7 +104,7 @@
|
|||||||
"Member": "Mitglied",
|
"Member": "Mitglied",
|
||||||
"members": "Mitglieder",
|
"members": "Mitglieder",
|
||||||
"Members": "Mitglieder",
|
"Members": "Mitglieder",
|
||||||
"My preferences": "Meine Voreinstellungen",
|
"My preferences": "Meine Vorlieben",
|
||||||
"My Profile": "Mein Profil",
|
"My Profile": "Mein Profil",
|
||||||
"My profile": "Mein Profil",
|
"My profile": "Mein Profil",
|
||||||
"Name": "Name",
|
"Name": "Name",
|
||||||
@@ -213,18 +213,7 @@
|
|||||||
"Comment deleted successfully": "Kommentar erfolgreich gelöscht",
|
"Comment deleted successfully": "Kommentar erfolgreich gelöscht",
|
||||||
"Failed to delete comment": "Löschen des Kommentars fehlgeschlagen",
|
"Failed to delete comment": "Löschen des Kommentars fehlgeschlagen",
|
||||||
"Comment resolved successfully": "Kommentar erfolgreich gelöst",
|
"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",
|
"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 invitation": "Einladung widerrufen",
|
||||||
"Revoke": "Widerrufen",
|
"Revoke": "Widerrufen",
|
||||||
"Don't": "Nicht",
|
"Don't": "Nicht",
|
||||||
@@ -233,9 +222,7 @@
|
|||||||
"Anyone with this link can join this workspace.": "Jeder mit diesem Link kann dem Arbeitsbereich beitreten.",
|
"Anyone with this link can join this workspace.": "Jeder mit diesem Link kann dem Arbeitsbereich beitreten.",
|
||||||
"Invite link": "Einladungslink",
|
"Invite link": "Einladungslink",
|
||||||
"Copy": "Kopieren",
|
"Copy": "Kopieren",
|
||||||
"Copy to space": "In Raum kopieren",
|
|
||||||
"Copied": "Kopiert",
|
"Copied": "Kopiert",
|
||||||
"Duplicate": "Duplizieren",
|
|
||||||
"Select a user": "Benutzer auswählen",
|
"Select a user": "Benutzer auswählen",
|
||||||
"Select a group": "Gruppe 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.",
|
"Export all pages and attachments in this space.": "Alle Seiten und Anhänge in diesem Bereich exportieren.",
|
||||||
@@ -367,9 +354,6 @@
|
|||||||
"Character count: {{characterCount}}": "Zeichenzahl: {{characterCount}}",
|
"Character count: {{characterCount}}": "Zeichenzahl: {{characterCount}}",
|
||||||
"New update": "Neues Update",
|
"New update": "Neues Update",
|
||||||
"{{latestVersion}} is available": "{{latestVersion}} ist verfügbar",
|
"{{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",
|
"Delete member": "Mitglied löschen",
|
||||||
"Member deleted successfully": "Mitglied erfolgreich gelöscht",
|
"Member deleted successfully": "Mitglied erfolgreich gelöscht",
|
||||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "Sind Sie sicher, dass Sie dieses Arbeitsbereichsmitglied löschen möchten? Diese Aktion ist unwiderruflich.",
|
"Are you sure you want to delete this workspace member? This action is irreversible.": "Sind Sie sicher, dass Sie dieses Arbeitsbereichsmitglied löschen möchten? Diese Aktion ist unwiderruflich.",
|
||||||
@@ -402,98 +386,5 @@
|
|||||||
"Failed to share page": "Fehler beim Teilen der Seite",
|
"Failed to share page": "Fehler beim Teilen der Seite",
|
||||||
"Copy page": "Seite kopieren",
|
"Copy page": "Seite kopieren",
|
||||||
"Copy page to a different space.": "Seite in einen anderen Bereich kopieren.",
|
"Copy page to a different space.": "Seite in einen anderen Bereich kopieren.",
|
||||||
"Page copied successfully": "Seite erfolgreich kopiert",
|
"Page copied successfully": "Seite erfolgreich kopiert"
|
||||||
"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,18 +213,7 @@
|
|||||||
"Comment deleted successfully": "Comment deleted successfully",
|
"Comment deleted successfully": "Comment deleted successfully",
|
||||||
"Failed to delete comment": "Failed to delete comment",
|
"Failed to delete comment": "Failed to delete comment",
|
||||||
"Comment resolved successfully": "Comment resolved successfully",
|
"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",
|
"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 invitation": "Revoke invitation",
|
||||||
"Revoke": "Revoke",
|
"Revoke": "Revoke",
|
||||||
"Don't": "Don't",
|
"Don't": "Don't",
|
||||||
@@ -233,9 +222,7 @@
|
|||||||
"Anyone with this link can join this workspace.": "Anyone with this link can join this workspace.",
|
"Anyone with this link can join this workspace.": "Anyone with this link can join this workspace.",
|
||||||
"Invite link": "Invite link",
|
"Invite link": "Invite link",
|
||||||
"Copy": "Copy",
|
"Copy": "Copy",
|
||||||
"Copy to space": "Copy to space",
|
|
||||||
"Copied": "Copied",
|
"Copied": "Copied",
|
||||||
"Duplicate": "Duplicate",
|
|
||||||
"Select a user": "Select a user",
|
"Select a user": "Select a user",
|
||||||
"Select a group": "Select a group",
|
"Select a group": "Select a group",
|
||||||
"Export all pages and attachments in this space.": "Export all pages and attachments in this space.",
|
"Export all pages and attachments in this space.": "Export all pages and attachments in this space.",
|
||||||
@@ -403,7 +390,6 @@
|
|||||||
"Copy page": "Copy page",
|
"Copy page": "Copy page",
|
||||||
"Copy page to a different space.": "Copy page to a different space.",
|
"Copy page to a different space.": "Copy page to a different space.",
|
||||||
"Page copied successfully": "Page copied successfully",
|
"Page copied successfully": "Page copied successfully",
|
||||||
"Page duplicated successfully": "Page duplicated successfully",
|
|
||||||
"Find": "Find",
|
"Find": "Find",
|
||||||
"Not found": "Not found",
|
"Not found": "Not found",
|
||||||
"Previous Match (Shift+Enter)": "Previous Match (Shift+Enter)",
|
"Previous Match (Shift+Enter)": "Previous Match (Shift+Enter)",
|
||||||
@@ -414,7 +400,6 @@
|
|||||||
"Replace (Enter)": "Replace (Enter)",
|
"Replace (Enter)": "Replace (Enter)",
|
||||||
"Replace all (Ctrl+Alt+Enter)": "Replace all (Ctrl+Alt+Enter)",
|
"Replace all (Ctrl+Alt+Enter)": "Replace all (Ctrl+Alt+Enter)",
|
||||||
"Replace all": "Replace all",
|
"Replace all": "Replace all",
|
||||||
"View all spaces": "View all spaces",
|
|
||||||
"Error": "Error",
|
"Error": "Error",
|
||||||
"Failed to disable MFA": "Failed to disable MFA",
|
"Failed to disable MFA": "Failed to disable MFA",
|
||||||
"Disable two-factor authentication": "Disable two-factor authentication",
|
"Disable two-factor authentication": "Disable two-factor authentication",
|
||||||
@@ -480,20 +465,5 @@
|
|||||||
"Enter one of your backup codes": "Enter one of your backup codes",
|
"Enter one of your backup codes": "Enter one of your backup codes",
|
||||||
"Backup code": "Backup code",
|
"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.",
|
"Enter one of your backup codes. Each backup code can only be used once.": "Enter one of your backup codes. Each backup code can only be used once.",
|
||||||
"Verify": "Verify",
|
"Verify": "Verify"
|
||||||
"Trash": "Trash",
|
|
||||||
"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"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,18 +213,7 @@
|
|||||||
"Comment deleted successfully": "Comentario eliminado con éxito",
|
"Comment deleted successfully": "Comentario eliminado con éxito",
|
||||||
"Failed to delete comment": "No se pudo eliminar el comentario",
|
"Failed to delete comment": "No se pudo eliminar el comentario",
|
||||||
"Comment resolved successfully": "Comentario resuelto con éxito",
|
"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",
|
"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 invitation": "Revocar invitación",
|
||||||
"Revoke": "Revocar",
|
"Revoke": "Revocar",
|
||||||
"Don't": "No",
|
"Don't": "No",
|
||||||
@@ -233,9 +222,7 @@
|
|||||||
"Anyone with this link can join this workspace.": "Cualquiera con este enlace puede unirse a este espacio de trabajo.",
|
"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",
|
"Invite link": "Enlace de invitación",
|
||||||
"Copy": "Copiar",
|
"Copy": "Copiar",
|
||||||
"Copy to space": "Copiar al espacio",
|
|
||||||
"Copied": "Copiado",
|
"Copied": "Copiado",
|
||||||
"Duplicate": "Duplicar",
|
|
||||||
"Select a user": "Seleccionar un usuario",
|
"Select a user": "Seleccionar un usuario",
|
||||||
"Select a group": "Seleccionar un grupo",
|
"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.",
|
"Export all pages and attachments in this space.": "Exportar todas las páginas y archivos adjuntos en este espacio.",
|
||||||
@@ -367,9 +354,6 @@
|
|||||||
"Character count: {{characterCount}}": "Recuento de caracteres: {{characterCount}}",
|
"Character count: {{characterCount}}": "Recuento de caracteres: {{characterCount}}",
|
||||||
"New update": "Nueva actualización",
|
"New update": "Nueva actualización",
|
||||||
"{{latestVersion}} is available": "{{latestVersion}} está disponible",
|
"{{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",
|
"Delete member": "Eliminar miembro",
|
||||||
"Member deleted successfully": "Miembro eliminado con éxito",
|
"Member deleted successfully": "Miembro eliminado con éxito",
|
||||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "¿Está seguro que desea eliminar este miembro del área de trabajo? Esta acción es irreversible.",
|
"Are you sure you want to delete this workspace member? This action is irreversible.": "¿Está seguro que desea eliminar este miembro del área de trabajo? Esta acción es irreversible.",
|
||||||
@@ -400,100 +384,7 @@
|
|||||||
"Share deleted successfully": "Compartición eliminada con éxito",
|
"Share deleted successfully": "Compartición eliminada con éxito",
|
||||||
"Share not found": "Compartición no encontrada",
|
"Share not found": "Compartición no encontrada",
|
||||||
"Failed to share page": "Error al compartir la página",
|
"Failed to share page": "Error al compartir la página",
|
||||||
"Copy page": "Copiar página",
|
"Copy page": "Copy page",
|
||||||
"Copy page to a different space.": "Copiar página en otro espacio",
|
"Copy page to a different space.": "Copy page to a different space.",
|
||||||
"Page copied successfully": "Página copiada exitosamente",
|
"Page copied successfully": "Page copied successfully"
|
||||||
"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,18 +213,7 @@
|
|||||||
"Comment deleted successfully": "Commentaire supprimé avec succès",
|
"Comment deleted successfully": "Commentaire supprimé avec succès",
|
||||||
"Failed to delete comment": "Échec de la suppression du commentaire",
|
"Failed to delete comment": "Échec de la suppression du commentaire",
|
||||||
"Comment resolved successfully": "Commentaire résolu avec succès",
|
"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",
|
"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 invitation": "Révoquer l'invitation",
|
||||||
"Revoke": "Révoquer",
|
"Revoke": "Révoquer",
|
||||||
"Don't": "Ne pas",
|
"Don't": "Ne pas",
|
||||||
@@ -233,9 +222,7 @@
|
|||||||
"Anyone with this link can join this workspace.": "Toute personne ayant ce lien peut rejoindre cet espace de travail.",
|
"Anyone with this link can join this workspace.": "Toute personne ayant ce lien peut rejoindre cet espace de travail.",
|
||||||
"Invite link": "Lien d'invitation",
|
"Invite link": "Lien d'invitation",
|
||||||
"Copy": "Copier",
|
"Copy": "Copier",
|
||||||
"Copy to space": "Copier dans l'espace",
|
|
||||||
"Copied": "Copié",
|
"Copied": "Copié",
|
||||||
"Duplicate": "Dupliquer",
|
|
||||||
"Select a user": "Sélectionner un utilisateur",
|
"Select a user": "Sélectionner un utilisateur",
|
||||||
"Select a group": "Sélectionner un groupe",
|
"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.",
|
"Export all pages and attachments in this space.": "Exporter toutes les pages et pièces jointes dans cet espace.",
|
||||||
@@ -367,9 +354,6 @@
|
|||||||
"Character count: {{characterCount}}": "Nombre de caractères : {{characterCount}}",
|
"Character count: {{characterCount}}": "Nombre de caractères : {{characterCount}}",
|
||||||
"New update": "Nouvelle mise à jour",
|
"New update": "Nouvelle mise à jour",
|
||||||
"{{latestVersion}} is available": "{{latestVersion}} est disponible",
|
"{{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",
|
"Delete member": "Supprimer le membre",
|
||||||
"Member deleted successfully": "Membre supprimé avec succès",
|
"Member deleted successfully": "Membre supprimé avec succès",
|
||||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "Êtes-vous sûr de vouloir supprimer ce membre de l'espace de travail? Cette action est irréversible.",
|
"Are you sure you want to delete this workspace member? This action is irreversible.": "Êtes-vous sûr de vouloir supprimer ce membre de l'espace de travail? Cette action est irréversible.",
|
||||||
@@ -402,98 +386,5 @@
|
|||||||
"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": "Copier la page",
|
||||||
"Copy page to a different space.": "Copier la page dans un autre espace.",
|
"Copy page to a different space.": "Copier la page dans un autre espace.",
|
||||||
"Page copied successfully": "Page copiée avec succès",
|
"Page copied successfully": "Page copiée avec succès"
|
||||||
"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,18 +213,7 @@
|
|||||||
"Comment deleted successfully": "Commento eliminato con successo",
|
"Comment deleted successfully": "Commento eliminato con successo",
|
||||||
"Failed to delete comment": "Impossibile eliminare il commento",
|
"Failed to delete comment": "Impossibile eliminare il commento",
|
||||||
"Comment resolved successfully": "Commento risolto con successo",
|
"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",
|
"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 invitation": "Revoca invito",
|
||||||
"Revoke": "Revoca",
|
"Revoke": "Revoca",
|
||||||
"Don't": "Non",
|
"Don't": "Non",
|
||||||
@@ -233,9 +222,7 @@
|
|||||||
"Anyone with this link can join this workspace.": "Chiunque con questo link può unirsi a questa area di lavoro.",
|
"Anyone with this link can join this workspace.": "Chiunque con questo link può unirsi a questa area di lavoro.",
|
||||||
"Invite link": "Link d'invito",
|
"Invite link": "Link d'invito",
|
||||||
"Copy": "Copia",
|
"Copy": "Copia",
|
||||||
"Copy to space": "Copia nello spazio",
|
|
||||||
"Copied": "Copiato",
|
"Copied": "Copiato",
|
||||||
"Duplicate": "Duplica",
|
|
||||||
"Select a user": "Seleziona un utente",
|
"Select a user": "Seleziona un utente",
|
||||||
"Select a group": "Seleziona un gruppo",
|
"Select a group": "Seleziona un gruppo",
|
||||||
"Export all pages and attachments in this space.": "Esporta tutte le pagine e gli allegati di questo spazio.",
|
"Export all pages and attachments in this space.": "Esporta tutte le pagine e gli allegati di questo spazio.",
|
||||||
@@ -367,9 +354,6 @@
|
|||||||
"Character count: {{characterCount}}": "Conteggio caratteri: {{characterCount}}",
|
"Character count: {{characterCount}}": "Conteggio caratteri: {{characterCount}}",
|
||||||
"New update": "Nuovo aggiornamento",
|
"New update": "Nuovo aggiornamento",
|
||||||
"{{latestVersion}} is available": "{{latestVersion}} è disponibile",
|
"{{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",
|
"Delete member": "Elimina membro",
|
||||||
"Member deleted successfully": "Membro eliminato con successo",
|
"Member deleted successfully": "Membro eliminato con successo",
|
||||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "Sei sicuro di voler eliminare questo membro del workspace? Questa azione è irreversibile.",
|
"Are you sure you want to delete this workspace member? This action is irreversible.": "Sei sicuro di voler eliminare questo membro del workspace? Questa azione è irreversibile.",
|
||||||
@@ -402,98 +386,5 @@
|
|||||||
"Failed to share page": "Condivisione della pagina fallita",
|
"Failed to share page": "Condivisione della pagina fallita",
|
||||||
"Copy page": "Copia pagina",
|
"Copy page": "Copia pagina",
|
||||||
"Copy page to a different space.": "Copia pagina in un altro spazio.",
|
"Copy page to a different space.": "Copia pagina in un altro spazio.",
|
||||||
"Page copied successfully": "Pagina copiata con successo",
|
"Page copied successfully": "Pagina copiata con successo"
|
||||||
"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,18 +213,7 @@
|
|||||||
"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 unresolved successfully": "コメントが再解決されました",
|
|
||||||
"Failed to resolve comment": "コメントの解決に失敗しました",
|
"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 invitation": "招待を取り消す",
|
||||||
"Revoke": "取り消す",
|
"Revoke": "取り消す",
|
||||||
"Don't": "取り消さない",
|
"Don't": "取り消さない",
|
||||||
@@ -233,9 +222,7 @@
|
|||||||
"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": "スペースにコピー",
|
|
||||||
"Copied": "コピーしました",
|
"Copied": "コピーしました",
|
||||||
"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.": "このスペースのすべてのページと添付ファイルをエクスポートします。",
|
||||||
@@ -367,9 +354,6 @@
|
|||||||
"Character count: {{characterCount}}": "文字数: {{characterCount}}",
|
"Character count: {{characterCount}}": "文字数: {{characterCount}}",
|
||||||
"New update": "新規更新",
|
"New update": "新規更新",
|
||||||
"{{latestVersion}} is available": "{{latestVersion}}は利用可能です",
|
"{{latestVersion}} is available": "{{latestVersion}}は利用可能です",
|
||||||
"Default page edit mode": "デフォルトのページ編集モード",
|
|
||||||
"Choose your preferred page edit mode. Avoid accidental edits.": "希望のページ編集モードを選択してください。誤って編集を防ぎます。",
|
|
||||||
"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.": "ワークスペースメンバーを削除してもよろしいですか?この操作は元に戻せません。",
|
||||||
@@ -402,98 +386,5 @@
|
|||||||
"Failed to share page": "ページの共有に失敗しました",
|
"Failed to share page": "ページの共有に失敗しました",
|
||||||
"Copy page": "ページをコピー",
|
"Copy page": "ページをコピー",
|
||||||
"Copy page to a different space.": "ページを別のスペースにコピーします。",
|
"Copy page to a different space.": "ページを別のスペースにコピーします。",
|
||||||
"Page copied successfully": "ページのコピーに成功しました",
|
"Page copied successfully": "ページのコピーに成功しました"
|
||||||
"Page duplicated successfully": "ページが正常に複製されました",
|
|
||||||
"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,18 +213,7 @@
|
|||||||
"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 unresolved successfully": "댓글 미해결로 변경 완료",
|
|
||||||
"Failed to resolve comment": "댓글 처리 실패",
|
"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 invitation": "초대 취소",
|
||||||
"Revoke": "취소",
|
"Revoke": "취소",
|
||||||
"Don't": "하지 않음",
|
"Don't": "하지 않음",
|
||||||
@@ -233,9 +222,7 @@
|
|||||||
"Anyone with this link can join this workspace.": "이 링크를 가진 모든 사용자가 이 Workspace에 참여할 수 있습니다.",
|
"Anyone with this link can join this workspace.": "이 링크를 가진 모든 사용자가 이 Workspace에 참여할 수 있습니다.",
|
||||||
"Invite link": "초대 링크",
|
"Invite link": "초대 링크",
|
||||||
"Copy": "복사",
|
"Copy": "복사",
|
||||||
"Copy to space": "공간에 복사하기",
|
|
||||||
"Copied": "복사됨",
|
"Copied": "복사됨",
|
||||||
"Duplicate": "중복",
|
|
||||||
"Select a user": "사용자 선택",
|
"Select a user": "사용자 선택",
|
||||||
"Select a group": "팀 선택",
|
"Select a group": "팀 선택",
|
||||||
"Export all pages and attachments in this space.": "이 Space의 모든 페이지와 첨부파일을 내보냅니다.",
|
"Export all pages and attachments in this space.": "이 Space의 모든 페이지와 첨부파일을 내보냅니다.",
|
||||||
@@ -367,9 +354,6 @@
|
|||||||
"Character count: {{characterCount}}": "문자 수: {{characterCount}}",
|
"Character count: {{characterCount}}": "문자 수: {{characterCount}}",
|
||||||
"New update": "새로운 업데이트",
|
"New update": "새로운 업데이트",
|
||||||
"{{latestVersion}} is available": "{{latestVersion}}이 사용 가능합니다",
|
"{{latestVersion}} is available": "{{latestVersion}}이 사용 가능합니다",
|
||||||
"Default page edit mode": "기본 페이지 편집 모드",
|
|
||||||
"Choose your preferred page edit mode. Avoid accidental edits.": "선호하는 페이지 편집 모드를 선택하세요. 실수로 인한 편집을 방지하세요.",
|
|
||||||
"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.": "이 워크스페이스 멤버를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
|
||||||
@@ -400,100 +384,7 @@
|
|||||||
"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 to a different space.": "다른 공간으로 페이지 복사하기.",
|
"Copy page to a different space.": "Copy page to a different space.",
|
||||||
"Page copied successfully": "페이지가 성공적으로 복사되었습니다",
|
"Page copied successfully": "Page copied successfully"
|
||||||
"Page 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,18 +213,7 @@
|
|||||||
"Comment deleted successfully": "Reactie met succes verwijderd",
|
"Comment deleted successfully": "Reactie met succes verwijderd",
|
||||||
"Failed to delete comment": "Verwijderen van reactie mislukt",
|
"Failed to delete comment": "Verwijderen van reactie mislukt",
|
||||||
"Comment resolved successfully": "Reactie succesvol opgelost",
|
"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",
|
"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 invitation": "Uitnodiging intrekken",
|
||||||
"Revoke": "Intrekken",
|
"Revoke": "Intrekken",
|
||||||
"Don't": "Niet doen",
|
"Don't": "Niet doen",
|
||||||
@@ -233,9 +222,7 @@
|
|||||||
"Anyone with this link can join this workspace.": "Iedereen met deze link kan zich aansluiten bij deze werkruimte.",
|
"Anyone with this link can join this workspace.": "Iedereen met deze link kan zich aansluiten bij deze werkruimte.",
|
||||||
"Invite link": "Uitnodigingslink",
|
"Invite link": "Uitnodigingslink",
|
||||||
"Copy": "Kopieer",
|
"Copy": "Kopieer",
|
||||||
"Copy to space": "Kopiëren naar ruimte",
|
|
||||||
"Copied": "Gekopieerd",
|
"Copied": "Gekopieerd",
|
||||||
"Duplicate": "Dupliceren",
|
|
||||||
"Select a user": "Selecteer een gebruiker",
|
"Select a user": "Selecteer een gebruiker",
|
||||||
"Select a group": "Selecteer een groep",
|
"Select a group": "Selecteer een groep",
|
||||||
"Export all pages and attachments in this space.": "Exporteer alle pagina's en bijlagen in deze ruimte.",
|
"Export all pages and attachments in this space.": "Exporteer alle pagina's en bijlagen in deze ruimte.",
|
||||||
@@ -367,9 +354,6 @@
|
|||||||
"Character count: {{characterCount}}": "Aantal tekens: {{characterCount}}",
|
"Character count: {{characterCount}}": "Aantal tekens: {{characterCount}}",
|
||||||
"New update": "Nieuwe update",
|
"New update": "Nieuwe update",
|
||||||
"{{latestVersion}} is available": "{{latestVersion}} is beschikbaar",
|
"{{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",
|
"Delete member": "Verwijder lid",
|
||||||
"Member deleted successfully": "Lid succesvol verwijderd",
|
"Member deleted successfully": "Lid succesvol verwijderd",
|
||||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "Weet u zeker dat u dit lid van de werkruimte wilt verwijderen? Deze actie kan niet ongedaan gemaakt worden.",
|
"Are you sure you want to delete this workspace member? This action is irreversible.": "Weet u zeker dat u dit lid van de werkruimte wilt verwijderen? Deze actie kan niet ongedaan gemaakt worden.",
|
||||||
@@ -400,100 +384,7 @@
|
|||||||
"Share deleted successfully": "Delen succesvol verwijderd",
|
"Share deleted successfully": "Delen succesvol verwijderd",
|
||||||
"Share not found": "Delen niet gevonden",
|
"Share not found": "Delen niet gevonden",
|
||||||
"Failed to share page": "Pagina delen mislukt",
|
"Failed to share page": "Pagina delen mislukt",
|
||||||
"Copy page": "Pagina kopiëren",
|
"Copy page": "Copy page",
|
||||||
"Copy page to a different space.": "Kopieer pagina naar een andere ruimte.",
|
"Copy page to a different space.": "Copy page to a different space.",
|
||||||
"Page copied successfully": "Pagina succesvol gekopieerd",
|
"Page copied successfully": "Page copied successfully"
|
||||||
"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,18 +213,7 @@
|
|||||||
"Comment deleted successfully": "Comentário excluído com sucesso",
|
"Comment deleted successfully": "Comentário excluído com sucesso",
|
||||||
"Failed to delete comment": "Falha ao excluir comentário",
|
"Failed to delete comment": "Falha ao excluir comentário",
|
||||||
"Comment resolved successfully": "Comentário resolvido com sucesso",
|
"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",
|
"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 invitation": "Cancelar o convite",
|
||||||
"Revoke": "Anular",
|
"Revoke": "Anular",
|
||||||
"Don't": "Não",
|
"Don't": "Não",
|
||||||
@@ -233,9 +222,7 @@
|
|||||||
"Anyone with this link can join this workspace.": "Qualquer um com este link pode participar deste espaço de trabalho.",
|
"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",
|
"Invite link": "Link do convite",
|
||||||
"Copy": "Copiar",
|
"Copy": "Copiar",
|
||||||
"Copy to space": "Copiar para o espaço",
|
|
||||||
"Copied": "Copiado",
|
"Copied": "Copiado",
|
||||||
"Duplicate": "Duplicar",
|
|
||||||
"Select a user": "Selecione um usuário",
|
"Select a user": "Selecione um usuário",
|
||||||
"Select a group": "Selecione um grupo",
|
"Select a group": "Selecione um grupo",
|
||||||
"Export all pages and attachments in this space.": "Exportar todas as páginas e anexos deste espaço.",
|
"Export all pages and attachments in this space.": "Exportar todas as páginas e anexos deste espaço.",
|
||||||
@@ -367,9 +354,6 @@
|
|||||||
"Character count: {{characterCount}}": "Contagem de caracteres: {{characterCount}}",
|
"Character count: {{characterCount}}": "Contagem de caracteres: {{characterCount}}",
|
||||||
"New update": "Nova atualização",
|
"New update": "Nova atualização",
|
||||||
"{{latestVersion}} is available": "{{latestVersion}} está disponível",
|
"{{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",
|
"Delete member": "Excluir membro",
|
||||||
"Member deleted successfully": "Membro removido com sucesso",
|
"Member deleted successfully": "Membro removido com sucesso",
|
||||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "Você tem certeza que deseja deletar este membro do workspace? Esta ação é irreversível.",
|
"Are you sure you want to delete this workspace member? This action is irreversible.": "Você tem certeza que deseja deletar este membro do workspace? Esta ação é irreversível.",
|
||||||
@@ -400,100 +384,7 @@
|
|||||||
"Share deleted successfully": "Compartilhamento excluído com sucesso",
|
"Share deleted successfully": "Compartilhamento excluído com sucesso",
|
||||||
"Share not found": "Compartilhamento não encontrado",
|
"Share not found": "Compartilhamento não encontrado",
|
||||||
"Failed to share page": "Falha ao compartilhar página",
|
"Failed to share page": "Falha ao compartilhar página",
|
||||||
"Copy page": "Copiar página",
|
"Copy page": "Copy page",
|
||||||
"Copy page to a different space.": "Copiar página para um espaço diferente.",
|
"Copy page to a different space.": "Copy page to a different space.",
|
||||||
"Page copied successfully": "Página copiada com sucesso",
|
"Page copied successfully": "Page copied successfully"
|
||||||
"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 current password": "Введите ваш текущий пароль",
|
||||||
"enter your full name": "введите ваше полное имя",
|
"enter your full name": "введите ваше полное имя",
|
||||||
"Enter your new password": "Введите ваш новый пароль",
|
"Enter your new password": "Введите ваш новый пароль",
|
||||||
"Enter your new preferred email": "Введите ваш новый предпочтительный адрес электронной почты",
|
"Enter your new preferred email": "Введите ваш новый предпочитаемый адрес электронной почты",
|
||||||
"Enter your password": "Введите ваш пароль",
|
"Enter your password": "Введите ваш пароль",
|
||||||
"Error fetching page data.": "Ошибка при загрузке данных страницы.",
|
"Error fetching page data.": "Ошибка при загрузке данных страницы.",
|
||||||
"Error loading page history.": "Ошибка при загрузке истории страницы.",
|
"Error loading page history.": "Ошибка при загрузке истории страницы.",
|
||||||
@@ -213,18 +213,7 @@
|
|||||||
"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 unresolved successfully": "Комментарий успешно размечен как нерешённый",
|
|
||||||
"Failed to resolve comment": "Не удалось разрешить комментарий",
|
"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 invitation": "Отозвать приглашение",
|
||||||
"Revoke": "Отозвать",
|
"Revoke": "Отозвать",
|
||||||
"Don't": "Нет",
|
"Don't": "Нет",
|
||||||
@@ -233,9 +222,7 @@
|
|||||||
"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": "Копировать в пространство",
|
|
||||||
"Copied": "Скопировано",
|
"Copied": "Скопировано",
|
||||||
"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.": "Экспортировать все страницы и вложения в этом пространстве.",
|
||||||
@@ -367,9 +354,6 @@
|
|||||||
"Character count: {{characterCount}}": "Количество символов: {{characterCount}}",
|
"Character count: {{characterCount}}": "Количество символов: {{characterCount}}",
|
||||||
"New update": "Новое обновление",
|
"New update": "Новое обновление",
|
||||||
"{{latestVersion}} is available": "Доступна новая версия {{latestVersion}}",
|
"{{latestVersion}} is available": "Доступна новая версия {{latestVersion}}",
|
||||||
"Default page edit mode": "Режим редактирования страницы по умолчанию",
|
|
||||||
"Choose your preferred page edit mode. Avoid accidental edits.": "Выберите предпочитаемый режим редактирования страницы. Избегайте случайных изменений.",
|
|
||||||
"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.": "Вы уверены, что хотите удалить этого участника рабочей области? Это действие необратимо.",
|
||||||
@@ -402,98 +386,5 @@
|
|||||||
"Failed to share page": "Не удалось поделиться страницей",
|
"Failed to share page": "Не удалось поделиться страницей",
|
||||||
"Copy page": "Копировать страницу",
|
"Copy page": "Копировать страницу",
|
||||||
"Copy page to a different space.": "Копировать страницу в другое пространство.",
|
"Copy page to a different space.": "Копировать страницу в другое пространство.",
|
||||||
"Page copied successfully": "Страница успешно скопирована",
|
"Page copied successfully": "Страница успешно скопирована"
|
||||||
"Page duplicated successfully": "Страница успешно дублирована",
|
|
||||||
"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": "Предпросмотр"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,18 +213,7 @@
|
|||||||
"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 unresolved successfully": "Коментар успішно розв'язано",
|
|
||||||
"Failed to resolve comment": "Не вдалося вирішити коментар",
|
"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 invitation": "Відкликати запрошення",
|
||||||
"Revoke": "Відкликати",
|
"Revoke": "Відкликати",
|
||||||
"Don't": "Ні",
|
"Don't": "Ні",
|
||||||
@@ -233,9 +222,7 @@
|
|||||||
"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": "Скопіювати в простір",
|
|
||||||
"Copied": "Скопійовано",
|
"Copied": "Скопійовано",
|
||||||
"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.": "Експортувати всі сторінки та вкладення в цьому просторі.",
|
||||||
@@ -367,9 +354,6 @@
|
|||||||
"Character count: {{characterCount}}": "Кількість символів: {{characterCount}}",
|
"Character count: {{characterCount}}": "Кількість символів: {{characterCount}}",
|
||||||
"New update": "Нове оновлення",
|
"New update": "Нове оновлення",
|
||||||
"{{latestVersion}} is available": "Доступна нова версія {{latestVersion}}",
|
"{{latestVersion}} is available": "Доступна нова версія {{latestVersion}}",
|
||||||
"Default page edit mode": "Режим редагування сторінки за замовчуванням",
|
|
||||||
"Choose your preferred page edit mode. Avoid accidental edits.": "Виберіть бажаний режим редагування сторінки. Уникайте випадкових редагувань.",
|
|
||||||
"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.": "Ви впевнені, що хочете видалити цього учасника робочої області? Ця дія незворотна.",
|
||||||
@@ -402,98 +386,5 @@
|
|||||||
"Failed to share page": "Не вдалося поділитися сторінкою",
|
"Failed to share page": "Не вдалося поділитися сторінкою",
|
||||||
"Copy page": "Копіювати сторінки",
|
"Copy page": "Копіювати сторінки",
|
||||||
"Copy page to a different space.": "Скопіювати сторінку в інший простір.",
|
"Copy page to a different space.": "Скопіювати сторінку в інший простір.",
|
||||||
"Page copied successfully": "Сторінку успішно скопійовано",
|
"Page copied successfully": "Сторінку успішно скопійовано"
|
||||||
"Page duplicated successfully": "Сторінку успішно дубльовано",
|
|
||||||
"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,18 +213,7 @@
|
|||||||
"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 unresolved successfully": "成功标记评论为未解决",
|
|
||||||
"Failed to resolve comment": "标记评论为解决失败",
|
"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 invitation": "撤回邀请",
|
||||||
"Revoke": "撤销",
|
"Revoke": "撤销",
|
||||||
"Don't": "不要",
|
"Don't": "不要",
|
||||||
@@ -233,9 +222,7 @@
|
|||||||
"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": "复制到空间",
|
|
||||||
"Copied": "已复制",
|
"Copied": "已复制",
|
||||||
"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.": "导出当前空间的所有页面和附件",
|
||||||
@@ -367,9 +354,6 @@
|
|||||||
"Character count: {{characterCount}}": "字符数:{{characterCount}}",
|
"Character count: {{characterCount}}": "字符数:{{characterCount}}",
|
||||||
"New update": "新更新",
|
"New update": "新更新",
|
||||||
"{{latestVersion}} is available": "{{latestVersion}} 已经可以使用",
|
"{{latestVersion}} is available": "{{latestVersion}} 已经可以使用",
|
||||||
"Default page edit mode": "默认页面编辑模式",
|
|
||||||
"Choose your preferred page edit mode. Avoid accidental edits.": "选择您偏好的页面编辑模式。避免意外编辑。",
|
|
||||||
"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.": "您确定要删除此工作区成员吗?此操作不可逆。",
|
||||||
@@ -402,98 +386,5 @@
|
|||||||
"Failed to share page": "页面分享失败",
|
"Failed to share page": "页面分享失败",
|
||||||
"Copy page": "复制页面",
|
"Copy page": "复制页面",
|
||||||
"Copy page to a different space.": "将页面复制到不同的空间。",
|
"Copy page to a different space.": "将页面复制到不同的空间。",
|
||||||
"Page copied successfully": "页面复制成功",
|
"Page copied successfully": "页面复制成功"
|
||||||
"Page duplicated successfully": "页面复制成功",
|
|
||||||
"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": "预览"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,10 +31,8 @@ import Shares from "@/pages/settings/shares/shares.tsx";
|
|||||||
import ShareLayout from "@/features/share/components/share-layout.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 { useTrackOrigin } from "@/hooks/use-track-origin";
|
||||||
import SpacesPage from "@/pages/spaces/spaces.tsx";
|
|
||||||
import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page";
|
import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page";
|
||||||
import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page";
|
import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page";
|
||||||
import SpaceTrash from "@/pages/space/space-trash.tsx";
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -50,7 +48,10 @@ export default function App() {
|
|||||||
<Route path={"/forgot-password"} element={<ForgotPassword />} />
|
<Route path={"/forgot-password"} element={<ForgotPassword />} />
|
||||||
<Route path={"/password-reset"} element={<PasswordReset />} />
|
<Route path={"/password-reset"} element={<PasswordReset />} />
|
||||||
<Route path={"/login/mfa"} element={<MfaChallengePage />} />
|
<Route path={"/login/mfa"} element={<MfaChallengePage />} />
|
||||||
<Route path={"/login/mfa/setup"} element={<MfaSetupRequiredPage />} />
|
<Route
|
||||||
|
path={"/login/mfa/setup"}
|
||||||
|
element={<MfaSetupRequiredPage />}
|
||||||
|
/>
|
||||||
|
|
||||||
{!isCloud() && (
|
{!isCloud() && (
|
||||||
<Route path={"/setup/register"} element={<SetupWorkspace />} />
|
<Route path={"/setup/register"} element={<SetupWorkspace />} />
|
||||||
@@ -76,9 +77,7 @@ export default function App() {
|
|||||||
|
|
||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
<Route path={"/home"} element={<Home />} />
|
<Route path={"/home"} element={<Home />} />
|
||||||
<Route path={"/spaces"} element={<SpacesPage />} />
|
|
||||||
<Route path={"/s/:spaceSlug"} element={<SpaceHome />} />
|
<Route path={"/s/:spaceSlug"} element={<SpaceHome />} />
|
||||||
<Route path={"/s/:spaceSlug/trash"} element={<SpaceTrash />} />
|
|
||||||
<Route
|
<Route
|
||||||
path={"/s/:spaceSlug/p/:pageSlug"}
|
path={"/s/:spaceSlug/p/:pageSlug"}
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -29,22 +29,19 @@ export default function ExportModal({
|
|||||||
}: ExportModalProps) {
|
}: ExportModalProps) {
|
||||||
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
|
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
|
||||||
const [includeChildren, setIncludeChildren] = useState<boolean>(false);
|
const [includeChildren, setIncludeChildren] = useState<boolean>(false);
|
||||||
const [includeAttachments, setIncludeAttachments] = useState<boolean>(false);
|
const [includeAttachments, setIncludeAttachments] = useState<boolean>(true);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
try {
|
try {
|
||||||
if (type === "page") {
|
if (type === "page") {
|
||||||
await exportPage({
|
await exportPage({ pageId: id, format, includeChildren });
|
||||||
pageId: id,
|
|
||||||
format,
|
|
||||||
includeChildren,
|
|
||||||
includeAttachments,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (type === "space") {
|
if (type === "space") {
|
||||||
await exportSpace({ spaceId: id, format, includeAttachments });
|
await exportSpace({ spaceId: id, format, includeAttachments });
|
||||||
}
|
}
|
||||||
|
setIncludeChildren(false);
|
||||||
|
setIncludeAttachments(true);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
@@ -99,18 +96,6 @@ export default function ExportModal({
|
|||||||
checked={includeChildren}
|
checked={includeChildren}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</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>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -27,8 +27,6 @@ export function AppHeader() {
|
|||||||
const { isTrial, trialDaysLeft } = useTrial();
|
const { isTrial, trialDaysLeft } = useTrial();
|
||||||
|
|
||||||
const isHomeRoute = location.pathname.startsWith("/home");
|
const isHomeRoute = location.pathname.startsWith("/home");
|
||||||
const isSpacesRoute = location.pathname === "/spaces";
|
|
||||||
const hideSidebar = isHomeRoute || isSpacesRoute;
|
|
||||||
|
|
||||||
const items = links.map((link) => (
|
const items = links.map((link) => (
|
||||||
<Link key={link.label} to={link.link} className={classes.link}>
|
<Link key={link.label} to={link.link} className={classes.link}>
|
||||||
@@ -40,7 +38,7 @@ export function AppHeader() {
|
|||||||
<>
|
<>
|
||||||
<Group h="100%" px="md" justify="space-between" wrap={"nowrap"}>
|
<Group h="100%" px="md" justify="space-between" wrap={"nowrap"}>
|
||||||
<Group wrap="nowrap">
|
<Group wrap="nowrap">
|
||||||
{!hideSidebar && (
|
{!isHomeRoute && (
|
||||||
<>
|
<>
|
||||||
<Tooltip label={t("Sidebar toggle")}>
|
<Tooltip label={t("Sidebar toggle")}>
|
||||||
<SidebarToggle
|
<SidebarToggle
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Box, ScrollArea, Text } from "@mantine/core";
|
import { Box, ScrollArea, Text } from "@mantine/core";
|
||||||
import CommentListWithTabs from "@/features/comment/components/comment-list-with-tabs.tsx";
|
import CommentList from "@/features/comment/components/comment-list.tsx";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
import React, { ReactNode } from "react";
|
import React, { ReactNode } from "react";
|
||||||
@@ -18,7 +18,7 @@ export default function Aside() {
|
|||||||
|
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case "comments":
|
case "comments":
|
||||||
component = <CommentListWithTabs />;
|
component = <CommentList />;
|
||||||
title = "Comments";
|
title = "Comments";
|
||||||
break;
|
break;
|
||||||
case "toc":
|
case "toc":
|
||||||
@@ -38,17 +38,13 @@ export default function Aside() {
|
|||||||
{t(title)}
|
{t(title)}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{tab === "comments" ? (
|
<ScrollArea
|
||||||
<CommentListWithTabs />
|
style={{ height: "85vh" }}
|
||||||
) : (
|
scrollbarSize={5}
|
||||||
<ScrollArea
|
type="scroll"
|
||||||
style={{ height: "85vh" }}
|
>
|
||||||
scrollbarSize={5}
|
<div style={{ paddingBottom: "200px" }}>{component}</div>
|
||||||
type="scroll"
|
</ScrollArea>
|
||||||
>
|
|
||||||
<div style={{ paddingBottom: "200px" }}>{component}</div>
|
|
||||||
</ScrollArea>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -73,15 +73,13 @@ export default function GlobalAppShell({
|
|||||||
const isSettingsRoute = location.pathname.startsWith("/settings");
|
const isSettingsRoute = location.pathname.startsWith("/settings");
|
||||||
const isSpaceRoute = location.pathname.startsWith("/s/");
|
const isSpaceRoute = location.pathname.startsWith("/s/");
|
||||||
const isHomeRoute = location.pathname.startsWith("/home");
|
const isHomeRoute = location.pathname.startsWith("/home");
|
||||||
const isSpacesRoute = location.pathname === "/spaces";
|
|
||||||
const isPageRoute = location.pathname.includes("/p/");
|
const isPageRoute = location.pathname.includes("/p/");
|
||||||
const hideSidebar = isHomeRoute || isSpacesRoute;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
header={{ height: 45 }}
|
header={{ height: 45 }}
|
||||||
navbar={
|
navbar={
|
||||||
!hideSidebar && {
|
!isHomeRoute && {
|
||||||
width: isSpaceRoute ? sidebarWidth : 300,
|
width: isSpaceRoute ? sidebarWidth : 300,
|
||||||
breakpoint: "sm",
|
breakpoint: "sm",
|
||||||
collapsed: {
|
collapsed: {
|
||||||
@@ -102,7 +100,7 @@ export default function GlobalAppShell({
|
|||||||
<AppShell.Header px="md" className={classes.header}>
|
<AppShell.Header px="md" className={classes.header}>
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
</AppShell.Header>
|
</AppShell.Header>
|
||||||
{!hideSidebar && (
|
{!isHomeRoute && (
|
||||||
<AppShell.Navbar
|
<AppShell.Navbar
|
||||||
className={classes.navbar}
|
className={classes.navbar}
|
||||||
withBorder={false}
|
withBorder={false}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { Group, Text, ScrollArea, ActionIcon, Tooltip } from "@mantine/core";
|
import { Group, Text, ScrollArea, ActionIcon } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
IconUser,
|
IconUser,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
@@ -42,7 +42,6 @@ interface DataItem {
|
|||||||
isEnterprise?: boolean;
|
isEnterprise?: boolean;
|
||||||
isAdmin?: boolean;
|
isAdmin?: boolean;
|
||||||
isSelfhosted?: boolean;
|
isSelfhosted?: boolean;
|
||||||
showDisabledInNonEE?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DataGroup {
|
interface DataGroup {
|
||||||
@@ -85,7 +84,6 @@ const groupedData: DataGroup[] = [
|
|||||||
isCloud: true,
|
isCloud: true,
|
||||||
isEnterprise: true,
|
isEnterprise: true,
|
||||||
isAdmin: true,
|
isAdmin: true,
|
||||||
showDisabledInNonEE: true,
|
|
||||||
},
|
},
|
||||||
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
|
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
|
||||||
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
|
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
|
||||||
@@ -119,11 +117,6 @@ export default function SettingsSidebar() {
|
|||||||
}, [location.pathname]);
|
}, [location.pathname]);
|
||||||
|
|
||||||
const canShowItem = (item: DataItem) => {
|
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 (item.isCloud && item.isEnterprise) {
|
||||||
if (!(isCloud() || workspace?.hasLicenseKey)) return false;
|
if (!(isCloud() || workspace?.hasLicenseKey)) return false;
|
||||||
return item.isAdmin ? isAdmin : true;
|
return item.isAdmin ? isAdmin : true;
|
||||||
@@ -148,13 +141,6 @@ export default function SettingsSidebar() {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isItemDisabled = (item: DataItem) => {
|
|
||||||
if (item.showDisabledInNonEE && item.isEnterprise) {
|
|
||||||
return !(isCloud() || workspace?.hasLicenseKey);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const menuItems = groupedData.map((group) => {
|
const menuItems = groupedData.map((group) => {
|
||||||
if (group.heading === "System" && (!isAdmin || isCloud())) {
|
if (group.heading === "System" && (!isAdmin || isCloud())) {
|
||||||
return null;
|
return null;
|
||||||
@@ -199,48 +185,23 @@ export default function SettingsSidebar() {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDisabled = isItemDisabled(item);
|
return (
|
||||||
const linkElement = (
|
|
||||||
<Link
|
<Link
|
||||||
onMouseEnter={!isDisabled ? prefetchHandler : undefined}
|
onMouseEnter={prefetchHandler}
|
||||||
className={classes.link}
|
className={classes.link}
|
||||||
data-active={active.startsWith(item.path) || undefined}
|
data-active={active.startsWith(item.path) || undefined}
|
||||||
data-disabled={isDisabled || undefined}
|
|
||||||
key={item.label}
|
key={item.label}
|
||||||
to={isDisabled ? "#" : item.path}
|
to={item.path}
|
||||||
onClick={(e) => {
|
onClick={() => {
|
||||||
if (isDisabled) {
|
|
||||||
e.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (mobileSidebarOpened) {
|
if (mobileSidebarOpened) {
|
||||||
toggleMobileSidebar();
|
toggleMobileSidebar();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={{
|
|
||||||
opacity: isDisabled ? 0.5 : 1,
|
|
||||||
cursor: isDisabled ? "not-allowed" : "pointer",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<item.icon className={classes.linkIcon} stroke={2} />
|
<item.icon className={classes.linkIcon} stroke={2} />
|
||||||
<span>{t(item.label)}</span>
|
<span>{t(item.label)}</span>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isDisabled) {
|
|
||||||
return (
|
|
||||||
<Tooltip
|
|
||||||
key={item.label}
|
|
||||||
label={t("Available in enterprise edition")}
|
|
||||||
position="right"
|
|
||||||
withArrow
|
|
||||||
>
|
|
||||||
{linkElement}
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return linkElement;
|
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
import { useState, useCallback, useRef } from "react";
|
|
||||||
import { useAiGenerateStreamMutation } from "@/ee/ai/queries/ai-query.ts";
|
|
||||||
import { AiGenerateDto } from "@/ee/ai/types/ai.types.ts";
|
|
||||||
|
|
||||||
export function useAiStream() {
|
|
||||||
const [content, setContent] = useState("");
|
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
|
||||||
const mutation = useAiGenerateStreamMutation();
|
|
||||||
|
|
||||||
const startStream = useCallback(
|
|
||||||
async (data: AiGenerateDto) => {
|
|
||||||
setContent("");
|
|
||||||
setIsStreaming(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const controller = await mutation.mutateAsync({
|
|
||||||
...data,
|
|
||||||
onChunk: (chunk) => {
|
|
||||||
setContent((prev) => prev + chunk.content);
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
console.error("AI stream error:", error);
|
|
||||||
setIsStreaming(false);
|
|
||||||
},
|
|
||||||
onComplete: () => {
|
|
||||||
setIsStreaming(false);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
abortControllerRef.current = controller;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to start stream:", error);
|
|
||||||
setIsStreaming(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[mutation]
|
|
||||||
);
|
|
||||||
|
|
||||||
const stopStream = useCallback(() => {
|
|
||||||
if (abortControllerRef.current) {
|
|
||||||
abortControllerRef.current.abort();
|
|
||||||
abortControllerRef.current = null;
|
|
||||||
setIsStreaming(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const resetContent = useCallback(() => {
|
|
||||||
setContent("");
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
content,
|
|
||||||
isStreaming,
|
|
||||||
startStream,
|
|
||||||
stopStream,
|
|
||||||
resetContent,
|
|
||||||
isLoading: mutation.isPending,
|
|
||||||
error: mutation.error,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import {
|
|
||||||
useMutation,
|
|
||||||
UseMutationResult,
|
|
||||||
useQuery,
|
|
||||||
UseQueryResult,
|
|
||||||
} from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
generateAiContent,
|
|
||||||
generateAiContentStream,
|
|
||||||
getAiConfig,
|
|
||||||
} from "@/ee/ai/services/ai-service.ts";
|
|
||||||
import {
|
|
||||||
AiConfigResponse,
|
|
||||||
AiContentResponse,
|
|
||||||
AiGenerateDto,
|
|
||||||
AiStreamChunk,
|
|
||||||
AiStreamError,
|
|
||||||
} from "@/ee/ai/types/ai.types.ts";
|
|
||||||
|
|
||||||
export function useAiGenerateMutation(): UseMutationResult<
|
|
||||||
AiContentResponse,
|
|
||||||
Error,
|
|
||||||
AiGenerateDto
|
|
||||||
> {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: AiGenerateDto) => generateAiContent(data),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StreamCallbacks {
|
|
||||||
onChunk: (chunk: AiStreamChunk) => void;
|
|
||||||
onError?: (error: AiStreamError) => void;
|
|
||||||
onComplete?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAiGenerateStreamMutation(): UseMutationResult<
|
|
||||||
AbortController,
|
|
||||||
Error,
|
|
||||||
AiGenerateDto & StreamCallbacks
|
|
||||||
> {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({ onChunk, onError, onComplete, ...data }) =>
|
|
||||||
generateAiContentStream(data, onChunk, onError, onComplete),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import api from "@/lib/api-client.ts";
|
|
||||||
import {
|
|
||||||
AiGenerateDto,
|
|
||||||
AiContentResponse,
|
|
||||||
AiStreamChunk,
|
|
||||||
AiStreamError,
|
|
||||||
} from "@/ee/ai/types/ai.types.ts";
|
|
||||||
|
|
||||||
export async function generateAiContent(
|
|
||||||
data: AiGenerateDto,
|
|
||||||
): Promise<AiContentResponse> {
|
|
||||||
const req = await api.post<AiContentResponse>("/ai/generate", data);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateAiContentStream(
|
|
||||||
data: AiGenerateDto,
|
|
||||||
onChunk: (chunk: AiStreamChunk) => void,
|
|
||||||
onError?: (error: AiStreamError) => void,
|
|
||||||
onComplete?: () => void,
|
|
||||||
): Promise<AbortController> {
|
|
||||||
const abortController = new AbortController();
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/ai/generate/stream", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
signal: abortController.signal,
|
|
||||||
credentials: "include", // This ensures cookies are sent, matching axios withCredentials
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = response.body?.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
|
|
||||||
if (!reader) {
|
|
||||||
throw new Error("Response body is not readable");
|
|
||||||
}
|
|
||||||
|
|
||||||
const processStream = async () => {
|
|
||||||
try {
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
|
|
||||||
const chunk = decoder.decode(value, { stream: true });
|
|
||||||
const lines = chunk.split("\n");
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.startsWith("data: ")) {
|
|
||||||
const data = line.slice(6);
|
|
||||||
if (data === "[DONE]") {
|
|
||||||
onComplete?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(data);
|
|
||||||
if (parsed.error) {
|
|
||||||
onError?.(parsed);
|
|
||||||
} else {
|
|
||||||
onChunk(parsed);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Ignore parse errors for incomplete chunks
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error.name !== "AbortError") {
|
|
||||||
onError?.({ error: error.message });
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
reader.releaseLock();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
processStream();
|
|
||||||
} catch (error) {
|
|
||||||
onError?.({ error: error.message });
|
|
||||||
}
|
|
||||||
|
|
||||||
return abortController;
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
export enum AiAction {
|
|
||||||
IMPROVE_WRITING = "improve_writing",
|
|
||||||
FIX_SPELLING_GRAMMAR = "fix_spelling_grammar",
|
|
||||||
MAKE_SHORTER = "make_shorter",
|
|
||||||
MAKE_LONGER = "make_longer",
|
|
||||||
SIMPLIFY = "simplify",
|
|
||||||
CHANGE_TONE = "change_tone",
|
|
||||||
SUMMARIZE = "summarize",
|
|
||||||
CONTINUE_WRITING = "continue_writing",
|
|
||||||
TRANSLATE = "translate",
|
|
||||||
CUSTOM = "custom",
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AiGenerateDto {
|
|
||||||
action?: AiAction;
|
|
||||||
content: string;
|
|
||||||
prompt?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AiContentResponse {
|
|
||||||
content: string;
|
|
||||||
usage?: {
|
|
||||||
promptTokens: number;
|
|
||||||
completionTokens: number;
|
|
||||||
totalTokens: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AiConfigResponse {
|
|
||||||
configured: boolean;
|
|
||||||
availableActions: AiAction[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AiStreamChunk {
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AiStreamError {
|
|
||||||
error: string;
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
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")
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,7 @@ export default function OssDetails() {
|
|||||||
withTableBorder
|
withTableBorder
|
||||||
>
|
>
|
||||||
<Table.Caption>
|
<Table.Caption>
|
||||||
To unlock enterprise features like SSO, MFA, Resolve comments, contact sales@docmost.com.
|
To unlock enterprise features like SSO, contact sales@docmost.com.
|
||||||
</Table.Caption>
|
</Table.Caption>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Group, Text, Button, Tooltip } from "@mantine/core";
|
import { Group, Text, Button } from "@mantine/core";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -7,8 +7,6 @@ import { getMfaStatus } from "@/ee/mfa";
|
|||||||
import { MfaSetupModal } from "@/ee/mfa";
|
import { MfaSetupModal } from "@/ee/mfa";
|
||||||
import { MfaDisableModal } from "@/ee/mfa";
|
import { MfaDisableModal } from "@/ee/mfa";
|
||||||
import { MfaBackupCodesModal } 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() {
|
export function MfaSettings() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -16,19 +14,16 @@ export function MfaSettings() {
|
|||||||
const [setupModalOpen, setSetupModalOpen] = useState(false);
|
const [setupModalOpen, setSetupModalOpen] = useState(false);
|
||||||
const [disableModalOpen, setDisableModalOpen] = useState(false);
|
const [disableModalOpen, setDisableModalOpen] = useState(false);
|
||||||
const [backupCodesModalOpen, setBackupCodesModalOpen] = useState(false);
|
const [backupCodesModalOpen, setBackupCodesModalOpen] = useState(false);
|
||||||
const { hasLicenseKey } = useLicense();
|
|
||||||
|
|
||||||
const { data: mfaStatus, isLoading } = useQuery({
|
const { data: mfaStatus, isLoading } = useQuery({
|
||||||
queryKey: ["mfa-status"],
|
queryKey: ["mfa-status"],
|
||||||
queryFn: getMfaStatus,
|
queryFn: getMfaStatus,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading || !mfaStatus) {
|
if (isLoading) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const canUseMfa = isCloud() || hasLicenseKey;
|
|
||||||
|
|
||||||
// Check if MFA is truly enabled
|
// Check if MFA is truly enabled
|
||||||
const isMfaEnabled = mfaStatus?.isEnabled === true;
|
const isMfaEnabled = mfaStatus?.isEnabled === true;
|
||||||
|
|
||||||
@@ -66,19 +61,13 @@ export function MfaSettings() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isMfaEnabled ? (
|
{!isMfaEnabled ? (
|
||||||
<Tooltip
|
<Button
|
||||||
label={t("Available in enterprise edition")}
|
variant="default"
|
||||||
disabled={canUseMfa}
|
onClick={() => setSetupModalOpen(true)}
|
||||||
|
style={{ whiteSpace: "nowrap" }}
|
||||||
>
|
>
|
||||||
<Button
|
{t("Add 2FA method")}
|
||||||
disabled={!canUseMfa}
|
</Button>
|
||||||
variant="default"
|
|
||||||
onClick={() => setSetupModalOpen(true)}
|
|
||||||
style={{ whiteSpace: "nowrap" }}
|
|
||||||
>
|
|
||||||
{t("Add 2FA method")}
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
) : (
|
) : (
|
||||||
<Group gap="sm" wrap="nowrap">
|
<Group gap="sm" wrap="nowrap">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -59,3 +59,10 @@ export async function validateMfaAccess(): Promise<MfaAccessValidationResponse>
|
|||||||
return { valid: false };
|
return { valid: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resetUserMfa(
|
||||||
|
userId: string,
|
||||||
|
): Promise<{ success: boolean }> {
|
||||||
|
const req = await api.post<{ success: boolean }>('/mfa/reset', { userId });
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Group, Text, Box, Badge } from "@mantine/core";
|
import { Group, Text, Box } from "@mantine/core";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import classes from "./comment.module.css";
|
import classes from "./comment.module.css";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
@@ -7,34 +7,22 @@ import CommentEditor from "@/features/comment/components/comment-editor";
|
|||||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
||||||
import CommentActions from "@/features/comment/components/comment-actions";
|
import CommentActions from "@/features/comment/components/comment-actions";
|
||||||
import CommentMenu from "@/features/comment/components/comment-menu";
|
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 { useHover } from "@mantine/hooks";
|
||||||
import {
|
import {
|
||||||
useDeleteCommentMutation,
|
useDeleteCommentMutation,
|
||||||
useUpdateCommentMutation,
|
useUpdateCommentMutation,
|
||||||
} from "@/features/comment/queries/comment-query";
|
} from "@/features/comment/queries/comment-query";
|
||||||
import { useResolveCommentMutation } from "@/ee/comment/queries/comment-query";
|
|
||||||
import { IComment } from "@/features/comment/types/comment.types";
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
interface CommentListItemProps {
|
interface CommentListItemProps {
|
||||||
comment: IComment;
|
comment: IComment;
|
||||||
pageId: string;
|
pageId: string;
|
||||||
canComment: boolean;
|
|
||||||
userSpaceRole?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommentListItem({
|
function CommentListItem({ comment, pageId }: CommentListItemProps) {
|
||||||
comment,
|
|
||||||
pageId,
|
|
||||||
canComment,
|
|
||||||
userSpaceRole,
|
|
||||||
}: CommentListItemProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { hovered, ref } = useHover();
|
const { hovered, ref } = useHover();
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -42,13 +30,11 @@ function CommentListItem({
|
|||||||
const [content, setContent] = useState<string>(comment.content);
|
const [content, setContent] = useState<string>(comment.content);
|
||||||
const updateCommentMutation = useUpdateCommentMutation();
|
const updateCommentMutation = useUpdateCommentMutation();
|
||||||
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
||||||
const resolveCommentMutation = useResolveCommentMutation();
|
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
const emit = useQueryEmit();
|
const emit = useQueryEmit();
|
||||||
const isCloudEE = useIsCloudEE();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setContent(comment.content);
|
setContent(comment.content)
|
||||||
}, [comment]);
|
}, [comment]);
|
||||||
|
|
||||||
async function handleUpdateComment() {
|
async function handleUpdateComment() {
|
||||||
@@ -86,35 +72,8 @@ function CommentListItem({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
function handleCommentClick(comment: IComment) {
|
||||||
const el = document.querySelector(
|
const el = document.querySelector(`.comment-mark[data-comment-id="${comment.id}"]`);
|
||||||
`.comment-mark[data-comment-id="${comment.id}"]`,
|
|
||||||
);
|
|
||||||
if (el) {
|
if (el) {
|
||||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
el.classList.add("comment-highlight");
|
el.classList.add("comment-highlight");
|
||||||
@@ -147,42 +106,28 @@ function CommentListItem({
|
|||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<div style={{ visibility: hovered ? "visible" : "hidden" }}>
|
<div style={{ visibility: hovered ? "visible" : "hidden" }}>
|
||||||
{!comment.parentCommentId && canComment && isCloudEE && (
|
{/*!comment.parentCommentId && (
|
||||||
<ResolveComment
|
<ResolveComment commentId={comment.id} pageId={comment.pageId} resolvedAt={comment.resolvedAt} />
|
||||||
editor={editor}
|
)*/}
|
||||||
commentId={comment.id}
|
|
||||||
pageId={comment.pageId}
|
|
||||||
resolvedAt={comment.resolvedAt}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(currentUser?.user?.id === comment.creatorId || userSpaceRole === 'admin') && (
|
{currentUser?.user?.id === comment.creatorId && (
|
||||||
<CommentMenu
|
<CommentMenu
|
||||||
onEditComment={handleEditToggle}
|
onEditComment={handleEditToggle}
|
||||||
onDeleteComment={handleDeleteComment}
|
onDeleteComment={handleDeleteComment}
|
||||||
onResolveComment={handleResolveComment}
|
|
||||||
canEdit={currentUser?.user?.id === comment.creatorId}
|
|
||||||
isResolved={comment.resolvedAt != null}
|
|
||||||
isParentComment={!comment.parentCommentId}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group gap="xs">
|
<Text size="xs" fw={500} c="dimmed">
|
||||||
<Text size="xs" fw={500} c="dimmed">
|
{timeAgo(comment.createdAt)}
|
||||||
{timeAgo(comment.createdAt)}
|
</Text>
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{!comment.parentCommentId && comment?.selection && (
|
{!comment.parentCommentId && comment?.selection && (
|
||||||
<Box
|
<Box className={classes.textSelection} onClick={() => handleCommentClick(comment)}>
|
||||||
className={classes.textSelection}
|
|
||||||
onClick={() => handleCommentClick(comment)}
|
|
||||||
>
|
|
||||||
<Text size="sm">{comment?.selection}</Text>
|
<Text size="sm">{comment?.selection}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,330 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
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";
|
||||||
|
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||||
|
|
||||||
|
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 emit = useQueryEmit();
|
||||||
|
|
||||||
|
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} />
|
||||||
|
<MemoizedChildComments comments={comments} parentId={comment.id} pageId={page?.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;
|
||||||
|
pageId: string;
|
||||||
|
}
|
||||||
|
const ChildComments = ({ comments, parentId, pageId }: 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} />
|
||||||
|
<MemoizedChildComments
|
||||||
|
comments={comments}
|
||||||
|
parentId={childComment.id}
|
||||||
|
pageId={pageId}
|
||||||
|
/>
|
||||||
|
</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 CommentList;
|
||||||
@@ -1,28 +1,15 @@
|
|||||||
import { ActionIcon, Menu, Tooltip } from "@mantine/core";
|
import { ActionIcon, Menu } from "@mantine/core";
|
||||||
import { IconDots, IconEdit, IconTrash, IconCircleCheck, IconCircleCheckFilled } from "@tabler/icons-react";
|
import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react";
|
||||||
import { modals } from "@mantine/modals";
|
import { modals } from "@mantine/modals";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
|
|
||||||
|
|
||||||
type CommentMenuProps = {
|
type CommentMenuProps = {
|
||||||
onEditComment: () => void;
|
onEditComment: () => void;
|
||||||
onDeleteComment: () => void;
|
onDeleteComment: () => void;
|
||||||
onResolveComment?: () => void;
|
|
||||||
canEdit?: boolean;
|
|
||||||
isResolved?: boolean;
|
|
||||||
isParentComment?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function CommentMenu({
|
function CommentMenu({ onEditComment, onDeleteComment }: CommentMenuProps) {
|
||||||
onEditComment,
|
|
||||||
onDeleteComment,
|
|
||||||
onResolveComment,
|
|
||||||
canEdit = true,
|
|
||||||
isResolved = false,
|
|
||||||
isParentComment = false
|
|
||||||
}: CommentMenuProps) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isCloudEE = useIsCloudEE();
|
|
||||||
|
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
const openDeleteModal = () =>
|
const openDeleteModal = () =>
|
||||||
@@ -43,34 +30,9 @@ function CommentMenu({
|
|||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
|
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
{canEdit && (
|
<Menu.Item onClick={onEditComment} leftSection={<IconEdit size={14} />}>
|
||||||
<Menu.Item onClick={onEditComment} leftSection={<IconEdit size={14} />}>
|
{t("Edit comment")}
|
||||||
{t("Edit comment")}
|
</Menu.Item>
|
||||||
</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
|
<Menu.Item
|
||||||
leftSection={<IconTrash size={14} />}
|
leftSection={<IconTrash size={14} />}
|
||||||
onClick={openDeleteModal}
|
onClick={openDeleteModal}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
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,11 +8,13 @@ import {
|
|||||||
createComment,
|
createComment,
|
||||||
deleteComment,
|
deleteComment,
|
||||||
getPageComments,
|
getPageComments,
|
||||||
|
resolveComment,
|
||||||
updateComment,
|
updateComment,
|
||||||
} from "@/features/comment/services/comment-service";
|
} from "@/features/comment/services/comment-service";
|
||||||
import {
|
import {
|
||||||
ICommentParams,
|
ICommentParams,
|
||||||
IComment,
|
IComment,
|
||||||
|
IResolveComment,
|
||||||
} from "@/features/comment/types/comment.types";
|
} from "@/features/comment/types/comment.types";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { IPagination } from "@/lib/types.ts";
|
import { IPagination } from "@/lib/types.ts";
|
||||||
@@ -106,4 +108,34 @@ export function useDeleteCommentMutation(pageId?: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// EE: useResolveCommentMutation has been moved to @/ee/comment/queries/comment-query
|
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",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ export interface IComment {
|
|||||||
editedAt?: Date;
|
editedAt?: Date;
|
||||||
deletedAt?: Date;
|
deletedAt?: Date;
|
||||||
creator: IUser;
|
creator: IUser;
|
||||||
resolvedBy?: IUser;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ICommentData {
|
export interface ICommentData {
|
||||||
@@ -29,7 +28,6 @@ export interface ICommentData {
|
|||||||
|
|
||||||
export interface IResolveComment {
|
export interface IResolveComment {
|
||||||
commentId: string;
|
commentId: string;
|
||||||
pageId: string;
|
|
||||||
resolved: boolean;
|
resolved: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
.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,8 +1,9 @@
|
|||||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
import React, { useMemo, useCallback } from "react";
|
import { useMemo } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
|
AspectRatio,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
FocusTrap,
|
FocusTrap,
|
||||||
@@ -13,18 +14,14 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconEdit } from "@tabler/icons-react";
|
import { IconEdit } from "@tabler/icons-react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { useForm } from "@mantine/form";
|
import { useForm, zodResolver } from "@mantine/form";
|
||||||
import { zodResolver } from "mantine-form-zod-resolver";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import {
|
import {
|
||||||
getEmbedProviderById,
|
getEmbedProviderById,
|
||||||
getEmbedUrlAndProvider,
|
getEmbedUrlAndProvider,
|
||||||
sanitizeUrl,
|
|
||||||
} from "@docmost/editor-ext";
|
} from "@docmost/editor-ext";
|
||||||
import { ResizableWrapper } from "../common/resizable-wrapper";
|
|
||||||
import classes from "./embed-view.module.css";
|
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
url: z
|
url: z
|
||||||
@@ -36,7 +33,7 @@ const schema = z.object({
|
|||||||
export default function EmbedView(props: NodeViewProps) {
|
export default function EmbedView(props: NodeViewProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { node, selected, updateAttributes, editor } = props;
|
const { node, selected, updateAttributes, editor } = props;
|
||||||
const { src, provider, height: nodeHeight } = node.attrs;
|
const { src, provider } = node.attrs;
|
||||||
|
|
||||||
const embedUrl = useMemo(() => {
|
const embedUrl = useMemo(() => {
|
||||||
if (src) {
|
if (src) {
|
||||||
@@ -52,13 +49,6 @@ export default function EmbedView(props: NodeViewProps) {
|
|||||||
validate: zodResolver(schema),
|
validate: zodResolver(schema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleResize = useCallback(
|
|
||||||
(newHeight: number) => {
|
|
||||||
updateAttributes({ height: newHeight });
|
|
||||||
},
|
|
||||||
[updateAttributes],
|
|
||||||
);
|
|
||||||
|
|
||||||
async function onSubmit(data: { url: string }) {
|
async function onSubmit(data: { url: string }) {
|
||||||
if (!editor.isEditable) {
|
if (!editor.isEditable) {
|
||||||
return;
|
return;
|
||||||
@@ -67,11 +57,11 @@ export default function EmbedView(props: NodeViewProps) {
|
|||||||
if (provider) {
|
if (provider) {
|
||||||
const embedProvider = getEmbedProviderById(provider);
|
const embedProvider = getEmbedProviderById(provider);
|
||||||
if (embedProvider.id === "iframe") {
|
if (embedProvider.id === "iframe") {
|
||||||
updateAttributes({ src: sanitizeUrl(data.url) });
|
updateAttributes({ src: data.url });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (embedProvider.regex.test(data.url)) {
|
if (embedProvider.regex.test(data.url)) {
|
||||||
updateAttributes({ src: sanitizeUrl(data.url) });
|
updateAttributes({ src: data.url });
|
||||||
} else {
|
} else {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: t("Invalid {{provider}} embed link", {
|
message: t("Invalid {{provider}} embed link", {
|
||||||
@@ -87,25 +77,17 @@ export default function EmbedView(props: NodeViewProps) {
|
|||||||
return (
|
return (
|
||||||
<NodeViewWrapper>
|
<NodeViewWrapper>
|
||||||
{embedUrl ? (
|
{embedUrl ? (
|
||||||
<ResizableWrapper
|
<>
|
||||||
initialHeight={nodeHeight || 480}
|
<AspectRatio ratio={16 / 9}>
|
||||||
minHeight={200}
|
<iframe
|
||||||
maxHeight={1200}
|
src={embedUrl}
|
||||||
onResize={handleResize}
|
allow="encrypted-media"
|
||||||
isEditable={editor.isEditable}
|
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||||
className={clsx(classes.embedWrapper, {
|
allowFullScreen
|
||||||
"ProseMirror-selectednode": selected,
|
frameBorder="0"
|
||||||
})}
|
></iframe>
|
||||||
>
|
</AspectRatio>
|
||||||
<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
|
<Popover
|
||||||
width={300}
|
width={300}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Highlight } from "@tiptap/extension-highlight";
|
|||||||
import { Typography } from "@tiptap/extension-typography";
|
import { Typography } from "@tiptap/extension-typography";
|
||||||
import { TextStyle } from "@tiptap/extension-text-style";
|
import { TextStyle } from "@tiptap/extension-text-style";
|
||||||
import { Color } from "@tiptap/extension-color";
|
import { Color } from "@tiptap/extension-color";
|
||||||
|
import Table from "@tiptap/extension-table";
|
||||||
import SlashCommand from "@/features/editor/extensions/slash-command";
|
import SlashCommand from "@/features/editor/extensions/slash-command";
|
||||||
import { Collaboration } from "@tiptap/extension-collaboration";
|
import { Collaboration } from "@tiptap/extension-collaboration";
|
||||||
import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor";
|
import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor";
|
||||||
@@ -24,7 +25,6 @@ import {
|
|||||||
TableCell,
|
TableCell,
|
||||||
TableRow,
|
TableRow,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
CustomTable,
|
|
||||||
TrailingNode,
|
TrailingNode,
|
||||||
TiptapImage,
|
TiptapImage,
|
||||||
Callout,
|
Callout,
|
||||||
@@ -160,7 +160,7 @@ export const mainExtensions = [
|
|||||||
return ReactNodeViewRenderer(MentionView);
|
return ReactNodeViewRenderer(MentionView);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
CustomTable.configure({
|
Table.configure({
|
||||||
resizable: true,
|
resizable: true,
|
||||||
lastColumnResizable: false,
|
lastColumnResizable: false,
|
||||||
allowTableNodeSelection: true,
|
allowTableNodeSelection: true,
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export default function PageEditor({
|
|||||||
const [isLocalSynced, setLocalSynced] = useState(false);
|
const [isLocalSynced, setLocalSynced] = useState(false);
|
||||||
const [isRemoteSynced, setRemoteSynced] = useState(false);
|
const [isRemoteSynced, setRemoteSynced] = useState(false);
|
||||||
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
||||||
yjsConnectionStatusAtom
|
yjsConnectionStatusAtom,
|
||||||
);
|
);
|
||||||
const menuContainerRef = useRef(null);
|
const menuContainerRef = useRef(null);
|
||||||
const documentName = `page.${pageId}`;
|
const documentName = `page.${pageId}`;
|
||||||
@@ -262,7 +262,7 @@ export default function PageEditor({
|
|||||||
debouncedUpdateContent(editorJson);
|
debouncedUpdateContent(editorJson);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[pageId, editable, remoteProvider]
|
[pageId, editable, remoteProvider],
|
||||||
);
|
);
|
||||||
|
|
||||||
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
|
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
|
||||||
@@ -278,12 +278,7 @@ export default function PageEditor({
|
|||||||
}, 3000);
|
}, 3000);
|
||||||
|
|
||||||
const handleActiveCommentEvent = (event) => {
|
const handleActiveCommentEvent = (event) => {
|
||||||
const { commentId, resolved } = event.detail;
|
const { commentId } = event.detail;
|
||||||
|
|
||||||
if (resolved) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setActiveCommentId(commentId);
|
setActiveCommentId(commentId);
|
||||||
setAsideState({ tab: "comments", isAsideOpen: true });
|
setAsideState({ tab: "comments", isAsideOpen: true });
|
||||||
|
|
||||||
@@ -300,7 +295,7 @@ export default function PageEditor({
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener(
|
document.removeEventListener(
|
||||||
"ACTIVE_COMMENT_EVENT",
|
"ACTIVE_COMMENT_EVENT",
|
||||||
handleActiveCommentEvent
|
handleActiveCommentEvent,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -142,11 +142,6 @@
|
|||||||
.comment-mark {
|
.comment-mark {
|
||||||
background: rgba(255, 215, 0, 0.14);
|
background: rgba(255, 215, 0, 0.14);
|
||||||
border-bottom: 2px solid rgb(166, 158, 12);
|
border-bottom: 2px solid rgb(166, 158, 12);
|
||||||
|
|
||||||
&.resolved {
|
|
||||||
background: none;
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-highlight {
|
.comment-highlight {
|
||||||
@@ -192,7 +187,7 @@
|
|||||||
mask-size: 100% 100%;
|
mask-size: 100% 100%;
|
||||||
background-color: currentColor;
|
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");
|
--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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Modal, Button, Group, Text } from "@mantine/core";
|
import { Modal, Button, Group, Text } from "@mantine/core";
|
||||||
import { duplicatePage } from "@/features/page/services/page-service.ts";
|
import { copyPageToSpace } from "@/features/page/services/page-service.ts";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -30,7 +30,7 @@ export default function CopyPageModal({
|
|||||||
if (!targetSpace) return;
|
if (!targetSpace) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const copiedPage = await duplicatePage({
|
const copiedPage = await copyPageToSpace({
|
||||||
pageId,
|
pageId,
|
||||||
spaceId: targetSpace.id,
|
spaceId: targetSpace.id,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
leftSection={<IconTrash size={16} />}
|
leftSection={<IconTrash size={16} />}
|
||||||
onClick={handleDeletePage}
|
onClick={handleDeletePage}
|
||||||
>
|
>
|
||||||
{t("Move to trash")}
|
{t("Delete")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { Modal, Button, Group, Text, Select, Switch } from "@mantine/core";
|
||||||
|
import { exportPage } from "@/features/page/services/page-service.ts";
|
||||||
|
import { useState } from "react";
|
||||||
|
import * as React from "react";
|
||||||
|
import { ExportFormat } from "@/features/page/types/page.types.ts";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
interface PageExportModalProps {
|
||||||
|
pageId: string;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PageExportModal({
|
||||||
|
pageId,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: PageExportModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
try {
|
||||||
|
await exportPage({ pageId: pageId, format });
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
notifications.show({
|
||||||
|
message: t("Export failed:") + err.response?.data.message,
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
console.error("export error", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (format: ExportFormat) => {
|
||||||
|
setFormat(format);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal.Root
|
||||||
|
opened={open}
|
||||||
|
onClose={onClose}
|
||||||
|
size={500}
|
||||||
|
padding="xl"
|
||||||
|
yOffset="10vh"
|
||||||
|
xOffset={0}
|
||||||
|
mah={400}
|
||||||
|
>
|
||||||
|
<Modal.Overlay />
|
||||||
|
<Modal.Content style={{ overflow: "hidden" }}>
|
||||||
|
<Modal.Header py={0}>
|
||||||
|
<Modal.Title fw={500}>{t("Export page")}</Modal.Title>
|
||||||
|
<Modal.CloseButton />
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<div>
|
||||||
|
<Text size="md">{t("Format")}</Text>
|
||||||
|
</div>
|
||||||
|
<ExportFormatSelection format={format} onChange={handleChange} />
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group justify="space-between" wrap="nowrap" pt="md">
|
||||||
|
<div>
|
||||||
|
<Text size="md">{t("Include subpages")}</Text>
|
||||||
|
</div>
|
||||||
|
<Switch defaultChecked />
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group justify="center" mt="md">
|
||||||
|
<Button onClick={onClose} variant="default">
|
||||||
|
{t("Cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleExport}>{t("Export")}</Button>
|
||||||
|
</Group>
|
||||||
|
</Modal.Body>
|
||||||
|
</Modal.Content>
|
||||||
|
</Modal.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExportFormatSelection {
|
||||||
|
format: ExportFormat;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
function ExportFormatSelection({ format, onChange }: ExportFormatSelection) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
data={[
|
||||||
|
{ value: "markdown", label: "Markdown" },
|
||||||
|
{ value: "html", label: "HTML" },
|
||||||
|
]}
|
||||||
|
defaultValue={format}
|
||||||
|
onChange={onChange}
|
||||||
|
styles={{ wrapper: { maxWidth: 120 } }}
|
||||||
|
comboboxProps={{ width: "120" }}
|
||||||
|
allowDeselect={false}
|
||||||
|
withCheckIcon={false}
|
||||||
|
aria-label={t("Select export format")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -319,7 +319,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
>
|
>
|
||||||
{(props) => (
|
{(props) => (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
label={t("Available in enterprise edition")}
|
label="Available in enterprise edition"
|
||||||
disabled={canUseConfluence}
|
disabled={canUseConfluence}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -4,33 +4,22 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
type UseDeleteModalProps = {
|
type UseDeleteModalProps = {
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
isPermanent?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useDeletePageModal() {
|
export function useDeletePageModal() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const openDeleteModal = ({
|
const openDeleteModal = ({ onConfirm }: UseDeleteModalProps) => {
|
||||||
onConfirm,
|
|
||||||
isPermanent = false,
|
|
||||||
}: UseDeleteModalProps) => {
|
|
||||||
modals.openConfirmModal({
|
modals.openConfirmModal({
|
||||||
title: isPermanent
|
title: t("Are you sure you want to delete this page?"),
|
||||||
? t("Are you sure you want to delete this page?")
|
|
||||||
: t("Move this page to trash?"),
|
|
||||||
children: (
|
children: (
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
{isPermanent
|
{t(
|
||||||
? t(
|
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.",
|
||||||
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.",
|
)}
|
||||||
)
|
|
||||||
: t("Pages in trash will be permanently deleted after 30 days.")}
|
|
||||||
</Text>
|
</Text>
|
||||||
),
|
),
|
||||||
centered: true,
|
centered: true,
|
||||||
labels: {
|
labels: { confirm: t("Delete"), cancel: t("Cancel") },
|
||||||
confirm: isPermanent ? t("Delete") : t("Move to trash"),
|
|
||||||
cancel: t("Cancel"),
|
|
||||||
},
|
|
||||||
confirmProps: { color: "red" },
|
confirmProps: { color: "red" },
|
||||||
onConfirm,
|
onConfirm,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import {
|
|||||||
UseInfiniteQueryResult,
|
UseInfiniteQueryResult,
|
||||||
useMutation,
|
useMutation,
|
||||||
useQuery,
|
useQuery,
|
||||||
|
useQueryClient,
|
||||||
UseQueryResult,
|
UseQueryResult,
|
||||||
keepPreviousData,
|
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
createPage,
|
createPage,
|
||||||
@@ -18,8 +18,6 @@ import {
|
|||||||
getPageBreadcrumbs,
|
getPageBreadcrumbs,
|
||||||
getRecentChanges,
|
getRecentChanges,
|
||||||
getAllSidebarPages,
|
getAllSidebarPages,
|
||||||
getDeletedPages,
|
|
||||||
restorePage,
|
|
||||||
} from "@/features/page/services/page-service";
|
} from "@/features/page/services/page-service";
|
||||||
import {
|
import {
|
||||||
IMovePage,
|
IMovePage,
|
||||||
@@ -28,17 +26,12 @@ import {
|
|||||||
SidebarPagesParams,
|
SidebarPagesParams,
|
||||||
} from "@/features/page/types/page.types";
|
} from "@/features/page/types/page.types";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
import { IPagination } from "@/lib/types.ts";
|
||||||
import { queryClient } from "@/main.tsx";
|
import { queryClient } from "@/main.tsx";
|
||||||
import { buildTree } from "@/features/page/tree/utils";
|
import { buildTree } from "@/features/page/tree/utils";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { validate as isValidUuid } from "uuid";
|
import { validate as isValidUuid } from "uuid";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
|
|
||||||
import { SimpleTree } from "react-arborist";
|
|
||||||
import { SpaceTreeNode } from "@/features/page/tree/types";
|
|
||||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
|
||||||
|
|
||||||
export function usePageQuery(
|
export function usePageQuery(
|
||||||
pageInput: Partial<IPageInput>,
|
pageInput: Partial<IPageInput>,
|
||||||
@@ -77,7 +70,10 @@ export function useCreatePageMutation() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function updatePageData(data: IPage) {
|
export function updatePageData(data: IPage) {
|
||||||
const pageBySlug = queryClient.getQueryData<IPage>(["pages", data.slugId]);
|
const pageBySlug = queryClient.getQueryData<IPage>([
|
||||||
|
"pages",
|
||||||
|
data.slugId,
|
||||||
|
]);
|
||||||
const pageById = queryClient.getQueryData<IPage>(["pages", data.id]);
|
const pageById = queryClient.getQueryData<IPage>(["pages", data.id]);
|
||||||
|
|
||||||
if (pageBySlug) {
|
if (pageBySlug) {
|
||||||
@@ -91,13 +87,7 @@ export function updatePageData(data: IPage) {
|
|||||||
queryClient.setQueryData(["pages", data.id], { ...pageById, ...data });
|
queryClient.setQueryData(["pages", data.id], { ...pageById, ...data });
|
||||||
}
|
}
|
||||||
|
|
||||||
invalidateOnUpdatePage(
|
invalidateOnUpdatePage(data.spaceId, data.parentPageId, data.id, data.title, data.icon);
|
||||||
data.spaceId,
|
|
||||||
data.parentPageId,
|
|
||||||
data.id,
|
|
||||||
data.title,
|
|
||||||
data.icon,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUpdateTitlePageMutation() {
|
export function useUpdateTitlePageMutation() {
|
||||||
@@ -112,30 +102,7 @@ export function useUpdatePageMutation() {
|
|||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
updatePage(data);
|
updatePage(data);
|
||||||
|
|
||||||
invalidateOnUpdatePage(
|
invalidateOnUpdatePage(data.spaceId, data.parentPageId, data.id, data.title, data.icon);
|
||||||
data.spaceId,
|
|
||||||
data.parentPageId,
|
|
||||||
data.id,
|
|
||||||
data.title,
|
|
||||||
data.icon,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRemovePageMutation() {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (pageId: string) => deletePage(pageId, false),
|
|
||||||
onSuccess: (_, pageId) => {
|
|
||||||
notifications.show({ message: "Page moved to trash" });
|
|
||||||
invalidateOnDeletePage(pageId);
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
predicate: (item) =>
|
|
||||||
["trash-list"].includes(item.queryKey[0] as string),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
notifications.show({ message: "Failed to delete page", color: "red" });
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -143,16 +110,10 @@ export function useRemovePageMutation() {
|
|||||||
export function useDeletePageMutation() {
|
export function useDeletePageMutation() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (pageId: string) => deletePage(pageId, true),
|
mutationFn: (pageId: string) => deletePage(pageId),
|
||||||
onSuccess: (data, pageId) => {
|
onSuccess: (data, pageId) => {
|
||||||
notifications.show({ message: t("Page deleted successfully") });
|
notifications.show({ message: t("Page deleted successfully") });
|
||||||
invalidateOnDeletePage(pageId);
|
invalidateOnDeletePage(pageId);
|
||||||
|
|
||||||
// Invalidate to refresh trash lists
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
predicate: (item) =>
|
|
||||||
["trash-list"].includes(item.queryKey[0] as string),
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
notifications.show({ message: t("Failed to delete page"), color: "red" });
|
notifications.show({ message: t("Failed to delete page"), color: "red" });
|
||||||
@@ -169,87 +130,7 @@ export function useMovePageMutation() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useRestorePageMutation() {
|
export function useGetSidebarPagesQuery(data: SidebarPagesParams|null): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> {
|
||||||
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
|
||||||
const emit = useQueryEmit();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (pageId: string) => restorePage(pageId),
|
|
||||||
onSuccess: async (restoredPage) => {
|
|
||||||
notifications.show({ message: "Page restored successfully" });
|
|
||||||
|
|
||||||
// Add the restored page back to the tree
|
|
||||||
const treeApi = new SimpleTree<SpaceTreeNode>(treeData);
|
|
||||||
|
|
||||||
// Check if the page already exists in the tree (it shouldn't)
|
|
||||||
if (!treeApi.find(restoredPage.id)) {
|
|
||||||
// Create the tree node data with hasChildren from backend
|
|
||||||
const nodeData: SpaceTreeNode = {
|
|
||||||
id: restoredPage.id,
|
|
||||||
slugId: restoredPage.slugId,
|
|
||||||
name: restoredPage.title || "Untitled",
|
|
||||||
icon: restoredPage.icon,
|
|
||||||
position: restoredPage.position,
|
|
||||||
spaceId: restoredPage.spaceId,
|
|
||||||
parentPageId: restoredPage.parentPageId,
|
|
||||||
hasChildren: restoredPage.hasChildren || false,
|
|
||||||
children: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Determine the parent and index
|
|
||||||
const parentId = restoredPage.parentPageId || null;
|
|
||||||
let index = 0;
|
|
||||||
|
|
||||||
if (parentId) {
|
|
||||||
const parentNode = treeApi.find(parentId);
|
|
||||||
if (parentNode) {
|
|
||||||
index = parentNode.children?.length || 0;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Root level page
|
|
||||||
index = treeApi.data.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the node to the tree
|
|
||||||
treeApi.create({
|
|
||||||
parentId,
|
|
||||||
index,
|
|
||||||
data: nodeData,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update the tree data
|
|
||||||
setTreeData(treeApi.data);
|
|
||||||
|
|
||||||
// Emit websocket event to sync with other users
|
|
||||||
setTimeout(() => {
|
|
||||||
emit({
|
|
||||||
operation: "addTreeNode",
|
|
||||||
spaceId: restoredPage.spaceId,
|
|
||||||
payload: {
|
|
||||||
parentId,
|
|
||||||
index,
|
|
||||||
data: nodeData,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, 50);
|
|
||||||
}
|
|
||||||
|
|
||||||
// await queryClient.invalidateQueries({ queryKey: ["sidebar-pages", restoredPage.spaceId] });
|
|
||||||
|
|
||||||
// Also invalidate deleted pages query to refresh the trash list
|
|
||||||
await queryClient.invalidateQueries({
|
|
||||||
queryKey: ["trash-list", restoredPage.spaceId],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
notifications.show({ message: "Failed to restore page", color: "red" });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useGetSidebarPagesQuery(
|
|
||||||
data: SidebarPagesParams | null,
|
|
||||||
): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> {
|
|
||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: ["sidebar-pages", data],
|
queryKey: ["sidebar-pages", data],
|
||||||
queryFn: ({ pageParam }) => getSidebarPages({ ...data, page: pageParam }),
|
queryFn: ({ pageParam }) => getSidebarPages({ ...data, page: pageParam }),
|
||||||
@@ -307,20 +188,6 @@ export function useRecentChangesQuery(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDeletedPagesQuery(
|
|
||||||
spaceId: string,
|
|
||||||
params?: QueryParams,
|
|
||||||
): UseQueryResult<IPagination<IPage>, Error> {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["trash-list", spaceId, params],
|
|
||||||
queryFn: () => getDeletedPages(spaceId, params),
|
|
||||||
enabled: !!spaceId,
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
refetchOnMount: true,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function invalidateOnCreatePage(data: Partial<IPage>) {
|
export function invalidateOnCreatePage(data: Partial<IPage>) {
|
||||||
const newPage: Partial<IPage> = {
|
const newPage: Partial<IPage> = {
|
||||||
creatorId: data.creatorId,
|
creatorId: data.creatorId,
|
||||||
@@ -335,40 +202,34 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let queryKey: QueryKey = null;
|
let queryKey: QueryKey = null;
|
||||||
if (data.parentPageId === null) {
|
if (data.parentPageId===null) {
|
||||||
queryKey = ["root-sidebar-pages", data.spaceId];
|
queryKey = ['root-sidebar-pages', data.spaceId];
|
||||||
} else {
|
}else{
|
||||||
queryKey = [
|
queryKey = ['sidebar-pages', {pageId: data.parentPageId, spaceId: data.spaceId}]
|
||||||
"sidebar-pages",
|
|
||||||
{ pageId: data.parentPageId, spaceId: data.spaceId },
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//update all sidebar pages
|
//update all sidebar pages
|
||||||
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(
|
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(queryKey, (old) => {
|
||||||
queryKey,
|
if (!old) return old;
|
||||||
(old) => {
|
return {
|
||||||
if (!old) return old;
|
...old,
|
||||||
return {
|
pages: old.pages.map((page,index) => {
|
||||||
...old,
|
if (index === old.pages.length - 1) {
|
||||||
pages: old.pages.map((page, index) => {
|
return {
|
||||||
if (index === old.pages.length - 1) {
|
...page,
|
||||||
return {
|
items: [...page.items, newPage],
|
||||||
...page,
|
};
|
||||||
items: [...page.items, newPage],
|
}
|
||||||
};
|
return page;
|
||||||
}
|
}),
|
||||||
return page;
|
};
|
||||||
}),
|
});
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
//update sidebar haschildren
|
//update sidebar haschildren
|
||||||
if (data.parentPageId !== null) {
|
if (data.parentPageId!==null){
|
||||||
//update sub sidebar pages haschildern
|
//update sub sidebar pages haschildern
|
||||||
const subSideBarMatches = queryClient.getQueriesData({
|
const subSideBarMatches = queryClient.getQueriesData({
|
||||||
queryKey: ["sidebar-pages"],
|
queryKey: ['sidebar-pages'],
|
||||||
exact: false,
|
exact: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -380,10 +241,8 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
|||||||
pages: old.pages.map((page) => ({
|
pages: old.pages.map((page) => ({
|
||||||
...page,
|
...page,
|
||||||
items: page.items.map((sidebarPage: IPage) =>
|
items: page.items.map((sidebarPage: IPage) =>
|
||||||
sidebarPage.id === data.parentPageId
|
sidebarPage.id === data.parentPageId ? { ...sidebarPage, hasChildren: true } : sidebarPage
|
||||||
? { ...sidebarPage, hasChildren: true }
|
)
|
||||||
: sidebarPage,
|
|
||||||
),
|
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -391,7 +250,7 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
|||||||
|
|
||||||
//update root sidebar pages haschildern
|
//update root sidebar pages haschildern
|
||||||
const rootSideBarMatches = queryClient.getQueriesData({
|
const rootSideBarMatches = queryClient.getQueriesData({
|
||||||
queryKey: ["root-sidebar-pages", data.spaceId],
|
queryKey: ['root-sidebar-pages', data.spaceId],
|
||||||
exact: false,
|
exact: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -403,10 +262,8 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
|||||||
pages: old.pages.map((page) => ({
|
pages: old.pages.map((page) => ({
|
||||||
...page,
|
...page,
|
||||||
items: page.items.map((sidebarPage: IPage) =>
|
items: page.items.map((sidebarPage: IPage) =>
|
||||||
sidebarPage.id === data.parentPageId
|
sidebarPage.id === data.parentPageId ? { ...sidebarPage, hasChildren: true } : sidebarPage
|
||||||
? { ...sidebarPage, hasChildren: true }
|
)
|
||||||
: sidebarPage,
|
|
||||||
),
|
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -419,37 +276,26 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function invalidateOnUpdatePage(
|
export function invalidateOnUpdatePage(spaceId: string, parentPageId: string, id: string, title: string, icon: string) {
|
||||||
spaceId: string,
|
|
||||||
parentPageId: string,
|
|
||||||
id: string,
|
|
||||||
title: string,
|
|
||||||
icon: string,
|
|
||||||
) {
|
|
||||||
let queryKey: QueryKey = null;
|
let queryKey: QueryKey = null;
|
||||||
if (parentPageId === null) {
|
if(parentPageId===null){
|
||||||
queryKey = ["root-sidebar-pages", spaceId];
|
queryKey = ['root-sidebar-pages', spaceId];
|
||||||
} else {
|
}else{
|
||||||
queryKey = ["sidebar-pages", { pageId: parentPageId, spaceId: spaceId }];
|
queryKey = ['sidebar-pages', {pageId: parentPageId, spaceId: spaceId}]
|
||||||
}
|
}
|
||||||
//update all sidebar pages
|
//update all sidebar pages
|
||||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
|
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(queryKey, (old) => {
|
||||||
queryKey,
|
if (!old) return old;
|
||||||
(old) => {
|
return {
|
||||||
if (!old) return old;
|
...old,
|
||||||
return {
|
pages: old.pages.map((page) => ({
|
||||||
...old,
|
...page,
|
||||||
pages: old.pages.map((page) => ({
|
items: page.items.map((sidebarPage: IPage) =>
|
||||||
...page,
|
sidebarPage.id === id ? { ...sidebarPage, title: title, icon: icon } : sidebarPage
|
||||||
items: page.items.map((sidebarPage: IPage) =>
|
)
|
||||||
sidebarPage.id === id
|
})),
|
||||||
? { ...sidebarPage, title: title, icon: icon }
|
};
|
||||||
: sidebarPage,
|
});
|
||||||
),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
//update recent changes
|
//update recent changes
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
@@ -465,7 +311,7 @@ export function invalidateOnMovePage() {
|
|||||||
});
|
});
|
||||||
//invalidate all sub sidebar pages
|
//invalidate all sub sidebar pages
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["sidebar-pages"],
|
queryKey: ['sidebar-pages'],
|
||||||
});
|
});
|
||||||
// ---
|
// ---
|
||||||
}
|
}
|
||||||
@@ -474,8 +320,7 @@ export function invalidateOnDeletePage(pageId: string) {
|
|||||||
//update all sidebar pages
|
//update all sidebar pages
|
||||||
const allSideBarMatches = queryClient.getQueriesData({
|
const allSideBarMatches = queryClient.getQueriesData({
|
||||||
predicate: (query) =>
|
predicate: (query) =>
|
||||||
query.queryKey[0] === "root-sidebar-pages" ||
|
query.queryKey[0] === 'root-sidebar-pages' || query.queryKey[0] === 'sidebar-pages',
|
||||||
query.queryKey[0] === "sidebar-pages",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
allSideBarMatches.forEach(([key, d]) => {
|
allSideBarMatches.forEach(([key, d]) => {
|
||||||
@@ -485,9 +330,7 @@ export function invalidateOnDeletePage(pageId: string) {
|
|||||||
...old,
|
...old,
|
||||||
pages: old.pages.map((page) => ({
|
pages: old.pages.map((page) => ({
|
||||||
...page,
|
...page,
|
||||||
items: page.items.filter(
|
items: page.items.filter((sidebarPage: IPage) => sidebarPage.id !== pageId),
|
||||||
(sidebarPage: IPage) => sidebarPage.id !== pageId,
|
|
||||||
),
|
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
IPageInput,
|
IPageInput,
|
||||||
SidebarPagesParams,
|
SidebarPagesParams,
|
||||||
} from '@/features/page/types/page.types';
|
} from '@/features/page/types/page.types';
|
||||||
import { QueryParams } from "@/lib/types";
|
|
||||||
import { IAttachment, IPagination } from "@/lib/types.ts";
|
import { IAttachment, IPagination } from "@/lib/types.ts";
|
||||||
import { saveAs } from "file-saver";
|
import { saveAs } from "file-saver";
|
||||||
import { InfiniteData } from "@tanstack/react-query";
|
import { InfiniteData } from "@tanstack/react-query";
|
||||||
@@ -31,21 +30,8 @@ export async function updatePage(data: Partial<IPageInput>): Promise<IPage> {
|
|||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deletePage(pageId: string, permanentlyDelete = false): Promise<void> {
|
export async function deletePage(pageId: string): Promise<void> {
|
||||||
await api.post("/pages/delete", { pageId, permanentlyDelete });
|
await api.post("/pages/delete", { pageId });
|
||||||
}
|
|
||||||
|
|
||||||
export async function getDeletedPages(
|
|
||||||
spaceId: string,
|
|
||||||
params?: QueryParams,
|
|
||||||
): Promise<IPagination<IPage>> {
|
|
||||||
const req = await api.post("/pages/trash", { spaceId, ...params });
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function restorePage(pageId: string): Promise<IPage> {
|
|
||||||
const response = await api.post<IPage>("/pages/restore", { pageId });
|
|
||||||
return response.data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function movePage(data: IMovePage): Promise<void> {
|
export async function movePage(data: IMovePage): Promise<void> {
|
||||||
@@ -56,8 +42,8 @@ export async function movePageToSpace(data: IMovePageToSpace): Promise<void> {
|
|||||||
await api.post<void>("/pages/move-to-space", data);
|
await api.post<void>("/pages/move-to-space", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function duplicatePage(data: ICopyPageToSpace): Promise<IPage> {
|
export async function copyPageToSpace(data: ICopyPageToSpace): Promise<IPage> {
|
||||||
const req = await api.post<IPage>("/pages/duplicate", data);
|
const req = await api.post<IPage>("/pages/copy-to-space", data);
|
||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
import { Modal, Text, ScrollArea } from "@mantine/core";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import ReadonlyPageEditor from "@/features/editor/readonly-page-editor.tsx";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
opened: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
pageTitle: string;
|
|
||||||
pageContent: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TrashPageContentModal({
|
|
||||||
opened,
|
|
||||||
onClose,
|
|
||||||
pageTitle,
|
|
||||||
pageContent,
|
|
||||||
}: Props) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const title = pageTitle || t("Untitled");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal.Root size={1200} opened={opened} onClose={onClose}>
|
|
||||||
<Modal.Overlay />
|
|
||||||
<Modal.Content style={{ overflow: "hidden" }}>
|
|
||||||
<Modal.Header>
|
|
||||||
<Modal.Title>
|
|
||||||
<Text size="md" fw={500}>
|
|
||||||
{t("Preview")}
|
|
||||||
</Text>
|
|
||||||
</Modal.Title>
|
|
||||||
<Modal.CloseButton />
|
|
||||||
</Modal.Header>
|
|
||||||
<Modal.Body p={0}>
|
|
||||||
<ScrollArea h="650" w="100%" scrollbarSize={5}>
|
|
||||||
<ReadonlyPageEditor title={title} content={pageContent} />
|
|
||||||
</ScrollArea>
|
|
||||||
</Modal.Body>
|
|
||||||
</Modal.Content>
|
|
||||||
</Modal.Root>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
import { useParams } from "react-router-dom";
|
|
||||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query";
|
|
||||||
import {
|
|
||||||
Container,
|
|
||||||
Title,
|
|
||||||
Table,
|
|
||||||
Group,
|
|
||||||
ActionIcon,
|
|
||||||
Text,
|
|
||||||
Alert,
|
|
||||||
Stack,
|
|
||||||
Menu,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import {
|
|
||||||
IconInfoCircle,
|
|
||||||
IconDots,
|
|
||||||
IconRestore,
|
|
||||||
IconTrash,
|
|
||||||
IconFileDescription,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import {
|
|
||||||
useDeletedPagesQuery,
|
|
||||||
useRestorePageMutation,
|
|
||||||
useDeletePageMutation,
|
|
||||||
} from "@/features/page/queries/page-query";
|
|
||||||
import { modals } from "@mantine/modals";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { formattedDate } from "@/lib/time";
|
|
||||||
import { useState } from "react";
|
|
||||||
import TrashPageContentModal from "@/features/page/trash/components/trash-page-content-modal";
|
|
||||||
import { UserInfo } from "@/components/common/user-info.tsx";
|
|
||||||
import Paginate from "@/components/common/paginate.tsx";
|
|
||||||
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
|
|
||||||
|
|
||||||
export default function Trash() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { spaceSlug } = useParams();
|
|
||||||
const { page, setPage } = usePaginateAndSearch();
|
|
||||||
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
|
||||||
const { data: deletedPages, isLoading } = useDeletedPagesQuery(space?.id, {
|
|
||||||
page, limit: 50
|
|
||||||
});
|
|
||||||
const restorePageMutation = useRestorePageMutation();
|
|
||||||
const deletePageMutation = useDeletePageMutation();
|
|
||||||
|
|
||||||
const [selectedPage, setSelectedPage] = useState<{
|
|
||||||
title: string;
|
|
||||||
content: any;
|
|
||||||
} | null>(null);
|
|
||||||
const [modalOpened, setModalOpened] = useState(false);
|
|
||||||
|
|
||||||
const handleRestorePage = async (pageId: string) => {
|
|
||||||
await restorePageMutation.mutateAsync(pageId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeletePage = async (pageId: string) => {
|
|
||||||
await deletePageMutation.mutateAsync(pageId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openDeleteModal = (pageId: string, pageTitle: string) => {
|
|
||||||
modals.openConfirmModal({
|
|
||||||
title: t("Are you sure you want to delete this page?"),
|
|
||||||
children: (
|
|
||||||
<Text size="sm">
|
|
||||||
{t(
|
|
||||||
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.",
|
|
||||||
{ title: pageTitle || "Untitled" },
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
centered: true,
|
|
||||||
labels: { confirm: t("Delete"), cancel: t("Cancel") },
|
|
||||||
confirmProps: { color: "red" },
|
|
||||||
onConfirm: () => handleDeletePage(pageId),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const openRestoreModal = (pageId: string, pageTitle: string) => {
|
|
||||||
modals.openConfirmModal({
|
|
||||||
title: t("Restore page"),
|
|
||||||
children: (
|
|
||||||
<Text size="sm">
|
|
||||||
{t("Restore '{{title}}' and its sub-pages?", {
|
|
||||||
title: pageTitle || "Untitled",
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
centered: true,
|
|
||||||
labels: { confirm: t("Restore"), cancel: t("Cancel") },
|
|
||||||
confirmProps: { color: "blue" },
|
|
||||||
onConfirm: () => handleRestorePage(pageId),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasPages = deletedPages && deletedPages.items.length > 0;
|
|
||||||
|
|
||||||
const handlePageClick = (page: any) => {
|
|
||||||
setSelectedPage({ title: page.title, content: page.content });
|
|
||||||
setModalOpened(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container size="lg" py="lg">
|
|
||||||
<Stack gap="md">
|
|
||||||
<Group justify="space-between" mb="md">
|
|
||||||
<Title order={2}>{t("Trash")}</Title>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Alert icon={<IconInfoCircle size={16} />} variant="light" color="red">
|
|
||||||
<Text size="sm">
|
|
||||||
{t("Pages in trash will be permanently deleted after 30 days.")}
|
|
||||||
</Text>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
{isLoading || !deletedPages ? (
|
|
||||||
<></>
|
|
||||||
) : hasPages ? (
|
|
||||||
<Table.ScrollContainer minWidth={500}>
|
|
||||||
<Table highlightOnHover verticalSpacing="sm">
|
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th>{t("Page")}</Table.Th>
|
|
||||||
<Table.Th style={{ whiteSpace: "nowrap" }}>
|
|
||||||
{t("Deleted by")}
|
|
||||||
</Table.Th>
|
|
||||||
<Table.Th style={{ whiteSpace: "nowrap" }}>
|
|
||||||
{t("Deleted at")}
|
|
||||||
</Table.Th>
|
|
||||||
<Table.Th></Table.Th>
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
<Table.Tbody>
|
|
||||||
{deletedPages.items.map((page) => (
|
|
||||||
<Table.Tr key={page.id}>
|
|
||||||
<Table.Td>
|
|
||||||
<Group
|
|
||||||
wrap="nowrap"
|
|
||||||
style={{ cursor: "pointer" }}
|
|
||||||
onClick={() => handlePageClick(page)}
|
|
||||||
>
|
|
||||||
{page.icon || (
|
|
||||||
<ActionIcon
|
|
||||||
variant="transparent"
|
|
||||||
color="gray"
|
|
||||||
size={18}
|
|
||||||
>
|
|
||||||
<IconFileDescription size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<Text fw={500} size="sm" lineClamp={1}>
|
|
||||||
{page.title || t("Untitled")}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<UserInfo user={page.deletedBy} size="sm" />
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text
|
|
||||||
c="dimmed"
|
|
||||||
style={{ whiteSpace: "nowrap" }}
|
|
||||||
size="xs"
|
|
||||||
fw={500}
|
|
||||||
>
|
|
||||||
{formattedDate(page.deletedAt)}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Menu>
|
|
||||||
<Menu.Target>
|
|
||||||
<ActionIcon variant="subtle" color="gray">
|
|
||||||
<IconDots size={20} stroke={1.5} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Menu.Target>
|
|
||||||
<Menu.Dropdown>
|
|
||||||
<Menu.Item
|
|
||||||
leftSection={<IconRestore size={16} />}
|
|
||||||
onClick={() =>
|
|
||||||
openRestoreModal(page.id, page.title)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("Restore")}
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Item
|
|
||||||
color="red"
|
|
||||||
leftSection={<IconTrash size={16} />}
|
|
||||||
onClick={() => openDeleteModal(page.id, page.title)}
|
|
||||||
>
|
|
||||||
{t("Delete")}
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu.Dropdown>
|
|
||||||
</Menu>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</Table.ScrollContainer>
|
|
||||||
) : (
|
|
||||||
<Text ta="center" py="xl" c="dimmed">
|
|
||||||
{t("No pages in trash")}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{deletedPages && deletedPages.items.length > 0 && (
|
|
||||||
<Paginate
|
|
||||||
currentPage={page}
|
|
||||||
hasPrevPage={deletedPages.meta.hasPrevPage}
|
|
||||||
hasNextPage={deletedPages.meta.hasNextPage}
|
|
||||||
onPageChange={setPage}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{selectedPage && (
|
|
||||||
<TrashPageContentModal
|
|
||||||
opened={modalOpened}
|
|
||||||
onClose={() => setModalOpened(false)}
|
|
||||||
pageTitle={selectedPage.title}
|
|
||||||
pageContent={selectedPage.content}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,4 @@
|
|||||||
import {
|
import { NodeApi, NodeRendererProps, Tree, TreeApi } from "react-arborist";
|
||||||
NodeApi,
|
|
||||||
NodeRendererProps,
|
|
||||||
Tree,
|
|
||||||
TreeApi,
|
|
||||||
SimpleTree,
|
|
||||||
} from "react-arborist";
|
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
||||||
import {
|
import {
|
||||||
@@ -72,7 +66,6 @@ import MovePageModal from "../../components/move-page-modal.tsx";
|
|||||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||||
import CopyPageModal from "../../components/copy-page-modal.tsx";
|
import CopyPageModal from "../../components/copy-page-modal.tsx";
|
||||||
import { duplicatePage } from "../../services/page-service.ts";
|
|
||||||
|
|
||||||
interface SpaceTreeProps {
|
interface SpaceTreeProps {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
@@ -97,14 +90,8 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
|||||||
const treeApiRef = useRef<TreeApi<SpaceTreeNode>>();
|
const treeApiRef = useRef<TreeApi<SpaceTreeNode>>();
|
||||||
const [openTreeNodes, setOpenTreeNodes] = useAtom<OpenMap>(openTreeNodesAtom);
|
const [openTreeNodes, setOpenTreeNodes] = useAtom<OpenMap>(openTreeNodesAtom);
|
||||||
const rootElement = useRef<HTMLDivElement>();
|
const rootElement = useRef<HTMLDivElement>();
|
||||||
const [isRootReady, setIsRootReady] = useState(false);
|
|
||||||
const { ref: sizeRef, width, height } = useElementSize();
|
const { ref: sizeRef, width, height } = useElementSize();
|
||||||
const mergedRef = useMergedRef((element) => {
|
const mergedRef = useMergedRef(rootElement, sizeRef);
|
||||||
rootElement.current = element;
|
|
||||||
if (element && !isRootReady) {
|
|
||||||
setIsRootReady(true);
|
|
||||||
}
|
|
||||||
}, sizeRef);
|
|
||||||
const [isDataLoaded, setIsDataLoaded] = useState(false);
|
const [isDataLoaded, setIsDataLoaded] = useState(false);
|
||||||
const { data: currentPage } = usePageQuery({
|
const { data: currentPage } = usePageQuery({
|
||||||
pageId: extractPageSlugId(pageSlug),
|
pageId: extractPageSlugId(pageSlug),
|
||||||
@@ -212,17 +199,16 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
|||||||
}
|
}
|
||||||
}, [currentPage?.id]);
|
}, [currentPage?.id]);
|
||||||
|
|
||||||
// Clean up tree API on unmount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
if (treeApiRef.current) {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
setTreeApi(null);
|
setTreeApi(treeApiRef.current);
|
||||||
};
|
}
|
||||||
}, [setTreeApi]);
|
}, [treeApiRef.current]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={mergedRef} className={classes.treeContainer}>
|
<div ref={mergedRef} className={classes.treeContainer}>
|
||||||
{isRootReady && rootElement.current && (
|
{rootElement.current && (
|
||||||
<Tree
|
<Tree
|
||||||
data={data.filter((node) => node?.spaceId === spaceId)}
|
data={data.filter((node) => node?.spaceId === spaceId)}
|
||||||
disableDrag={readOnly}
|
disableDrag={readOnly}
|
||||||
@@ -231,13 +217,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
|||||||
{...controllers}
|
{...controllers}
|
||||||
width={width}
|
width={width}
|
||||||
height={rootElement.current.clientHeight}
|
height={rootElement.current.clientHeight}
|
||||||
ref={(ref) => {
|
ref={treeApiRef}
|
||||||
treeApiRef.current = ref;
|
|
||||||
if (ref) {
|
|
||||||
//@ts-ignore
|
|
||||||
setTreeApi(ref);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
openByDefault={false}
|
openByDefault={false}
|
||||||
disableMultiSelection={true}
|
disableMultiSelection={true}
|
||||||
className={classes.tree}
|
className={classes.tree}
|
||||||
@@ -403,7 +383,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
|||||||
<span className={classes.text}>{node.data.name || t("untitled")}</span>
|
<span className={classes.text}>{node.data.name || t("untitled")}</span>
|
||||||
|
|
||||||
<div className={classes.actions}>
|
<div className={classes.actions}>
|
||||||
<NodeMenu node={node} treeApi={tree} spaceId={node.data.spaceId} />
|
<NodeMenu node={node} treeApi={tree} />
|
||||||
|
|
||||||
{!tree.props.disableEdit && (
|
{!tree.props.disableEdit && (
|
||||||
<CreateNode
|
<CreateNode
|
||||||
@@ -456,16 +436,13 @@ function CreateNode({ node, treeApi, onExpandTree }: CreateNodeProps) {
|
|||||||
interface NodeMenuProps {
|
interface NodeMenuProps {
|
||||||
node: NodeApi<SpaceTreeNode>;
|
node: NodeApi<SpaceTreeNode>;
|
||||||
treeApi: TreeApi<SpaceTreeNode>;
|
treeApi: TreeApi<SpaceTreeNode>;
|
||||||
spaceId: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
|
function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const clipboard = useClipboard({ timeout: 500 });
|
const clipboard = useClipboard({ timeout: 500 });
|
||||||
const { spaceSlug } = useParams();
|
const { spaceSlug } = useParams();
|
||||||
const { openDeleteModal } = useDeletePageModal();
|
const { openDeleteModal } = useDeletePageModal();
|
||||||
const [data, setData] = useAtom(treeDataAtom);
|
|
||||||
const emit = useQueryEmit();
|
|
||||||
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||||
useDisclosure(false);
|
useDisclosure(false);
|
||||||
const [
|
const [
|
||||||
@@ -484,68 +461,6 @@ function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
|
|||||||
notifications.show({ message: t("Link copied") });
|
notifications.show({ message: t("Link copied") });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDuplicatePage = async () => {
|
|
||||||
try {
|
|
||||||
const duplicatedPage = await duplicatePage({
|
|
||||||
pageId: node.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Find the index of the current node
|
|
||||||
const parentId =
|
|
||||||
node.parent?.id === "__REACT_ARBORIST_INTERNAL_ROOT__"
|
|
||||||
? null
|
|
||||||
: node.parent?.id;
|
|
||||||
const siblings = parentId ? node.parent.children : treeApi?.props.data;
|
|
||||||
const currentIndex =
|
|
||||||
siblings?.findIndex((sibling) => sibling.id === node.id) || 0;
|
|
||||||
const newIndex = currentIndex + 1;
|
|
||||||
|
|
||||||
// Add the duplicated page to the tree
|
|
||||||
const treeNodeData: SpaceTreeNode = {
|
|
||||||
id: duplicatedPage.id,
|
|
||||||
slugId: duplicatedPage.slugId,
|
|
||||||
name: duplicatedPage.title,
|
|
||||||
position: duplicatedPage.position,
|
|
||||||
spaceId: duplicatedPage.spaceId,
|
|
||||||
parentPageId: duplicatedPage.parentPageId,
|
|
||||||
icon: duplicatedPage.icon,
|
|
||||||
hasChildren: duplicatedPage.hasChildren,
|
|
||||||
children: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update local tree
|
|
||||||
const simpleTree = new SimpleTree(data);
|
|
||||||
simpleTree.create({
|
|
||||||
parentId,
|
|
||||||
index: newIndex,
|
|
||||||
data: treeNodeData,
|
|
||||||
});
|
|
||||||
setData(simpleTree.data);
|
|
||||||
|
|
||||||
// Emit socket event
|
|
||||||
setTimeout(() => {
|
|
||||||
emit({
|
|
||||||
operation: "addTreeNode",
|
|
||||||
spaceId: spaceId,
|
|
||||||
payload: {
|
|
||||||
parentId,
|
|
||||||
index: newIndex,
|
|
||||||
data: treeNodeData,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, 50);
|
|
||||||
|
|
||||||
notifications.show({
|
|
||||||
message: t("Page duplicated successfully"),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
notifications.show({
|
|
||||||
message: err.response?.data.message || "An error occurred",
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Menu shadow="md" width={200}>
|
<Menu shadow="md" width={200}>
|
||||||
@@ -590,17 +505,6 @@ function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
|
|||||||
|
|
||||||
{!(treeApi.props.disableEdit as boolean) && (
|
{!(treeApi.props.disableEdit as boolean) && (
|
||||||
<>
|
<>
|
||||||
<Menu.Item
|
|
||||||
leftSection={<IconCopy size={16} />}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
handleDuplicatePage();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("Duplicate")}
|
|
||||||
</Menu.Item>
|
|
||||||
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<IconArrowRight size={16} />}
|
leftSection={<IconArrowRight size={16} />}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -620,7 +524,7 @@ function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
|
|||||||
openCopyPageModal();
|
openCopyPageModal();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("Copy to space")}
|
{t("Copy")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
@@ -633,7 +537,7 @@ function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
|
|||||||
openDeleteModal({ onConfirm: () => treeApi?.delete(node) });
|
openDeleteModal({ onConfirm: () => treeApi?.delete(node) });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("Move to trash")}
|
{t("Delete")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { IMovePage, IPage } from "@/features/page/types/page.types.ts";
|
|||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
useCreatePageMutation,
|
useCreatePageMutation,
|
||||||
useRemovePageMutation,
|
useDeletePageMutation,
|
||||||
useMovePageMutation,
|
useMovePageMutation,
|
||||||
useUpdatePageMutation,
|
useUpdatePageMutation,
|
||||||
} from "@/features/page/queries/page-query.ts";
|
} from "@/features/page/queries/page-query.ts";
|
||||||
@@ -28,7 +28,7 @@ export function useTreeMutation<T>(spaceId: string) {
|
|||||||
const tree = useMemo(() => new SimpleTree<SpaceTreeNode>(data), [data]);
|
const tree = useMemo(() => new SimpleTree<SpaceTreeNode>(data), [data]);
|
||||||
const createPageMutation = useCreatePageMutation();
|
const createPageMutation = useCreatePageMutation();
|
||||||
const updatePageMutation = useUpdatePageMutation();
|
const updatePageMutation = useUpdatePageMutation();
|
||||||
const removePageMutation = useRemovePageMutation();
|
const deletePageMutation = useDeletePageMutation();
|
||||||
const movePageMutation = useMovePageMutation();
|
const movePageMutation = useMovePageMutation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { spaceSlug } = useParams();
|
const { spaceSlug } = useParams();
|
||||||
@@ -225,7 +225,7 @@ export function useTreeMutation<T>(spaceId: string) {
|
|||||||
|
|
||||||
const onDelete: DeleteHandler<T> = async (args: { ids: string[] }) => {
|
const onDelete: DeleteHandler<T> = async (args: { ids: string[] }) => {
|
||||||
try {
|
try {
|
||||||
await removePageMutation.mutateAsync(args.ids[0]);
|
await deletePageMutation.mutateAsync(args.ids[0]);
|
||||||
|
|
||||||
const node = tree.find(args.ids[0]);
|
const node = tree.find(args.ids[0]);
|
||||||
if (!node) {
|
if (!node) {
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export interface IPage {
|
|||||||
hasChildren: boolean;
|
hasChildren: boolean;
|
||||||
creator: ICreator;
|
creator: ICreator;
|
||||||
lastUpdatedBy: ILastUpdatedBy;
|
lastUpdatedBy: ILastUpdatedBy;
|
||||||
deletedBy: IDeletedBy;
|
|
||||||
space: Partial<ISpace>;
|
space: Partial<ISpace>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,12 +34,6 @@ interface ILastUpdatedBy {
|
|||||||
avatarUrl: string;
|
avatarUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IDeletedBy {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
avatarUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IMovePage {
|
export interface IMovePage {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
position?: string;
|
position?: string;
|
||||||
@@ -56,7 +49,7 @@ export interface IMovePageToSpace {
|
|||||||
|
|
||||||
export interface ICopyPageToSpace {
|
export interface ICopyPageToSpace {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
spaceId?: string;
|
spaceId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SidebarPagesParams {
|
export interface SidebarPagesParams {
|
||||||
@@ -79,7 +72,6 @@ export interface IExportPageParams {
|
|||||||
pageId: string;
|
pageId: string;
|
||||||
format: ExportFormat;
|
format: ExportFormat;
|
||||||
includeChildren?: boolean;
|
includeChildren?: boolean;
|
||||||
includeAttachments?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ExportFormat {
|
export enum ExportFormat {
|
||||||
|
|||||||
@@ -61,26 +61,47 @@ export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) {
|
|||||||
type: "group",
|
type: "group",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Create fresh data structure based on current search results
|
// Function to merge items into groups without duplicates
|
||||||
const newData = [];
|
const mergeItemsIntoGroups = (existingGroups, newItems, groupName) => {
|
||||||
|
const existingValues = new Set(
|
||||||
|
existingGroups.flatMap((group) =>
|
||||||
|
group.items.map((item) => item.value),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const newItemsFiltered = newItems.filter(
|
||||||
|
(item) => !existingValues.has(item.value),
|
||||||
|
);
|
||||||
|
|
||||||
if (userItems && userItems.length > 0) {
|
const updatedGroups = existingGroups.map((group) => {
|
||||||
newData.push({
|
if (group.group === groupName) {
|
||||||
group: t("Select a user"),
|
return { ...group, items: [...group.items, ...newItemsFiltered] };
|
||||||
items: userItems,
|
}
|
||||||
|
return group;
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
if (groupItems && groupItems.length > 0) {
|
// Use spread syntax to avoid mutation
|
||||||
newData.push({
|
return updatedGroups.some((group) => group.group === groupName)
|
||||||
group: t("Select a group"),
|
? updatedGroups
|
||||||
items: groupItems,
|
: [...updatedGroups, { group: groupName, items: newItemsFiltered }];
|
||||||
});
|
};
|
||||||
}
|
|
||||||
|
|
||||||
setData(newData);
|
// Merge user items into groups
|
||||||
|
const updatedUserGroups = mergeItemsIntoGroups(
|
||||||
|
data,
|
||||||
|
userItems,
|
||||||
|
t("Select a user"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Merge group items into groups
|
||||||
|
const finalData = mergeItemsIntoGroups(
|
||||||
|
updatedUserGroups,
|
||||||
|
groupItems,
|
||||||
|
t("Select a group"),
|
||||||
|
);
|
||||||
|
|
||||||
|
setData(finalData);
|
||||||
}
|
}
|
||||||
}, [suggestion, t]);
|
}, [suggestion, data]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
@@ -93,7 +114,6 @@ export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) {
|
|||||||
searchable
|
searchable
|
||||||
searchValue={searchValue}
|
searchValue={searchValue}
|
||||||
onSearchChange={setSearchValue}
|
onSearchChange={setSearchValue}
|
||||||
filter={({ options }) => options}
|
|
||||||
clearable
|
clearable
|
||||||
variant="filled"
|
variant="filled"
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
IconPlus,
|
IconPlus,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconTrash,
|
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import classes from "./space-sidebar.module.css";
|
import classes from "./space-sidebar.module.css";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
@@ -207,7 +206,6 @@ interface SpaceMenuProps {
|
|||||||
}
|
}
|
||||||
function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
|
function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { spaceSlug } = useParams();
|
|
||||||
const [importOpened, { open: openImportModal, close: closeImportModal }] =
|
const [importOpened, { open: openImportModal, close: closeImportModal }] =
|
||||||
useDisclosure(false);
|
useDisclosure(false);
|
||||||
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||||
@@ -255,14 +253,6 @@ function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
|
|||||||
>
|
>
|
||||||
{t("Space settings")}
|
{t("Space settings")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Item
|
|
||||||
component={Link}
|
|
||||||
to={`/s/${spaceSlug}/trash`}
|
|
||||||
leftSection={<IconTrash size={16} />}
|
|
||||||
>
|
|
||||||
{t("Trash")}
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Text, Avatar, SimpleGrid, Card, rem, Group, Button } from "@mantine/core";
|
import { Text, Avatar, SimpleGrid, Card, rem } from "@mantine/core";
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
prefetchSpace,
|
prefetchSpace,
|
||||||
@@ -9,13 +9,12 @@ import { Link } from "react-router-dom";
|
|||||||
import classes from "./space-grid.module.css";
|
import classes from "./space-grid.module.css";
|
||||||
import { formatMemberCount } from "@/lib";
|
import { formatMemberCount } from "@/lib";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { IconArrowRight } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
export default function SpaceGrid() {
|
export default function SpaceGrid() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data, isLoading } = useGetSpacesQuery({ page: 1, limit: 10 });
|
const { data, isLoading } = useGetSpacesQuery({ page: 1 });
|
||||||
|
|
||||||
const cards = data?.items.slice(0, 9).map((space, index) => (
|
const cards = data?.items.map((space, index) => (
|
||||||
<Card
|
<Card
|
||||||
key={space.id}
|
key={space.id}
|
||||||
p="xs"
|
p="xs"
|
||||||
@@ -47,27 +46,11 @@ export default function SpaceGrid() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Group justify="space-between" align="center" mb="md">
|
<Text fz="sm" fw={500} mb={"md"}>
|
||||||
<Text fz="sm" fw={500}>
|
{t("Spaces you belong to")}
|
||||||
{t("Spaces you belong to")}
|
</Text>
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3 }}>{cards}</SimpleGrid>
|
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3 }}>{cards}</SimpleGrid>
|
||||||
|
|
||||||
{data?.items && data.items.length > 9 && (
|
|
||||||
<Group justify="flex-end" mt="lg">
|
|
||||||
<Button
|
|
||||||
component={Link}
|
|
||||||
to="/spaces"
|
|
||||||
variant="subtle"
|
|
||||||
rightSection={<IconArrowRight size={16} />}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{t("View all spaces")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
.spaceLink {
|
|
||||||
text-decoration: none;
|
|
||||||
color: inherit;
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
import {
|
|
||||||
Table,
|
|
||||||
Text,
|
|
||||||
Group,
|
|
||||||
ActionIcon,
|
|
||||||
Box,
|
|
||||||
Space,
|
|
||||||
Menu,
|
|
||||||
Avatar,
|
|
||||||
Anchor,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { IconDots, IconSettings } from "@tabler/icons-react";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
|
||||||
import { formatMemberCount } from "@/lib";
|
|
||||||
import { getSpaceUrl } from "@/lib/config";
|
|
||||||
import { prefetchSpace } from "@/features/space/queries/space-query";
|
|
||||||
import { SearchInput } from "@/components/common/search-input";
|
|
||||||
import Paginate from "@/components/common/paginate";
|
|
||||||
import NoTableResults from "@/components/common/no-table-results";
|
|
||||||
import SpaceSettingsModal from "@/features/space/components/settings-modal";
|
|
||||||
import classes from "./all-spaces-list.module.css";
|
|
||||||
|
|
||||||
interface AllSpacesListProps {
|
|
||||||
spaces: any[];
|
|
||||||
onSearch: (query: string) => void;
|
|
||||||
page: number;
|
|
||||||
hasPrevPage?: boolean;
|
|
||||||
hasNextPage?: boolean;
|
|
||||||
onPageChange: (page: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AllSpacesList({
|
|
||||||
spaces,
|
|
||||||
onSearch,
|
|
||||||
page,
|
|
||||||
hasPrevPage,
|
|
||||||
hasNextPage,
|
|
||||||
onPageChange,
|
|
||||||
}: AllSpacesListProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [settingsOpened, { open: openSettings, close: closeSettings }] =
|
|
||||||
useDisclosure(false);
|
|
||||||
const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleOpenSettings = (spaceId: string) => {
|
|
||||||
setSelectedSpaceId(spaceId);
|
|
||||||
openSettings();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<SearchInput onSearch={onSearch} />
|
|
||||||
|
|
||||||
<Space h="md" />
|
|
||||||
|
|
||||||
<Table.ScrollContainer minWidth={500}>
|
|
||||||
<Table highlightOnHover verticalSpacing="sm">
|
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th>{t("Space")}</Table.Th>
|
|
||||||
<Table.Th>{t("Members")}</Table.Th>
|
|
||||||
<Table.Th w={100}></Table.Th>
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
|
|
||||||
<Table.Tbody>
|
|
||||||
{spaces.length > 0 ? (
|
|
||||||
spaces.map((space) => (
|
|
||||||
<Table.Tr key={space.id}>
|
|
||||||
<Table.Td>
|
|
||||||
<Anchor
|
|
||||||
size="sm"
|
|
||||||
underline="never"
|
|
||||||
style={{
|
|
||||||
cursor: "pointer",
|
|
||||||
color: "var(--mantine-color-text)",
|
|
||||||
}}
|
|
||||||
component={Link}
|
|
||||||
to={getSpaceUrl(space.slug)}
|
|
||||||
>
|
|
||||||
<Group
|
|
||||||
gap="sm"
|
|
||||||
wrap="nowrap"
|
|
||||||
className={classes.spaceLink}
|
|
||||||
onMouseEnter={() => prefetchSpace(space.slug, space.id)}
|
|
||||||
>
|
|
||||||
<Avatar
|
|
||||||
color="initials"
|
|
||||||
variant="filled"
|
|
||||||
name={space.name}
|
|
||||||
size={40}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<Text fz="sm" fw={500} lineClamp={1}>
|
|
||||||
{space.name}
|
|
||||||
</Text>
|
|
||||||
{space.description && (
|
|
||||||
<Text fz="xs" c="dimmed" lineClamp={2}>
|
|
||||||
{space.description}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
</Anchor>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
|
||||||
{formatMemberCount(space.memberCount, t)}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Group gap="xs" justify="flex-end">
|
|
||||||
<Menu position="bottom-end">
|
|
||||||
<Menu.Target>
|
|
||||||
<ActionIcon variant="subtle" color="gray">
|
|
||||||
<IconDots size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Menu.Target>
|
|
||||||
<Menu.Dropdown>
|
|
||||||
<Menu.Item
|
|
||||||
leftSection={<IconSettings size={16} />}
|
|
||||||
onClick={() => handleOpenSettings(space.id)}
|
|
||||||
>
|
|
||||||
{t("Space settings")}
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu.Dropdown>
|
|
||||||
</Menu>
|
|
||||||
</Group>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<NoTableResults colSpan={3} />
|
|
||||||
)}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</Table.ScrollContainer>
|
|
||||||
|
|
||||||
{spaces.length > 0 && (
|
|
||||||
<Paginate
|
|
||||||
currentPage={page}
|
|
||||||
hasPrevPage={hasPrevPage}
|
|
||||||
hasNextPage={hasNextPage}
|
|
||||||
onPageChange={onPageChange}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedSpaceId && (
|
|
||||||
<SpaceSettingsModal
|
|
||||||
spaceId={selectedSpaceId}
|
|
||||||
opened={settingsOpened}
|
|
||||||
onClose={closeSettings}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default as AllSpacesList } from "./all-spaces-list";
|
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { isCloud } from "@/lib/config";
|
||||||
|
import { useLicense } from "@/ee/hooks/use-license";
|
||||||
import { MfaSettings } from "@/ee/mfa";
|
import { MfaSettings } from "@/ee/mfa";
|
||||||
|
|
||||||
export function AccountMfaSection() {
|
export function AccountMfaSection() {
|
||||||
|
const { hasLicenseKey } = useLicense();
|
||||||
|
const showMfa = isCloud() || hasLicenseKey;
|
||||||
|
|
||||||
|
if (!showMfa) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return <MfaSettings />;
|
return <MfaSettings />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,20 +63,6 @@ export type RefetchRootTreeNodeEvent = {
|
|||||||
spaceId: string;
|
spaceId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ResolveCommentEvent = {
|
|
||||||
operation: "resolveComment";
|
|
||||||
pageId: string;
|
|
||||||
commentId: string;
|
|
||||||
resolved: boolean;
|
|
||||||
resolvedAt?: Date;
|
|
||||||
resolvedById?: string;
|
|
||||||
resolvedBy?: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
avatarUrl?: string | null;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WebSocketEvent =
|
export type WebSocketEvent =
|
||||||
| InvalidateEvent
|
| InvalidateEvent
|
||||||
| InvalidateCommentsEvent
|
| InvalidateCommentsEvent
|
||||||
@@ -85,5 +71,4 @@ export type WebSocketEvent =
|
|||||||
| AddTreeNodeEvent
|
| AddTreeNodeEvent
|
||||||
| MoveTreeNodeEvent
|
| MoveTreeNodeEvent
|
||||||
| DeleteTreeNodeEvent
|
| DeleteTreeNodeEvent
|
||||||
| RefetchRootTreeNodeEvent
|
| RefetchRootTreeNodeEvent;
|
||||||
| ResolveCommentEvent;
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
} from "../page/queries/page-query";
|
} from "../page/queries/page-query";
|
||||||
import { RQ_KEY } from "../comment/queries/comment-query";
|
import { RQ_KEY } from "../comment/queries/comment-query";
|
||||||
import { queryClient } from "@/main.tsx";
|
import { queryClient } from "@/main.tsx";
|
||||||
import { IComment } from "@/features/comment/types/comment.types";
|
|
||||||
|
|
||||||
export const useQuerySubscription = () => {
|
export const useQuerySubscription = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -97,30 +96,6 @@ export const useQuerySubscription = () => {
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "resolveComment": {
|
|
||||||
const currentComments = queryClient.getQueryData(
|
|
||||||
RQ_KEY(data.pageId),
|
|
||||||
) as IPagination<IComment>;
|
|
||||||
|
|
||||||
if (currentComments && currentComments.items) {
|
|
||||||
const updatedComments = currentComments.items.map((comment) =>
|
|
||||||
comment.id === data.commentId
|
|
||||||
? {
|
|
||||||
...comment,
|
|
||||||
resolvedAt: data.resolvedAt,
|
|
||||||
resolvedById: data.resolvedById,
|
|
||||||
resolvedBy: data.resolvedBy
|
|
||||||
}
|
|
||||||
: comment,
|
|
||||||
);
|
|
||||||
|
|
||||||
queryClient.setQueryData(RQ_KEY(data.pageId), {
|
|
||||||
...currentComments,
|
|
||||||
items: updatedComments,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [queryClient, socket]);
|
}, [queryClient, socket]);
|
||||||
|
|||||||
+46
-4
@@ -1,23 +1,41 @@
|
|||||||
import { Menu, ActionIcon, Text } from "@mantine/core";
|
import { Menu, ActionIcon, Text } from "@mantine/core";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { IconDots, IconTrash } from "@tabler/icons-react";
|
import { IconDots, IconTrash, IconShieldOff } from "@tabler/icons-react";
|
||||||
import { modals } from "@mantine/modals";
|
import { modals } from "@mantine/modals";
|
||||||
import { useDeleteWorkspaceMemberMutation } from "@/features/workspace/queries/workspace-query.ts";
|
import {
|
||||||
|
useDeleteWorkspaceMemberMutation,
|
||||||
|
useResetUserMfaMutation
|
||||||
|
} from "@/features/workspace/queries/workspace-query.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
|
import { useLicense } from "@/ee/hooks/use-license.tsx";
|
||||||
|
import { isCloud } from "@/lib/config.ts";
|
||||||
|
import { UserRole } from "@/lib/types.ts";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
userId: string;
|
userId: string;
|
||||||
|
userRole: string;
|
||||||
}
|
}
|
||||||
export default function MemberActionMenu({ userId }: Props) {
|
export default function MemberActionMenu({ userId, userRole }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const deleteWorkspaceMemberMutation = useDeleteWorkspaceMemberMutation();
|
const deleteWorkspaceMemberMutation = useDeleteWorkspaceMemberMutation();
|
||||||
const { isAdmin } = useUserRole();
|
const resetUserMfaMutation = useResetUserMfaMutation();
|
||||||
|
const { isAdmin, isOwner } = useUserRole();
|
||||||
|
const { hasLicenseKey } = useLicense();
|
||||||
|
|
||||||
|
// Show MFA reset only for self-hosted enterprise edition
|
||||||
|
// Admins cannot reset MFA for owners
|
||||||
|
const canResetMfa = isOwner || (isAdmin && userRole !== UserRole.OWNER);
|
||||||
|
const showMfaReset = !isCloud() && hasLicenseKey && canResetMfa;
|
||||||
|
|
||||||
const onRevoke = async () => {
|
const onRevoke = async () => {
|
||||||
await deleteWorkspaceMemberMutation.mutateAsync({ userId });
|
await deleteWorkspaceMemberMutation.mutateAsync({ userId });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onResetMfa = async () => {
|
||||||
|
await resetUserMfaMutation.mutateAsync({ userId });
|
||||||
|
};
|
||||||
|
|
||||||
const openRevokeModal = () =>
|
const openRevokeModal = () =>
|
||||||
modals.openConfirmModal({
|
modals.openConfirmModal({
|
||||||
title: t("Delete member"),
|
title: t("Delete member"),
|
||||||
@@ -34,6 +52,22 @@ export default function MemberActionMenu({ userId }: Props) {
|
|||||||
onConfirm: onRevoke,
|
onConfirm: onRevoke,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const openResetMfaModal = () =>
|
||||||
|
modals.openConfirmModal({
|
||||||
|
title: t("Reset MFA"),
|
||||||
|
children: (
|
||||||
|
<Text size="sm">
|
||||||
|
{t(
|
||||||
|
"Are you sure you want to reset MFA for this user? They will need to set up MFA again.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
centered: true,
|
||||||
|
labels: { confirm: t("Reset"), cancel: t("Cancel") },
|
||||||
|
confirmProps: { color: "red" },
|
||||||
|
onConfirm: onResetMfa,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Menu
|
<Menu
|
||||||
@@ -51,6 +85,14 @@ export default function MemberActionMenu({ userId }: Props) {
|
|||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
|
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
|
{showMfaReset && (
|
||||||
|
<Menu.Item
|
||||||
|
onClick={openResetMfaModal}
|
||||||
|
leftSection={<IconShieldOff size={16} />}
|
||||||
|
>
|
||||||
|
{t("Reset MFA")}
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
c="red"
|
c="red"
|
||||||
onClick={openRevokeModal}
|
onClick={openRevokeModal}
|
||||||
|
|||||||
+1
-1
@@ -98,7 +98,7 @@ export default function WorkspaceMembersTable() {
|
|||||||
/>
|
/>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
{isAdmin && <MemberActionMenu userId={user.id} />}
|
{isAdmin && <MemberActionMenu userId={user.id} userRole={user.role} />}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
getAppVersion,
|
getAppVersion,
|
||||||
deleteWorkspaceMember,
|
deleteWorkspaceMember,
|
||||||
} from "@/features/workspace/services/workspace-service";
|
} from "@/features/workspace/services/workspace-service";
|
||||||
|
import { resetUserMfa } from "@/ee/mfa";
|
||||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
import { IPagination, QueryParams } from "@/lib/types.ts";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import {
|
import {
|
||||||
@@ -192,3 +193,29 @@ export function useAppVersion(
|
|||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useResetUserMfaMutation() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<
|
||||||
|
{ success: boolean },
|
||||||
|
Error,
|
||||||
|
{ userId: string }
|
||||||
|
>({
|
||||||
|
mutationFn: ({ userId }) => resetUserMfa(userId),
|
||||||
|
onSuccess: () => {
|
||||||
|
notifications.show({
|
||||||
|
message: t("MFA has been reset successfully"),
|
||||||
|
color: "green"
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["workspaceMembers"],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const errorMessage = error["response"]?.data?.message || t("Failed to reset MFA");
|
||||||
|
notifications.show({ message: errorMessage, color: "red" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import { isCloud } from "@/lib/config";
|
|
||||||
import { useLicense } from "@/ee/hooks/use-license";
|
|
||||||
|
|
||||||
export const useIsCloudEE = () => {
|
|
||||||
const { hasLicenseKey } = useLicense();
|
|
||||||
return isCloud() || !!hasLicenseKey;
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
const APP_ROUTE = {
|
const APP_ROUTE = {
|
||||||
HOME: "/home",
|
HOME: "/home",
|
||||||
SPACES: "/spaces",
|
|
||||||
AUTH: {
|
AUTH: {
|
||||||
LOGIN: "/login",
|
LOGIN: "/login",
|
||||||
SIGNUP: "/signup",
|
SIGNUP: "/signup",
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import Trash from "@/features/page/trash/components/trash.tsx";
|
|
||||||
import { useParams } from "react-router-dom";
|
|
||||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
|
||||||
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
|
||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
SpaceCaslAction,
|
|
||||||
SpaceCaslSubject,
|
|
||||||
} from "@/features/space/permissions/permissions.type.ts";
|
|
||||||
|
|
||||||
export default function SpaceTrash() {
|
|
||||||
const { spaceSlug } = useParams();
|
|
||||||
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
|
||||||
|
|
||||||
const spaceRules = space?.membership?.permissions;
|
|
||||||
const spaceAbility = useSpaceAbility(spaceRules);
|
|
||||||
|
|
||||||
if (!space) {
|
|
||||||
return <></>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
|
||||||
return <></>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Trash />;
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { Container, Title, Text, Group, Box } from "@mantine/core";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Helmet } from "react-helmet-async";
|
|
||||||
import { getAppName } from "@/lib/config";
|
|
||||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
|
|
||||||
import CreateSpaceModal from "@/features/space/components/create-space-modal";
|
|
||||||
import { AllSpacesList } from "@/features/space/components/spaces-page";
|
|
||||||
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
|
|
||||||
import useUserRole from "@/hooks/use-user-role";
|
|
||||||
|
|
||||||
export default function Spaces() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { isAdmin } = useUserRole();
|
|
||||||
const { search, page, setPage, handleSearch } = usePaginateAndSearch();
|
|
||||||
|
|
||||||
const { data, isLoading } = useGetSpacesQuery({
|
|
||||||
page,
|
|
||||||
limit: 30,
|
|
||||||
query: search,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Helmet>
|
|
||||||
<title>
|
|
||||||
{t("Spaces")} - {getAppName()}
|
|
||||||
</title>
|
|
||||||
</Helmet>
|
|
||||||
|
|
||||||
<Container size={"800"} pt="xl">
|
|
||||||
<Group justify="space-between" mb="xl">
|
|
||||||
<Title order={3}>{t("Spaces")}</Title>
|
|
||||||
{isAdmin && <CreateSpaceModal />}
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Box>
|
|
||||||
<Text size="sm" c="dimmed" mb="md">
|
|
||||||
{t("Spaces you belong to")}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<AllSpacesList
|
|
||||||
spaces={data?.items || []}
|
|
||||||
onSearch={handleSearch}
|
|
||||||
page={page}
|
|
||||||
hasPrevPage={data?.meta?.hasPrevPage}
|
|
||||||
hasNextPage={data?.meta?.hasNextPage}
|
|
||||||
onPageChange={setPage}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Container>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "server",
|
"name": "server",
|
||||||
"version": "0.22.2",
|
"version": "0.21.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
"@nestjs/schedule": "^6.0.0",
|
"@nestjs/schedule": "^6.0.0",
|
||||||
"@nestjs/terminus": "^11.0.0",
|
"@nestjs/terminus": "^11.0.0",
|
||||||
"@nestjs/websockets": "^11.1.3",
|
"@nestjs/websockets": "^11.1.3",
|
||||||
"@node-saml/passport-saml": "^5.1.0",
|
"@node-saml/passport-saml": "^5.0.1",
|
||||||
"@react-email/components": "0.0.28",
|
"@react-email/components": "0.0.28",
|
||||||
"@react-email/render": "1.0.2",
|
"@react-email/render": "1.0.2",
|
||||||
"@socket.io/redis-adapter": "^8.3.0",
|
"@socket.io/redis-adapter": "^8.3.0",
|
||||||
@@ -70,10 +70,8 @@
|
|||||||
"nanoid": "3.3.11",
|
"nanoid": "3.3.11",
|
||||||
"nestjs-kysely": "^1.2.0",
|
"nestjs-kysely": "^1.2.0",
|
||||||
"nodemailer": "^7.0.3",
|
"nodemailer": "^7.0.3",
|
||||||
"openai": "^5.12.2",
|
|
||||||
"openid-client": "^5.7.1",
|
"openid-client": "^5.7.1",
|
||||||
"otpauth": "^9.4.0",
|
"otpauth": "^9.4.0",
|
||||||
"p-limit": "^6.2.0",
|
|
||||||
"passport-google-oauth20": "^2.0.0",
|
"passport-google-oauth20": "^2.0.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"pg": "^8.16.0",
|
"pg": "^8.16.0",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Typography } from '@tiptap/extension-typography';
|
|||||||
import { TextStyle } from '@tiptap/extension-text-style';
|
import { TextStyle } from '@tiptap/extension-text-style';
|
||||||
import { Color } from '@tiptap/extension-color';
|
import { Color } from '@tiptap/extension-color';
|
||||||
import { Youtube } from '@tiptap/extension-youtube';
|
import { Youtube } from '@tiptap/extension-youtube';
|
||||||
|
import Table from '@tiptap/extension-table';
|
||||||
import {
|
import {
|
||||||
Callout,
|
Callout,
|
||||||
Comment,
|
Comment,
|
||||||
@@ -23,7 +24,6 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableRow,
|
TableRow,
|
||||||
CustomTable,
|
|
||||||
TiptapImage,
|
TiptapImage,
|
||||||
TiptapVideo,
|
TiptapVideo,
|
||||||
TrailingNode,
|
TrailingNode,
|
||||||
@@ -65,7 +65,7 @@ export const tiptapExtensions = [
|
|||||||
Details,
|
Details,
|
||||||
DetailsContent,
|
DetailsContent,
|
||||||
DetailsSummary,
|
DetailsSummary,
|
||||||
CustomTable,
|
Table,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableRow,
|
TableRow,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
|
|||||||
@@ -76,11 +76,6 @@ export function sanitizeFileName(fileName: string): string {
|
|||||||
return sanitizedFilename.slice(0, 255);
|
return sanitizedFilename.slice(0, 255);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeAccent(str: string): string {
|
|
||||||
if (!str) return str;
|
|
||||||
return str.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function extractBearerTokenFromHeader(
|
export function extractBearerTokenFromHeader(
|
||||||
request: FastifyRequest,
|
request: FastifyRequest,
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
|
|||||||
@@ -12,14 +12,10 @@ export class InternalLogFilter extends ConsoleLogger {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
this.allowedLogLevels =
|
||||||
const isDebugMode = process.env.DEBUG_MODE === 'true';
|
process.env.NODE_ENV === 'production'
|
||||||
|
? ['log', 'error', 'fatal']
|
||||||
if (isProduction && !isDebugMode) {
|
: ['log', 'debug', 'verbose', 'warn', 'error', 'fatal'];
|
||||||
this.allowedLogLevels = ['log', 'error', 'fatal'];
|
|
||||||
} else {
|
|
||||||
this.allowedLogLevels = ['log', 'debug', 'verbose', 'warn', 'error', 'fatal'];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private isLogLevelAllowed(level: string): boolean {
|
private isLogLevelAllowed(level: string): boolean {
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ import { validate as isValidUUID } from 'uuid';
|
|||||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||||
import { TokenService } from '../auth/services/token.service';
|
import { TokenService } from '../auth/services/token.service';
|
||||||
import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload';
|
import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload';
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AttachmentController {
|
export class AttachmentController {
|
||||||
@@ -357,11 +356,6 @@ export class AttachmentController {
|
|||||||
throw new BadRequestException('Invalid image attachment type');
|
throw new BadRequestException('Invalid image attachment type');
|
||||||
}
|
}
|
||||||
|
|
||||||
const filenameWithoutExt = path.basename(fileName, path.extname(fileName));
|
|
||||||
if (!isValidUUID(filenameWithoutExt)) {
|
|
||||||
throw new BadRequestException('Invalid file id');
|
|
||||||
}
|
|
||||||
|
|
||||||
const filePath = `${getAttachmentFolderPath(attachmentType, workspace.id)}/${fileName}`;
|
const filePath = `${getAttachmentFolderPath(attachmentType, workspace.id)}/${fileName}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy {
|
|||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
async process(job: Job<any, void>): Promise<void> {
|
async process(job: Job<Space, void>): Promise<void> {
|
||||||
try {
|
try {
|
||||||
if (job.name === QueueJob.DELETE_SPACE_ATTACHMENTS) {
|
if (job.name === QueueJob.DELETE_SPACE_ATTACHMENTS) {
|
||||||
await this.attachmentService.handleDeleteSpaceAttachments(job.data.id);
|
await this.attachmentService.handleDeleteSpaceAttachments(job.data.id);
|
||||||
@@ -20,11 +20,6 @@ export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy {
|
|||||||
if (job.name === QueueJob.DELETE_USER_AVATARS) {
|
if (job.name === QueueJob.DELETE_USER_AVATARS) {
|
||||||
await this.attachmentService.handleDeleteUserAvatars(job.data.id);
|
await this.attachmentService.handleDeleteUserAvatars(job.data.id);
|
||||||
}
|
}
|
||||||
if (job.name === QueueJob.DELETE_PAGE_ATTACHMENTS) {
|
|
||||||
await this.attachmentService.handleDeletePageAttachments(
|
|
||||||
job.data.pageId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -321,50 +321,4 @@ export class AttachmentService {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleDeletePageAttachments(pageId: string) {
|
|
||||||
try {
|
|
||||||
// Fetch attachments for this page from database
|
|
||||||
const attachments = await this.db
|
|
||||||
.selectFrom('attachments')
|
|
||||||
.select(['id', 'filePath'])
|
|
||||||
.where('pageId', '=', pageId)
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
if (!attachments || attachments.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const failedDeletions = [];
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
attachments.map(async (attachment) => {
|
|
||||||
try {
|
|
||||||
// Delete from storage
|
|
||||||
await this.storageService.delete(attachment.filePath);
|
|
||||||
// Delete from database
|
|
||||||
await this.attachmentRepo.deleteAttachmentById(attachment.id);
|
|
||||||
} catch (err) {
|
|
||||||
failedDeletions.push(attachment.id);
|
|
||||||
this.logger.error(
|
|
||||||
`Failed to delete attachment ${attachment.id} for page ${pageId}:`,
|
|
||||||
err,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (failedDeletions.length > 0) {
|
|
||||||
this.logger.warn(
|
|
||||||
`Failed to delete ${failedDeletions.length} attachments for page ${pageId}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(
|
|
||||||
`Error in handleDeletePageAttachments for page ${pageId}:`,
|
|
||||||
err,
|
|
||||||
);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export class CommentController {
|
|||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
) {
|
) {
|
||||||
const page = await this.pageRepo.findById(createCommentDto.pageId);
|
const page = await this.pageRepo.findById(createCommentDto.pageId);
|
||||||
if (!page || page.deletedAt) {
|
if (!page) {
|
||||||
throw new NotFoundException('Page not found');
|
throw new NotFoundException('Page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,11 +53,9 @@ export class CommentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return this.commentService.create(
|
return this.commentService.create(
|
||||||
{
|
user.id,
|
||||||
userId: user.id,
|
page.id,
|
||||||
page,
|
workspace.id,
|
||||||
workspaceId: workspace.id,
|
|
||||||
},
|
|
||||||
createCommentDto,
|
createCommentDto,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -69,6 +67,7 @@ export class CommentController {
|
|||||||
@Body()
|
@Body()
|
||||||
pagination: PaginationOptions,
|
pagination: PaginationOptions,
|
||||||
@AuthUser() user: User,
|
@AuthUser() user: User,
|
||||||
|
// @AuthWorkspace() workspace: Workspace,
|
||||||
) {
|
) {
|
||||||
const page = await this.pageRepo.findById(input.pageId);
|
const page = await this.pageRepo.findById(input.pageId);
|
||||||
if (!page) {
|
if (!page) {
|
||||||
@@ -90,10 +89,12 @@ export class CommentController {
|
|||||||
throw new NotFoundException('Comment not found');
|
throw new NotFoundException('Comment not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ability = await this.spaceAbility.createForUser(
|
const page = await this.pageRepo.findById(comment.pageId);
|
||||||
user,
|
if (!page) {
|
||||||
comment.spaceId,
|
throw new NotFoundException('Page not found');
|
||||||
);
|
}
|
||||||
|
|
||||||
|
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||||
throw new ForbiddenException();
|
throw new ForbiddenException();
|
||||||
}
|
}
|
||||||
@@ -102,76 +103,19 @@ export class CommentController {
|
|||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('update')
|
@Post('update')
|
||||||
async update(@Body() dto: UpdateCommentDto, @AuthUser() user: User) {
|
update(@Body() updateCommentDto: UpdateCommentDto, @AuthUser() user: User) {
|
||||||
const comment = await this.commentRepo.findById(dto.commentId);
|
//TODO: only comment creators can update their comments
|
||||||
if (!comment) {
|
return this.commentService.update(
|
||||||
throw new NotFoundException('Comment not found');
|
updateCommentDto.commentId,
|
||||||
}
|
updateCommentDto,
|
||||||
|
|
||||||
const ability = await this.spaceAbility.createForUser(
|
|
||||||
user,
|
user,
|
||||||
comment.spaceId,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// must be a space member with edit permission
|
|
||||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
|
|
||||||
throw new ForbiddenException(
|
|
||||||
'You must have space edit permission to edit comments',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.commentService.update(comment, dto, user);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('delete')
|
@Post('delete')
|
||||||
async delete(@Body() input: CommentIdDto, @AuthUser() user: User) {
|
remove(@Body() input: CommentIdDto, @AuthUser() user: User) {
|
||||||
const comment = await this.commentRepo.findById(input.commentId);
|
// TODO: only comment creators and admins can delete their comments
|
||||||
if (!comment) {
|
return this.commentService.remove(input.commentId, user);
|
||||||
throw new NotFoundException('Comment not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const ability = await this.spaceAbility.createForUser(
|
|
||||||
user,
|
|
||||||
comment.spaceId,
|
|
||||||
);
|
|
||||||
|
|
||||||
// must be a space member with edit permission
|
|
||||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user is the comment owner
|
|
||||||
const isOwner = comment.creatorId === user.id;
|
|
||||||
|
|
||||||
if (isOwner) {
|
|
||||||
/*
|
|
||||||
// Check if comment has children from other users
|
|
||||||
const hasChildrenFromOthers =
|
|
||||||
await this.commentRepo.hasChildrenFromOtherUsers(comment.id, user.id);
|
|
||||||
|
|
||||||
// Owner can delete if no children from other users
|
|
||||||
if (!hasChildrenFromOthers) {
|
|
||||||
await this.commentRepo.deleteComment(comment.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If has children from others, only space admin can delete
|
|
||||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
|
|
||||||
throw new ForbiddenException(
|
|
||||||
'Only space admins can delete comments with replies from other users',
|
|
||||||
);
|
|
||||||
}*/
|
|
||||||
await this.commentRepo.deleteComment(comment.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Space admin can delete any comment
|
|
||||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
|
|
||||||
throw new ForbiddenException(
|
|
||||||
'You can only delete your own comments or must be a space admin',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await this.commentRepo.deleteComment(comment.id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,24 +7,21 @@ import {
|
|||||||
import { CreateCommentDto } from './dto/create-comment.dto';
|
import { CreateCommentDto } from './dto/create-comment.dto';
|
||||||
import { UpdateCommentDto } from './dto/update-comment.dto';
|
import { UpdateCommentDto } from './dto/update-comment.dto';
|
||||||
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
|
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
|
||||||
import { Comment, Page, User } from '@docmost/db/types/entity.types';
|
import { Comment, User } from '@docmost/db/types/entity.types';
|
||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
import { PaginationResult } from '@docmost/db/pagination/pagination';
|
import { PaginationResult } from '@docmost/db/pagination/pagination';
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CommentService {
|
export class CommentService {
|
||||||
constructor(
|
constructor(
|
||||||
private commentRepo: CommentRepo,
|
private commentRepo: CommentRepo,
|
||||||
private pageRepo: PageRepo,
|
private pageRepo: PageRepo,
|
||||||
private spaceMemberRepo: SpaceMemberRepo,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findById(commentId: string) {
|
async findById(commentId: string) {
|
||||||
const comment = await this.commentRepo.findById(commentId, {
|
const comment = await this.commentRepo.findById(commentId, {
|
||||||
includeCreator: true,
|
includeCreator: true,
|
||||||
includeResolvedBy: true,
|
|
||||||
});
|
});
|
||||||
if (!comment) {
|
if (!comment) {
|
||||||
throw new NotFoundException('Comment not found');
|
throw new NotFoundException('Comment not found');
|
||||||
@@ -33,10 +30,11 @@ export class CommentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(
|
async create(
|
||||||
opts: { userId: string; page: Page; workspaceId: string },
|
userId: string,
|
||||||
|
pageId: string,
|
||||||
|
workspaceId: string,
|
||||||
createCommentDto: CreateCommentDto,
|
createCommentDto: CreateCommentDto,
|
||||||
) {
|
) {
|
||||||
const { userId, page, workspaceId } = opts;
|
|
||||||
const commentContent = JSON.parse(createCommentDto.content);
|
const commentContent = JSON.parse(createCommentDto.content);
|
||||||
|
|
||||||
if (createCommentDto.parentCommentId) {
|
if (createCommentDto.parentCommentId) {
|
||||||
@@ -44,7 +42,7 @@ export class CommentService {
|
|||||||
createCommentDto.parentCommentId,
|
createCommentDto.parentCommentId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!parentComment || parentComment.pageId !== page.id) {
|
if (!parentComment || parentComment.pageId !== pageId) {
|
||||||
throw new BadRequestException('Parent comment not found');
|
throw new BadRequestException('Parent comment not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,16 +51,17 @@ export class CommentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.commentRepo.insertComment({
|
const createdComment = await this.commentRepo.insertComment({
|
||||||
pageId: page.id,
|
pageId: pageId,
|
||||||
content: commentContent,
|
content: commentContent,
|
||||||
selection: createCommentDto?.selection?.substring(0, 250),
|
selection: createCommentDto?.selection?.substring(0, 250),
|
||||||
type: 'inline',
|
type: 'inline',
|
||||||
parentCommentId: createCommentDto?.parentCommentId,
|
parentCommentId: createCommentDto?.parentCommentId,
|
||||||
creatorId: userId,
|
creatorId: userId,
|
||||||
workspaceId: workspaceId,
|
workspaceId: workspaceId,
|
||||||
spaceId: page.spaceId,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return createdComment;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByPageId(
|
async findByPageId(
|
||||||
@@ -75,16 +74,26 @@ export class CommentService {
|
|||||||
throw new BadRequestException('Page not found');
|
throw new BadRequestException('Page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.commentRepo.findPageComments(pageId, pagination);
|
const pageComments = await this.commentRepo.findPageComments(
|
||||||
|
pageId,
|
||||||
|
pagination,
|
||||||
|
);
|
||||||
|
|
||||||
|
return pageComments;
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(
|
async update(
|
||||||
comment: Comment,
|
commentId: string,
|
||||||
updateCommentDto: UpdateCommentDto,
|
updateCommentDto: UpdateCommentDto,
|
||||||
authUser: User,
|
authUser: User,
|
||||||
): Promise<Comment> {
|
): Promise<Comment> {
|
||||||
const commentContent = JSON.parse(updateCommentDto.content);
|
const commentContent = JSON.parse(updateCommentDto.content);
|
||||||
|
|
||||||
|
const comment = await this.commentRepo.findById(commentId);
|
||||||
|
if (!comment) {
|
||||||
|
throw new NotFoundException('Comment not found');
|
||||||
|
}
|
||||||
|
|
||||||
if (comment.creatorId !== authUser.id) {
|
if (comment.creatorId !== authUser.id) {
|
||||||
throw new ForbiddenException('You can only edit your own comments');
|
throw new ForbiddenException('You can only edit your own comments');
|
||||||
}
|
}
|
||||||
@@ -95,14 +104,26 @@ export class CommentService {
|
|||||||
{
|
{
|
||||||
content: commentContent,
|
content: commentContent,
|
||||||
editedAt: editedAt,
|
editedAt: editedAt,
|
||||||
updatedAt: editedAt,
|
|
||||||
},
|
},
|
||||||
comment.id,
|
commentId,
|
||||||
);
|
);
|
||||||
comment.content = commentContent;
|
comment.content = commentContent;
|
||||||
comment.editedAt = editedAt;
|
comment.editedAt = editedAt;
|
||||||
comment.updatedAt = editedAt;
|
|
||||||
|
|
||||||
return comment;
|
return comment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async remove(commentId: string, authUser: User): Promise<void> {
|
||||||
|
const comment = await this.commentRepo.findById(commentId);
|
||||||
|
|
||||||
|
if (!comment) {
|
||||||
|
throw new NotFoundException('Comment not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (comment.creatorId !== authUser.id) {
|
||||||
|
throw new ForbiddenException('You can only delete your own comments');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.commentRepo.deleteComment(commentId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -1,13 +1,13 @@
|
|||||||
import { IsString, IsNotEmpty, IsOptional } from 'class-validator';
|
import { IsString, IsNotEmpty } from 'class-validator';
|
||||||
|
|
||||||
export class DuplicatePageDto {
|
export class CopyPageToSpaceDto {
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsString()
|
@IsString()
|
||||||
pageId: string;
|
pageId: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsNotEmpty()
|
||||||
@IsString()
|
@IsString()
|
||||||
spaceId?: string;
|
spaceId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CopyPageMapEntry = {
|
export type CopyPageMapEntry = {
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { IsNotEmpty, IsString } from 'class-validator';
|
|
||||||
|
|
||||||
export class DeletedPageDto {
|
|
||||||
@IsNotEmpty()
|
|
||||||
@IsString()
|
|
||||||
spaceId: string;
|
|
||||||
}
|
|
||||||
@@ -31,9 +31,3 @@ export class PageInfoDto extends PageIdDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
includeContent: boolean;
|
includeContent: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DeletePageDto extends PageIdDto {
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
permanentlyDelete?: boolean;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,12 +13,7 @@ import { PageService } from './services/page.service';
|
|||||||
import { CreatePageDto } from './dto/create-page.dto';
|
import { CreatePageDto } from './dto/create-page.dto';
|
||||||
import { UpdatePageDto } from './dto/update-page.dto';
|
import { UpdatePageDto } from './dto/update-page.dto';
|
||||||
import { MovePageDto, MovePageToSpaceDto } from './dto/move-page.dto';
|
import { MovePageDto, MovePageToSpaceDto } from './dto/move-page.dto';
|
||||||
import {
|
import { PageHistoryIdDto, PageIdDto, PageInfoDto } from './dto/page.dto';
|
||||||
PageHistoryIdDto,
|
|
||||||
PageIdDto,
|
|
||||||
PageInfoDto,
|
|
||||||
DeletePageDto,
|
|
||||||
} from './dto/page.dto';
|
|
||||||
import { PageHistoryService } from './services/page-history.service';
|
import { PageHistoryService } from './services/page-history.service';
|
||||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||||
@@ -33,8 +28,7 @@ import {
|
|||||||
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
import { RecentPageDto } from './dto/recent-page.dto';
|
import { RecentPageDto } from './dto/recent-page.dto';
|
||||||
import { DuplicatePageDto } from './dto/duplicate-page.dto';
|
import { CopyPageToSpaceDto } from './dto/copy-page.dto';
|
||||||
import { DeletedPageDto } from './dto/deleted-page.dto';
|
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('pages')
|
@Controller('pages')
|
||||||
@@ -106,35 +100,7 @@ export class PageController {
|
|||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('delete')
|
@Post('delete')
|
||||||
async delete(@Body() deletePageDto: DeletePageDto, @AuthUser() user: User) {
|
async delete(@Body() pageIdDto: PageIdDto, @AuthUser() user: User) {
|
||||||
const page = await this.pageRepo.findById(deletePageDto.pageId);
|
|
||||||
|
|
||||||
if (!page) {
|
|
||||||
throw new NotFoundException('Page not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
|
||||||
|
|
||||||
if (deletePageDto.permanentlyDelete) {
|
|
||||||
// Permanent deletion requires space admin permissions
|
|
||||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
|
|
||||||
throw new ForbiddenException(
|
|
||||||
'Only space admins can permanently delete pages',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await this.pageService.forceDelete(deletePageDto.pageId);
|
|
||||||
} else {
|
|
||||||
// Soft delete requires page manage permissions
|
|
||||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
await this.pageService.remove(deletePageDto.pageId, user.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post('restore')
|
|
||||||
async restore(@Body() pageIdDto: PageIdDto, @AuthUser() user: User) {
|
|
||||||
const page = await this.pageRepo.findById(pageIdDto.pageId);
|
const page = await this.pageRepo.findById(pageIdDto.pageId);
|
||||||
|
|
||||||
if (!page) {
|
if (!page) {
|
||||||
@@ -145,14 +111,13 @@ export class PageController {
|
|||||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
||||||
throw new ForbiddenException();
|
throw new ForbiddenException();
|
||||||
}
|
}
|
||||||
|
await this.pageService.forceDelete(pageIdDto.pageId);
|
||||||
|
}
|
||||||
|
|
||||||
await this.pageRepo.restorePage(pageIdDto.pageId);
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('restore')
|
||||||
// Return the restored page data with hasChildren info
|
async restore(@Body() pageIdDto: PageIdDto) {
|
||||||
const restoredPage = await this.pageRepo.findById(pageIdDto.pageId, {
|
// await this.pageService.restore(deletePageDto.id);
|
||||||
includeHasChildren: true,
|
|
||||||
});
|
|
||||||
return restoredPage;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@@ -181,31 +146,6 @@ export class PageController {
|
|||||||
return this.pageService.getRecentPages(user.id, pagination);
|
return this.pageService.getRecentPages(user.id, pagination);
|
||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post('trash')
|
|
||||||
async getDeletedPages(
|
|
||||||
@Body() deletedPageDto: DeletedPageDto,
|
|
||||||
@Body() pagination: PaginationOptions,
|
|
||||||
@AuthUser() user: User,
|
|
||||||
) {
|
|
||||||
if (deletedPageDto.spaceId) {
|
|
||||||
const ability = await this.spaceAbility.createForUser(
|
|
||||||
user,
|
|
||||||
deletedPageDto.spaceId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.pageService.getDeletedSpacePages(
|
|
||||||
deletedPageDto.spaceId,
|
|
||||||
pagination,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: scope to workspaces
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('/history')
|
@Post('/history')
|
||||||
async getPageHistory(
|
async getPageHistory(
|
||||||
@@ -302,41 +242,33 @@ export class PageController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('duplicate')
|
@Post('copy-to-space')
|
||||||
async duplicatePage(@Body() dto: DuplicatePageDto, @AuthUser() user: User) {
|
async copyPageToSpace(
|
||||||
|
@Body() dto: CopyPageToSpaceDto,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
) {
|
||||||
const copiedPage = await this.pageRepo.findById(dto.pageId);
|
const copiedPage = await this.pageRepo.findById(dto.pageId);
|
||||||
if (!copiedPage) {
|
if (!copiedPage) {
|
||||||
throw new NotFoundException('Page to copy not found');
|
throw new NotFoundException('Page to copy not found');
|
||||||
}
|
}
|
||||||
|
if (copiedPage.spaceId === dto.spaceId) {
|
||||||
// If spaceId is provided, it's a copy to different space
|
throw new BadRequestException('Page is already in this space');
|
||||||
if (dto.spaceId) {
|
|
||||||
const abilities = await Promise.all([
|
|
||||||
this.spaceAbility.createForUser(user, copiedPage.spaceId),
|
|
||||||
this.spaceAbility.createForUser(user, dto.spaceId),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (
|
|
||||||
abilities.some((ability) =>
|
|
||||||
ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.pageService.duplicatePage(copiedPage, dto.spaceId, user);
|
|
||||||
} else {
|
|
||||||
// If no spaceId, it's a duplicate in same space
|
|
||||||
const ability = await this.spaceAbility.createForUser(
|
|
||||||
user,
|
|
||||||
copiedPage.spaceId,
|
|
||||||
);
|
|
||||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.pageService.duplicatePage(copiedPage, undefined, user);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const abilities = await Promise.all([
|
||||||
|
this.spaceAbility.createForUser(user, copiedPage.spaceId),
|
||||||
|
this.spaceAbility.createForUser(user, dto.spaceId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (
|
||||||
|
abilities.some((ability) =>
|
||||||
|
ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.pageService.copyPageToSpace(copiedPage, dto.spaceId, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
|
|||||||
@@ -2,12 +2,11 @@ import { Module } from '@nestjs/common';
|
|||||||
import { PageService } from './services/page.service';
|
import { PageService } from './services/page.service';
|
||||||
import { PageController } from './page.controller';
|
import { PageController } from './page.controller';
|
||||||
import { PageHistoryService } from './services/page-history.service';
|
import { PageHistoryService } from './services/page-history.service';
|
||||||
import { TrashCleanupService } from './services/trash-cleanup.service';
|
|
||||||
import { StorageModule } from '../../integrations/storage/storage.module';
|
import { StorageModule } from '../../integrations/storage/storage.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [PageController],
|
controllers: [PageController],
|
||||||
providers: [PageService, PageHistoryService, TrashCleanupService],
|
providers: [PageService, PageHistoryService],
|
||||||
exports: [PageService, PageHistoryService],
|
exports: [PageService, PageHistoryService],
|
||||||
imports: [StorageModule]
|
imports: [StorageModule]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import { InjectKysely } from 'nestjs-kysely';
|
|||||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
||||||
import { MovePageDto } from '../dto/move-page.dto';
|
import { MovePageDto } from '../dto/move-page.dto';
|
||||||
|
import { ExpressionBuilder } from 'kysely';
|
||||||
|
import { DB } from '@docmost/db/types/db';
|
||||||
import { generateSlugId } from '../../../common/helpers';
|
import { generateSlugId } from '../../../common/helpers';
|
||||||
import { executeTx } from '@docmost/db/utils';
|
import { executeTx } from '@docmost/db/utils';
|
||||||
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
|
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
|
||||||
@@ -29,15 +31,9 @@ import {
|
|||||||
removeMarkTypeFromDoc,
|
removeMarkTypeFromDoc,
|
||||||
} from '../../../common/helpers/prosemirror/utils';
|
} from '../../../common/helpers/prosemirror/utils';
|
||||||
import { jsonToNode, jsonToText } from 'src/collaboration/collaboration.util';
|
import { jsonToNode, jsonToText } from 'src/collaboration/collaboration.util';
|
||||||
import {
|
import { CopyPageMapEntry, ICopyPageAttachment } from '../dto/copy-page.dto';
|
||||||
CopyPageMapEntry,
|
|
||||||
ICopyPageAttachment,
|
|
||||||
} from '../dto/duplicate-page.dto';
|
|
||||||
import { Node as PMNode } from '@tiptap/pm/model';
|
import { Node as PMNode } from '@tiptap/pm/model';
|
||||||
import { StorageService } from '../../../integrations/storage/storage.service';
|
import { StorageService } from '../../../integrations/storage/storage.service';
|
||||||
import { InjectQueue } from '@nestjs/bullmq';
|
|
||||||
import { Queue } from 'bullmq';
|
|
||||||
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PageService {
|
export class PageService {
|
||||||
@@ -48,7 +44,6 @@ export class PageService {
|
|||||||
private attachmentRepo: AttachmentRepo,
|
private attachmentRepo: AttachmentRepo,
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
private readonly storageService: StorageService,
|
private readonly storageService: StorageService,
|
||||||
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findById(
|
async findById(
|
||||||
@@ -109,8 +104,7 @@ export class PageService {
|
|||||||
.selectFrom('pages')
|
.selectFrom('pages')
|
||||||
.select(['position'])
|
.select(['position'])
|
||||||
.where('spaceId', '=', spaceId)
|
.where('spaceId', '=', spaceId)
|
||||||
.where('deletedAt', 'is', null)
|
.orderBy('position', 'desc')
|
||||||
.orderBy('position', (ob) => ob.collate('C').desc())
|
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (parentPageId) {
|
if (parentPageId) {
|
||||||
@@ -172,6 +166,23 @@ export class PageService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
withHasChildren(eb: ExpressionBuilder<DB, 'pages'>) {
|
||||||
|
return eb
|
||||||
|
.selectFrom('pages as child')
|
||||||
|
.select((eb) =>
|
||||||
|
eb
|
||||||
|
.case()
|
||||||
|
.when(eb.fn.countAll(), '>', 0)
|
||||||
|
.then(true)
|
||||||
|
.else(false)
|
||||||
|
.end()
|
||||||
|
.as('count'),
|
||||||
|
)
|
||||||
|
.whereRef('child.parentPageId', '=', 'pages.id')
|
||||||
|
.limit(1)
|
||||||
|
.as('hasChildren');
|
||||||
|
}
|
||||||
|
|
||||||
async getSidebarPages(
|
async getSidebarPages(
|
||||||
spaceId: string,
|
spaceId: string,
|
||||||
pagination: PaginationOptions,
|
pagination: PaginationOptions,
|
||||||
@@ -188,11 +199,9 @@ export class PageService {
|
|||||||
'parentPageId',
|
'parentPageId',
|
||||||
'spaceId',
|
'spaceId',
|
||||||
'creatorId',
|
'creatorId',
|
||||||
'deletedAt',
|
|
||||||
])
|
])
|
||||||
.select((eb) => this.pageRepo.withHasChildren(eb))
|
.select((eb) => this.withHasChildren(eb))
|
||||||
.orderBy('position', (ob) => ob.collate('C').asc())
|
.orderBy('position', 'asc')
|
||||||
.where('deletedAt', 'is', null)
|
|
||||||
.where('spaceId', '=', spaceId);
|
.where('spaceId', '=', spaceId);
|
||||||
|
|
||||||
if (pageId) {
|
if (pageId) {
|
||||||
@@ -249,24 +258,11 @@ export class PageService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async duplicatePage(
|
async copyPageToSpace(rootPage: Page, spaceId: string, authUser: User) {
|
||||||
rootPage: Page,
|
//TODO:
|
||||||
targetSpaceId: string | undefined,
|
// i. maintain internal links within copied pages
|
||||||
authUser: User,
|
|
||||||
) {
|
|
||||||
const spaceId = targetSpaceId || rootPage.spaceId;
|
|
||||||
const isDuplicateInSameSpace =
|
|
||||||
!targetSpaceId || targetSpaceId === rootPage.spaceId;
|
|
||||||
|
|
||||||
let nextPosition: string;
|
const nextPosition = await this.nextPagePosition(spaceId);
|
||||||
|
|
||||||
if (isDuplicateInSameSpace) {
|
|
||||||
// For duplicate in same space, position right after the original page
|
|
||||||
nextPosition = generateJitteredKeyBetween(rootPage.position, null);
|
|
||||||
} else {
|
|
||||||
// For copy to different space, position at the end
|
|
||||||
nextPosition = await this.nextPagePosition(spaceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
|
const pages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
|
||||||
includeContent: true,
|
includeContent: true,
|
||||||
@@ -330,38 +326,12 @@ export class PageService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update internal page links in mention nodes
|
|
||||||
prosemirrorDoc.descendants((node: PMNode) => {
|
|
||||||
if (
|
|
||||||
node.type.name === 'mention' &&
|
|
||||||
node.attrs.entityType === 'page'
|
|
||||||
) {
|
|
||||||
const referencedPageId = node.attrs.entityId;
|
|
||||||
|
|
||||||
// Check if the referenced page is within the pages being copied
|
|
||||||
if (referencedPageId && pageMap.has(referencedPageId)) {
|
|
||||||
const mappedPage = pageMap.get(referencedPageId);
|
|
||||||
//@ts-ignore
|
|
||||||
node.attrs.entityId = mappedPage.newPageId;
|
|
||||||
//@ts-ignore
|
|
||||||
node.attrs.slugId = mappedPage.newSlugId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const prosemirrorJson = prosemirrorDoc.toJSON();
|
const prosemirrorJson = prosemirrorDoc.toJSON();
|
||||||
|
|
||||||
// Add "Copy of " prefix to the root page title only for duplicates in same space
|
|
||||||
let title = page.title;
|
|
||||||
if (isDuplicateInSameSpace && page.id === rootPage.id) {
|
|
||||||
const originalTitle = page.title || 'Untitled';
|
|
||||||
title = `Copy of ${originalTitle}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: pageFromMap.newPageId,
|
id: pageFromMap.newPageId,
|
||||||
slugId: pageFromMap.newSlugId,
|
slugId: pageFromMap.newSlugId,
|
||||||
title: title,
|
title: page.title,
|
||||||
icon: page.icon,
|
icon: page.icon,
|
||||||
content: prosemirrorJson,
|
content: prosemirrorJson,
|
||||||
textContent: jsonToText(prosemirrorJson),
|
textContent: jsonToText(prosemirrorJson),
|
||||||
@@ -407,50 +377,33 @@ export class PageService {
|
|||||||
attachment.id,
|
attachment.id,
|
||||||
newAttachmentId,
|
newAttachmentId,
|
||||||
);
|
);
|
||||||
|
await this.storageService.copy(attachment.filePath, newPathFile);
|
||||||
try {
|
await this.db
|
||||||
await this.storageService.copy(attachment.filePath, newPathFile);
|
.insertInto('attachments')
|
||||||
|
.values({
|
||||||
await this.db
|
id: newAttachmentId,
|
||||||
.insertInto('attachments')
|
type: attachment.type,
|
||||||
.values({
|
filePath: newPathFile,
|
||||||
id: newAttachmentId,
|
fileName: attachment.fileName,
|
||||||
type: attachment.type,
|
fileSize: attachment.fileSize,
|
||||||
filePath: newPathFile,
|
mimeType: attachment.mimeType,
|
||||||
fileName: attachment.fileName,
|
fileExt: attachment.fileExt,
|
||||||
fileSize: attachment.fileSize,
|
creatorId: attachment.creatorId,
|
||||||
mimeType: attachment.mimeType,
|
workspaceId: attachment.workspaceId,
|
||||||
fileExt: attachment.fileExt,
|
pageId: newPageId,
|
||||||
creatorId: attachment.creatorId,
|
spaceId: spaceId,
|
||||||
workspaceId: attachment.workspaceId,
|
})
|
||||||
pageId: newPageId,
|
.execute();
|
||||||
spaceId: spaceId,
|
|
||||||
})
|
|
||||||
.execute();
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(
|
|
||||||
`Duplicate page: failed to copy attachment ${attachment.id}`,
|
|
||||||
err,
|
|
||||||
);
|
|
||||||
// Continue with other attachments even if one fails
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(err);
|
this.logger.log(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const newPageId = pageMap.get(rootPage.id).newPageId;
|
const newPageId = pageMap.get(rootPage.id).newPageId;
|
||||||
const duplicatedPage = await this.pageRepo.findById(newPageId, {
|
return await this.pageRepo.findById(newPageId, {
|
||||||
includeSpace: true,
|
includeSpace: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasChildren = pages.length > 1;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...duplicatedPage,
|
|
||||||
hasChildren,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async movePage(dto: MovePageDto, movedPage: Page) {
|
async movePage(dto: MovePageDto, movedPage: Page) {
|
||||||
@@ -497,11 +450,9 @@ export class PageService {
|
|||||||
'position',
|
'position',
|
||||||
'parentPageId',
|
'parentPageId',
|
||||||
'spaceId',
|
'spaceId',
|
||||||
'deletedAt',
|
|
||||||
])
|
])
|
||||||
.select((eb) => this.pageRepo.withHasChildren(eb))
|
.select((eb) => this.withHasChildren(eb))
|
||||||
.where('id', '=', childPageId)
|
.where('id', '=', childPageId)
|
||||||
.where('deletedAt', 'is', null)
|
|
||||||
.unionAll((exp) =>
|
.unionAll((exp) =>
|
||||||
exp
|
exp
|
||||||
.selectFrom('pages as p')
|
.selectFrom('pages as p')
|
||||||
@@ -513,7 +464,6 @@ export class PageService {
|
|||||||
'p.position',
|
'p.position',
|
||||||
'p.parentPageId',
|
'p.parentPageId',
|
||||||
'p.spaceId',
|
'p.spaceId',
|
||||||
'p.deletedAt',
|
|
||||||
])
|
])
|
||||||
.select(
|
.select(
|
||||||
exp
|
exp
|
||||||
@@ -528,13 +478,11 @@ export class PageService {
|
|||||||
.as('count'),
|
.as('count'),
|
||||||
)
|
)
|
||||||
.whereRef('child.parentPageId', '=', 'id')
|
.whereRef('child.parentPageId', '=', 'id')
|
||||||
.where('child.deletedAt', 'is', null)
|
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.as('hasChildren'),
|
.as('hasChildren'),
|
||||||
)
|
)
|
||||||
//.select((eb) => this.withHasChildren(eb))
|
//.select((eb) => this.withHasChildren(eb))
|
||||||
.innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id')
|
.innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id'),
|
||||||
.where('p.deletedAt', 'is', null),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.selectFrom('page_ancestors')
|
.selectFrom('page_ancestors')
|
||||||
@@ -558,58 +506,98 @@ export class PageService {
|
|||||||
return await this.pageRepo.getRecentPages(userId, pagination);
|
return await this.pageRepo.getRecentPages(userId, pagination);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDeletedSpacePages(
|
|
||||||
spaceId: string,
|
|
||||||
pagination: PaginationOptions,
|
|
||||||
): Promise<PaginationResult<Page>> {
|
|
||||||
return await this.pageRepo.getDeletedPagesInSpace(spaceId, pagination);
|
|
||||||
}
|
|
||||||
|
|
||||||
async forceDelete(pageId: string): Promise<void> {
|
async forceDelete(pageId: string): Promise<void> {
|
||||||
// Get all descendant IDs (including the page itself) using recursive CTE
|
await this.pageRepo.deletePage(pageId);
|
||||||
const descendants = await this.db
|
|
||||||
.withRecursive('page_descendants', (db) =>
|
|
||||||
db
|
|
||||||
.selectFrom('pages')
|
|
||||||
.select(['id'])
|
|
||||||
.where('id', '=', pageId)
|
|
||||||
.unionAll((exp) =>
|
|
||||||
exp
|
|
||||||
.selectFrom('pages as p')
|
|
||||||
.select(['p.id'])
|
|
||||||
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.selectFrom('page_descendants')
|
|
||||||
.selectAll()
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
const pageIds = descendants.map((d) => d.id);
|
|
||||||
|
|
||||||
// Queue attachment deletion for all pages with unique job IDs to prevent duplicates
|
|
||||||
for (const id of pageIds) {
|
|
||||||
await this.attachmentQueue.add(
|
|
||||||
QueueJob.DELETE_PAGE_ATTACHMENTS,
|
|
||||||
{
|
|
||||||
pageId: id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
jobId: `delete-page-attachments-${id}`,
|
|
||||||
attempts: 3,
|
|
||||||
backoff: {
|
|
||||||
type: 'exponential',
|
|
||||||
delay: 5000,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pageIds.length > 0) {
|
|
||||||
await this.db.deleteFrom('pages').where('id', 'in', pageIds).execute();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async remove(pageId: string, userId: string): Promise<void> {
|
|
||||||
await this.pageRepo.removePage(pageId, userId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
// TODO: page deletion and restoration
|
||||||
|
async delete(pageId: string): Promise<void> {
|
||||||
|
await this.dataSource.transaction(async (manager: EntityManager) => {
|
||||||
|
const page = await manager
|
||||||
|
.createQueryBuilder(Page, 'page')
|
||||||
|
.where('page.id = :pageId', { pageId })
|
||||||
|
.select(['page.id', 'page.workspaceId'])
|
||||||
|
.getOne();
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
throw new NotFoundException(`Page not found`);
|
||||||
|
}
|
||||||
|
await this.softDeleteChildrenRecursive(page.id, manager);
|
||||||
|
await this.pageOrderingService.removePageFromHierarchy(page, manager);
|
||||||
|
|
||||||
|
await manager.softDelete(Page, pageId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async softDeleteChildrenRecursive(
|
||||||
|
parentId: string,
|
||||||
|
manager: EntityManager,
|
||||||
|
): Promise<void> {
|
||||||
|
const childrenPage = await manager
|
||||||
|
.createQueryBuilder(Page, 'page')
|
||||||
|
.where('page.parentPageId = :parentId', { parentId })
|
||||||
|
.select(['page.id', 'page.title', 'page.parentPageId'])
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
for (const child of childrenPage) {
|
||||||
|
await this.softDeleteChildrenRecursive(child.id, manager);
|
||||||
|
await manager.softDelete(Page, child.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async restore(pageId: string): Promise<void> {
|
||||||
|
await this.dataSource.transaction(async (manager: EntityManager) => {
|
||||||
|
const isDeleted = await manager
|
||||||
|
.createQueryBuilder(Page, 'page')
|
||||||
|
.where('page.id = :pageId', { pageId })
|
||||||
|
.withDeleted()
|
||||||
|
.getCount();
|
||||||
|
|
||||||
|
if (!isDeleted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await manager.recover(Page, { id: pageId });
|
||||||
|
|
||||||
|
await this.restoreChildrenRecursive(pageId, manager);
|
||||||
|
|
||||||
|
// Fetch the page details to find out its parent and workspace
|
||||||
|
const restoredPage = await manager
|
||||||
|
.createQueryBuilder(Page, 'page')
|
||||||
|
.where('page.id = :pageId', { pageId })
|
||||||
|
.select(['page.id', 'page.title', 'page.spaceId', 'page.parentPageId'])
|
||||||
|
.getOne();
|
||||||
|
|
||||||
|
if (!restoredPage) {
|
||||||
|
throw new NotFoundException(`Restored page not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// add page back to its hierarchy
|
||||||
|
await this.pageOrderingService.addPageToOrder(
|
||||||
|
restoredPage.spaceId,
|
||||||
|
pageId,
|
||||||
|
restoredPage.parentPageId,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async restoreChildrenRecursive(
|
||||||
|
parentId: string,
|
||||||
|
manager: EntityManager,
|
||||||
|
): Promise<void> {
|
||||||
|
const childrenPage = await manager
|
||||||
|
.createQueryBuilder(Page, 'page')
|
||||||
|
.setLock('pessimistic_write')
|
||||||
|
.where('page.parentPageId = :parentId', { parentId })
|
||||||
|
.select(['page.id', 'page.title', 'page.parentPageId'])
|
||||||
|
.withDeleted()
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
for (const child of childrenPage) {
|
||||||
|
await this.restoreChildrenRecursive(child.id, manager);
|
||||||
|
await manager.recover(Page, { id: child.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { Interval } from '@nestjs/schedule';
|
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
|
||||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
|
||||||
import { InjectQueue } from '@nestjs/bullmq';
|
|
||||||
import { Queue } from 'bullmq';
|
|
||||||
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class TrashCleanupService {
|
|
||||||
private readonly logger = new Logger(TrashCleanupService.name);
|
|
||||||
private readonly RETENTION_DAYS = 30;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
|
||||||
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Interval('trash-cleanup', 24 * 60 * 60 * 1000) // every 24 hours
|
|
||||||
async cleanupOldTrash() {
|
|
||||||
try {
|
|
||||||
this.logger.debug('Starting trash cleanup job');
|
|
||||||
|
|
||||||
const retentionDate = new Date();
|
|
||||||
retentionDate.setDate(retentionDate.getDate() - this.RETENTION_DAYS);
|
|
||||||
|
|
||||||
// Get all pages that were deleted more than 30 days ago
|
|
||||||
const oldDeletedPages = await this.db
|
|
||||||
.selectFrom('pages')
|
|
||||||
.select(['id', 'spaceId', 'workspaceId'])
|
|
||||||
.where('deletedAt', '<', retentionDate)
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
if (oldDeletedPages.length === 0) {
|
|
||||||
this.logger.debug('No old trash items to clean up');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.debug(`Found ${oldDeletedPages.length} pages to clean up`);
|
|
||||||
|
|
||||||
// Process each page
|
|
||||||
for (const page of oldDeletedPages) {
|
|
||||||
try {
|
|
||||||
await this.cleanupPage(page.id);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(
|
|
||||||
`Failed to cleanup page ${page.id}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
||||||
error instanceof Error ? error.stack : undefined,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.debug('Trash cleanup job completed');
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(
|
|
||||||
'Trash cleanup job failed',
|
|
||||||
error instanceof Error ? error.stack : undefined,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async cleanupPage(pageId: string) {
|
|
||||||
// Get all descendants using recursive CTE (including the page itself)
|
|
||||||
const descendants = await this.db
|
|
||||||
.withRecursive('page_descendants', (db) =>
|
|
||||||
db
|
|
||||||
.selectFrom('pages')
|
|
||||||
.select(['id'])
|
|
||||||
.where('id', '=', pageId)
|
|
||||||
.unionAll((exp) =>
|
|
||||||
exp
|
|
||||||
.selectFrom('pages as p')
|
|
||||||
.select(['p.id'])
|
|
||||||
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.selectFrom('page_descendants')
|
|
||||||
.selectAll()
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
const pageIds = descendants.map((d) => d.id);
|
|
||||||
|
|
||||||
this.logger.debug(
|
|
||||||
`Cleaning up page ${pageId} with ${pageIds.length - 1} descendants`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Queue attachment deletion for all pages with unique job IDs to prevent duplicates
|
|
||||||
for (const id of pageIds) {
|
|
||||||
await this.attachmentQueue.add(
|
|
||||||
QueueJob.DELETE_PAGE_ATTACHMENTS,
|
|
||||||
{
|
|
||||||
pageId: id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
jobId: `delete-page-attachments-${id}`,
|
|
||||||
attempts: 3,
|
|
||||||
backoff: {
|
|
||||||
type: 'exponential',
|
|
||||||
delay: 5000,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (pageIds.length > 0) {
|
|
||||||
await this.db.deleteFrom('pages').where('id', 'in', pageIds).execute();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Log but don't throw - pages might have been deleted by another node
|
|
||||||
this.logger.warn(
|
|
||||||
`Error deleting pages, they may have been already deleted: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -44,22 +44,15 @@ export class SearchService {
|
|||||||
'creatorId',
|
'creatorId',
|
||||||
'createdAt',
|
'createdAt',
|
||||||
'updatedAt',
|
'updatedAt',
|
||||||
sql<number>`ts_rank(tsv, to_tsquery('english', f_unaccent(${searchQuery})))`.as(
|
sql<number>`ts_rank(tsv, to_tsquery(${searchQuery}))`.as('rank'),
|
||||||
'rank',
|
sql<string>`ts_headline('english', text_content, to_tsquery(${searchQuery}),'MinWords=9, MaxWords=10, MaxFragments=3')`.as(
|
||||||
),
|
|
||||||
sql<string>`ts_headline('english', text_content, to_tsquery('english', f_unaccent(${searchQuery})),'MinWords=9, MaxWords=10, MaxFragments=3')`.as(
|
|
||||||
'highlight',
|
'highlight',
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
.where(
|
.where('tsv', '@@', sql<string>`to_tsquery(${searchQuery})`)
|
||||||
'tsv',
|
|
||||||
'@@',
|
|
||||||
sql<string>`to_tsquery('english', f_unaccent(${searchQuery}))`,
|
|
||||||
)
|
|
||||||
.$if(Boolean(searchParams.creatorId), (qb) =>
|
.$if(Boolean(searchParams.creatorId), (qb) =>
|
||||||
qb.where('creatorId', '=', searchParams.creatorId),
|
qb.where('creatorId', '=', searchParams.creatorId),
|
||||||
)
|
)
|
||||||
.where('deletedAt', 'is', null)
|
|
||||||
.orderBy('rank', 'desc')
|
.orderBy('rank', 'desc')
|
||||||
.limit(searchParams.limit | 20)
|
.limit(searchParams.limit | 20)
|
||||||
.offset(searchParams.offset || 0);
|
.offset(searchParams.offset || 0);
|
||||||
@@ -145,37 +138,21 @@ export class SearchService {
|
|||||||
const query = suggestion.query.toLowerCase().trim();
|
const query = suggestion.query.toLowerCase().trim();
|
||||||
|
|
||||||
if (suggestion.includeUsers) {
|
if (suggestion.includeUsers) {
|
||||||
const userQuery = this.db
|
users = await this.db
|
||||||
.selectFrom('users')
|
.selectFrom('users')
|
||||||
.select(['id', 'name', 'email', 'avatarUrl'])
|
.select(['id', 'name', 'email', 'avatarUrl'])
|
||||||
|
.where((eb) => eb(sql`LOWER(users.name)`, 'like', `%${query}%`))
|
||||||
.where('workspaceId', '=', workspaceId)
|
.where('workspaceId', '=', workspaceId)
|
||||||
.where('deletedAt', 'is', null)
|
.where('deletedAt', 'is', null)
|
||||||
.where((eb) =>
|
.limit(limit)
|
||||||
eb.or([
|
.execute();
|
||||||
eb(
|
|
||||||
sql`LOWER(f_unaccent(users.name))`,
|
|
||||||
'like',
|
|
||||||
sql`LOWER(f_unaccent(${`%${query}%`}))`,
|
|
||||||
),
|
|
||||||
eb(sql`users.email`, 'ilike', sql`f_unaccent(${`%${query}%`})`),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
.limit(limit);
|
|
||||||
|
|
||||||
users = await userQuery.execute();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (suggestion.includeGroups) {
|
if (suggestion.includeGroups) {
|
||||||
groups = await this.db
|
groups = await this.db
|
||||||
.selectFrom('groups')
|
.selectFrom('groups')
|
||||||
.select(['id', 'name', 'description'])
|
.select(['id', 'name', 'description'])
|
||||||
.where((eb) =>
|
.where((eb) => eb(sql`LOWER(groups.name)`, 'like', `%${query}%`))
|
||||||
eb(
|
|
||||||
sql`LOWER(f_unaccent(groups.name))`,
|
|
||||||
'like',
|
|
||||||
sql`LOWER(f_unaccent(${`%${query}%`}))`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.where('workspaceId', '=', workspaceId)
|
.where('workspaceId', '=', workspaceId)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.execute();
|
.execute();
|
||||||
@@ -185,14 +162,7 @@ export class SearchService {
|
|||||||
let pageSearch = this.db
|
let pageSearch = this.db
|
||||||
.selectFrom('pages')
|
.selectFrom('pages')
|
||||||
.select(['id', 'slugId', 'title', 'icon', 'spaceId'])
|
.select(['id', 'slugId', 'title', 'icon', 'spaceId'])
|
||||||
.where((eb) =>
|
.where((eb) => eb(sql`LOWER(pages.title)`, 'like', `%${query}%`))
|
||||||
eb(
|
|
||||||
sql`LOWER(f_unaccent(pages.title))`,
|
|
||||||
'like',
|
|
||||||
sql`LOWER(f_unaccent(${`%${query}%`}))`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.where('deletedAt', 'is', null)
|
|
||||||
.where('workspaceId', '=', workspaceId)
|
.where('workspaceId', '=', workspaceId)
|
||||||
.limit(limit);
|
.limit(limit);
|
||||||
|
|
||||||
|
|||||||
@@ -108,12 +108,12 @@ export class ShareService {
|
|||||||
includeCreator: true,
|
includeCreator: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!page || page.deletedAt) {
|
page.content = await this.updatePublicAttachments(page);
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
throw new NotFoundException('Shared page not found');
|
throw new NotFoundException('Shared page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
page.content = await this.updatePublicAttachments(page);
|
|
||||||
|
|
||||||
return { page, share };
|
return { page, share };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +132,6 @@ export class ShareService {
|
|||||||
sql`0`.as('level'),
|
sql`0`.as('level'),
|
||||||
])
|
])
|
||||||
.where(isValidUUID(pageId) ? 'id' : 'slugId', '=', pageId)
|
.where(isValidUUID(pageId) ? 'id' : 'slugId', '=', pageId)
|
||||||
.where('deletedAt', 'is', null)
|
|
||||||
.unionAll((union) =>
|
.unionAll((union) =>
|
||||||
union
|
union
|
||||||
.selectFrom('pages as p')
|
.selectFrom('pages as p')
|
||||||
@@ -145,8 +144,7 @@ export class ShareService {
|
|||||||
// Increase the level by 1 for each ancestor.
|
// Increase the level by 1 for each ancestor.
|
||||||
sql`ph.level + 1`.as('level'),
|
sql`ph.level + 1`.as('level'),
|
||||||
])
|
])
|
||||||
.innerJoin('page_hierarchy as ph', 'ph.parentPageId', 'p.id')
|
.innerJoin('page_hierarchy as ph', 'ph.parentPageId', 'p.id'),
|
||||||
.where('p.deletedAt', 'is', null),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.selectFrom('page_hierarchy')
|
.selectFrom('page_hierarchy')
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { AcceptInviteDto, InviteUserDto } from '../dto/invitation.dto';
|
|||||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
import { sql } from 'kysely';
|
|
||||||
import { executeTx } from '@docmost/db/utils';
|
import { executeTx } from '@docmost/db/utils';
|
||||||
import {
|
import {
|
||||||
Group,
|
Group,
|
||||||
@@ -56,11 +55,7 @@ export class WorkspaceInvitationService {
|
|||||||
|
|
||||||
if (pagination.query) {
|
if (pagination.query) {
|
||||||
query = query.where((eb) =>
|
query = query.where((eb) =>
|
||||||
eb(
|
eb('email', 'ilike', `%${pagination.query}%`),
|
||||||
sql`email`,
|
|
||||||
'ilike',
|
|
||||||
sql`f_unaccent(${'%' + pagination.query + '%'})`,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
import { type Kysely, sql } from 'kysely';
|
|
||||||
|
|
||||||
export async function up(db: Kysely<any>): Promise<void> {
|
|
||||||
// Add last_edited_by_id column to comments table
|
|
||||||
await db.schema
|
|
||||||
.alterTable('comments')
|
|
||||||
.addColumn('last_edited_by_id', 'uuid', (col) =>
|
|
||||||
col.references('users.id').onDelete('set null'),
|
|
||||||
)
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
// Add resolved_by_id column to comments table
|
|
||||||
await db.schema
|
|
||||||
.alterTable('comments')
|
|
||||||
.addColumn('resolved_by_id', 'uuid', (col) =>
|
|
||||||
col.references('users.id').onDelete('set null'),
|
|
||||||
)
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
// Add updated_at timestamp column to comments table
|
|
||||||
await db.schema
|
|
||||||
.alterTable('comments')
|
|
||||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
|
||||||
col.notNull().defaultTo(sql`now()`),
|
|
||||||
)
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
// Add space_id column to comments table
|
|
||||||
await db.schema
|
|
||||||
.alterTable('comments')
|
|
||||||
.addColumn('space_id', 'uuid', (col) =>
|
|
||||||
col.references('spaces.id').onDelete('cascade'),
|
|
||||||
)
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
// Backfill space_id from the related pages
|
|
||||||
await db
|
|
||||||
.updateTable('comments as c')
|
|
||||||
.set((eb) => ({
|
|
||||||
space_id: eb.ref('p.space_id'),
|
|
||||||
}))
|
|
||||||
.from('pages as p')
|
|
||||||
.whereRef('c.page_id', '=', 'p.id')
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
// Make space_id NOT NULL after populating data
|
|
||||||
await db.schema
|
|
||||||
.alterTable('comments')
|
|
||||||
.alterColumn('space_id', (col) => col.setNotNull())
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function down(db: Kysely<any>): Promise<void> {
|
|
||||||
await db.schema
|
|
||||||
.alterTable('comments')
|
|
||||||
.dropColumn('last_edited_by_id')
|
|
||||||
.execute();
|
|
||||||
await db.schema.alterTable('comments').dropColumn('resolved_by_id').execute();
|
|
||||||
await db.schema.alterTable('comments').dropColumn('updated_at').execute();
|
|
||||||
await db.schema.alterTable('comments').dropColumn('space_id').execute();
|
|
||||||
}
|
|
||||||
-50
@@ -1,50 +0,0 @@
|
|||||||
import { type Kysely, sql } from 'kysely';
|
|
||||||
|
|
||||||
export async function up(db: Kysely<any>): Promise<void> {
|
|
||||||
// Create unaccent extension
|
|
||||||
await sql`CREATE EXTENSION IF NOT EXISTS unaccent`.execute(db);
|
|
||||||
|
|
||||||
// Create pg_trgm extension
|
|
||||||
await sql`CREATE EXTENSION IF NOT EXISTS pg_trgm`.execute(db);
|
|
||||||
|
|
||||||
// Create IMMUTABLE wrapper function for unaccent
|
|
||||||
// This allows us to create indexes on unaccented columns for better performance
|
|
||||||
// https://stackoverflow.com/a/11007216/8299075
|
|
||||||
await sql`
|
|
||||||
CREATE OR REPLACE FUNCTION f_unaccent(text) RETURNS text
|
|
||||||
AS $$
|
|
||||||
SELECT unaccent('unaccent', $1);
|
|
||||||
$$ LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT;
|
|
||||||
`.execute(db);
|
|
||||||
|
|
||||||
// Update the pages tsvector trigger to use the immutable function
|
|
||||||
await sql`
|
|
||||||
CREATE OR REPLACE FUNCTION pages_tsvector_trigger() RETURNS trigger AS $$
|
|
||||||
begin
|
|
||||||
new.tsv :=
|
|
||||||
setweight(to_tsvector('english', f_unaccent(coalesce(new.title, ''))), 'A') ||
|
|
||||||
setweight(to_tsvector('english', f_unaccent(substring(coalesce(new.text_content, ''), 1, 1000000))), 'B');
|
|
||||||
return new;
|
|
||||||
end;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
`.execute(db);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function down(db: Kysely<any>): Promise<void> {
|
|
||||||
await sql`
|
|
||||||
CREATE OR REPLACE FUNCTION pages_tsvector_trigger() RETURNS trigger AS $$
|
|
||||||
begin
|
|
||||||
new.tsv :=
|
|
||||||
setweight(to_tsvector('english', coalesce(new.title, '')), 'A') ||
|
|
||||||
setweight(to_tsvector('english', coalesce(new.text_content, '')), 'B');
|
|
||||||
return new;
|
|
||||||
end;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
`.execute(db);
|
|
||||||
|
|
||||||
await sql`DROP FUNCTION IF EXISTS f_unaccent(text)`.execute(db);
|
|
||||||
|
|
||||||
await sql`DROP EXTENSION IF EXISTS pg_trgm`.execute(db);
|
|
||||||
|
|
||||||
await sql`DROP EXTENSION IF EXISTS unaccent`.execute(db);
|
|
||||||
}
|
|
||||||
@@ -20,13 +20,12 @@ export class CommentRepo {
|
|||||||
// todo, add workspaceId
|
// todo, add workspaceId
|
||||||
async findById(
|
async findById(
|
||||||
commentId: string,
|
commentId: string,
|
||||||
opts?: { includeCreator: boolean; includeResolvedBy: boolean },
|
opts?: { includeCreator: boolean },
|
||||||
): Promise<Comment> {
|
): Promise<Comment> {
|
||||||
return await this.db
|
return await this.db
|
||||||
.selectFrom('comments')
|
.selectFrom('comments')
|
||||||
.selectAll('comments')
|
.selectAll('comments')
|
||||||
.$if(opts?.includeCreator, (qb) => qb.select(this.withCreator))
|
.$if(opts?.includeCreator, (qb) => qb.select(this.withCreator))
|
||||||
.$if(opts?.includeResolvedBy, (qb) => qb.select(this.withResolvedBy))
|
|
||||||
.where('id', '=', commentId)
|
.where('id', '=', commentId)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
@@ -36,7 +35,6 @@ export class CommentRepo {
|
|||||||
.selectFrom('comments')
|
.selectFrom('comments')
|
||||||
.selectAll('comments')
|
.selectAll('comments')
|
||||||
.select((eb) => this.withCreator(eb))
|
.select((eb) => this.withCreator(eb))
|
||||||
.select((eb) => this.withResolvedBy(eb))
|
|
||||||
.where('pageId', '=', pageId)
|
.where('pageId', '=', pageId)
|
||||||
.orderBy('createdAt', 'asc');
|
.orderBy('createdAt', 'asc');
|
||||||
|
|
||||||
@@ -82,37 +80,7 @@ export class CommentRepo {
|
|||||||
).as('creator');
|
).as('creator');
|
||||||
}
|
}
|
||||||
|
|
||||||
withResolvedBy(eb: ExpressionBuilder<DB, 'comments'>) {
|
|
||||||
return jsonObjectFrom(
|
|
||||||
eb
|
|
||||||
.selectFrom('users')
|
|
||||||
.select(['users.id', 'users.name', 'users.avatarUrl'])
|
|
||||||
.whereRef('users.id', '=', 'comments.resolvedById'),
|
|
||||||
).as('resolvedBy');
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteComment(commentId: string): Promise<void> {
|
async deleteComment(commentId: string): Promise<void> {
|
||||||
await this.db.deleteFrom('comments').where('id', '=', commentId).execute();
|
await this.db.deleteFrom('comments').where('id', '=', commentId).execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
async hasChildren(commentId: string): Promise<boolean> {
|
|
||||||
const result = await this.db
|
|
||||||
.selectFrom('comments')
|
|
||||||
.select((eb) => eb.fn.count('id').as('count'))
|
|
||||||
.where('parentCommentId', '=', commentId)
|
|
||||||
.executeTakeFirst();
|
|
||||||
|
|
||||||
return Number(result?.count) > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async hasChildrenFromOtherUsers(commentId: string, userId: string): Promise<boolean> {
|
|
||||||
const result = await this.db
|
|
||||||
.selectFrom('comments')
|
|
||||||
.select((eb) => eb.fn.count('id').as('count'))
|
|
||||||
.where('parentCommentId', '=', commentId)
|
|
||||||
.where('creatorId', '!=', userId)
|
|
||||||
.executeTakeFirst();
|
|
||||||
|
|
||||||
return Number(result?.count) > 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||||
import { dbOrTx, executeTx } from '@docmost/db/utils';
|
import { dbOrTx, executeTx } from '@docmost/db/utils';
|
||||||
import { sql } from 'kysely';
|
|
||||||
import { GroupUser, InsertableGroupUser } from '@docmost/db/types/entity.types';
|
import { GroupUser, InsertableGroupUser } from '@docmost/db/types/entity.types';
|
||||||
import { PaginationOptions } from '../../pagination/pagination-options';
|
import { PaginationOptions } from '../../pagination/pagination-options';
|
||||||
import { executeWithPagination } from '@docmost/db/pagination/pagination';
|
import { executeWithPagination } from '@docmost/db/pagination/pagination';
|
||||||
@@ -57,7 +56,7 @@ export class GroupUserRepo {
|
|||||||
|
|
||||||
if (pagination.query) {
|
if (pagination.query) {
|
||||||
query = query.where((eb) =>
|
query = query.where((eb) =>
|
||||||
eb(sql`f_unaccent(users.name)`, 'ilike', sql`f_unaccent(${'%' + pagination.query + '%'})`),
|
eb('users.name', 'ilike', `%${pagination.query}%`),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -114,10 +114,10 @@ export class GroupRepo {
|
|||||||
|
|
||||||
if (pagination.query) {
|
if (pagination.query) {
|
||||||
query = query.where((eb) =>
|
query = query.where((eb) =>
|
||||||
eb(sql`f_unaccent(name)`, 'ilike', sql`f_unaccent(${'%' + pagination.query + '%'})`).or(
|
eb('name', 'ilike', `%${pagination.query}%`).or(
|
||||||
sql`f_unaccent(description)`,
|
'description',
|
||||||
'ilike',
|
'ilike',
|
||||||
sql`f_unaccent(${'%' + pagination.query + '%'})`,
|
`%${pagination.query}%`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
|
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
|
||||||
import { dbOrTx, executeTx } from '../../utils';
|
import { dbOrTx } from '../../utils';
|
||||||
import {
|
import {
|
||||||
InsertablePage,
|
InsertablePage,
|
||||||
Page,
|
Page,
|
||||||
@@ -22,24 +22,6 @@ export class PageRepo {
|
|||||||
private spaceMemberRepo: SpaceMemberRepo,
|
private spaceMemberRepo: SpaceMemberRepo,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
withHasChildren(eb: ExpressionBuilder<DB, 'pages'>) {
|
|
||||||
return eb
|
|
||||||
.selectFrom('pages as child')
|
|
||||||
.select((eb) =>
|
|
||||||
eb
|
|
||||||
.case()
|
|
||||||
.when(eb.fn.countAll(), '>', 0)
|
|
||||||
.then(true)
|
|
||||||
.else(false)
|
|
||||||
.end()
|
|
||||||
.as('count'),
|
|
||||||
)
|
|
||||||
.whereRef('child.parentPageId', '=', 'pages.id')
|
|
||||||
.where('child.deletedAt', 'is', null)
|
|
||||||
.limit(1)
|
|
||||||
.as('hasChildren');
|
|
||||||
}
|
|
||||||
|
|
||||||
private baseFields: Array<keyof Page> = [
|
private baseFields: Array<keyof Page> = [
|
||||||
'id',
|
'id',
|
||||||
'slugId',
|
'slugId',
|
||||||
@@ -68,7 +50,6 @@ export class PageRepo {
|
|||||||
includeCreator?: boolean;
|
includeCreator?: boolean;
|
||||||
includeLastUpdatedBy?: boolean;
|
includeLastUpdatedBy?: boolean;
|
||||||
includeContributors?: boolean;
|
includeContributors?: boolean;
|
||||||
includeHasChildren?: boolean;
|
|
||||||
withLock?: boolean;
|
withLock?: boolean;
|
||||||
trx?: KyselyTransaction;
|
trx?: KyselyTransaction;
|
||||||
},
|
},
|
||||||
@@ -79,10 +60,7 @@ export class PageRepo {
|
|||||||
.selectFrom('pages')
|
.selectFrom('pages')
|
||||||
.select(this.baseFields)
|
.select(this.baseFields)
|
||||||
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
||||||
.$if(opts?.includeYdoc, (qb) => qb.select('ydoc'))
|
.$if(opts?.includeYdoc, (qb) => qb.select('ydoc'));
|
||||||
.$if(opts?.includeHasChildren, (qb) =>
|
|
||||||
qb.select((eb) => this.withHasChildren(eb)),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (opts?.includeCreator) {
|
if (opts?.includeCreator) {
|
||||||
query = query.select((eb) => this.withCreator(eb));
|
query = query.select((eb) => this.withCreator(eb));
|
||||||
@@ -161,113 +139,12 @@ export class PageRepo {
|
|||||||
await query.execute();
|
await query.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
async removePage(pageId: string, deletedById: string): Promise<void> {
|
|
||||||
const currentDate = new Date();
|
|
||||||
|
|
||||||
const descendants = await this.db
|
|
||||||
.withRecursive('page_descendants', (db) =>
|
|
||||||
db
|
|
||||||
.selectFrom('pages')
|
|
||||||
.select(['id'])
|
|
||||||
.where('id', '=', pageId)
|
|
||||||
.unionAll((exp) =>
|
|
||||||
exp
|
|
||||||
.selectFrom('pages as p')
|
|
||||||
.select(['p.id'])
|
|
||||||
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.selectFrom('page_descendants')
|
|
||||||
.selectAll()
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
const pageIds = descendants.map((d) => d.id);
|
|
||||||
|
|
||||||
if (pageIds.length > 0) {
|
|
||||||
await executeTx(this.db, async (trx) => {
|
|
||||||
await trx
|
|
||||||
.updateTable('pages')
|
|
||||||
.set({
|
|
||||||
deletedById: deletedById,
|
|
||||||
deletedAt: currentDate,
|
|
||||||
})
|
|
||||||
.where('id', 'in', pageIds)
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
await trx.deleteFrom('shares').where('pageId', 'in', pageIds).execute();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async restorePage(pageId: string): Promise<void> {
|
|
||||||
// First, check if the page being restored has a deleted parent
|
|
||||||
const pageToRestore = await this.db
|
|
||||||
.selectFrom('pages')
|
|
||||||
.select(['id', 'parentPageId'])
|
|
||||||
.where('id', '=', pageId)
|
|
||||||
.executeTakeFirst();
|
|
||||||
|
|
||||||
if (!pageToRestore) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the parent is also deleted
|
|
||||||
let shouldDetachFromParent = false;
|
|
||||||
if (pageToRestore.parentPageId) {
|
|
||||||
const parent = await this.db
|
|
||||||
.selectFrom('pages')
|
|
||||||
.select(['id', 'deletedAt'])
|
|
||||||
.where('id', '=', pageToRestore.parentPageId)
|
|
||||||
.executeTakeFirst();
|
|
||||||
|
|
||||||
// If parent is deleted, we should detach this page from it
|
|
||||||
shouldDetachFromParent = parent?.deletedAt !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find all descendants to restore
|
|
||||||
const pages = await this.db
|
|
||||||
.withRecursive('page_descendants', (db) =>
|
|
||||||
db
|
|
||||||
.selectFrom('pages')
|
|
||||||
.select(['id'])
|
|
||||||
.where('id', '=', pageId)
|
|
||||||
.unionAll((exp) =>
|
|
||||||
exp
|
|
||||||
.selectFrom('pages as p')
|
|
||||||
.select(['p.id'])
|
|
||||||
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.selectFrom('page_descendants')
|
|
||||||
.selectAll()
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
const pageIds = pages.map((p) => p.id);
|
|
||||||
|
|
||||||
// Restore all pages, but only detach the root page if its parent is deleted
|
|
||||||
await this.db
|
|
||||||
.updateTable('pages')
|
|
||||||
.set({ deletedById: null, deletedAt: null })
|
|
||||||
.where('id', 'in', pageIds)
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
// If we need to detach the restored page from its deleted parent
|
|
||||||
if (shouldDetachFromParent) {
|
|
||||||
await this.db
|
|
||||||
.updateTable('pages')
|
|
||||||
.set({ parentPageId: null })
|
|
||||||
.where('id', '=', pageId)
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getRecentPagesInSpace(spaceId: string, pagination: PaginationOptions) {
|
async getRecentPagesInSpace(spaceId: string, pagination: PaginationOptions) {
|
||||||
const query = this.db
|
const query = this.db
|
||||||
.selectFrom('pages')
|
.selectFrom('pages')
|
||||||
.select(this.baseFields)
|
.select(this.baseFields)
|
||||||
.select((eb) => this.withSpace(eb))
|
.select((eb) => this.withSpace(eb))
|
||||||
.where('spaceId', '=', spaceId)
|
.where('spaceId', '=', spaceId)
|
||||||
.where('deletedAt', 'is', null)
|
|
||||||
.orderBy('updatedAt', 'desc');
|
.orderBy('updatedAt', 'desc');
|
||||||
|
|
||||||
const result = executeWithPagination(query, {
|
const result = executeWithPagination(query, {
|
||||||
@@ -286,7 +163,6 @@ export class PageRepo {
|
|||||||
.select(this.baseFields)
|
.select(this.baseFields)
|
||||||
.select((eb) => this.withSpace(eb))
|
.select((eb) => this.withSpace(eb))
|
||||||
.where('spaceId', 'in', userSpaceIds)
|
.where('spaceId', 'in', userSpaceIds)
|
||||||
.where('deletedAt', 'is', null)
|
|
||||||
.orderBy('updatedAt', 'desc');
|
.orderBy('updatedAt', 'desc');
|
||||||
|
|
||||||
const hasEmptyIds = userSpaceIds.length === 0;
|
const hasEmptyIds = userSpaceIds.length === 0;
|
||||||
@@ -299,41 +175,6 @@ export class PageRepo {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDeletedPagesInSpace(spaceId: string, pagination: PaginationOptions) {
|
|
||||||
const query = this.db
|
|
||||||
.selectFrom('pages')
|
|
||||||
.select(this.baseFields)
|
|
||||||
.select('content')
|
|
||||||
.select((eb) => this.withSpace(eb))
|
|
||||||
.select((eb) => this.withDeletedBy(eb))
|
|
||||||
.where('spaceId', '=', spaceId)
|
|
||||||
.where('deletedAt', 'is not', null)
|
|
||||||
// Only include pages that are either root pages (no parent) or whose parent is not deleted
|
|
||||||
// This prevents showing orphaned pages when their parent has been soft-deleted
|
|
||||||
.where((eb) =>
|
|
||||||
eb.or([
|
|
||||||
eb('parentPageId', 'is', null),
|
|
||||||
eb.not(
|
|
||||||
eb.exists(
|
|
||||||
eb
|
|
||||||
.selectFrom('pages as parent')
|
|
||||||
.select('parent.id')
|
|
||||||
.where('parent.id', '=', eb.ref('pages.parentPageId'))
|
|
||||||
.where('parent.deletedAt', 'is not', null),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
.orderBy('deletedAt', 'desc');
|
|
||||||
|
|
||||||
const result = executeWithPagination(query, {
|
|
||||||
page: pagination.page,
|
|
||||||
perPage: pagination.limit,
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
withSpace(eb: ExpressionBuilder<DB, 'pages'>) {
|
withSpace(eb: ExpressionBuilder<DB, 'pages'>) {
|
||||||
return jsonObjectFrom(
|
return jsonObjectFrom(
|
||||||
eb
|
eb
|
||||||
@@ -361,15 +202,6 @@ export class PageRepo {
|
|||||||
).as('lastUpdatedBy');
|
).as('lastUpdatedBy');
|
||||||
}
|
}
|
||||||
|
|
||||||
withDeletedBy(eb: ExpressionBuilder<DB, 'pages'>) {
|
|
||||||
return jsonObjectFrom(
|
|
||||||
eb
|
|
||||||
.selectFrom('users')
|
|
||||||
.select(['users.id', 'users.name', 'users.avatarUrl'])
|
|
||||||
.whereRef('users.id', '=', 'pages.deletedById'),
|
|
||||||
).as('deletedBy');
|
|
||||||
}
|
|
||||||
|
|
||||||
withContributors(eb: ExpressionBuilder<DB, 'pages'>) {
|
withContributors(eb: ExpressionBuilder<DB, 'pages'>) {
|
||||||
return jsonArrayFrom(
|
return jsonArrayFrom(
|
||||||
eb
|
eb
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { BadRequestException, Injectable } from '@nestjs/common';
|
|||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||||
import { dbOrTx } from '@docmost/db/utils';
|
import { dbOrTx } from '@docmost/db/utils';
|
||||||
import { sql } from 'kysely';
|
|
||||||
import {
|
import {
|
||||||
InsertableSpaceMember,
|
InsertableSpaceMember,
|
||||||
SpaceMember,
|
SpaceMember,
|
||||||
@@ -120,21 +119,9 @@ export class SpaceMemberRepo {
|
|||||||
|
|
||||||
if (pagination.query) {
|
if (pagination.query) {
|
||||||
query = query.where((eb) =>
|
query = query.where((eb) =>
|
||||||
eb(
|
eb('users.name', 'ilike', `%${pagination.query}%`)
|
||||||
sql`f_unaccent(users.name)`,
|
.or('users.email', 'ilike', `%${pagination.query}%`)
|
||||||
'ilike',
|
.or('groups.name', 'ilike', `%${pagination.query}%`),
|
||||||
sql`f_unaccent(${'%' + pagination.query + '%'})`,
|
|
||||||
)
|
|
||||||
.or(
|
|
||||||
sql`users.email`,
|
|
||||||
'ilike',
|
|
||||||
sql`f_unaccent(${'%' + pagination.query + '%'})`,
|
|
||||||
)
|
|
||||||
.or(
|
|
||||||
sql`f_unaccent(groups.name)`,
|
|
||||||
'ilike',
|
|
||||||
sql`f_unaccent(${'%' + pagination.query + '%'})`,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,14 +228,10 @@ export class SpaceMemberRepo {
|
|||||||
|
|
||||||
if (pagination.query) {
|
if (pagination.query) {
|
||||||
query = query.where((eb) =>
|
query = query.where((eb) =>
|
||||||
eb(
|
eb('name', 'ilike', `%${pagination.query}%`).or(
|
||||||
sql`f_unaccent(name)`,
|
'description',
|
||||||
'ilike',
|
'ilike',
|
||||||
sql`f_unaccent(${'%' + pagination.query + '%'})`,
|
`%${pagination.query}%`,
|
||||||
).or(
|
|
||||||
sql`f_unaccent(description)`,
|
|
||||||
'ilike',
|
|
||||||
sql`f_unaccent(${'%' + pagination.query + '%'})`,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,10 +110,10 @@ export class SpaceRepo {
|
|||||||
|
|
||||||
if (pagination.query) {
|
if (pagination.query) {
|
||||||
query = query.where((eb) =>
|
query = query.where((eb) =>
|
||||||
eb(sql`f_unaccent(name)`, 'ilike', sql`f_unaccent(${'%' + pagination.query + '%'})`).or(
|
eb('name', 'ilike', `%${pagination.query}%`).or(
|
||||||
sql`f_unaccent(description)`,
|
'description',
|
||||||
'ilike',
|
'ilike',
|
||||||
sql`f_unaccent(${'%' + pagination.query + '%'})`,
|
`%${pagination.query}%`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,14 +149,10 @@ export class UserRepo {
|
|||||||
|
|
||||||
if (pagination.query) {
|
if (pagination.query) {
|
||||||
query = query.where((eb) =>
|
query = query.where((eb) =>
|
||||||
eb(
|
eb('users.name', 'ilike', `%${pagination.query}%`).or(
|
||||||
sql`f_unaccent(users.name)`,
|
'users.email',
|
||||||
'ilike',
|
'ilike',
|
||||||
sql`f_unaccent(${'%' + pagination.query + '%'})`,
|
`%${pagination.query}%`,
|
||||||
).or(
|
|
||||||
sql`users.email`,
|
|
||||||
'ilike',
|
|
||||||
sql`f_unaccent(${'%' + pagination.query + '%'})`,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user