mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 22:53:08 +08:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1615e0f4ad | |||
| 1cb2535de3 | |||
| 83bc273cb0 | |||
| c7beaa3742 | |||
| 4a228e5a51 | |||
| edff375476 | |||
| 95016b2bfc | |||
| ca83712364 | |||
| 39550fe906 | |||
| e74ecb2604 | |||
| 992fb23160 | |||
| d58a3bba9b | |||
| 6ef47fc432 | |||
| 9e6765d83c | |||
| ec0ed5c630 | |||
| 77b334ea37 | |||
| 5da92a538a | |||
| f90c5a636b | |||
| 6db93ef0c7 | |||
| a3d058042f | |||
| 4ab9261cf5 | |||
| ca9558b246 | |||
| ec12e80423 | |||
| 28fcb11cb4 | |||
| 6b627d289c | |||
| 78bce0e29d | |||
| 0bd7ecb9b0 | |||
| 1f815880a4 | |||
| 37b9056070 | |||
| ad5cf1e18b | |||
| 32c7ecd9cf | |||
| b30bf61dc4 | |||
| 662460252f | |||
| 8522844673 | |||
| f8dc9845a7 | |||
| 4dfed2b2af |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.21.0",
|
||||
"version": "0.22.2",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
"Member": "Mitglied",
|
||||
"members": "Mitglieder",
|
||||
"Members": "Mitglieder",
|
||||
"My preferences": "Meine Vorlieben",
|
||||
"My preferences": "Meine Voreinstellungen",
|
||||
"My Profile": "Mein Profil",
|
||||
"My profile": "Mein Profil",
|
||||
"Name": "Name",
|
||||
@@ -213,7 +213,18 @@
|
||||
"Comment deleted successfully": "Kommentar erfolgreich gelöscht",
|
||||
"Failed to delete comment": "Löschen des Kommentars fehlgeschlagen",
|
||||
"Comment resolved successfully": "Kommentar erfolgreich gelöst",
|
||||
"Comment re-opened successfully": "Kommentar erfolgreich wieder geöffnet",
|
||||
"Comment unresolved successfully": "Kommentar erfolgreich ungelöst",
|
||||
"Failed to resolve comment": "Lösen des Kommentars fehlgeschlagen",
|
||||
"Resolve comment": "Kommentar lösen",
|
||||
"Unresolve comment": "Kommentar nicht lösen",
|
||||
"Resolve Comment Thread": "Kommentarthread lösen",
|
||||
"Unresolve Comment Thread": "Kommentarthread nicht lösen",
|
||||
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "Sind Sie sicher, dass Sie diesen Kommentarthread lösen möchten? Dies wird als abgeschlossen markiert.",
|
||||
"Are you sure you want to unresolve this comment thread?": "Sind Sie sicher, dass Sie diesen Kommentarthread nicht lösen möchten?",
|
||||
"Resolved": "Gelöst",
|
||||
"No active comments.": "Keine aktiven Kommentare.",
|
||||
"No resolved comments.": "Keine gelösten Kommentare.",
|
||||
"Revoke invitation": "Einladung widerrufen",
|
||||
"Revoke": "Widerrufen",
|
||||
"Don't": "Nicht",
|
||||
@@ -222,7 +233,9 @@
|
||||
"Anyone with this link can join this workspace.": "Jeder mit diesem Link kann dem Arbeitsbereich beitreten.",
|
||||
"Invite link": "Einladungslink",
|
||||
"Copy": "Kopieren",
|
||||
"Copy to space": "In Raum kopieren",
|
||||
"Copied": "Kopiert",
|
||||
"Duplicate": "Duplizieren",
|
||||
"Select a user": "Benutzer auswählen",
|
||||
"Select a group": "Gruppe auswählen",
|
||||
"Export all pages and attachments in this space.": "Alle Seiten und Anhänge in diesem Bereich exportieren.",
|
||||
@@ -354,6 +367,9 @@
|
||||
"Character count: {{characterCount}}": "Zeichenzahl: {{characterCount}}",
|
||||
"New update": "Neues Update",
|
||||
"{{latestVersion}} is available": "{{latestVersion}} ist verfügbar",
|
||||
"Default page edit mode": "Standard-Seitenbearbeitungsmodus",
|
||||
"Choose your preferred page edit mode. Avoid accidental edits.": "Wählen Sie Ihren bevorzugten Seitenbearbeitungsmodus. Vermeiden Sie versehentliche Bearbeitungen.",
|
||||
"Reading": "Lesen",
|
||||
"Delete member": "Mitglied löschen",
|
||||
"Member deleted successfully": "Mitglied erfolgreich gelöscht",
|
||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "Sind Sie sicher, dass Sie dieses Arbeitsbereichsmitglied löschen möchten? Diese Aktion ist unwiderruflich.",
|
||||
@@ -386,5 +402,98 @@
|
||||
"Failed to share page": "Fehler beim Teilen der Seite",
|
||||
"Copy page": "Seite kopieren",
|
||||
"Copy page to a different space.": "Seite in einen anderen Bereich kopieren.",
|
||||
"Page copied successfully": "Seite erfolgreich kopiert"
|
||||
"Page copied successfully": "Seite erfolgreich kopiert",
|
||||
"Page duplicated successfully": "Seite erfolgreich dupliziert",
|
||||
"Find": "Finden",
|
||||
"Not found": "Nicht gefunden",
|
||||
"Previous Match (Shift+Enter)": "Vorheriger Treffer (Shift+Enter)",
|
||||
"Next match (Enter)": "Nächster Treffer (Enter)",
|
||||
"Match case (Alt+C)": "Groß-/Kleinschreibung beachten (Alt+C)",
|
||||
"Replace": "Ersetzen",
|
||||
"Close (Escape)": "Schließen (Escape)",
|
||||
"Replace (Enter)": "Ersetzen (Enter)",
|
||||
"Replace all (Ctrl+Alt+Enter)": "Alle ersetzen (Ctrl+Alt+Enter)",
|
||||
"Replace all": "Alle ersetzen",
|
||||
"View all spaces": "Alle Räume anzeigen",
|
||||
"Error": "Fehler",
|
||||
"Failed to disable MFA": "Deaktivierung der MFA fehlgeschlagen",
|
||||
"Disable two-factor authentication": "Zwei-Faktor-Authentifizierung deaktivieren",
|
||||
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "Die Deaktivierung der Zwei-Faktor-Authentifizierung macht Ihr Konto weniger sicher. Sie benötigen nur Ihr Passwort, um sich anzumelden.",
|
||||
"Please enter your password to disable two-factor authentication:": "Bitte geben Sie Ihr Passwort ein, um die Zwei-Faktor-Authentifizierung zu deaktivieren:",
|
||||
"Two-factor authentication has been enabled": "Zwei-Faktor-Authentifizierung wurde aktiviert",
|
||||
"Two-factor authentication has been disabled": "Zwei-Faktor-Authentifizierung wurde deaktiviert",
|
||||
"2-step verification": "2-Schritt-Verifizierung",
|
||||
"Protect your account with an additional verification layer when signing in.": "Schützen Sie Ihr Konto mit einer zusätzlichen Verifizierungsschicht beim Anmelden.",
|
||||
"Two-factor authentication is active on your account.": "Die Zwei-Faktor-Authentifizierung ist auf Ihrem Konto aktiv.",
|
||||
"Add 2FA method": "2FA-Methode hinzufügen",
|
||||
"Backup codes": "Sicherungscodes",
|
||||
"Disable": "Deaktivieren",
|
||||
"Invalid verification code": "Ungültiger Bestätigungscode",
|
||||
"New backup codes have been generated": "Neue Sicherungscodes wurden generiert",
|
||||
"Failed to regenerate backup codes": "Fehler beim Generieren neuer Sicherungscodes",
|
||||
"About backup codes": "Über Sicherungscodes",
|
||||
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Sicherungscodes können verwendet werden, um auf Ihr Konto zuzugreifen, wenn Sie den Zugang zu Ihrer Authenticator-App verlieren. Jeder Code kann nur einmal verwendet werden.",
|
||||
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "Sie können jederzeit neue Sicherungscodes generieren. Dies wird alle vorhandenen Codes ungültig machen.",
|
||||
"Confirm password": "Passwort bestätigen",
|
||||
"Generate new backup codes": "Neue Sicherungscodes generieren",
|
||||
"Save your new backup codes": "Speichern Sie Ihre neuen Sicherungscodes",
|
||||
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "Speichern Sie diese Codes an einem sicheren Ort. Ihre alten Sicherungscodes sind nicht mehr gültig.",
|
||||
"Your new backup codes": "Ihre neuen Sicherungscodes",
|
||||
"I've saved my backup codes": "Ich habe meine Sicherungscodes gespeichert",
|
||||
"Failed to setup MFA": "Fehler beim Einrichten der MFA",
|
||||
"Setup & Verify": "Einrichten & Überprüfen",
|
||||
"Add to authenticator": "Zum Authenticator hinzufügen",
|
||||
"1. Scan this QR code with your authenticator app": "1. Scannen Sie diesen QR-Code mit Ihrer Authenticator-App",
|
||||
"Can't scan the code?": "Code kann nicht gescannt werden?",
|
||||
"Enter this code manually in your authenticator app:": "Geben Sie diesen Code manuell in Ihrer Authenticator-App ein:",
|
||||
"2. Enter the 6-digit code from your authenticator": "2. Geben Sie den 6-stelligen Code aus Ihrem Authenticator ein",
|
||||
"Verify and enable": "Überprüfen und aktivieren",
|
||||
"Failed to generate QR code. Please try again.": "Fehler beim Generieren des QR-Codes. Bitte versuchen Sie es erneut.",
|
||||
"Backup": "Sicherung",
|
||||
"Save codes": "Codes speichern",
|
||||
"Save your backup codes": "Speichern Sie Ihre Sicherungscodes",
|
||||
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Diese Codes können verwendet werden, um auf Ihr Konto zuzugreifen, wenn Sie den Zugang zu Ihrer Authenticator-App verlieren. Jeder Code kann nur einmal verwendet werden.",
|
||||
"Print": "Drucken",
|
||||
"Two-factor authentication has been set up. Please log in again.": "Zwei-Faktor-Authentifizierung wurde eingerichtet. Bitte melden Sie sich erneut an.",
|
||||
"Two-Factor authentication required": "Zwei-Faktor-Authentifizierung erforderlich",
|
||||
"Your workspace requires two-factor authentication for all users": "Ihr Arbeitsbereich erfordert die Zwei-Faktor-Authentifizierung für alle Benutzer",
|
||||
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "Um weiterhin auf Ihren Arbeitsbereich zuzugreifen, müssen Sie die Zwei-Faktor-Authentifizierung einrichten. Dies fügt Ihrem Konto eine zusätzliche Sicherheitsebene hinzu.",
|
||||
"Set up two-factor authentication": "Zwei-Faktor-Authentifizierung einrichten",
|
||||
"Cancel and logout": "Abbrechen und abmelden",
|
||||
"Your workspace requires two-factor authentication. Please set it up to continue.": "Ihr Arbeitsbereich erfordert eine Zwei-Faktor-Authentifizierung. Bitte richten Sie diese ein, um fortzufahren.",
|
||||
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "Dadurch wird Ihrem Konto eine zusätzliche Sicherheitsebene hinzugefügt, indem ein Bestätigungscode von Ihrer Authenticator-App verlangt wird.",
|
||||
"Password is required": "Passwort erforderlich",
|
||||
"Password must be at least 8 characters": "Passwort muss mindestens 8 Zeichen lang sein",
|
||||
"Please enter a 6-digit code": "Bitte geben Sie einen 6-stelligen Code ein",
|
||||
"Code must be exactly 6 digits": "Code muss genau 6-stellig sein",
|
||||
"Enter the 6-digit code found in your authenticator app": "Geben Sie den 6-stelligen Code ein, der in Ihrer Authenticator-App zu finden ist",
|
||||
"Need help authenticating?": "Brauchen Sie Hilfe bei der Authentifizierung?",
|
||||
"MFA QR Code": "MFA QR-Code",
|
||||
"Account created successfully. Please log in to set up two-factor authentication.": "Konto erfolgreich erstellt. Bitte melden Sie sich an, um die Zwei-Faktor-Authentifizierung einzurichten.",
|
||||
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "Passwort erfolgreich zurückgesetzt. Bitte melden Sie sich mit Ihrem neuen Passwort an und führen Sie die Zwei-Faktor-Authentifizierung durch.",
|
||||
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "Passwort erfolgreich zurückgesetzt. Bitte melden Sie sich mit Ihrem neuen Passwort an, um die Zwei-Faktor-Authentifizierung einzurichten.",
|
||||
"Password reset was successful. Please log in with your new password.": "Passwort erfolgreich zurückgesetzt. Bitte melden Sie sich mit Ihrem neuen Passwort an.",
|
||||
"Two-factor authentication": "Zwei-Faktor-Authentifizierung",
|
||||
"Use authenticator app instead": "Stattdessen Authenticator-App verwenden",
|
||||
"Verify backup code": "Sicherungscode überprüfen",
|
||||
"Use backup code": "Sicherungscode verwenden",
|
||||
"Enter one of your backup codes": "Geben Sie einen Ihrer Sicherungscodes ein",
|
||||
"Backup code": "Sicherungscode",
|
||||
"Enter one of your backup codes. Each backup code can only be used once.": "Geben Sie einen Ihrer Sicherungscodes ein. Jeder Sicherungscode kann nur einmal verwendet werden.",
|
||||
"Verify": "Überprüfen",
|
||||
"Trash": "Papierkorb",
|
||||
"Pages in trash will be permanently deleted after 30 days.": "Seiten im Papierkorb werden nach 30 Tagen endgültig gelöscht.",
|
||||
"Deleted": "Gelöscht",
|
||||
"No pages in trash": "Keine Seiten im Papierkorb",
|
||||
"Permanently delete page?": "Seite endgültig löschen?",
|
||||
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Sind Sie sicher, dass Sie '{{title}}' endgültig löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"Restore '{{title}}' and its sub-pages?": "'{{title}}' und seine Unterseiten wiederherstellen?",
|
||||
"Move to trash": "In den Papierkorb verschieben",
|
||||
"Move this page to trash?": "Diese Seite in den Papierkorb verschieben?",
|
||||
"Restore page": "Seite wiederherstellen",
|
||||
"Page moved to trash": "Seite in den Papierkorb verschoben",
|
||||
"Page restored successfully": "Seite erfolgreich wiederhergestellt",
|
||||
"Deleted by": "Gelöscht von",
|
||||
"Deleted at": "Gelöscht am",
|
||||
"Preview": "Vorschau"
|
||||
}
|
||||
|
||||
@@ -213,7 +213,18 @@
|
||||
"Comment deleted successfully": "Comment deleted successfully",
|
||||
"Failed to delete comment": "Failed to delete comment",
|
||||
"Comment resolved successfully": "Comment resolved successfully",
|
||||
"Comment re-opened successfully": "Comment re-opened successfully",
|
||||
"Comment unresolved successfully": "Comment unresolved successfully",
|
||||
"Failed to resolve comment": "Failed to resolve comment",
|
||||
"Resolve comment": "Resolve comment",
|
||||
"Unresolve comment": "Unresolve comment",
|
||||
"Resolve Comment Thread": "Resolve Comment Thread",
|
||||
"Unresolve Comment Thread": "Unresolve Comment Thread",
|
||||
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "Are you sure you want to resolve this comment thread? This will mark it as completed.",
|
||||
"Are you sure you want to unresolve this comment thread?": "Are you sure you want to unresolve this comment thread?",
|
||||
"Resolved": "Resolved",
|
||||
"No active comments.": "No active comments.",
|
||||
"No resolved comments.": "No resolved comments.",
|
||||
"Revoke invitation": "Revoke invitation",
|
||||
"Revoke": "Revoke",
|
||||
"Don't": "Don't",
|
||||
@@ -222,7 +233,9 @@
|
||||
"Anyone with this link can join this workspace.": "Anyone with this link can join this workspace.",
|
||||
"Invite link": "Invite link",
|
||||
"Copy": "Copy",
|
||||
"Copy to space": "Copy to space",
|
||||
"Copied": "Copied",
|
||||
"Duplicate": "Duplicate",
|
||||
"Select a user": "Select a user",
|
||||
"Select a group": "Select a group",
|
||||
"Export all pages and attachments in this space.": "Export all pages and attachments in this space.",
|
||||
@@ -390,6 +403,7 @@
|
||||
"Copy page": "Copy page",
|
||||
"Copy page to a different space.": "Copy page to a different space.",
|
||||
"Page copied successfully": "Page copied successfully",
|
||||
"Page duplicated successfully": "Page duplicated successfully",
|
||||
"Find": "Find",
|
||||
"Not found": "Not found",
|
||||
"Previous Match (Shift+Enter)": "Previous Match (Shift+Enter)",
|
||||
@@ -400,6 +414,7 @@
|
||||
"Replace (Enter)": "Replace (Enter)",
|
||||
"Replace all (Ctrl+Alt+Enter)": "Replace all (Ctrl+Alt+Enter)",
|
||||
"Replace all": "Replace all",
|
||||
"View all spaces": "View all spaces",
|
||||
"Error": "Error",
|
||||
"Failed to disable MFA": "Failed to disable MFA",
|
||||
"Disable two-factor authentication": "Disable two-factor authentication",
|
||||
@@ -465,5 +480,20 @@
|
||||
"Enter one of your backup codes": "Enter one of your backup codes",
|
||||
"Backup code": "Backup code",
|
||||
"Enter one of your backup codes. Each backup code can only be used once.": "Enter one of your backup codes. Each backup code can only be used once.",
|
||||
"Verify": "Verify"
|
||||
"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,7 +213,18 @@
|
||||
"Comment deleted successfully": "Comentario eliminado con éxito",
|
||||
"Failed to delete comment": "No se pudo eliminar el comentario",
|
||||
"Comment resolved successfully": "Comentario resuelto con éxito",
|
||||
"Comment re-opened successfully": "Comentario reabierto con éxito",
|
||||
"Comment unresolved successfully": "Comentario no resuelto con éxito",
|
||||
"Failed to resolve comment": "No se pudo resolver el comentario",
|
||||
"Resolve comment": "Resolver comentario",
|
||||
"Unresolve comment": "No resolver comentario",
|
||||
"Resolve Comment Thread": "Resolver hilo de comentarios",
|
||||
"Unresolve Comment Thread": "No resolver hilo de comentarios",
|
||||
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "¿Está seguro de que desea resolver este hilo de comentarios? Esto lo marcará como completado.",
|
||||
"Are you sure you want to unresolve this comment thread?": "¿Está seguro de que desea no resolver este hilo de comentarios?",
|
||||
"Resolved": "Resuelto",
|
||||
"No active comments.": "No hay comentarios activos.",
|
||||
"No resolved comments.": "No hay comentarios resueltos.",
|
||||
"Revoke invitation": "Revocar invitación",
|
||||
"Revoke": "Revocar",
|
||||
"Don't": "No",
|
||||
@@ -222,7 +233,9 @@
|
||||
"Anyone with this link can join this workspace.": "Cualquiera con este enlace puede unirse a este espacio de trabajo.",
|
||||
"Invite link": "Enlace de invitación",
|
||||
"Copy": "Copiar",
|
||||
"Copy to space": "Copiar al espacio",
|
||||
"Copied": "Copiado",
|
||||
"Duplicate": "Duplicar",
|
||||
"Select a user": "Seleccionar un usuario",
|
||||
"Select a group": "Seleccionar un grupo",
|
||||
"Export all pages and attachments in this space.": "Exportar todas las páginas y archivos adjuntos en este espacio.",
|
||||
@@ -354,6 +367,9 @@
|
||||
"Character count: {{characterCount}}": "Recuento de caracteres: {{characterCount}}",
|
||||
"New update": "Nueva actualización",
|
||||
"{{latestVersion}} is available": "{{latestVersion}} está disponible",
|
||||
"Default page edit mode": "Modo de edición de página predeterminado",
|
||||
"Choose your preferred page edit mode. Avoid accidental edits.": "Elige tu modo de edición de página preferido. Evita ediciones accidentales.",
|
||||
"Reading": "Leyendo",
|
||||
"Delete member": "Eliminar miembro",
|
||||
"Member deleted successfully": "Miembro eliminado con éxito",
|
||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "¿Está seguro que desea eliminar este miembro del área de trabajo? Esta acción es irreversible.",
|
||||
@@ -384,7 +400,100 @@
|
||||
"Share deleted successfully": "Compartición eliminada con éxito",
|
||||
"Share not found": "Compartición no encontrada",
|
||||
"Failed to share page": "Error al compartir la página",
|
||||
"Copy page": "Copy page",
|
||||
"Copy page to a different space.": "Copy page to a different space.",
|
||||
"Page copied successfully": "Page copied successfully"
|
||||
"Copy page": "Copiar página",
|
||||
"Copy page to a different space.": "Copiar página en otro espacio",
|
||||
"Page copied successfully": "Página copiada exitosamente",
|
||||
"Page duplicated successfully": "Página duplicada con éxito",
|
||||
"Find": "Buscar",
|
||||
"Not found": "No encontrado",
|
||||
"Previous Match (Shift+Enter)": "Coincidencia anterior (Shift+Enter)",
|
||||
"Next match (Enter)": "Siguiente coincidencia (Enter)",
|
||||
"Match case (Alt+C)": "Distinguir mayúsculas y minúsculas (Alt+C)",
|
||||
"Replace": "Reemplazar",
|
||||
"Close (Escape)": "Cerrar (Escape)",
|
||||
"Replace (Enter)": "Reemplazar (Enter)",
|
||||
"Replace all (Ctrl+Alt+Enter)": "Reemplazar todo (Ctrl+Alt+Enter)",
|
||||
"Replace all": "Reemplazar todo",
|
||||
"View all spaces": "Ver todos los espacios",
|
||||
"Error": "Error",
|
||||
"Failed to disable MFA": "No se pudo desactivar MFA",
|
||||
"Disable two-factor authentication": "Desactivar la autenticación de dos factores",
|
||||
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "Desactivar la autenticación de dos factores hará que tu cuenta sea menos segura. Solo necesitarás tu contraseña para iniciar sesión.",
|
||||
"Please enter your password to disable two-factor authentication:": "Por favor ingresa tu contraseña para desactivar la autenticación de dos factores:",
|
||||
"Two-factor authentication has been enabled": "La autenticación de dos factores ha sido activada",
|
||||
"Two-factor authentication has been disabled": "La autenticación de dos factores ha sido desactivada",
|
||||
"2-step verification": "Verificación en 2 pasos",
|
||||
"Protect your account with an additional verification layer when signing in.": "Protege tu cuenta con una capa adicional de verificación al iniciar sesión.",
|
||||
"Two-factor authentication is active on your account.": "La autenticación de dos factores está activa en tu cuenta.",
|
||||
"Add 2FA method": "Agregar método 2FA",
|
||||
"Backup codes": "Códigos de seguridad",
|
||||
"Disable": "Desactivar",
|
||||
"Invalid verification code": "Código de verificación no válido",
|
||||
"New backup codes have been generated": "Nuevos códigos de seguridad han sido generados",
|
||||
"Failed to regenerate backup codes": "No se pudo regenerar los códigos de seguridad",
|
||||
"About backup codes": "Acerca de los códigos de seguridad",
|
||||
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Los códigos de seguridad pueden usarse para acceder a tu cuenta si pierdes acceso a tu aplicación autenticadora. Cada código solo puede ser usado una vez.",
|
||||
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "Puedes regenerar nuevos códigos de seguridad en cualquier momento. Esto invalidará todos los códigos existentes.",
|
||||
"Confirm password": "Confirmar contraseña",
|
||||
"Generate new backup codes": "Generar nuevos códigos de seguridad",
|
||||
"Save your new backup codes": "Guarda tus nuevos códigos de seguridad",
|
||||
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "Asegúrate de guardar estos códigos en un lugar seguro. Tus viejos códigos de seguridad ya no son válidos.",
|
||||
"Your new backup codes": "Tus nuevos códigos de seguridad",
|
||||
"I've saved my backup codes": "He guardado mis códigos de seguridad",
|
||||
"Failed to setup MFA": "No se pudo configurar MFA",
|
||||
"Setup & Verify": "Configurar y verificar",
|
||||
"Add to authenticator": "Agregar al autenticador",
|
||||
"1. Scan this QR code with your authenticator app": "1. Escanea este código QR con tu aplicación autenticadora",
|
||||
"Can't scan the code?": "¿No puedes escanear el código?",
|
||||
"Enter this code manually in your authenticator app:": "Introduce este código manualmente en tu aplicación autenticadora:",
|
||||
"2. Enter the 6-digit code from your authenticator": "2. Introduce el código de 6 dígitos de tu autenticador",
|
||||
"Verify and enable": "Verificar y activar",
|
||||
"Failed to generate QR code. Please try again.": "No se pudo generar el código QR. Por favor, intente de nuevo.",
|
||||
"Backup": "Respaldo",
|
||||
"Save codes": "Guardar códigos",
|
||||
"Save your backup codes": "Guarda tus códigos de seguridad",
|
||||
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Estos códigos pueden usarse para acceder a tu cuenta si pierdes acceso a tu aplicación autenticadora. Cada código solo puede ser usado una vez.",
|
||||
"Print": "Imprimir",
|
||||
"Two-factor authentication has been set up. Please log in again.": "La autenticación de dos factores ha sido configurada. Por favor, inicie sesión nuevamente.",
|
||||
"Two-Factor authentication required": "Se requiere autenticación de dos factores",
|
||||
"Your workspace requires two-factor authentication for all users": "Tu espacio de trabajo requiere autenticación de dos factores para todos los usuarios",
|
||||
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "Para continuar accediendo a tu espacio de trabajo, debes configurar la autenticación de dos factores. Esto añade una capa extra de seguridad a tu cuenta.",
|
||||
"Set up two-factor authentication": "Configurar la autenticación de dos factores",
|
||||
"Cancel and logout": "Cancelar y cerrar sesión",
|
||||
"Your workspace requires two-factor authentication. Please set it up to continue.": "Tu espacio de trabajo requiere autenticación de dos factores. Por favor, configúralo para continuar.",
|
||||
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "Esto añade una capa extra de seguridad a tu cuenta al requerir un código de verificación de tu aplicación autenticadora.",
|
||||
"Password is required": "Se requiere contraseña",
|
||||
"Password must be at least 8 characters": "La contraseña debe tener al menos 8 caracteres",
|
||||
"Please enter a 6-digit code": "Por favor, introduce un código de 6 dígitos",
|
||||
"Code must be exactly 6 digits": "El código debe ser exactamente de 6 dígitos",
|
||||
"Enter the 6-digit code found in your authenticator app": "Introduce el código de 6 dígitos que se encuentra en tu aplicación autenticadora",
|
||||
"Need help authenticating?": "¿Necesitas ayuda para autenticar?",
|
||||
"MFA QR Code": "Código QR MFA",
|
||||
"Account created successfully. Please log in to set up two-factor authentication.": "Cuenta creada exitosamente. Por favor, inicie sesión para configurar la autenticación de dos factores.",
|
||||
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "Restablecimiento de contraseña exitoso. Por favor, inicie sesión con su nueva contraseña y complete la autenticación de dos factores.",
|
||||
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "Restablecimiento de contraseña exitoso. Por favor, inicie sesión con su nueva contraseña para configurar la autenticación de dos factores.",
|
||||
"Password reset was successful. Please log in with your new password.": "El restablecimiento de contraseña fue exitoso. Por favor, inicie sesión con su nueva contraseña.",
|
||||
"Two-factor authentication": "Autenticación de dos factores",
|
||||
"Use authenticator app instead": "Usar la aplicación autenticadora en su lugar",
|
||||
"Verify backup code": "Verificar código de seguridad",
|
||||
"Use backup code": "Usar código de seguridad",
|
||||
"Enter one of your backup codes": "Introduce uno de tus códigos de seguridad",
|
||||
"Backup code": "Código de seguridad",
|
||||
"Enter one of your backup codes. Each backup code can only be used once.": "Introduce uno de tus códigos de seguridad. Cada código de seguridad solo puede ser usado una vez.",
|
||||
"Verify": "Verificar",
|
||||
"Trash": "Papelera",
|
||||
"Pages in trash will be permanently deleted after 30 days.": "Las páginas en la papelera serán eliminadas permanentemente después de 30 días.",
|
||||
"Deleted": "Eliminado",
|
||||
"No pages in trash": "No hay páginas en la papelera",
|
||||
"Permanently delete page?": "¿Eliminar página permanentemente?",
|
||||
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "¿Está seguro de que desea eliminar '{{title}}' permanentemente? Esta acción no se puede deshacer.",
|
||||
"Restore '{{title}}' and its sub-pages?": "¿Restaurar '{{title}}' y sus subpáginas?",
|
||||
"Move to trash": "Mover a la papelera",
|
||||
"Move this page to trash?": "¿Mover esta página a la papelera?",
|
||||
"Restore page": "Restaurar página",
|
||||
"Page moved to trash": "Página movida a la papelera",
|
||||
"Page restored successfully": "Página restaurada con éxito",
|
||||
"Deleted by": "Eliminado por",
|
||||
"Deleted at": "Eliminado en",
|
||||
"Preview": "Vista previa"
|
||||
}
|
||||
|
||||
@@ -213,7 +213,18 @@
|
||||
"Comment deleted successfully": "Commentaire supprimé avec succès",
|
||||
"Failed to delete comment": "Échec de la suppression du commentaire",
|
||||
"Comment resolved successfully": "Commentaire résolu avec succès",
|
||||
"Comment re-opened successfully": "Commentaire rouvert avec succès",
|
||||
"Comment unresolved successfully": "Commentaire non résolu avec succès",
|
||||
"Failed to resolve comment": "Échec de la résolution du commentaire",
|
||||
"Resolve comment": "Résoudre le commentaire",
|
||||
"Unresolve comment": "Désorganiser le commentaire",
|
||||
"Resolve Comment Thread": "Résoudre le fil de commentaires",
|
||||
"Unresolve Comment Thread": "Désorganiser le fil de commentaires",
|
||||
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "Êtes-vous sûr de vouloir résoudre ce fil de commentaires ? Cela le marquera comme terminé.",
|
||||
"Are you sure you want to unresolve this comment thread?": "Êtes-vous sûr de vouloir désorganiser ce fil de commentaires ?",
|
||||
"Resolved": "Résolu",
|
||||
"No active comments.": "Aucun commentaire actif.",
|
||||
"No resolved comments.": "Aucun commentaire résolu.",
|
||||
"Revoke invitation": "Révoquer l'invitation",
|
||||
"Revoke": "Révoquer",
|
||||
"Don't": "Ne pas",
|
||||
@@ -222,7 +233,9 @@
|
||||
"Anyone with this link can join this workspace.": "Toute personne ayant ce lien peut rejoindre cet espace de travail.",
|
||||
"Invite link": "Lien d'invitation",
|
||||
"Copy": "Copier",
|
||||
"Copy to space": "Copier dans l'espace",
|
||||
"Copied": "Copié",
|
||||
"Duplicate": "Dupliquer",
|
||||
"Select a user": "Sélectionner un utilisateur",
|
||||
"Select a group": "Sélectionner un groupe",
|
||||
"Export all pages and attachments in this space.": "Exporter toutes les pages et pièces jointes dans cet espace.",
|
||||
@@ -354,6 +367,9 @@
|
||||
"Character count: {{characterCount}}": "Nombre de caractères : {{characterCount}}",
|
||||
"New update": "Nouvelle mise à jour",
|
||||
"{{latestVersion}} is available": "{{latestVersion}} est disponible",
|
||||
"Default page edit mode": "Mode d'édition de page par défaut",
|
||||
"Choose your preferred page edit mode. Avoid accidental edits.": "Choisissez votre mode d'édition de page préféré. Évitez les modifications accidentelles.",
|
||||
"Reading": "Lecture",
|
||||
"Delete member": "Supprimer le membre",
|
||||
"Member deleted successfully": "Membre supprimé avec succès",
|
||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "Êtes-vous sûr de vouloir supprimer ce membre de l'espace de travail? Cette action est irréversible.",
|
||||
@@ -386,5 +402,98 @@
|
||||
"Failed to share page": "Échec du partage de la page",
|
||||
"Copy page": "Copier la page",
|
||||
"Copy page to a different space.": "Copier la page dans un autre espace.",
|
||||
"Page copied successfully": "Page copiée avec succès"
|
||||
"Page copied successfully": "Page copiée avec succès",
|
||||
"Page duplicated successfully": "Page dupliquée avec succès",
|
||||
"Find": "Trouver",
|
||||
"Not found": "Non trouvé",
|
||||
"Previous Match (Shift+Enter)": "Correspondance précédente (Shift+Entrée)",
|
||||
"Next match (Enter)": "Correspondance suivante (Entrée)",
|
||||
"Match case (Alt+C)": "Respecter la casse (Alt+C)",
|
||||
"Replace": "Remplacer",
|
||||
"Close (Escape)": "Fermer (Échapper)",
|
||||
"Replace (Enter)": "Remplacer (Entrée)",
|
||||
"Replace all (Ctrl+Alt+Enter)": "Tout remplacer (Ctrl+Alt+Entrée)",
|
||||
"Replace all": "Tout remplacer",
|
||||
"View all spaces": "Voir tous les espaces",
|
||||
"Error": "Erreur",
|
||||
"Failed to disable MFA": "Impossible de désactiver l'A2F",
|
||||
"Disable two-factor authentication": "Désactiver l'authentification à deux facteurs",
|
||||
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "La désactivation de l'authentification à deux facteurs rendra votre compte moins sécurisé. Vous n'aurez besoin que de votre mot de passe pour vous connecter.",
|
||||
"Please enter your password to disable two-factor authentication:": "Veuillez entrer votre mot de passe pour désactiver l'authentification à deux facteurs :",
|
||||
"Two-factor authentication has been enabled": "L'authentification à deux facteurs a été activée",
|
||||
"Two-factor authentication has been disabled": "L'authentification à deux facteurs a été désactivée",
|
||||
"2-step verification": "Vérification en 2 étapes",
|
||||
"Protect your account with an additional verification layer when signing in.": "Protégez votre compte avec une couche de vérification supplémentaire lors de la connexion.",
|
||||
"Two-factor authentication is active on your account.": "L'authentification à deux facteurs est active sur votre compte.",
|
||||
"Add 2FA method": "Ajouter une méthode A2F",
|
||||
"Backup codes": "Codes de sauvegarde",
|
||||
"Disable": "Désactiver",
|
||||
"Invalid verification code": "Code de vérification invalide",
|
||||
"New backup codes have been generated": "De nouveaux codes de sauvegarde ont été générés",
|
||||
"Failed to regenerate backup codes": "Échec de la régénération des codes de sauvegarde",
|
||||
"About backup codes": "À propos des codes de sauvegarde",
|
||||
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Les codes de sauvegarde peuvent être utilisés pour accéder à votre compte si vous perdez l'accès à votre application d'authentification. Chaque code ne peut être utilisé qu'une seule fois.",
|
||||
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "Vous pouvez régénérer de nouveaux codes de sauvegarde à tout moment. Cela invalidera tous les codes existants.",
|
||||
"Confirm password": "Confirmer le mot de passe",
|
||||
"Generate new backup codes": "Générer de nouveaux codes de sauvegarde",
|
||||
"Save your new backup codes": "Enregistrez vos nouveaux codes de sauvegarde",
|
||||
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "Assurez-vous d'enregistrer ces codes dans un endroit sécurisé. Vos anciens codes de sauvegarde ne sont plus valides.",
|
||||
"Your new backup codes": "Vos nouveaux codes de sauvegarde",
|
||||
"I've saved my backup codes": "J'ai enregistré mes codes de sauvegarde",
|
||||
"Failed to setup MFA": "Échec de la configuration de l'A2F",
|
||||
"Setup & Verify": "Configurer et vérifier",
|
||||
"Add to authenticator": "Ajouter à l'authentification",
|
||||
"1. Scan this QR code with your authenticator app": "1. Scannez ce code QR avec votre application d'authentification",
|
||||
"Can't scan the code?": "Impossible de scanner le code ?",
|
||||
"Enter this code manually in your authenticator app:": "Entrez ce code manuellement dans votre application d'authentification :",
|
||||
"2. Enter the 6-digit code from your authenticator": "2. Entrez le code à 6 chiffres de votre authentificateur",
|
||||
"Verify and enable": "Vérifier et activer",
|
||||
"Failed to generate QR code. Please try again.": "Échec de la génération du code QR. Veuillez réessayer.",
|
||||
"Backup": "Sauvegarde",
|
||||
"Save codes": "Enregistrer les codes",
|
||||
"Save your backup codes": "Enregistrez vos codes de sauvegarde",
|
||||
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Ces codes peuvent être utilisés pour accéder à votre compte si vous perdez l'accès à votre application d'authentification. Chaque code ne peut être utilisé qu'une seule fois.",
|
||||
"Print": "Imprimer",
|
||||
"Two-factor authentication has been set up. Please log in again.": "L'authentification à deux facteurs a été configurée. Veuillez vous reconnecter.",
|
||||
"Two-Factor authentication required": "Authentification à deux facteurs requise",
|
||||
"Your workspace requires two-factor authentication for all users": "Votre espace de travail nécessite l'authentification à deux facteurs pour tous les utilisateurs",
|
||||
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "Pour continuer à accéder à votre espace de travail, vous devez configurer l'authentification à deux facteurs. Cela ajoute une couche de sécurité supplémentaire à votre compte.",
|
||||
"Set up two-factor authentication": "Configurer l'authentification à deux facteurs",
|
||||
"Cancel and logout": "Annuler et se déconnecter",
|
||||
"Your workspace requires two-factor authentication. Please set it up to continue.": "Votre espace de travail nécessite l'authentification à deux facteurs. Veuillez le configurer pour continuer.",
|
||||
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "Cela ajoute une couche de sécurité supplémentaire à votre compte en exigeant un code de vérification provenant de votre application d'authentification.",
|
||||
"Password is required": "Mot de passe requis",
|
||||
"Password must be at least 8 characters": "Le mot de passe doit comporter au moins 8 caractères",
|
||||
"Please enter a 6-digit code": "Veuillez entrer un code à 6 chiffres",
|
||||
"Code must be exactly 6 digits": "Le code doit être exactement de 6 chiffres",
|
||||
"Enter the 6-digit code found in your authenticator app": "Entrez le code à 6 chiffres trouvé dans votre application d'authentification",
|
||||
"Need help authenticating?": "Besoin d'aide pour l'authentification ?",
|
||||
"MFA QR Code": "Code QR de l'A2F",
|
||||
"Account created successfully. Please log in to set up two-factor authentication.": "Compte créé avec succès. Veuillez vous connecter pour configurer l'authentification à deux facteurs.",
|
||||
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "Réinitialisation du mot de passe réussie. Veuillez vous connecter avec votre nouveau mot de passe et compléter l'authentification à deux facteurs.",
|
||||
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "Réinitialisation du mot de passe réussie. Veuillez vous connecter avec votre nouveau mot de passe pour configurer l'authentification à deux facteurs.",
|
||||
"Password reset was successful. Please log in with your new password.": "La réinitialisation du mot de passe a réussi. Veuillez vous connecter avec votre nouveau mot de passe.",
|
||||
"Two-factor authentication": "Authentification à deux facteurs",
|
||||
"Use authenticator app instead": "Utilisez l'application d'authentification à la place",
|
||||
"Verify backup code": "Vérifier le code de sauvegarde",
|
||||
"Use backup code": "Utiliser le code de sauvegarde",
|
||||
"Enter one of your backup codes": "Entrez un de vos codes de sauvegarde",
|
||||
"Backup code": "Code de sauvegarde",
|
||||
"Enter one of your backup codes. Each backup code can only be used once.": "Entrez un de vos codes de sauvegarde. Chaque code de sauvegarde ne peut être utilisé qu'une seule fois.",
|
||||
"Verify": "Vérifier",
|
||||
"Trash": "Corbeille",
|
||||
"Pages in trash will be permanently deleted after 30 days.": "Les pages dans la corbeille seront définitivement supprimées après 30 jours.",
|
||||
"Deleted": "Supprimé",
|
||||
"No pages in trash": "Aucune page dans la corbeille",
|
||||
"Permanently delete page?": "Supprimer définitivement la page ?",
|
||||
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Êtes-vous sûr de vouloir supprimer définitivement « {{title}} » ? Cette action ne peut pas être annulée.",
|
||||
"Restore '{{title}}' and its sub-pages?": "Restaurer « {{title}} » et ses sous-pages ?",
|
||||
"Move to trash": "Déplacer vers la corbeille",
|
||||
"Move this page to trash?": "Déplacer cette page vers la corbeille ?",
|
||||
"Restore page": "Restaurer la page",
|
||||
"Page moved to trash": "Page déplacée vers la corbeille",
|
||||
"Page restored successfully": "Page restaurée avec succès",
|
||||
"Deleted by": "Supprimé par",
|
||||
"Deleted at": "Supprimé à",
|
||||
"Preview": "Aperçu"
|
||||
}
|
||||
|
||||
@@ -213,7 +213,18 @@
|
||||
"Comment deleted successfully": "Commento eliminato con successo",
|
||||
"Failed to delete comment": "Impossibile eliminare il commento",
|
||||
"Comment resolved successfully": "Commento risolto con successo",
|
||||
"Comment re-opened successfully": "Commento riaperto con successo",
|
||||
"Comment unresolved successfully": "Commento non risolto con successo",
|
||||
"Failed to resolve comment": "Impossibile risolvere il commento",
|
||||
"Resolve comment": "Risolvi commento",
|
||||
"Unresolve comment": "Annulla risoluzione commento",
|
||||
"Resolve Comment Thread": "Risolvi discussione commenti",
|
||||
"Unresolve Comment Thread": "Annulla risoluzione discussione commenti",
|
||||
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "Sei sicuro di voler risolvere questa discussione di commenti? Questo la contrassegnerà come completata.",
|
||||
"Are you sure you want to unresolve this comment thread?": "Sei sicuro di voler annullare la risoluzione di questa discussione di commenti?",
|
||||
"Resolved": "Risolto",
|
||||
"No active comments.": "Nessun commento attivo.",
|
||||
"No resolved comments.": "Nessun commento risolto.",
|
||||
"Revoke invitation": "Revoca invito",
|
||||
"Revoke": "Revoca",
|
||||
"Don't": "Non",
|
||||
@@ -222,7 +233,9 @@
|
||||
"Anyone with this link can join this workspace.": "Chiunque con questo link può unirsi a questa area di lavoro.",
|
||||
"Invite link": "Link d'invito",
|
||||
"Copy": "Copia",
|
||||
"Copy to space": "Copia nello spazio",
|
||||
"Copied": "Copiato",
|
||||
"Duplicate": "Duplica",
|
||||
"Select a user": "Seleziona un utente",
|
||||
"Select a group": "Seleziona un gruppo",
|
||||
"Export all pages and attachments in this space.": "Esporta tutte le pagine e gli allegati di questo spazio.",
|
||||
@@ -354,6 +367,9 @@
|
||||
"Character count: {{characterCount}}": "Conteggio caratteri: {{characterCount}}",
|
||||
"New update": "Nuovo aggiornamento",
|
||||
"{{latestVersion}} is available": "{{latestVersion}} è disponibile",
|
||||
"Default page edit mode": "Modalità di modifica pagina predefinita",
|
||||
"Choose your preferred page edit mode. Avoid accidental edits.": "Scegli la tua modalità di modifica della pagina preferita. Evita modifiche accidentali.",
|
||||
"Reading": "Lettura",
|
||||
"Delete member": "Elimina membro",
|
||||
"Member deleted successfully": "Membro eliminato con successo",
|
||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "Sei sicuro di voler eliminare questo membro del workspace? Questa azione è irreversibile.",
|
||||
@@ -386,5 +402,98 @@
|
||||
"Failed to share page": "Condivisione della pagina fallita",
|
||||
"Copy page": "Copia pagina",
|
||||
"Copy page to a different space.": "Copia pagina in un altro spazio.",
|
||||
"Page copied successfully": "Pagina copiata con successo"
|
||||
"Page copied successfully": "Pagina copiata con successo",
|
||||
"Page duplicated successfully": "Pagina duplicata con successo",
|
||||
"Find": "Trova",
|
||||
"Not found": "Non trovato",
|
||||
"Previous Match (Shift+Enter)": "Corrispondenza precedente (Shift+Invio)",
|
||||
"Next match (Enter)": "Corrispondenza successiva (Invio)",
|
||||
"Match case (Alt+C)": "Maiuscole/minuscole (Alt+C)",
|
||||
"Replace": "Sostituisci",
|
||||
"Close (Escape)": "Chiudi (Esc)",
|
||||
"Replace (Enter)": "Sostituisci (Invio)",
|
||||
"Replace all (Ctrl+Alt+Enter)": "Sostituisci tutto (Ctrl+Alt+Invio)",
|
||||
"Replace all": "Sostituisci tutto",
|
||||
"View all spaces": "Visualizza tutti gli spazi",
|
||||
"Error": "Errore",
|
||||
"Failed to disable MFA": "Disabilitazione MFA non riuscita",
|
||||
"Disable two-factor authentication": "Disabilita autenticazione a due fattori",
|
||||
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "Disabilitare l'autenticazione a due fattori renderà il tuo account meno sicuro. Avrai bisogno solo della tua password per accedere.",
|
||||
"Please enter your password to disable two-factor authentication:": "Inserisci la tua password per disabilitare l'autenticazione a due fattori:",
|
||||
"Two-factor authentication has been enabled": "Autenticazione a due fattori abilitata",
|
||||
"Two-factor authentication has been disabled": "Autenticazione a due fattori disabilitata",
|
||||
"2-step verification": "Verifica in 2 passaggi",
|
||||
"Protect your account with an additional verification layer when signing in.": "Proteggi il tuo account con un ulteriore livello di verifica durante l'accesso.",
|
||||
"Two-factor authentication is active on your account.": "L'autenticazione a due fattori è attiva sul tuo account.",
|
||||
"Add 2FA method": "Aggiungi metodo 2FA",
|
||||
"Backup codes": "Codici di backup",
|
||||
"Disable": "Disabilita",
|
||||
"Invalid verification code": "Codice di verifica non valido",
|
||||
"New backup codes have been generated": "Nuovi codici di backup generati",
|
||||
"Failed to regenerate backup codes": "Rigenerazione codici di backup non riuscita",
|
||||
"About backup codes": "Informazioni sui codici di backup",
|
||||
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "I codici di backup possono essere utilizzati per accedere al tuo account se perdi l'accesso alla tua app di autenticazione. Ogni codice può essere usato solo una volta.",
|
||||
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "Puoi rigenerare nuovi codici di backup in qualsiasi momento. Questo invaliderà tutti i codici esistenti.",
|
||||
"Confirm password": "Conferma password",
|
||||
"Generate new backup codes": "Genera nuovi codici di backup",
|
||||
"Save your new backup codes": "Salva i tuoi nuovi codici di backup",
|
||||
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "Assicurati di salvare questi codici in un luogo sicuro. I tuoi vecchi codici di backup non sono più validi.",
|
||||
"Your new backup codes": "I tuoi nuovi codici di backup",
|
||||
"I've saved my backup codes": "Ho salvato i miei codici di backup",
|
||||
"Failed to setup MFA": "Impostazione MFA non riuscita",
|
||||
"Setup & Verify": "Imposta e Verifica",
|
||||
"Add to authenticator": "Aggiungi ad authenticator",
|
||||
"1. Scan this QR code with your authenticator app": "1. Scansiona questo codice QR con la tua app di autenticazione",
|
||||
"Can't scan the code?": "Non riesci a scansionare il codice?",
|
||||
"Enter this code manually in your authenticator app:": "Inserisci questo codice manualmente nella tua app di autenticazione:",
|
||||
"2. Enter the 6-digit code from your authenticator": "2. Inserisci il codice a 6 cifre dal tuo autenticatore",
|
||||
"Verify and enable": "Verifica e abilita",
|
||||
"Failed to generate QR code. Please try again.": "Generazione del codice QR non riuscita. Si prega di riprovare.",
|
||||
"Backup": "Backup",
|
||||
"Save codes": "Salva codici",
|
||||
"Save your backup codes": "Salva i tuoi codici di backup",
|
||||
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Questi codici possono essere utilizzati per accedere al tuo account se perdi l'accesso alla tua app di autenticazione. Ogni codice può essere usato solo una volta.",
|
||||
"Print": "Stampa",
|
||||
"Two-factor authentication has been set up. Please log in again.": "L'autenticazione a due fattori è stata impostata. Effettua nuovamente l'accesso, per favore.",
|
||||
"Two-Factor authentication required": "Autenticazione a due fattori richiesta",
|
||||
"Your workspace requires two-factor authentication for all users": "Il tuo spazio di lavoro richiede l'autenticazione a due fattori per tutti gli utenti",
|
||||
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "Per continuare ad accedere al tuo spazio di lavoro, devi impostare l'autenticazione a due fattori. Questo aggiunge un ulteriore livello di sicurezza al tuo account.",
|
||||
"Set up two-factor authentication": "Imposta l'autenticazione a due fattori",
|
||||
"Cancel and logout": "Annulla e disconnetti",
|
||||
"Your workspace requires two-factor authentication. Please set it up to continue.": "Il tuo spazio di lavoro richiede l'autenticazione a due fattori. Impostala per continuare.",
|
||||
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "Questo aggiunge un ulteriore livello di sicurezza al tuo account richiedendo un codice di verifica dalla tua app di autenticazione.",
|
||||
"Password is required": "La password è richiesta",
|
||||
"Password must be at least 8 characters": "La password deve essere di almeno 8 caratteri",
|
||||
"Please enter a 6-digit code": "Inserisci un codice a 6 cifre",
|
||||
"Code must be exactly 6 digits": "Il codice deve essere esattamente di 6 cifre",
|
||||
"Enter the 6-digit code found in your authenticator app": "Inserisci il codice a 6 cifre trovato nella tua app di autenticazione",
|
||||
"Need help authenticating?": "Hai bisogno di aiuto per autenticarti?",
|
||||
"MFA QR Code": "Codice QR MFA",
|
||||
"Account created successfully. Please log in to set up two-factor authentication.": "Account creato con successo. Effettua l'accesso per impostare l'autenticazione a due fattori.",
|
||||
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "Reimpostazione della password riuscita. Accedi con la tua nuova password e completa l'autenticazione a due fattori.",
|
||||
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "Reimpostazione della password riuscita. Accedi con la tua nuova password per impostare l'autenticazione a due fattori.",
|
||||
"Password reset was successful. Please log in with your new password.": "Reimpostazione della password riuscita. Accedi con la tua nuova password.",
|
||||
"Two-factor authentication": "Autenticazione a due fattori",
|
||||
"Use authenticator app instead": "Usa l'app di autenticazione invece",
|
||||
"Verify backup code": "Verifica codice di backup",
|
||||
"Use backup code": "Usa codice di backup",
|
||||
"Enter one of your backup codes": "Inserisci uno dei tuoi codici di backup",
|
||||
"Backup code": "Codice di backup",
|
||||
"Enter one of your backup codes. Each backup code can only be used once.": "Inserisci uno dei tuoi codici di backup. Ogni codice di backup può essere utilizzato solo una volta.",
|
||||
"Verify": "Verifica",
|
||||
"Trash": "Cestino",
|
||||
"Pages in trash will be permanently deleted after 30 days.": "Le pagine nel cestino verranno eliminate definitivamente dopo 30 giorni.",
|
||||
"Deleted": "Eliminato",
|
||||
"No pages in trash": "Nessuna pagina nel cestino",
|
||||
"Permanently delete page?": "Eliminare definitivamente la pagina?",
|
||||
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Sei sicuro di voler eliminare definitivamente '{{title}}'? Questa azione non può essere annullata.",
|
||||
"Restore '{{title}}' and its sub-pages?": "Ripristinare '{{title}}' e le sue sottopagine?",
|
||||
"Move to trash": "Sposta nel cestino",
|
||||
"Move this page to trash?": "Spostare questa pagina nel cestino?",
|
||||
"Restore page": "Ripristina pagina",
|
||||
"Page moved to trash": "Pagina spostata nel cestino",
|
||||
"Page restored successfully": "Pagina ripristinata con successo",
|
||||
"Deleted by": "Eliminato da",
|
||||
"Deleted at": "Eliminato il",
|
||||
"Preview": "Anteprima"
|
||||
}
|
||||
|
||||
@@ -213,7 +213,18 @@
|
||||
"Comment deleted successfully": "コメントが削除されました",
|
||||
"Failed to delete comment": "コメントの削除に失敗しました",
|
||||
"Comment resolved successfully": "コメントが解決されました",
|
||||
"Comment re-opened successfully": "コメントが再開されました",
|
||||
"Comment unresolved successfully": "コメントが再解決されました",
|
||||
"Failed to resolve comment": "コメントの解決に失敗しました",
|
||||
"Resolve comment": "コメントを解決",
|
||||
"Unresolve comment": "コメントを再解決",
|
||||
"Resolve Comment Thread": "コメントスレッドを解決",
|
||||
"Unresolve Comment Thread": "コメントスレッドを再解決",
|
||||
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "このコメントスレッドを解決しますか? これにより完了としてマークされます。",
|
||||
"Are you sure you want to unresolve this comment thread?": "このコメントスレッドを再解決しますか?",
|
||||
"Resolved": "解決済",
|
||||
"No active comments.": "アクティブなコメントはありません。",
|
||||
"No resolved comments.": "解決されたコメントはありません。",
|
||||
"Revoke invitation": "招待を取り消す",
|
||||
"Revoke": "取り消す",
|
||||
"Don't": "取り消さない",
|
||||
@@ -222,7 +233,9 @@
|
||||
"Anyone with this link can join this workspace.": "このリンクを持っている人は誰でもこのワークスペースに参加できます。",
|
||||
"Invite link": "招待リンク",
|
||||
"Copy": "コピー",
|
||||
"Copy to space": "スペースにコピー",
|
||||
"Copied": "コピーしました",
|
||||
"Duplicate": "複製",
|
||||
"Select a user": "ユーザを選択",
|
||||
"Select a group": "グループを選択",
|
||||
"Export all pages and attachments in this space.": "このスペースのすべてのページと添付ファイルをエクスポートします。",
|
||||
@@ -354,6 +367,9 @@
|
||||
"Character count: {{characterCount}}": "文字数: {{characterCount}}",
|
||||
"New update": "新規更新",
|
||||
"{{latestVersion}} is available": "{{latestVersion}}は利用可能です",
|
||||
"Default page edit mode": "デフォルトのページ編集モード",
|
||||
"Choose your preferred page edit mode. Avoid accidental edits.": "希望のページ編集モードを選択してください。誤って編集を防ぎます。",
|
||||
"Reading": "読み取り",
|
||||
"Delete member": "メンバーを削除する",
|
||||
"Member deleted successfully": "メンバーが削除されました",
|
||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "ワークスペースメンバーを削除してもよろしいですか?この操作は元に戻せません。",
|
||||
@@ -386,5 +402,98 @@
|
||||
"Failed to share page": "ページの共有に失敗しました",
|
||||
"Copy page": "ページをコピー",
|
||||
"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,7 +213,18 @@
|
||||
"Comment deleted successfully": "댓글 삭제 완료",
|
||||
"Failed to delete comment": "댓글 삭제 실패",
|
||||
"Comment resolved successfully": "댓글 처리 완료",
|
||||
"Comment re-opened successfully": "댓글이 성공적으로 다시 열렸습니다",
|
||||
"Comment unresolved successfully": "댓글 미해결로 변경 완료",
|
||||
"Failed to resolve comment": "댓글 처리 실패",
|
||||
"Resolve comment": "댓글 해결하기",
|
||||
"Unresolve comment": "댓글 미해결로 변경하기",
|
||||
"Resolve Comment Thread": "댓글 스레드 해결하기",
|
||||
"Unresolve Comment Thread": "댓글 스레드 미해결로 변경하기",
|
||||
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "이 댓글 스레드를 해결하시겠습니까? 완료로 표시됩니다.",
|
||||
"Are you sure you want to unresolve this comment thread?": "이 댓글 스레드를 미해결로 변경하시겠습니까?",
|
||||
"Resolved": "해결됨",
|
||||
"No active comments.": "활성 댓글이 없습니다.",
|
||||
"No resolved comments.": "해결된 댓글이 없습니다.",
|
||||
"Revoke invitation": "초대 취소",
|
||||
"Revoke": "취소",
|
||||
"Don't": "하지 않음",
|
||||
@@ -222,7 +233,9 @@
|
||||
"Anyone with this link can join this workspace.": "이 링크를 가진 모든 사용자가 이 Workspace에 참여할 수 있습니다.",
|
||||
"Invite link": "초대 링크",
|
||||
"Copy": "복사",
|
||||
"Copy to space": "공간에 복사하기",
|
||||
"Copied": "복사됨",
|
||||
"Duplicate": "중복",
|
||||
"Select a user": "사용자 선택",
|
||||
"Select a group": "팀 선택",
|
||||
"Export all pages and attachments in this space.": "이 Space의 모든 페이지와 첨부파일을 내보냅니다.",
|
||||
@@ -354,6 +367,9 @@
|
||||
"Character count: {{characterCount}}": "문자 수: {{characterCount}}",
|
||||
"New update": "새로운 업데이트",
|
||||
"{{latestVersion}} is available": "{{latestVersion}}이 사용 가능합니다",
|
||||
"Default page edit mode": "기본 페이지 편집 모드",
|
||||
"Choose your preferred page edit mode. Avoid accidental edits.": "선호하는 페이지 편집 모드를 선택하세요. 실수로 인한 편집을 방지하세요.",
|
||||
"Reading": "읽기",
|
||||
"Delete member": "회원 삭제",
|
||||
"Member deleted successfully": "멤버가 성공적으로 제거되었습니다",
|
||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "이 워크스페이스 멤버를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
|
||||
@@ -384,7 +400,100 @@
|
||||
"Share deleted successfully": "공유가 성공적으로 삭제되었습니다",
|
||||
"Share not found": "공유를 찾을 수 없습니다",
|
||||
"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"
|
||||
"Copy page": "페이지 복사하기",
|
||||
"Copy page to a different space.": "다른 공간으로 페이지 복사하기.",
|
||||
"Page copied successfully": "페이지가 성공적으로 복사되었습니다",
|
||||
"Page duplicated successfully": "페이지가 성공적으로 복제되었습니다",
|
||||
"Find": "찾기",
|
||||
"Not found": "찾을 수 없음",
|
||||
"Previous Match (Shift+Enter)": "이전 일치 항목 (Shift+Enter)",
|
||||
"Next match (Enter)": "다음 일치 항목 (Enter)",
|
||||
"Match case (Alt+C)": "대소문자 구분 (Alt+C)",
|
||||
"Replace": "교체",
|
||||
"Close (Escape)": "닫기 (Escape)",
|
||||
"Replace (Enter)": "교체 (Enter)",
|
||||
"Replace all (Ctrl+Alt+Enter)": "모두 교체하기 (Ctrl+Alt+Enter)",
|
||||
"Replace all": "모두 교체하기",
|
||||
"View all spaces": "모든 공간 보기",
|
||||
"Error": "오류",
|
||||
"Failed to disable MFA": "MFA 비활성화 실패",
|
||||
"Disable two-factor authentication": "이중 인증 비활성화",
|
||||
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "이중 인증을 비활성화하면 계정의 보안이 낮아집니다. 로그인 시 비밀번호만 필요하게 됩니다.",
|
||||
"Please enter your password to disable two-factor authentication:": "이중 인증 비활성화를 위해 비밀번호를 입력하세요:",
|
||||
"Two-factor authentication has been enabled": "이중 인증이 활성화되었습니다",
|
||||
"Two-factor authentication has been disabled": "이중 인증이 비활성화되었습니다",
|
||||
"2-step verification": "2단계 인증",
|
||||
"Protect your account with an additional verification layer when signing in.": "로그인 시 추가 인증 단계를 통해 계정을 보호하세요.",
|
||||
"Two-factor authentication is active on your account.": "이중 인증이 계정에 활성화되어 있습니다.",
|
||||
"Add 2FA method": "2FA 방법 추가",
|
||||
"Backup codes": "백업 코드",
|
||||
"Disable": "비활성화",
|
||||
"Invalid verification code": "유효하지 않은 인증 코드",
|
||||
"New backup codes have been generated": "새 백업 코드가 생성되었습니다",
|
||||
"Failed to regenerate backup codes": "백업 코드 재생성 실패",
|
||||
"About backup codes": "백업 코드에 대하여",
|
||||
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "인증 앱에 접근할 수 없게 된 경우, 백업 코드를 사용하여 계정에 접근할 수 있습니다. 각 코드는 한 번만 사용할 수 있습니다.",
|
||||
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "언제든지 새 백업 코드를 재생성할 수 있습니다. 이 작업은 기존 모든 코드를 무효화합니다.",
|
||||
"Confirm password": "비밀번호 확인",
|
||||
"Generate new backup codes": "새 백업 코드 생성하기",
|
||||
"Save your new backup codes": "새 백업 코드 저장하기",
|
||||
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "이 코드를 안전한 장소에 저장하세요. 이전 백업 코드는 더 이상 유효하지 않습니다.",
|
||||
"Your new backup codes": "새 백업 코드",
|
||||
"I've saved my backup codes": "백업 코드를 저장했습니다",
|
||||
"Failed to setup MFA": "MFA 설정 실패",
|
||||
"Setup & Verify": "설정 및 확인",
|
||||
"Add to authenticator": "인증앱에 추가",
|
||||
"1. Scan this QR code with your authenticator app": "1. 인증앱으로 이 QR 코드를 스캔하십시오.",
|
||||
"Can't scan the code?": "코드를 스캔할 수 없습니까?",
|
||||
"Enter this code manually in your authenticator app:": "이 코드를 인증앱에 수동으로 입력해 주세요:",
|
||||
"2. Enter the 6-digit code from your authenticator": "2. 인증앱에서 6자리 코드를 입력하십시오",
|
||||
"Verify and enable": "확인 및 활성화",
|
||||
"Failed to generate QR code. Please try again.": "QR 코드 생성 실패. 다시 시도해 주세요.",
|
||||
"Backup": "백업",
|
||||
"Save codes": "코드 저장",
|
||||
"Save your backup codes": "백업 코드 저장하기",
|
||||
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "인증 앱에 대한 접근 권한을 잃은 경우, 이 코드를 사용하여 귀하의 계정에 접근할 수 있습니다. 각 코드는 한 번만 사용할 수 있습니다.",
|
||||
"Print": "인쇄",
|
||||
"Two-factor authentication has been set up. Please log in again.": "이중 인증이 설정되었습니다. 다시 로그인해 주세요.",
|
||||
"Two-Factor authentication required": "이중 인증 필요",
|
||||
"Your workspace requires two-factor authentication for all users": "워크스페이스에서는 모든 사용자에게 이중 인증이 필요합니다.",
|
||||
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "워크스페이스 접근을 계속하려면 이중 인증을 설정해야 합니다. 이는 계정에 추가 보안 계층을 추가합니다.",
|
||||
"Set up two-factor authentication": "이중 인증 설정하기",
|
||||
"Cancel and logout": "취소 및 로그아웃",
|
||||
"Your workspace requires two-factor authentication. Please set it up to continue.": "워크스페이스에서는 이중 인증이 필요합니다. 계속하려면 설정해 주세요.",
|
||||
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "인증앱에서 얻은 인증 코드를 요구하여 계정의 보안에 추가적인 계층을 추가합니다.",
|
||||
"Password is required": "비밀번호가 필요합니다",
|
||||
"Password must be at least 8 characters": "비밀번호는 최소 8자 이상이어야 합니다",
|
||||
"Please enter a 6-digit code": "6자리 코드를 입력해 주세요",
|
||||
"Code must be exactly 6 digits": "코드는 정확히 6자리여야 합니다",
|
||||
"Enter the 6-digit code found in your authenticator app": "인증앱에서 찾은 6자리 코드를 입력하십시오",
|
||||
"Need help authenticating?": "인증에 도움이 필요하십니까?",
|
||||
"MFA QR Code": "MFA QR 코드",
|
||||
"Account created successfully. Please log in to set up two-factor authentication.": "계정이 성공적으로 생성되었습니다. 이중 인증을 설정하려면 로그인해 주세요.",
|
||||
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "비밀번호 재설정 성공. 새 비밀번호로 로그인하여 이중 인증을 완료하세요.",
|
||||
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "비밀번호 재설정 성공. 새 비밀번호로 로그인하여 이중 인증을 설정하세요.",
|
||||
"Password reset was successful. Please log in with your new password.": "비밀번호 재설정이 성공적으로 완료되었습니다. 새 비밀번호로 로그인하세요.",
|
||||
"Two-factor authentication": "이중 인증",
|
||||
"Use authenticator app instead": "대신 인증 앱 사용",
|
||||
"Verify backup code": "백업 코드 확인",
|
||||
"Use backup code": "백업 코드 사용",
|
||||
"Enter one of your backup codes": "백업 코드 중 하나를 입력하세요",
|
||||
"Backup code": "백업 코드",
|
||||
"Enter one of your backup codes. Each backup code can only be used once.": "백업 코드 중 하나를 입력하세요. 각 백업 코드는 한 번만 사용할 수 있습니다.",
|
||||
"Verify": "확인",
|
||||
"Trash": "휴지통",
|
||||
"Pages in trash will be permanently deleted after 30 days.": "휴지통의 페이지는 30일 후에 영구적으로 삭제됩니다.",
|
||||
"Deleted": "삭제됨",
|
||||
"No pages in trash": "휴지통에 페이지가 없습니다",
|
||||
"Permanently delete page?": "페이지를 영구적으로 삭제하시겠습니까?",
|
||||
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "'{{title}}'을(를) 영구적으로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
|
||||
"Restore '{{title}}' and its sub-pages?": "'{{title}}' 및 하위 페이지를 복구하시겠습니까?",
|
||||
"Move to trash": "휴지통으로 이동",
|
||||
"Move this page to trash?": "이 페이지를 휴지통으로 이동하시겠습니까?",
|
||||
"Restore page": "페이지 복구",
|
||||
"Page moved to trash": "페이지가 휴지통으로 이동되었습니다",
|
||||
"Page restored successfully": "페이지가 성공적으로 복구되었습니다",
|
||||
"Deleted by": "삭제자",
|
||||
"Deleted at": "삭제 시간",
|
||||
"Preview": "미리보기"
|
||||
}
|
||||
|
||||
@@ -213,7 +213,18 @@
|
||||
"Comment deleted successfully": "Reactie met succes verwijderd",
|
||||
"Failed to delete comment": "Verwijderen van reactie mislukt",
|
||||
"Comment resolved successfully": "Reactie succesvol opgelost",
|
||||
"Comment re-opened successfully": "Reactie succesvol heropend",
|
||||
"Comment unresolved successfully": "Reactie succesvol niet-opgelost gemaakt",
|
||||
"Failed to resolve comment": "Reactie oplossen mislukt",
|
||||
"Resolve comment": "Reactie oplossen",
|
||||
"Unresolve comment": "Reactie niet oplossen",
|
||||
"Resolve Comment Thread": "Reactiedraad oplossen",
|
||||
"Unresolve Comment Thread": "Reactiedraad niet oplossen",
|
||||
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "Weet u zeker dat u deze reactiedraad wilt oplossen? Dit zal het als voltooid markeren.",
|
||||
"Are you sure you want to unresolve this comment thread?": "Weet u zeker dat u deze reactiedraad niet wilt oplossen?",
|
||||
"Resolved": "Opgelost",
|
||||
"No active comments.": "Geen actieve reacties.",
|
||||
"No resolved comments.": "Geen opgeloste reacties.",
|
||||
"Revoke invitation": "Uitnodiging intrekken",
|
||||
"Revoke": "Intrekken",
|
||||
"Don't": "Niet doen",
|
||||
@@ -222,7 +233,9 @@
|
||||
"Anyone with this link can join this workspace.": "Iedereen met deze link kan zich aansluiten bij deze werkruimte.",
|
||||
"Invite link": "Uitnodigingslink",
|
||||
"Copy": "Kopieer",
|
||||
"Copy to space": "Kopiëren naar ruimte",
|
||||
"Copied": "Gekopieerd",
|
||||
"Duplicate": "Dupliceren",
|
||||
"Select a user": "Selecteer een gebruiker",
|
||||
"Select a group": "Selecteer een groep",
|
||||
"Export all pages and attachments in this space.": "Exporteer alle pagina's en bijlagen in deze ruimte.",
|
||||
@@ -354,6 +367,9 @@
|
||||
"Character count: {{characterCount}}": "Aantal tekens: {{characterCount}}",
|
||||
"New update": "Nieuwe update",
|
||||
"{{latestVersion}} is available": "{{latestVersion}} is beschikbaar",
|
||||
"Default page edit mode": "Standaard pagina bewerkmodus",
|
||||
"Choose your preferred page edit mode. Avoid accidental edits.": "Kies uw voorkeurs bewerkmodus voor pagina's. Vermijd per ongeluk bewerken.",
|
||||
"Reading": "Lezen",
|
||||
"Delete member": "Verwijder lid",
|
||||
"Member deleted successfully": "Lid succesvol verwijderd",
|
||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "Weet u zeker dat u dit lid van de werkruimte wilt verwijderen? Deze actie kan niet ongedaan gemaakt worden.",
|
||||
@@ -384,7 +400,100 @@
|
||||
"Share deleted successfully": "Delen succesvol verwijderd",
|
||||
"Share not found": "Delen niet gevonden",
|
||||
"Failed to share page": "Pagina delen mislukt",
|
||||
"Copy page": "Copy page",
|
||||
"Copy page to a different space.": "Copy page to a different space.",
|
||||
"Page copied successfully": "Page copied successfully"
|
||||
"Copy page": "Pagina kopiëren",
|
||||
"Copy page to a different space.": "Kopieer pagina naar een andere ruimte.",
|
||||
"Page copied successfully": "Pagina succesvol gekopieerd",
|
||||
"Page duplicated successfully": "Pagina succesvol gedupliceerd",
|
||||
"Find": "Zoeken",
|
||||
"Not found": "Niet gevonden",
|
||||
"Previous Match (Shift+Enter)": "Vorige overeenkomst (Shift+Enter)",
|
||||
"Next match (Enter)": "Volgende overeenkomst (Enter)",
|
||||
"Match case (Alt+C)": "Hoofdlettergevoeligheid (Alt+C)",
|
||||
"Replace": "Vervangen",
|
||||
"Close (Escape)": "Sluiten (Escape)",
|
||||
"Replace (Enter)": "Vervangen (Enter)",
|
||||
"Replace all (Ctrl+Alt+Enter)": "Alles vervangen (Ctrl+Alt+Enter)",
|
||||
"Replace all": "Alles vervangen",
|
||||
"View all spaces": "Bekijk alle ruimtes",
|
||||
"Error": "Fout",
|
||||
"Failed to disable MFA": "MFA uitschakelen mislukt",
|
||||
"Disable two-factor authentication": "Twee-factor authenticatie uitschakelen",
|
||||
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "Indien u twee-factor authenticatie uitschakelt, zal uw account minder veilig zijn. U heeft alleen uw wachtwoord nodig om in te loggen.",
|
||||
"Please enter your password to disable two-factor authentication:": "Voer uw wachtwoord in om twee-factor authenticatie uit te schakelen:",
|
||||
"Two-factor authentication has been enabled": "Twee-factor authenticatie is ingeschakeld",
|
||||
"Two-factor authentication has been disabled": "Twee-factor authenticatie is uitgeschakeld",
|
||||
"2-step verification": "2-staps verificatie",
|
||||
"Protect your account with an additional verification layer when signing in.": "Bescherm uw account met een extra verificatielaag tijdens het inloggen.",
|
||||
"Two-factor authentication is active on your account.": "Twee-factor authenticatie is actief op uw account.",
|
||||
"Add 2FA method": "2FA-methode toevoegen",
|
||||
"Backup codes": "Back-up codes",
|
||||
"Disable": "Uitschakelen",
|
||||
"Invalid verification code": "Ongeldige verificatiecode",
|
||||
"New backup codes have been generated": "Nieuwe back-up codes zijn gegenereerd",
|
||||
"Failed to regenerate backup codes": "Back-up codes opnieuw genereren mislukt",
|
||||
"About backup codes": "Over back-up codes",
|
||||
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Back-up codes kunnen worden gebruikt om uw account te bereiken als u toegang tot uw authenticator-app verliest. Elke code kan slechts één keer worden gebruikt.",
|
||||
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "U kunt te allen tijde nieuwe back-up codes genereren. Dit zal alle bestaande codes ongeldig maken.",
|
||||
"Confirm password": "Bevestig wachtwoord",
|
||||
"Generate new backup codes": "Genereer nieuwe back-up codes",
|
||||
"Save your new backup codes": "Sla uw nieuwe back-up codes op",
|
||||
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "Zorg ervoor dat u deze codes op een veilige plek opslaat. Uw oude back-up codes zijn niet langer geldig.",
|
||||
"Your new backup codes": "Uw nieuwe back-up codes",
|
||||
"I've saved my backup codes": "Ik heb mijn back-up codes opgeslagen",
|
||||
"Failed to setup MFA": "MFA instellen mislukt",
|
||||
"Setup & Verify": "Instellen & Verifiëren",
|
||||
"Add to authenticator": "Toevoegen aan de authenticator",
|
||||
"1. Scan this QR code with your authenticator app": "1. Scan deze QR-code met uw authenticator-app",
|
||||
"Can't scan the code?": "Kan de code niet scannen?",
|
||||
"Enter this code manually in your authenticator app:": "Voer deze code handmatig in uw authenticator-app in:",
|
||||
"2. Enter the 6-digit code from your authenticator": "2. Voer de 6-cijferige code van uw authenticator in",
|
||||
"Verify and enable": "Verifiëren en inschakelen",
|
||||
"Failed to generate QR code. Please try again.": "Het genereren van de QR-code is mislukt. Probeer het opnieuw.",
|
||||
"Backup": "Back-up",
|
||||
"Save codes": "Codes opslaan",
|
||||
"Save your backup codes": "Sla uw back-up codes op",
|
||||
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Deze codes kunnen worden gebruikt om toegang te krijgen tot uw account als u de toegang tot uw authenticator-app verliest. Elke code kan slechts één keer worden gebruikt.",
|
||||
"Print": "Afdrukken",
|
||||
"Two-factor authentication has been set up. Please log in again.": "Twee-factor authenticatie is ingesteld. Log alstublieft opnieuw in.",
|
||||
"Two-Factor authentication required": "Twee-factor authenticatie vereist",
|
||||
"Your workspace requires two-factor authentication for all users": "Uw werkruimte vereist twee-factor authenticatie voor alle gebruikers",
|
||||
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "Om toegang te blijven krijgen tot uw werkruimte, moet u twee-factor authenticatie instellen. Dit voegt een extra beveiligingslaag toe aan uw account.",
|
||||
"Set up two-factor authentication": "Stel twee-factor authenticatie in",
|
||||
"Cancel and logout": "Annuleren en uitloggen",
|
||||
"Your workspace requires two-factor authentication. Please set it up to continue.": "Uw werkruimte vereist twee-factor authenticatie. Stel het in om door te gaan.",
|
||||
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "Dit voegt een extra beveiligingslaag toe aan uw account door een verificatiecode van uw authenticator-app te vereisen.",
|
||||
"Password is required": "Wachtwoord is vereist",
|
||||
"Password must be at least 8 characters": "Wachtwoord moet minimaal 8 tekens zijn",
|
||||
"Please enter a 6-digit code": "Voer alstublieft een 6-cijferige code in",
|
||||
"Code must be exactly 6 digits": "Code moet exact 6 cijfers zijn",
|
||||
"Enter the 6-digit code found in your authenticator app": "Voer de 6-cijferige code in die in uw authenticator-app staat",
|
||||
"Need help authenticating?": "Hulp nodig bij het authenticeren?",
|
||||
"MFA QR Code": "MFA QR-code",
|
||||
"Account created successfully. Please log in to set up two-factor authentication.": "Account succesvol aangemaakt. Log alstublieft in om twee-factor authenticatie in te stellen.",
|
||||
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "Wachtwoord reset succesvol. Log in met uw nieuwe wachtwoord en voltooi twee-factor authenticatie.",
|
||||
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "Wachtwoord reset succesvol. Log in met uw nieuwe wachtwoord om twee-factor authenticatie in te stellen.",
|
||||
"Password reset was successful. Please log in with your new password.": "De wachtwoord reset was succesvol. Log in met uw nieuwe wachtwoord.",
|
||||
"Two-factor authentication": "Twee-factor authenticatie",
|
||||
"Use authenticator app instead": "Gebruik in plaats daarvan de authenticator-app",
|
||||
"Verify backup code": "Back-up code verifiëren",
|
||||
"Use backup code": "Gebruik back-up code",
|
||||
"Enter one of your backup codes": "Voer een van uw back-up codes in",
|
||||
"Backup code": "Back-up code",
|
||||
"Enter one of your backup codes. Each backup code can only be used once.": "Voer een van uw back-up codes in. Elke back-up code kan slechts één keer worden gebruikt.",
|
||||
"Verify": "Verifiëren",
|
||||
"Trash": "Prullenbak",
|
||||
"Pages in trash will be permanently deleted after 30 days.": "Pagina's in de prullenbak worden na 30 dagen permanent verwijderd.",
|
||||
"Deleted": "Verwijderd",
|
||||
"No pages in trash": "Geen pagina's in de prullenbak",
|
||||
"Permanently delete page?": "Pagina permanent verwijderen?",
|
||||
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Weet u zeker dat u '{{title}}' permanent wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
|
||||
"Restore '{{title}}' and its sub-pages?": "'{{title}}' en zijn subpagina's herstellen?",
|
||||
"Move to trash": "Naar de prullenbak verplaatsen",
|
||||
"Move this page to trash?": "Deze pagina naar de prullenbak verplaatsen?",
|
||||
"Restore page": "Pagina herstellen",
|
||||
"Page moved to trash": "Pagina verplaatst naar de prullenbak",
|
||||
"Page restored successfully": "Pagina succesvol hersteld",
|
||||
"Deleted by": "Verwijderd door",
|
||||
"Deleted at": "Verwijderd op",
|
||||
"Preview": "Voorbeeld"
|
||||
}
|
||||
|
||||
@@ -213,7 +213,18 @@
|
||||
"Comment deleted successfully": "Comentário excluído com sucesso",
|
||||
"Failed to delete comment": "Falha ao excluir comentário",
|
||||
"Comment resolved successfully": "Comentário resolvido com sucesso",
|
||||
"Comment re-opened successfully": "Comentário reaberto com sucesso",
|
||||
"Comment unresolved successfully": "Comentário não resolvido com sucesso",
|
||||
"Failed to resolve comment": "Falha ao resolver comentário",
|
||||
"Resolve comment": "Resolver comentário",
|
||||
"Unresolve comment": "Não resolver comentário",
|
||||
"Resolve Comment Thread": "Resolver Fio de Comentários",
|
||||
"Unresolve Comment Thread": "Não resolver Fio de Comentários",
|
||||
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "Tem certeza de que deseja resolver este fio de comentários? Isso o marcará como concluído.",
|
||||
"Are you sure you want to unresolve this comment thread?": "Tem certeza de que deseja não resolver este fio de comentários?",
|
||||
"Resolved": "Resolvido",
|
||||
"No active comments.": "Sem comentários ativos.",
|
||||
"No resolved comments.": "Sem comentários resolvidos.",
|
||||
"Revoke invitation": "Cancelar o convite",
|
||||
"Revoke": "Anular",
|
||||
"Don't": "Não",
|
||||
@@ -222,7 +233,9 @@
|
||||
"Anyone with this link can join this workspace.": "Qualquer um com este link pode participar deste espaço de trabalho.",
|
||||
"Invite link": "Link do convite",
|
||||
"Copy": "Copiar",
|
||||
"Copy to space": "Copiar para o espaço",
|
||||
"Copied": "Copiado",
|
||||
"Duplicate": "Duplicar",
|
||||
"Select a user": "Selecione um usuário",
|
||||
"Select a group": "Selecione um grupo",
|
||||
"Export all pages and attachments in this space.": "Exportar todas as páginas e anexos deste espaço.",
|
||||
@@ -354,6 +367,9 @@
|
||||
"Character count: {{characterCount}}": "Contagem de caracteres: {{characterCount}}",
|
||||
"New update": "Nova atualização",
|
||||
"{{latestVersion}} is available": "{{latestVersion}} está disponível",
|
||||
"Default page edit mode": "Modo de edição de página padrão",
|
||||
"Choose your preferred page edit mode. Avoid accidental edits.": "Escolha o modo de edição de página preferido. Evite edições acidentais.",
|
||||
"Reading": "Leitura",
|
||||
"Delete member": "Excluir membro",
|
||||
"Member deleted successfully": "Membro removido com sucesso",
|
||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "Você tem certeza que deseja deletar este membro do workspace? Esta ação é irreversível.",
|
||||
@@ -384,7 +400,100 @@
|
||||
"Share deleted successfully": "Compartilhamento excluído com sucesso",
|
||||
"Share not found": "Compartilhamento não encontrado",
|
||||
"Failed to share page": "Falha ao compartilhar página",
|
||||
"Copy page": "Copy page",
|
||||
"Copy page to a different space.": "Copy page to a different space.",
|
||||
"Page copied successfully": "Page copied successfully"
|
||||
"Copy page": "Copiar página",
|
||||
"Copy page to a different space.": "Copiar página para um espaço diferente.",
|
||||
"Page copied successfully": "Página copiada com sucesso",
|
||||
"Page duplicated successfully": "Página duplicada com sucesso",
|
||||
"Find": "Encontrar",
|
||||
"Not found": "Não encontrado",
|
||||
"Previous Match (Shift+Enter)": "Correspondência anterior (Shift+Enter)",
|
||||
"Next match (Enter)": "Próxima correspondência (Enter)",
|
||||
"Match case (Alt+C)": "Diferenciar maiúsculas de minúsculas (Alt+C)",
|
||||
"Replace": "Substituir",
|
||||
"Close (Escape)": "Fechar (Escape)",
|
||||
"Replace (Enter)": "Substituir (Enter)",
|
||||
"Replace all (Ctrl+Alt+Enter)": "Substituir tudo (Ctrl+Alt+Enter)",
|
||||
"Replace all": "Substituir tudo",
|
||||
"View all spaces": "Ver todos os espaços",
|
||||
"Error": "Erro",
|
||||
"Failed to disable MFA": "Falha ao desativar a MFA",
|
||||
"Disable two-factor authentication": "Desativar autenticação de dois fatores",
|
||||
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "Desativar a autenticação de dois fatores tornará sua conta menos segura. Você só precisará de sua senha para entrar.",
|
||||
"Please enter your password to disable two-factor authentication:": "Por favor, insira sua senha para desativar a autenticação de dois fatores:",
|
||||
"Two-factor authentication has been enabled": "Autenticação de dois fatores foi ativada",
|
||||
"Two-factor authentication has been disabled": "Autenticação de dois fatores foi desativada",
|
||||
"2-step verification": "Verificação em duas etapas",
|
||||
"Protect your account with an additional verification layer when signing in.": "Proteja sua conta com uma camada adicional de verificação ao entrar.",
|
||||
"Two-factor authentication is active on your account.": "Autenticação de dois fatores está ativa na sua conta.",
|
||||
"Add 2FA method": "Adicionar método de 2FA",
|
||||
"Backup codes": "Códigos de backup",
|
||||
"Disable": "Desativar",
|
||||
"Invalid verification code": "Código de verificação inválido",
|
||||
"New backup codes have been generated": "Novos códigos de backup foram gerados",
|
||||
"Failed to regenerate backup codes": "Falha ao regenerar códigos de backup",
|
||||
"About backup codes": "Sobre códigos de backup",
|
||||
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Códigos de backup podem ser usados para acessar sua conta se perder acesso ao aplicativo autenticador. Cada código só pode ser usado uma vez.",
|
||||
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "Você pode regenerar novos códigos de backup a qualquer momento. Isso invalidará todos os códigos existentes.",
|
||||
"Confirm password": "Confirmar senha",
|
||||
"Generate new backup codes": "Gerar novos códigos de backup",
|
||||
"Save your new backup codes": "Salvar seus novos códigos de backup",
|
||||
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "Certifique-se de salvar esses códigos em um local seguro. Seus códigos de backup antigos não são mais válidos.",
|
||||
"Your new backup codes": "Seus novos códigos de backup",
|
||||
"I've saved my backup codes": "Eu salvei meus códigos de backup",
|
||||
"Failed to setup MFA": "Falha ao configurar a MFA",
|
||||
"Setup & Verify": "Configurar & Verificar",
|
||||
"Add to authenticator": "Adicionar ao autenticador",
|
||||
"1. Scan this QR code with your authenticator app": "1. Escaneie este código QR com seu aplicativo autenticador",
|
||||
"Can't scan the code?": "Não consegue escanear o código?",
|
||||
"Enter this code manually in your authenticator app:": "Digite este código manualmente em seu aplicativo autenticador:",
|
||||
"2. Enter the 6-digit code from your authenticator": "2. Digite o código de 6 dígitos do seu autenticador",
|
||||
"Verify and enable": "Verificar e ativar",
|
||||
"Failed to generate QR code. Please try again.": "Falha ao gerar código QR. Por favor, tente novamente.",
|
||||
"Backup": "Backup",
|
||||
"Save codes": "Salvar códigos",
|
||||
"Save your backup codes": "Salvar seus códigos de backup",
|
||||
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Esses códigos podem ser usados para acessar sua conta se você perder o acesso ao aplicativo autenticador. Cada código só pode ser usado uma vez.",
|
||||
"Print": "Imprimir",
|
||||
"Two-factor authentication has been set up. Please log in again.": "A autenticação de dois fatores foi configurada. Por favor, faça login novamente.",
|
||||
"Two-Factor authentication required": "Autenticação de dois fatores necessária",
|
||||
"Your workspace requires two-factor authentication for all users": "Seu espaço de trabalho requer autenticação de dois fatores para todos os usuários",
|
||||
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "Para continuar acessando seu espaço de trabalho, você deve configurar a autenticação de dois fatores. Isso adiciona uma camada extra de segurança à sua conta.",
|
||||
"Set up two-factor authentication": "Configurar autenticação de dois fatores",
|
||||
"Cancel and logout": "Cancelar e sair",
|
||||
"Your workspace requires two-factor authentication. Please set it up to continue.": "Seu espaço de trabalho requer autenticação de dois fatores. Por favor, configure para continuar.",
|
||||
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "Isso adiciona uma camada extra de segurança à sua conta, exigindo um código de verificação de seu aplicativo autenticador.",
|
||||
"Password is required": "Senha é necessária",
|
||||
"Password must be at least 8 characters": "A senha deve ter pelo menos 8 caracteres",
|
||||
"Please enter a 6-digit code": "Por favor, insira um código de 6 dígitos",
|
||||
"Code must be exactly 6 digits": "O código deve ter exatamente 6 dígitos",
|
||||
"Enter the 6-digit code found in your authenticator app": "Insira o código de 6 dígitos encontrado em seu aplicativo autenticador",
|
||||
"Need help authenticating?": "Precisa de ajuda para autenticar?",
|
||||
"MFA QR Code": "Código QR de MFA",
|
||||
"Account created successfully. Please log in to set up two-factor authentication.": "Conta criada com sucesso. Por favor, faça login para configurar a autenticação de dois fatores.",
|
||||
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "Redefinição de senha bem-sucedida. Por favor, faça login com sua nova senha e complete a autenticação de dois fatores.",
|
||||
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "Redefinição de senha bem-sucedida. Por favor, faça login com sua nova senha para configurar a autenticação de dois fatores.",
|
||||
"Password reset was successful. Please log in with your new password.": "Redefinição de senha foi bem-sucedida. Por favor, faça login com sua nova senha.",
|
||||
"Two-factor authentication": "Autenticação de dois fatores",
|
||||
"Use authenticator app instead": "Use o aplicativo autenticador em vez disso",
|
||||
"Verify backup code": "Verificar código de backup",
|
||||
"Use backup code": "Usar código de backup",
|
||||
"Enter one of your backup codes": "Digite um de seus códigos de backup",
|
||||
"Backup code": "Código de backup",
|
||||
"Enter one of your backup codes. Each backup code can only be used once.": "Digite um de seus códigos de backup. Cada código de backup só pode ser usado uma vez.",
|
||||
"Verify": "Verificar",
|
||||
"Trash": "Lixeira",
|
||||
"Pages in trash will be permanently deleted after 30 days.": "Páginas na lixeira serão excluídas permanentemente após 30 dias.",
|
||||
"Deleted": "Excluído",
|
||||
"No pages in trash": "Sem páginas na lixeira",
|
||||
"Permanently delete page?": "Excluir página permanentemente?",
|
||||
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Tem certeza de que deseja excluir permanentemente '{{title}}'? Esta ação não pode ser desfeita.",
|
||||
"Restore '{{title}}' and its sub-pages?": "Restaurar '{{title}}' e suas subpáginas?",
|
||||
"Move to trash": "Mover para a lixeira",
|
||||
"Move this page to trash?": "Mover esta página para a lixeira?",
|
||||
"Restore page": "Restaurar página",
|
||||
"Page moved to trash": "Página movida para a lixeira",
|
||||
"Page restored successfully": "Página restaurada com sucesso",
|
||||
"Deleted by": "Excluído por",
|
||||
"Deleted at": "Excluído em",
|
||||
"Preview": "Visualização"
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
"Enter your current password": "Введите ваш текущий пароль",
|
||||
"enter your full name": "введите ваше полное имя",
|
||||
"Enter your new password": "Введите ваш новый пароль",
|
||||
"Enter your new preferred email": "Введите ваш новый предпочитаемый адрес электронной почты",
|
||||
"Enter your new preferred email": "Введите ваш новый предпочтительный адрес электронной почты",
|
||||
"Enter your password": "Введите ваш пароль",
|
||||
"Error fetching page data.": "Ошибка при загрузке данных страницы.",
|
||||
"Error loading page history.": "Ошибка при загрузке истории страницы.",
|
||||
@@ -213,7 +213,18 @@
|
||||
"Comment deleted successfully": "Комментарий успешно удалён",
|
||||
"Failed to delete comment": "Не удалось удалить комментарий",
|
||||
"Comment resolved successfully": "Комментарий успешно разрешён",
|
||||
"Comment re-opened successfully": "Комментарий успешно открыт заново",
|
||||
"Comment unresolved successfully": "Комментарий успешно размечен как нерешённый",
|
||||
"Failed to resolve comment": "Не удалось разрешить комментарий",
|
||||
"Resolve comment": "Разрешить комментарий",
|
||||
"Unresolve comment": "Отметить комментарий как нерешённый",
|
||||
"Resolve Comment Thread": "Закрыть цепочку комментариев",
|
||||
"Unresolve Comment Thread": "Отметить цепочку комментариев как нерешённую",
|
||||
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "Вы уверены, что хотите закрыть эту цепочку комментариев? Это пометит её как завершённую.",
|
||||
"Are you sure you want to unresolve this comment thread?": "Вы уверены, что хотите отметить эту цепочку комментариев как нерешённую?",
|
||||
"Resolved": "Решено",
|
||||
"No active comments.": "Нет активных комментариев.",
|
||||
"No resolved comments.": "Нет решённых комментариев.",
|
||||
"Revoke invitation": "Отозвать приглашение",
|
||||
"Revoke": "Отозвать",
|
||||
"Don't": "Нет",
|
||||
@@ -222,7 +233,9 @@
|
||||
"Anyone with this link can join this workspace.": "Любой, у кого есть данная ссылка, может присоединиться к этой рабочей области.",
|
||||
"Invite link": "Ссылка для приглашения",
|
||||
"Copy": "Копировать",
|
||||
"Copy to space": "Копировать в пространство",
|
||||
"Copied": "Скопировано",
|
||||
"Duplicate": "Дублировать",
|
||||
"Select a user": "Выберите пользователя",
|
||||
"Select a group": "Выберите группу",
|
||||
"Export all pages and attachments in this space.": "Экспортировать все страницы и вложения в этом пространстве.",
|
||||
@@ -354,6 +367,9 @@
|
||||
"Character count: {{characterCount}}": "Количество символов: {{characterCount}}",
|
||||
"New update": "Новое обновление",
|
||||
"{{latestVersion}} is available": "Доступна новая версия {{latestVersion}}",
|
||||
"Default page edit mode": "Режим редактирования страницы по умолчанию",
|
||||
"Choose your preferred page edit mode. Avoid accidental edits.": "Выберите предпочитаемый режим редактирования страницы. Избегайте случайных изменений.",
|
||||
"Reading": "Чтение",
|
||||
"Delete member": "Удалить участника",
|
||||
"Member deleted successfully": "Участник успешно удален",
|
||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "Вы уверены, что хотите удалить этого участника рабочей области? Это действие необратимо.",
|
||||
@@ -386,5 +402,98 @@
|
||||
"Failed to share page": "Не удалось поделиться страницей",
|
||||
"Copy page": "Копировать страницу",
|
||||
"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,7 +213,18 @@
|
||||
"Comment deleted successfully": "Коментар успішно видалено",
|
||||
"Failed to delete comment": "Не вдалося видалити коментар",
|
||||
"Comment resolved successfully": "Коментар успішно вирішено",
|
||||
"Comment re-opened successfully": "Коментар успішно відкрито повторно",
|
||||
"Comment unresolved successfully": "Коментар успішно розв'язано",
|
||||
"Failed to resolve comment": "Не вдалося вирішити коментар",
|
||||
"Resolve comment": "Вирішити коментар",
|
||||
"Unresolve comment": "Розв'язати коментар",
|
||||
"Resolve Comment Thread": "Вирішити ланцюжок коментарів",
|
||||
"Unresolve Comment Thread": "Розв'язати ланцюжок коментарів",
|
||||
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "Ви впевнені, що хочете вирішити цей ланцюжок коментарів? Це позначить його як завершений.",
|
||||
"Are you sure you want to unresolve this comment thread?": "Ви впевнені, що хочете розв'язати цей ланцюжок коментарів?",
|
||||
"Resolved": "Вирішено",
|
||||
"No active comments.": "Немає активних коментарів.",
|
||||
"No resolved comments.": "Немає вирішених коментарів.",
|
||||
"Revoke invitation": "Відкликати запрошення",
|
||||
"Revoke": "Відкликати",
|
||||
"Don't": "Ні",
|
||||
@@ -222,7 +233,9 @@
|
||||
"Anyone with this link can join this workspace.": "Будь-хто, хто має це посилання, може приєднатися до цієї робочої області.",
|
||||
"Invite link": "Посилання для запрошення",
|
||||
"Copy": "Копіювати",
|
||||
"Copy to space": "Скопіювати в простір",
|
||||
"Copied": "Скопійовано",
|
||||
"Duplicate": "Дублювати",
|
||||
"Select a user": "Оберіть користувача",
|
||||
"Select a group": "Оберіть групу",
|
||||
"Export all pages and attachments in this space.": "Експортувати всі сторінки та вкладення в цьому просторі.",
|
||||
@@ -354,6 +367,9 @@
|
||||
"Character count: {{characterCount}}": "Кількість символів: {{characterCount}}",
|
||||
"New update": "Нове оновлення",
|
||||
"{{latestVersion}} is available": "Доступна нова версія {{latestVersion}}",
|
||||
"Default page edit mode": "Режим редагування сторінки за замовчуванням",
|
||||
"Choose your preferred page edit mode. Avoid accidental edits.": "Виберіть бажаний режим редагування сторінки. Уникайте випадкових редагувань.",
|
||||
"Reading": "Читання",
|
||||
"Delete member": "Видалити учасника",
|
||||
"Member deleted successfully": "Учасника успішно видалено",
|
||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "Ви впевнені, що хочете видалити цього учасника робочої області? Ця дія незворотна.",
|
||||
@@ -386,5 +402,98 @@
|
||||
"Failed to share page": "Не вдалося поділитися сторінкою",
|
||||
"Copy page": "Копіювати сторінки",
|
||||
"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,7 +213,18 @@
|
||||
"Comment deleted successfully": "成功删除评论",
|
||||
"Failed to delete comment": "删除评论失败",
|
||||
"Comment resolved successfully": "成功标记评论为解决",
|
||||
"Comment re-opened successfully": "成功重新打开评论",
|
||||
"Comment unresolved successfully": "成功标记评论为未解决",
|
||||
"Failed to resolve comment": "标记评论为解决失败",
|
||||
"Resolve comment": "解决评论",
|
||||
"Unresolve comment": "取消解决评论",
|
||||
"Resolve Comment Thread": "解决评论线程",
|
||||
"Unresolve Comment Thread": "取消解决评论线程",
|
||||
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "确定要解决此评论线程吗?这将标记为已完成。",
|
||||
"Are you sure you want to unresolve this comment thread?": "确定要取消解决此评论线程吗?",
|
||||
"Resolved": "已解决",
|
||||
"No active comments.": "没有活跃的评论。",
|
||||
"No resolved comments.": "没有已解决的评论。",
|
||||
"Revoke invitation": "撤回邀请",
|
||||
"Revoke": "撤销",
|
||||
"Don't": "不要",
|
||||
@@ -222,7 +233,9 @@
|
||||
"Anyone with this link can join this workspace.": "任何拥有此连接的人都可以加入此工作区",
|
||||
"Invite link": "邀请链接",
|
||||
"Copy": "复制",
|
||||
"Copy to space": "复制到空间",
|
||||
"Copied": "已复制",
|
||||
"Duplicate": "重复",
|
||||
"Select a user": "选择一个用户",
|
||||
"Select a group": "选择一个组",
|
||||
"Export all pages and attachments in this space.": "导出当前空间的所有页面和附件",
|
||||
@@ -354,6 +367,9 @@
|
||||
"Character count: {{characterCount}}": "字符数:{{characterCount}}",
|
||||
"New update": "新更新",
|
||||
"{{latestVersion}} is available": "{{latestVersion}} 已经可以使用",
|
||||
"Default page edit mode": "默认页面编辑模式",
|
||||
"Choose your preferred page edit mode. Avoid accidental edits.": "选择您偏好的页面编辑模式。避免意外编辑。",
|
||||
"Reading": "阅读",
|
||||
"Delete member": "删除成员",
|
||||
"Member deleted successfully": "成员删除成功",
|
||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "您确定要删除此工作区成员吗?此操作不可逆。",
|
||||
@@ -386,5 +402,98 @@
|
||||
"Failed to share page": "页面分享失败",
|
||||
"Copy page": "复制页面",
|
||||
"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,8 +31,10 @@ import Shares from "@/pages/settings/shares/shares.tsx";
|
||||
import ShareLayout from "@/features/share/components/share-layout.tsx";
|
||||
import ShareRedirect from "@/pages/share/share-redirect.tsx";
|
||||
import { useTrackOrigin } from "@/hooks/use-track-origin";
|
||||
import SpacesPage from "@/pages/spaces/spaces.tsx";
|
||||
import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page";
|
||||
import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page";
|
||||
import SpaceTrash from "@/pages/space/trash.tsx";
|
||||
|
||||
export default function App() {
|
||||
const { t } = useTranslation();
|
||||
@@ -77,7 +79,9 @@ export default function App() {
|
||||
|
||||
<Route element={<Layout />}>
|
||||
<Route path={"/home"} element={<Home />} />
|
||||
<Route path={"/spaces"} element={<SpacesPage />} />
|
||||
<Route path={"/s/:spaceSlug"} element={<SpaceHome />} />
|
||||
<Route path={"/s/:spaceSlug/trash"} element={<SpaceTrash />} />
|
||||
<Route
|
||||
path={"/s/:spaceSlug/p/:pageSlug"}
|
||||
element={
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Group, Text } from "@mantine/core";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import React from "react";
|
||||
import { IUser } from '@/features/user/types/user.types.ts';
|
||||
|
||||
interface UserInfoProps {
|
||||
user: Partial<IUser>;
|
||||
size?: string;
|
||||
}
|
||||
export function UserInfo({ user, size }: UserInfoProps) {
|
||||
return (
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<CustomAvatar avatarUrl={user?.avatarUrl} name={user?.name} size={size} />
|
||||
<div>
|
||||
<Text fz="sm" fw={500} lineClamp={1}>
|
||||
{user?.name}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{user?.email}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,8 @@ export function AppHeader() {
|
||||
const { isTrial, trialDaysLeft } = useTrial();
|
||||
|
||||
const isHomeRoute = location.pathname.startsWith("/home");
|
||||
const isSpacesRoute = location.pathname === "/spaces";
|
||||
const hideSidebar = isHomeRoute || isSpacesRoute;
|
||||
|
||||
const items = links.map((link) => (
|
||||
<Link key={link.label} to={link.link} className={classes.link}>
|
||||
@@ -38,7 +40,7 @@ export function AppHeader() {
|
||||
<>
|
||||
<Group h="100%" px="md" justify="space-between" wrap={"nowrap"}>
|
||||
<Group wrap="nowrap">
|
||||
{!isHomeRoute && (
|
||||
{!hideSidebar && (
|
||||
<>
|
||||
<Tooltip label={t("Sidebar toggle")}>
|
||||
<SidebarToggle
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Box, ScrollArea, Text } from "@mantine/core";
|
||||
import CommentList from "@/features/comment/components/comment-list.tsx";
|
||||
import CommentListWithTabs from "@/features/comment/components/comment-list-with-tabs.tsx";
|
||||
import { useAtom } from "jotai";
|
||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
import React, { ReactNode } from "react";
|
||||
@@ -18,7 +18,7 @@ export default function Aside() {
|
||||
|
||||
switch (tab) {
|
||||
case "comments":
|
||||
component = <CommentList />;
|
||||
component = <CommentListWithTabs />;
|
||||
title = "Comments";
|
||||
break;
|
||||
case "toc":
|
||||
@@ -38,13 +38,17 @@ export default function Aside() {
|
||||
{t(title)}
|
||||
</Text>
|
||||
|
||||
<ScrollArea
|
||||
style={{ height: "85vh" }}
|
||||
scrollbarSize={5}
|
||||
type="scroll"
|
||||
>
|
||||
<div style={{ paddingBottom: "200px" }}>{component}</div>
|
||||
</ScrollArea>
|
||||
{tab === "comments" ? (
|
||||
<CommentListWithTabs />
|
||||
) : (
|
||||
<ScrollArea
|
||||
style={{ height: "85vh" }}
|
||||
scrollbarSize={5}
|
||||
type="scroll"
|
||||
>
|
||||
<div style={{ paddingBottom: "200px" }}>{component}</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -73,13 +73,15 @@ export default function GlobalAppShell({
|
||||
const isSettingsRoute = location.pathname.startsWith("/settings");
|
||||
const isSpaceRoute = location.pathname.startsWith("/s/");
|
||||
const isHomeRoute = location.pathname.startsWith("/home");
|
||||
const isSpacesRoute = location.pathname === "/spaces";
|
||||
const isPageRoute = location.pathname.includes("/p/");
|
||||
const hideSidebar = isHomeRoute || isSpacesRoute;
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
header={{ height: 45 }}
|
||||
navbar={
|
||||
!isHomeRoute && {
|
||||
!hideSidebar && {
|
||||
width: isSpaceRoute ? sidebarWidth : 300,
|
||||
breakpoint: "sm",
|
||||
collapsed: {
|
||||
@@ -100,7 +102,7 @@ export default function GlobalAppShell({
|
||||
<AppShell.Header px="md" className={classes.header}>
|
||||
<AppHeader />
|
||||
</AppShell.Header>
|
||||
{!isHomeRoute && (
|
||||
{!hideSidebar && (
|
||||
<AppShell.Navbar
|
||||
className={classes.navbar}
|
||||
withBorder={false}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Group, Text, ScrollArea, ActionIcon } from "@mantine/core";
|
||||
import { Group, Text, ScrollArea, ActionIcon, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconUser,
|
||||
IconSettings,
|
||||
@@ -42,6 +42,7 @@ interface DataItem {
|
||||
isEnterprise?: boolean;
|
||||
isAdmin?: boolean;
|
||||
isSelfhosted?: boolean;
|
||||
showDisabledInNonEE?: boolean;
|
||||
}
|
||||
|
||||
interface DataGroup {
|
||||
@@ -84,6 +85,7 @@ const groupedData: DataGroup[] = [
|
||||
isCloud: true,
|
||||
isEnterprise: true,
|
||||
isAdmin: true,
|
||||
showDisabledInNonEE: true,
|
||||
},
|
||||
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
|
||||
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
|
||||
@@ -117,6 +119,11 @@ export default function SettingsSidebar() {
|
||||
}, [location.pathname]);
|
||||
|
||||
const canShowItem = (item: DataItem) => {
|
||||
if (item.showDisabledInNonEE && item.isEnterprise) {
|
||||
// Check admin permission regardless of license
|
||||
return item.isAdmin ? isAdmin : true;
|
||||
}
|
||||
|
||||
if (item.isCloud && item.isEnterprise) {
|
||||
if (!(isCloud() || workspace?.hasLicenseKey)) return false;
|
||||
return item.isAdmin ? isAdmin : true;
|
||||
@@ -141,6 +148,13 @@ export default function SettingsSidebar() {
|
||||
return true;
|
||||
};
|
||||
|
||||
const isItemDisabled = (item: DataItem) => {
|
||||
if (item.showDisabledInNonEE && item.isEnterprise) {
|
||||
return !(isCloud() || workspace?.hasLicenseKey);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const menuItems = groupedData.map((group) => {
|
||||
if (group.heading === "System" && (!isAdmin || isCloud())) {
|
||||
return null;
|
||||
@@ -185,23 +199,48 @@ export default function SettingsSidebar() {
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
const isDisabled = isItemDisabled(item);
|
||||
const linkElement = (
|
||||
<Link
|
||||
onMouseEnter={prefetchHandler}
|
||||
onMouseEnter={!isDisabled ? prefetchHandler : undefined}
|
||||
className={classes.link}
|
||||
data-active={active.startsWith(item.path) || undefined}
|
||||
data-disabled={isDisabled || undefined}
|
||||
key={item.label}
|
||||
to={item.path}
|
||||
onClick={() => {
|
||||
to={isDisabled ? "#" : item.path}
|
||||
onClick={(e) => {
|
||||
if (isDisabled) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (mobileSidebarOpened) {
|
||||
toggleMobileSidebar();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
opacity: isDisabled ? 0.5 : 1,
|
||||
cursor: isDisabled ? "not-allowed" : "pointer",
|
||||
}}
|
||||
>
|
||||
<item.icon className={classes.linkIcon} stroke={2} />
|
||||
<span>{t(item.label)}</span>
|
||||
</Link>
|
||||
);
|
||||
|
||||
if (isDisabled) {
|
||||
return (
|
||||
<Tooltip
|
||||
key={item.label}
|
||||
label={t("Available in enterprise edition")}
|
||||
position="right"
|
||||
withArrow
|
||||
>
|
||||
{linkElement}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return linkElement;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { IconCircleCheck, IconCircleCheckFilled } from "@tabler/icons-react";
|
||||
import { useResolveCommentMutation } from "@/ee/comment/queries/comment-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Editor } from "@tiptap/react";
|
||||
|
||||
interface ResolveCommentProps {
|
||||
editor: Editor;
|
||||
commentId: string;
|
||||
pageId: string;
|
||||
resolvedAt?: Date;
|
||||
}
|
||||
|
||||
function ResolveComment({
|
||||
editor,
|
||||
commentId,
|
||||
pageId,
|
||||
resolvedAt,
|
||||
}: ResolveCommentProps) {
|
||||
const { t } = useTranslation();
|
||||
const resolveCommentMutation = useResolveCommentMutation();
|
||||
|
||||
const isResolved = resolvedAt != null;
|
||||
const iconColor = isResolved ? "green" : "gray";
|
||||
|
||||
const handleResolveToggle = async () => {
|
||||
try {
|
||||
await resolveCommentMutation.mutateAsync({
|
||||
commentId,
|
||||
pageId,
|
||||
resolved: !isResolved,
|
||||
});
|
||||
|
||||
if (editor) {
|
||||
editor.commands.setCommentResolved(commentId, !isResolved);
|
||||
}
|
||||
|
||||
//
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle resolved state:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label={isResolved ? t("Re-Open comment") : t("Resolve comment")}
|
||||
position="top"
|
||||
>
|
||||
<ActionIcon
|
||||
onClick={handleResolveToggle}
|
||||
variant="subtle"
|
||||
color={isResolved ? "green" : "gray"}
|
||||
size="sm"
|
||||
loading={resolveCommentMutation.isPending}
|
||||
disabled={resolveCommentMutation.isPending}
|
||||
>
|
||||
{isResolved ? (
|
||||
<IconCircleCheckFilled size={18} />
|
||||
) : (
|
||||
<IconCircleCheck size={18} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default ResolveComment;
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { resolveComment } from "@/features/comment/services/comment-service";
|
||||
import {
|
||||
IComment,
|
||||
IResolveComment,
|
||||
} from "@/features/comment/types/comment.types";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||
import { RQ_KEY } from "@/features/comment/queries/comment-query";
|
||||
|
||||
export function useResolveCommentMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const emit = useQueryEmit();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: IResolveComment) => resolveComment(data),
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({ queryKey: RQ_KEY(variables.pageId) });
|
||||
const previousComments = queryClient.getQueryData(RQ_KEY(variables.pageId));
|
||||
queryClient.setQueryData(RQ_KEY(variables.pageId), (old: IPagination<IComment>) => {
|
||||
if (!old || !old.items) return old;
|
||||
const updatedItems = old.items.map((comment) =>
|
||||
comment.id === variables.commentId
|
||||
? {
|
||||
...comment,
|
||||
resolvedAt: variables.resolved ? new Date() : null,
|
||||
resolvedById: variables.resolved ? 'optimistic-user' : null,
|
||||
resolvedBy: variables.resolved ? { id: 'optimistic-user', name: 'Resolving...', avatarUrl: null } : null
|
||||
}
|
||||
: comment,
|
||||
);
|
||||
return {
|
||||
...old,
|
||||
items: updatedItems,
|
||||
};
|
||||
});
|
||||
return { previousComments };
|
||||
},
|
||||
onError: (err, variables, context) => {
|
||||
if (context?.previousComments) {
|
||||
queryClient.setQueryData(RQ_KEY(variables.pageId), context.previousComments);
|
||||
}
|
||||
notifications.show({
|
||||
message: t("Failed to resolve comment"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
onSuccess: (data: IComment, variables) => {
|
||||
const pageId = data.pageId;
|
||||
const currentComments = queryClient.getQueryData(
|
||||
RQ_KEY(pageId),
|
||||
) as IPagination<IComment>;
|
||||
if (currentComments && currentComments.items) {
|
||||
const updatedComments = currentComments.items.map((comment) =>
|
||||
comment.id === variables.commentId
|
||||
? { ...comment, resolvedAt: data.resolvedAt, resolvedById: data.resolvedById, resolvedBy: data.resolvedBy }
|
||||
: comment,
|
||||
);
|
||||
queryClient.setQueryData(RQ_KEY(pageId), {
|
||||
...currentComments,
|
||||
items: updatedComments,
|
||||
});
|
||||
}
|
||||
emit({
|
||||
operation: "resolveComment",
|
||||
pageId: pageId,
|
||||
commentId: variables.commentId,
|
||||
resolved: variables.resolved,
|
||||
resolvedAt: data.resolvedAt,
|
||||
resolvedById: data.resolvedById,
|
||||
resolvedBy: data.resolvedBy,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: RQ_KEY(pageId) });
|
||||
notifications.show({
|
||||
message: variables.resolved
|
||||
? t("Comment resolved successfully")
|
||||
: t("Comment re-opened successfully")
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export default function OssDetails() {
|
||||
withTableBorder
|
||||
>
|
||||
<Table.Caption>
|
||||
To unlock enterprise features like SSO, contact sales@docmost.com.
|
||||
To unlock enterprise features like SSO, MFA, Resolve comments, contact sales@docmost.com.
|
||||
</Table.Caption>
|
||||
<Table.Tbody>
|
||||
<Table.Tr>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from "react";
|
||||
import { Group, Text, Button } from "@mantine/core";
|
||||
import { Group, Text, Button, Tooltip } from "@mantine/core";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -7,6 +7,8 @@ import { getMfaStatus } from "@/ee/mfa";
|
||||
import { MfaSetupModal } from "@/ee/mfa";
|
||||
import { MfaDisableModal } from "@/ee/mfa";
|
||||
import { MfaBackupCodesModal } from "@/ee/mfa";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
import useLicense from "@/ee/hooks/use-license.tsx";
|
||||
|
||||
export function MfaSettings() {
|
||||
const { t } = useTranslation();
|
||||
@@ -14,16 +16,19 @@ export function MfaSettings() {
|
||||
const [setupModalOpen, setSetupModalOpen] = useState(false);
|
||||
const [disableModalOpen, setDisableModalOpen] = useState(false);
|
||||
const [backupCodesModalOpen, setBackupCodesModalOpen] = useState(false);
|
||||
const { hasLicenseKey } = useLicense();
|
||||
|
||||
const { data: mfaStatus, isLoading } = useQuery({
|
||||
queryKey: ["mfa-status"],
|
||||
queryFn: getMfaStatus,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
if (isLoading || !mfaStatus) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const canUseMfa = isCloud() || hasLicenseKey;
|
||||
|
||||
// Check if MFA is truly enabled
|
||||
const isMfaEnabled = mfaStatus?.isEnabled === true;
|
||||
|
||||
@@ -61,13 +66,19 @@ export function MfaSettings() {
|
||||
</div>
|
||||
|
||||
{!isMfaEnabled ? (
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setSetupModalOpen(true)}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
<Tooltip
|
||||
label={t("Available in enterprise edition")}
|
||||
disabled={canUseMfa}
|
||||
>
|
||||
{t("Add 2FA method")}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!canUseMfa}
|
||||
variant="default"
|
||||
onClick={() => setSetupModalOpen(true)}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{t("Add 2FA method")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Button
|
||||
|
||||
@@ -59,10 +59,3 @@ export async function validateMfaAccess(): Promise<MfaAccessValidationResponse>
|
||||
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 } from "@mantine/core";
|
||||
import { Group, Text, Box, Badge } from "@mantine/core";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import classes from "./comment.module.css";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
@@ -7,22 +7,34 @@ import CommentEditor from "@/features/comment/components/comment-editor";
|
||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
||||
import CommentActions from "@/features/comment/components/comment-actions";
|
||||
import CommentMenu from "@/features/comment/components/comment-menu";
|
||||
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
|
||||
import ResolveComment from "@/ee/comment/components/resolve-comment";
|
||||
import { useHover } from "@mantine/hooks";
|
||||
import {
|
||||
useDeleteCommentMutation,
|
||||
useUpdateCommentMutation,
|
||||
} from "@/features/comment/queries/comment-query";
|
||||
import { useResolveCommentMutation } from "@/ee/comment/queries/comment-query";
|
||||
import { IComment } from "@/features/comment/types/comment.types";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface CommentListItemProps {
|
||||
comment: IComment;
|
||||
pageId: string;
|
||||
canComment: boolean;
|
||||
userSpaceRole?: string;
|
||||
}
|
||||
|
||||
function CommentListItem({ comment, pageId }: CommentListItemProps) {
|
||||
function CommentListItem({
|
||||
comment,
|
||||
pageId,
|
||||
canComment,
|
||||
userSpaceRole,
|
||||
}: CommentListItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const { hovered, ref } = useHover();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -30,11 +42,13 @@ function CommentListItem({ comment, pageId }: CommentListItemProps) {
|
||||
const [content, setContent] = useState<string>(comment.content);
|
||||
const updateCommentMutation = useUpdateCommentMutation();
|
||||
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
||||
const resolveCommentMutation = useResolveCommentMutation();
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const emit = useQueryEmit();
|
||||
const isCloudEE = useIsCloudEE();
|
||||
|
||||
useEffect(() => {
|
||||
setContent(comment.content)
|
||||
setContent(comment.content);
|
||||
}, [comment]);
|
||||
|
||||
async function handleUpdateComment() {
|
||||
@@ -72,8 +86,35 @@ function CommentListItem({ comment, pageId }: CommentListItemProps) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResolveComment() {
|
||||
if (!isCloudEE) return;
|
||||
|
||||
try {
|
||||
const isResolved = comment.resolvedAt != null;
|
||||
|
||||
await resolveCommentMutation.mutateAsync({
|
||||
commentId: comment.id,
|
||||
pageId: comment.pageId,
|
||||
resolved: !isResolved,
|
||||
});
|
||||
|
||||
if (editor) {
|
||||
editor.commands.setCommentResolved(comment.id, !isResolved);
|
||||
}
|
||||
|
||||
emit({
|
||||
operation: "invalidateComment",
|
||||
pageId: pageId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle resolved state:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCommentClick(comment: IComment) {
|
||||
const el = document.querySelector(`.comment-mark[data-comment-id="${comment.id}"]`);
|
||||
const el = document.querySelector(
|
||||
`.comment-mark[data-comment-id="${comment.id}"]`,
|
||||
);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
el.classList.add("comment-highlight");
|
||||
@@ -106,28 +147,42 @@ function CommentListItem({ comment, pageId }: CommentListItemProps) {
|
||||
</Text>
|
||||
|
||||
<div style={{ visibility: hovered ? "visible" : "hidden" }}>
|
||||
{/*!comment.parentCommentId && (
|
||||
<ResolveComment commentId={comment.id} pageId={comment.pageId} resolvedAt={comment.resolvedAt} />
|
||||
)*/}
|
||||
{!comment.parentCommentId && canComment && isCloudEE && (
|
||||
<ResolveComment
|
||||
editor={editor}
|
||||
commentId={comment.id}
|
||||
pageId={comment.pageId}
|
||||
resolvedAt={comment.resolvedAt}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentUser?.user?.id === comment.creatorId && (
|
||||
{(currentUser?.user?.id === comment.creatorId || userSpaceRole === 'admin') && (
|
||||
<CommentMenu
|
||||
onEditComment={handleEditToggle}
|
||||
onDeleteComment={handleDeleteComment}
|
||||
onResolveComment={handleResolveComment}
|
||||
canEdit={currentUser?.user?.id === comment.creatorId}
|
||||
isResolved={comment.resolvedAt != null}
|
||||
isParentComment={!comment.parentCommentId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Text size="xs" fw={500} c="dimmed">
|
||||
{timeAgo(comment.createdAt)}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Text size="xs" fw={500} c="dimmed">
|
||||
{timeAgo(comment.createdAt)}
|
||||
</Text>
|
||||
</Group>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<div>
|
||||
{!comment.parentCommentId && comment?.selection && (
|
||||
<Box className={classes.textSelection} onClick={() => handleCommentClick(comment)}>
|
||||
<Box
|
||||
className={classes.textSelection}
|
||||
onClick={() => handleCommentClick(comment)}
|
||||
>
|
||||
<Text size="sm">{comment?.selection}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
import React, { useState, useRef, useCallback, memo, useMemo } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Divider, Paper, Tabs, Badge, Text, ScrollArea } from "@mantine/core";
|
||||
import CommentListItem from "@/features/comment/components/comment-list-item";
|
||||
import {
|
||||
useCommentsQuery,
|
||||
useCreateCommentMutation,
|
||||
} from "@/features/comment/queries/comment-query";
|
||||
import CommentEditor from "@/features/comment/components/comment-editor";
|
||||
import CommentActions from "@/features/comment/components/comment-actions";
|
||||
import { useFocusWithin } from "@mantine/hooks";
|
||||
import { IComment } from "@/features/comment/types/comment.types.ts";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
|
||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from "@/features/space/permissions/permissions.type.ts";
|
||||
|
||||
function CommentListWithTabs() {
|
||||
const { t } = useTranslation();
|
||||
const { pageSlug } = useParams();
|
||||
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
||||
const {
|
||||
data: comments,
|
||||
isLoading: isCommentsLoading,
|
||||
isError,
|
||||
} = useCommentsQuery({ pageId: page?.id, limit: 100 });
|
||||
const createCommentMutation = useCreateCommentMutation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const emit = useQueryEmit();
|
||||
const isCloudEE = useIsCloudEE();
|
||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||
|
||||
const spaceRules = space?.membership?.permissions;
|
||||
const spaceAbility = useSpaceAbility(spaceRules);
|
||||
|
||||
|
||||
const canComment: boolean = spaceAbility.can(
|
||||
SpaceCaslAction.Manage,
|
||||
SpaceCaslSubject.Page
|
||||
);
|
||||
|
||||
// Separate active and resolved comments
|
||||
const { activeComments, resolvedComments } = useMemo(() => {
|
||||
if (!comments?.items) {
|
||||
return { activeComments: [], resolvedComments: [] };
|
||||
}
|
||||
|
||||
const parentComments = comments.items.filter(
|
||||
(comment: IComment) => comment.parentCommentId === null
|
||||
);
|
||||
|
||||
const active = parentComments.filter(
|
||||
(comment: IComment) => !comment.resolvedAt
|
||||
);
|
||||
const resolved = parentComments.filter(
|
||||
(comment: IComment) => comment.resolvedAt
|
||||
);
|
||||
|
||||
return { activeComments: active, resolvedComments: resolved };
|
||||
}, [comments]);
|
||||
|
||||
const handleAddReply = useCallback(
|
||||
async (commentId: string, content: string) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const commentData = {
|
||||
pageId: page?.id,
|
||||
parentCommentId: commentId,
|
||||
content: JSON.stringify(content),
|
||||
};
|
||||
|
||||
await createCommentMutation.mutateAsync(commentData);
|
||||
|
||||
emit({
|
||||
operation: "invalidateComment",
|
||||
pageId: page?.id,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to post comment:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[createCommentMutation, page?.id]
|
||||
);
|
||||
|
||||
const renderComments = useCallback(
|
||||
(comment: IComment) => (
|
||||
<Paper
|
||||
shadow="sm"
|
||||
radius="md"
|
||||
p="sm"
|
||||
mb="sm"
|
||||
withBorder
|
||||
key={comment.id}
|
||||
data-comment-id={comment.id}
|
||||
>
|
||||
<div>
|
||||
<CommentListItem
|
||||
comment={comment}
|
||||
pageId={page?.id}
|
||||
canComment={canComment}
|
||||
userSpaceRole={space?.membership?.role}
|
||||
/>
|
||||
<MemoizedChildComments
|
||||
comments={comments}
|
||||
parentId={comment.id}
|
||||
pageId={page?.id}
|
||||
canComment={canComment}
|
||||
userSpaceRole={space?.membership?.role}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!comment.resolvedAt && canComment && (
|
||||
<>
|
||||
<Divider my={4} />
|
||||
<CommentEditorWithActions
|
||||
commentId={comment.id}
|
||||
onSave={handleAddReply}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
),
|
||||
[comments, handleAddReply, isLoading, space?.membership?.role]
|
||||
);
|
||||
|
||||
if (isCommentsLoading) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <div>{t("Error loading comments.")}</div>;
|
||||
}
|
||||
|
||||
const totalComments = activeComments.length + resolvedComments.length;
|
||||
|
||||
// If not cloud/enterprise, show simple list without tabs
|
||||
if (!isCloudEE) {
|
||||
if (totalComments === 0) {
|
||||
return <>{t("No comments yet.")}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea style={{ height: "85vh" }} scrollbarSize={5} type="scroll">
|
||||
<div style={{ paddingBottom: "200px" }}>
|
||||
{comments?.items
|
||||
.filter((comment: IComment) => comment.parentCommentId === null)
|
||||
.map((comment) => (
|
||||
<Paper
|
||||
shadow="sm"
|
||||
radius="md"
|
||||
p="sm"
|
||||
mb="sm"
|
||||
withBorder
|
||||
key={comment.id}
|
||||
data-comment-id={comment.id}
|
||||
>
|
||||
<div>
|
||||
<CommentListItem
|
||||
comment={comment}
|
||||
pageId={page?.id}
|
||||
canComment={canComment}
|
||||
userSpaceRole={space?.membership?.role}
|
||||
/>
|
||||
<MemoizedChildComments
|
||||
comments={comments}
|
||||
parentId={comment.id}
|
||||
pageId={page?.id}
|
||||
canComment={canComment}
|
||||
userSpaceRole={space?.membership?.role}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{canComment && (
|
||||
<>
|
||||
<Divider my={4} />
|
||||
<CommentEditorWithActions
|
||||
commentId={comment.id}
|
||||
onSave={handleAddReply}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: "85vh", display: "flex", flexDirection: "column", marginTop: '-15px' }}>
|
||||
<Tabs defaultValue="open" variant="default" style={{ flex: "0 0 auto" }}>
|
||||
<Tabs.List justify="center">
|
||||
<Tabs.Tab
|
||||
value="open"
|
||||
leftSection={
|
||||
<Badge size="sm" variant="light" color="blue">
|
||||
{activeComments.length}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
{t("Open")}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value="resolved"
|
||||
leftSection={
|
||||
<Badge size="sm" variant="light" color="green">
|
||||
{resolvedComments.length}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
{t("Resolved")}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<ScrollArea
|
||||
style={{ flex: "1 1 auto", height: "calc(85vh - 60px)" }}
|
||||
scrollbarSize={5}
|
||||
type="scroll"
|
||||
>
|
||||
<div style={{ paddingBottom: "200px" }}>
|
||||
<Tabs.Panel value="open" pt="xs">
|
||||
{activeComments.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||
{t("No open comments.")}
|
||||
</Text>
|
||||
) : (
|
||||
activeComments.map(renderComments)
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="resolved" pt="xs">
|
||||
{resolvedComments.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||
{t("No resolved comments.")}
|
||||
</Text>
|
||||
) : (
|
||||
resolvedComments.map(renderComments)
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChildCommentsProps {
|
||||
comments: IPagination<IComment>;
|
||||
parentId: string;
|
||||
pageId: string;
|
||||
canComment: boolean;
|
||||
userSpaceRole?: string;
|
||||
}
|
||||
const ChildComments = ({
|
||||
comments,
|
||||
parentId,
|
||||
pageId,
|
||||
canComment,
|
||||
userSpaceRole,
|
||||
}: ChildCommentsProps) => {
|
||||
const getChildComments = useCallback(
|
||||
(parentId: string) =>
|
||||
comments.items.filter(
|
||||
(comment: IComment) => comment.parentCommentId === parentId
|
||||
),
|
||||
[comments.items]
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{getChildComments(parentId).map((childComment) => (
|
||||
<div key={childComment.id}>
|
||||
<CommentListItem
|
||||
comment={childComment}
|
||||
pageId={pageId}
|
||||
canComment={canComment}
|
||||
userSpaceRole={userSpaceRole}
|
||||
/>
|
||||
<MemoizedChildComments
|
||||
comments={comments}
|
||||
parentId={childComment.id}
|
||||
pageId={pageId}
|
||||
canComment={canComment}
|
||||
userSpaceRole={userSpaceRole}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MemoizedChildComments = memo(ChildComments);
|
||||
|
||||
const CommentEditorWithActions = ({ commentId, onSave, isLoading }) => {
|
||||
const [content, setContent] = useState("");
|
||||
const { ref, focused } = useFocusWithin();
|
||||
const commentEditorRef = useRef(null);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
onSave(commentId, content);
|
||||
setContent("");
|
||||
commentEditorRef.current?.clearContent();
|
||||
}, [commentId, content, onSave]);
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<CommentEditor
|
||||
ref={commentEditorRef}
|
||||
onUpdate={setContent}
|
||||
onSave={handleSave}
|
||||
editable={true}
|
||||
/>
|
||||
{focused && <CommentActions onSave={handleSave} isLoading={isLoading} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentListWithTabs;
|
||||
@@ -1,162 +0,0 @@
|
||||
import React, { useState, useRef, useCallback, memo } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Divider, Paper } from "@mantine/core";
|
||||
import CommentListItem from "@/features/comment/components/comment-list-item";
|
||||
import {
|
||||
useCommentsQuery,
|
||||
useCreateCommentMutation,
|
||||
} from "@/features/comment/queries/comment-query";
|
||||
import CommentEditor from "@/features/comment/components/comment-editor";
|
||||
import CommentActions from "@/features/comment/components/comment-actions";
|
||||
import { useFocusWithin } from "@mantine/hooks";
|
||||
import { IComment } from "@/features/comment/types/comment.types.ts";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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,15 +1,28 @@
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react";
|
||||
import { ActionIcon, Menu, Tooltip } from "@mantine/core";
|
||||
import { IconDots, IconEdit, IconTrash, IconCircleCheck, IconCircleCheckFilled } from "@tabler/icons-react";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
|
||||
|
||||
type CommentMenuProps = {
|
||||
onEditComment: () => void;
|
||||
onDeleteComment: () => void;
|
||||
onResolveComment?: () => void;
|
||||
canEdit?: boolean;
|
||||
isResolved?: boolean;
|
||||
isParentComment?: boolean;
|
||||
};
|
||||
|
||||
function CommentMenu({ onEditComment, onDeleteComment }: CommentMenuProps) {
|
||||
function CommentMenu({
|
||||
onEditComment,
|
||||
onDeleteComment,
|
||||
onResolveComment,
|
||||
canEdit = true,
|
||||
isResolved = false,
|
||||
isParentComment = false
|
||||
}: CommentMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const isCloudEE = useIsCloudEE();
|
||||
|
||||
//@ts-ignore
|
||||
const openDeleteModal = () =>
|
||||
@@ -30,9 +43,34 @@ function CommentMenu({ onEditComment, onDeleteComment }: CommentMenuProps) {
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={onEditComment} leftSection={<IconEdit size={14} />}>
|
||||
{t("Edit comment")}
|
||||
</Menu.Item>
|
||||
{canEdit && (
|
||||
<Menu.Item onClick={onEditComment} leftSection={<IconEdit size={14} />}>
|
||||
{t("Edit comment")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{isParentComment && (
|
||||
isCloudEE ? (
|
||||
<Menu.Item
|
||||
onClick={onResolveComment}
|
||||
leftSection={
|
||||
isResolved ?
|
||||
<IconCircleCheckFilled size={14} /> :
|
||||
<IconCircleCheck size={14} />
|
||||
}
|
||||
>
|
||||
{isResolved ? t("Re-open comment") : t("Resolve comment")}
|
||||
</Menu.Item>
|
||||
) : (
|
||||
<Tooltip label={t("Available in enterprise edition")} position="left">
|
||||
<Menu.Item
|
||||
disabled
|
||||
leftSection={<IconCircleCheck size={14} />}
|
||||
>
|
||||
{t("Resolve comment")}
|
||||
</Menu.Item>
|
||||
</Tooltip>
|
||||
)
|
||||
)}
|
||||
<Menu.Item
|
||||
leftSection={<IconTrash size={14} />}
|
||||
onClick={openDeleteModal}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
import { IconCircleCheck } from "@tabler/icons-react";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { useResolveCommentMutation } from "@/features/comment/queries/comment-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function ResolveComment({ commentId, pageId, resolvedAt }) {
|
||||
const { t } = useTranslation();
|
||||
const resolveCommentMutation = useResolveCommentMutation();
|
||||
|
||||
const isResolved = resolvedAt != null;
|
||||
const iconColor = isResolved ? "green" : "gray";
|
||||
|
||||
//@ts-ignore
|
||||
const openConfirmModal = () =>
|
||||
modals.openConfirmModal({
|
||||
title: t("Are you sure you want to resolve this comment thread?"),
|
||||
centered: true,
|
||||
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
|
||||
onConfirm: handleResolveToggle,
|
||||
});
|
||||
|
||||
const handleResolveToggle = async () => {
|
||||
try {
|
||||
await resolveCommentMutation.mutateAsync({
|
||||
commentId,
|
||||
resolved: !isResolved,
|
||||
});
|
||||
//TODO: remove comment mark
|
||||
// Remove comment thread from state on resolve
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle resolved state:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ActionIcon
|
||||
onClick={openConfirmModal}
|
||||
variant="default"
|
||||
style={{ border: "none" }}
|
||||
>
|
||||
<IconCircleCheck size={20} stroke={2} color={iconColor} />
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
|
||||
export default ResolveComment;
|
||||
@@ -8,13 +8,11 @@ import {
|
||||
createComment,
|
||||
deleteComment,
|
||||
getPageComments,
|
||||
resolveComment,
|
||||
updateComment,
|
||||
} from "@/features/comment/services/comment-service";
|
||||
import {
|
||||
ICommentParams,
|
||||
IComment,
|
||||
IResolveComment,
|
||||
} from "@/features/comment/types/comment.types";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
@@ -108,34 +106,4 @@ export function useDeleteCommentMutation(pageId?: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useResolveCommentMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: IResolveComment) => resolveComment(data),
|
||||
onSuccess: (data: IComment, variables) => {
|
||||
const currentComments = queryClient.getQueryData(
|
||||
RQ_KEY(data.pageId),
|
||||
) as IComment[];
|
||||
|
||||
/*
|
||||
if (currentComments) {
|
||||
const updatedComments = currentComments.map((comment) =>
|
||||
comment.id === variables.commentId
|
||||
? { ...comment, ...data }
|
||||
: comment,
|
||||
);
|
||||
queryClient.setQueryData(RQ_KEY(data.pageId), updatedComments);
|
||||
}*/
|
||||
|
||||
notifications.show({ message: t("Comment resolved successfully") });
|
||||
},
|
||||
onError: (error) => {
|
||||
notifications.show({
|
||||
message: t("Failed to resolve comment"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
// EE: useResolveCommentMutation has been moved to @/ee/comment/queries/comment-query
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface IComment {
|
||||
editedAt?: Date;
|
||||
deletedAt?: Date;
|
||||
creator: IUser;
|
||||
resolvedBy?: IUser;
|
||||
}
|
||||
|
||||
export interface ICommentData {
|
||||
@@ -28,6 +29,7 @@ export interface ICommentData {
|
||||
|
||||
export interface IResolveComment {
|
||||
commentId: string;
|
||||
pageId: string;
|
||||
resolved: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
.wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.resizing {
|
||||
user-select: none;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 10;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.resizeHandleBottom {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 24px;
|
||||
cursor: ns-resize;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
touch-action: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
|
||||
@mixin light {
|
||||
background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.05));
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.05)
|
||||
);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@mixin light {
|
||||
background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.1)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper:hover .resizeHandleBottom,
|
||||
.resizing .resizeHandleBottom {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.resizeBar {
|
||||
width: 50px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
@mixin light {
|
||||
background-color: var(--mantine-color-gray-5);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
background-color: var(--mantine-color-gray-6);
|
||||
}
|
||||
}
|
||||
|
||||
.resizeHandleBottom:hover .resizeBar,
|
||||
.resizing .resizeBar {
|
||||
@mixin light {
|
||||
background-color: var(--mantine-color-gray-7);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
background-color: var(--mantine-color-gray-4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import React, { ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import classes from "./resizable-wrapper.module.css";
|
||||
|
||||
interface ResizableWrapperProps {
|
||||
children: ReactNode;
|
||||
initialHeight?: number;
|
||||
minHeight?: number;
|
||||
maxHeight?: number;
|
||||
onResize?: (height: number) => void;
|
||||
isEditable?: boolean;
|
||||
className?: string;
|
||||
showHandles?: "always" | "hover";
|
||||
direction?: "vertical" | "horizontal" | "both";
|
||||
}
|
||||
|
||||
export const ResizableWrapper: React.FC<ResizableWrapperProps> = ({
|
||||
children,
|
||||
initialHeight = 480,
|
||||
minHeight = 200,
|
||||
maxHeight = 1200,
|
||||
onResize,
|
||||
isEditable = true,
|
||||
className,
|
||||
showHandles = "hover",
|
||||
direction = "vertical",
|
||||
}) => {
|
||||
const [resizeParams, setResizeParams] = useState<{
|
||||
initialSize: number;
|
||||
initialClientY: number;
|
||||
initialClientX: number;
|
||||
} | null>(null);
|
||||
const [currentHeight, setCurrentHeight] = useState(initialHeight);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resizeParams) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!wrapperRef.current) return;
|
||||
|
||||
if (direction === "vertical" || direction === "both") {
|
||||
const deltaY = e.clientY - resizeParams.initialClientY;
|
||||
const newHeight = Math.min(
|
||||
Math.max(resizeParams.initialSize + deltaY, minHeight),
|
||||
maxHeight
|
||||
);
|
||||
setCurrentHeight(newHeight);
|
||||
wrapperRef.current.style.height = `${newHeight}px`;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setResizeParams(null);
|
||||
if (onResize && currentHeight !== initialHeight) {
|
||||
onResize(currentHeight);
|
||||
}
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [resizeParams, currentHeight, initialHeight, onResize, minHeight, maxHeight, direction]);
|
||||
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setResizeParams({
|
||||
initialSize: currentHeight,
|
||||
initialClientY: e.clientY,
|
||||
initialClientX: e.clientX,
|
||||
});
|
||||
|
||||
document.body.style.cursor = "ns-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
}, [currentHeight]);
|
||||
|
||||
const shouldShowHandles =
|
||||
isEditable &&
|
||||
(showHandles === "always" || (showHandles === "hover" && (isHovered || resizeParams)));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={clsx(classes.wrapper, className, {
|
||||
[classes.resizing]: !!resizeParams,
|
||||
})}
|
||||
style={{ height: currentHeight }}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{children}
|
||||
{!!resizeParams && <div className={classes.overlay} />}
|
||||
{shouldShowHandles && direction === "vertical" && (
|
||||
<div
|
||||
className={classes.resizeHandleBottom}
|
||||
onMouseDown={handleResizeStart}
|
||||
>
|
||||
<div className={classes.resizeBar} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
.embedWrapper {
|
||||
@mixin light {
|
||||
background-color: var(--mantine-color-gray-0);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
background-color: var(--mantine-color-dark-7);
|
||||
}
|
||||
}
|
||||
|
||||
.embedIframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { useMemo } from "react";
|
||||
import React, { useMemo, useCallback } from "react";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
ActionIcon,
|
||||
AspectRatio,
|
||||
Button,
|
||||
Card,
|
||||
FocusTrap,
|
||||
@@ -14,14 +13,18 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { IconEdit } from "@tabler/icons-react";
|
||||
import { z } from "zod";
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zodResolver } from "mantine-form-zod-resolver";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n from "i18next";
|
||||
import {
|
||||
getEmbedProviderById,
|
||||
getEmbedUrlAndProvider,
|
||||
sanitizeUrl,
|
||||
} from "@docmost/editor-ext";
|
||||
import { ResizableWrapper } from "../common/resizable-wrapper";
|
||||
import classes from "./embed-view.module.css";
|
||||
|
||||
const schema = z.object({
|
||||
url: z
|
||||
@@ -33,7 +36,7 @@ const schema = z.object({
|
||||
export default function EmbedView(props: NodeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { node, selected, updateAttributes, editor } = props;
|
||||
const { src, provider } = node.attrs;
|
||||
const { src, provider, height: nodeHeight } = node.attrs;
|
||||
|
||||
const embedUrl = useMemo(() => {
|
||||
if (src) {
|
||||
@@ -49,6 +52,13 @@ export default function EmbedView(props: NodeViewProps) {
|
||||
validate: zodResolver(schema),
|
||||
});
|
||||
|
||||
const handleResize = useCallback(
|
||||
(newHeight: number) => {
|
||||
updateAttributes({ height: newHeight });
|
||||
},
|
||||
[updateAttributes],
|
||||
);
|
||||
|
||||
async function onSubmit(data: { url: string }) {
|
||||
if (!editor.isEditable) {
|
||||
return;
|
||||
@@ -57,11 +67,11 @@ export default function EmbedView(props: NodeViewProps) {
|
||||
if (provider) {
|
||||
const embedProvider = getEmbedProviderById(provider);
|
||||
if (embedProvider.id === "iframe") {
|
||||
updateAttributes({ src: data.url });
|
||||
updateAttributes({ src: sanitizeUrl(data.url) });
|
||||
return;
|
||||
}
|
||||
if (embedProvider.regex.test(data.url)) {
|
||||
updateAttributes({ src: data.url });
|
||||
updateAttributes({ src: sanitizeUrl(data.url) });
|
||||
} else {
|
||||
notifications.show({
|
||||
message: t("Invalid {{provider}} embed link", {
|
||||
@@ -77,17 +87,25 @@ export default function EmbedView(props: NodeViewProps) {
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
{embedUrl ? (
|
||||
<>
|
||||
<AspectRatio ratio={16 / 9}>
|
||||
<iframe
|
||||
src={embedUrl}
|
||||
allow="encrypted-media"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
allowFullScreen
|
||||
frameBorder="0"
|
||||
></iframe>
|
||||
</AspectRatio>
|
||||
</>
|
||||
<ResizableWrapper
|
||||
initialHeight={nodeHeight || 480}
|
||||
minHeight={200}
|
||||
maxHeight={1200}
|
||||
onResize={handleResize}
|
||||
isEditable={editor.isEditable}
|
||||
className={clsx(classes.embedWrapper, {
|
||||
"ProseMirror-selectednode": selected,
|
||||
})}
|
||||
>
|
||||
<iframe
|
||||
className={classes.embedIframe}
|
||||
src={sanitizeUrl(embedUrl)}
|
||||
allow="encrypted-media"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
allowFullScreen
|
||||
frameBorder="0"
|
||||
/>
|
||||
</ResizableWrapper>
|
||||
) : (
|
||||
<Popover
|
||||
width={300}
|
||||
|
||||
@@ -10,7 +10,6 @@ import { Highlight } from "@tiptap/extension-highlight";
|
||||
import { Typography } from "@tiptap/extension-typography";
|
||||
import { TextStyle } from "@tiptap/extension-text-style";
|
||||
import { Color } from "@tiptap/extension-color";
|
||||
import Table from "@tiptap/extension-table";
|
||||
import SlashCommand from "@/features/editor/extensions/slash-command";
|
||||
import { Collaboration } from "@tiptap/extension-collaboration";
|
||||
import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor";
|
||||
@@ -25,6 +24,7 @@ import {
|
||||
TableCell,
|
||||
TableRow,
|
||||
TableHeader,
|
||||
CustomTable,
|
||||
TrailingNode,
|
||||
TiptapImage,
|
||||
Callout,
|
||||
@@ -160,7 +160,7 @@ export const mainExtensions = [
|
||||
return ReactNodeViewRenderer(MentionView);
|
||||
},
|
||||
}),
|
||||
Table.configure({
|
||||
CustomTable.configure({
|
||||
resizable: true,
|
||||
lastColumnResizable: false,
|
||||
allowTableNodeSelection: true,
|
||||
|
||||
@@ -75,7 +75,7 @@ export default function PageEditor({
|
||||
const [isLocalSynced, setLocalSynced] = useState(false);
|
||||
const [isRemoteSynced, setRemoteSynced] = useState(false);
|
||||
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
||||
yjsConnectionStatusAtom,
|
||||
yjsConnectionStatusAtom
|
||||
);
|
||||
const menuContainerRef = useRef(null);
|
||||
const documentName = `page.${pageId}`;
|
||||
@@ -262,7 +262,7 @@ export default function PageEditor({
|
||||
debouncedUpdateContent(editorJson);
|
||||
},
|
||||
},
|
||||
[pageId, editable, remoteProvider],
|
||||
[pageId, editable, remoteProvider]
|
||||
);
|
||||
|
||||
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
|
||||
@@ -278,7 +278,12 @@ export default function PageEditor({
|
||||
}, 3000);
|
||||
|
||||
const handleActiveCommentEvent = (event) => {
|
||||
const { commentId } = event.detail;
|
||||
const { commentId, resolved } = event.detail;
|
||||
|
||||
if (resolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveCommentId(commentId);
|
||||
setAsideState({ tab: "comments", isAsideOpen: true });
|
||||
|
||||
@@ -295,7 +300,7 @@ export default function PageEditor({
|
||||
return () => {
|
||||
document.removeEventListener(
|
||||
"ACTIVE_COMMENT_EVENT",
|
||||
handleActiveCommentEvent,
|
||||
handleActiveCommentEvent
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -142,6 +142,11 @@
|
||||
.comment-mark {
|
||||
background: rgba(255, 215, 0, 0.14);
|
||||
border-bottom: 2px solid rgb(166, 158, 12);
|
||||
|
||||
&.resolved {
|
||||
background: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.comment-highlight {
|
||||
@@ -187,7 +192,7 @@
|
||||
mask-size: 100% 100%;
|
||||
background-color: currentColor;
|
||||
|
||||
& -open {
|
||||
&-open {
|
||||
--svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M10 3v2H5v14h14v-5h2v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zm7.586 2H13V3h8v8h-2V6.414l-7 7L10.586 12z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Modal, Button, Group, Text } from "@mantine/core";
|
||||
import { copyPageToSpace } from "@/features/page/services/page-service.ts";
|
||||
import { duplicatePage } from "@/features/page/services/page-service.ts";
|
||||
import { useState } from "react";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -30,7 +30,7 @@ export default function CopyPageModal({
|
||||
if (!targetSpace) return;
|
||||
|
||||
try {
|
||||
const copiedPage = await copyPageToSpace({
|
||||
const copiedPage = await duplicatePage({
|
||||
pageId,
|
||||
spaceId: targetSpace.id,
|
||||
});
|
||||
|
||||
@@ -231,7 +231,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onClick={handleDeletePage}
|
||||
>
|
||||
{t("Delete")}
|
||||
{t("Move to trash")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -319,7 +319,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
||||
>
|
||||
{(props) => (
|
||||
<Tooltip
|
||||
label="Available in enterprise edition"
|
||||
label={t("Available in enterprise edition")}
|
||||
disabled={canUseConfluence}
|
||||
>
|
||||
<Button
|
||||
|
||||
@@ -4,26 +4,37 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
type UseDeleteModalProps = {
|
||||
onConfirm: () => void;
|
||||
isPermanent?: boolean;
|
||||
};
|
||||
|
||||
export function useDeletePageModal() {
|
||||
const { t } = useTranslation();
|
||||
const openDeleteModal = ({ onConfirm }: UseDeleteModalProps) => {
|
||||
const openDeleteModal = ({
|
||||
onConfirm,
|
||||
isPermanent = false,
|
||||
}: UseDeleteModalProps) => {
|
||||
modals.openConfirmModal({
|
||||
title: t("Are you sure you want to delete this page?"),
|
||||
title: isPermanent
|
||||
? t("Are you sure you want to delete this page?")
|
||||
: t("Move this page to trash?"),
|
||||
children: (
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.",
|
||||
)}
|
||||
{isPermanent
|
||||
? t(
|
||||
"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>
|
||||
),
|
||||
centered: true,
|
||||
labels: { confirm: t("Delete"), cancel: t("Cancel") },
|
||||
labels: {
|
||||
confirm: isPermanent ? t("Delete") : t("Move to trash"),
|
||||
cancel: t("Cancel"),
|
||||
},
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm,
|
||||
});
|
||||
};
|
||||
|
||||
return { openDeleteModal } as const;
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
UseInfiniteQueryResult,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
UseQueryResult,
|
||||
keepPreviousData,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
createPage,
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
getPageBreadcrumbs,
|
||||
getRecentChanges,
|
||||
getAllSidebarPages,
|
||||
getDeletedPages,
|
||||
restorePage,
|
||||
} from "@/features/page/services/page-service";
|
||||
import {
|
||||
IMovePage,
|
||||
@@ -26,12 +28,17 @@ import {
|
||||
SidebarPagesParams,
|
||||
} from "@/features/page/types/page.types";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { buildTree } from "@/features/page/tree/utils";
|
||||
import { useEffect } from "react";
|
||||
import { validate as isValidUuid } from "uuid";
|
||||
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(
|
||||
pageInput: Partial<IPageInput>,
|
||||
@@ -70,10 +77,7 @@ export function useCreatePageMutation() {
|
||||
}
|
||||
|
||||
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]);
|
||||
|
||||
if (pageBySlug) {
|
||||
@@ -87,7 +91,13 @@ export function updatePageData(data: IPage) {
|
||||
queryClient.setQueryData(["pages", data.id], { ...pageById, ...data });
|
||||
}
|
||||
|
||||
invalidateOnUpdatePage(data.spaceId, data.parentPageId, data.id, data.title, data.icon);
|
||||
invalidateOnUpdatePage(
|
||||
data.spaceId,
|
||||
data.parentPageId,
|
||||
data.id,
|
||||
data.title,
|
||||
data.icon,
|
||||
);
|
||||
}
|
||||
|
||||
export function useUpdateTitlePageMutation() {
|
||||
@@ -102,7 +112,29 @@ export function useUpdatePageMutation() {
|
||||
onSuccess: (data) => {
|
||||
updatePage(data);
|
||||
|
||||
invalidateOnUpdatePage(data.spaceId, data.parentPageId, data.id, data.title, data.icon);
|
||||
invalidateOnUpdatePage(
|
||||
data.spaceId,
|
||||
data.parentPageId,
|
||||
data.id,
|
||||
data.title,
|
||||
data.icon,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemovePageMutation() {
|
||||
return useMutation({
|
||||
mutationFn: (pageId: string) => deletePage(pageId, false),
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: "Page moved to trash" });
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (item) =>
|
||||
["trash-list"].includes(item.queryKey[0] as string),
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
notifications.show({ message: "Failed to delete page", color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -110,10 +142,16 @@ export function useUpdatePageMutation() {
|
||||
export function useDeletePageMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation({
|
||||
mutationFn: (pageId: string) => deletePage(pageId),
|
||||
mutationFn: (pageId: string) => deletePage(pageId, true),
|
||||
onSuccess: (data, pageId) => {
|
||||
notifications.show({ message: t("Page deleted successfully") });
|
||||
invalidateOnDeletePage(pageId);
|
||||
|
||||
// Invalidate to refresh trash lists
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (item) =>
|
||||
["trash-list"].includes(item.queryKey[0] as string),
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
notifications.show({ message: t("Failed to delete page"), color: "red" });
|
||||
@@ -130,7 +168,87 @@ export function useMovePageMutation() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useGetSidebarPagesQuery(data: SidebarPagesParams|null): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> {
|
||||
export function useRestorePageMutation() {
|
||||
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({
|
||||
queryKey: ["sidebar-pages", data],
|
||||
queryFn: ({ pageParam }) => getSidebarPages({ ...data, page: pageParam }),
|
||||
@@ -188,6 +306,20 @@ 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>) {
|
||||
const newPage: Partial<IPage> = {
|
||||
creatorId: data.creatorId,
|
||||
@@ -202,34 +334,40 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
||||
};
|
||||
|
||||
let queryKey: QueryKey = null;
|
||||
if (data.parentPageId===null) {
|
||||
queryKey = ['root-sidebar-pages', data.spaceId];
|
||||
}else{
|
||||
queryKey = ['sidebar-pages', {pageId: data.parentPageId, spaceId: data.spaceId}]
|
||||
if (data.parentPageId === null) {
|
||||
queryKey = ["root-sidebar-pages", data.spaceId];
|
||||
} else {
|
||||
queryKey = [
|
||||
"sidebar-pages",
|
||||
{ pageId: data.parentPageId, spaceId: data.spaceId },
|
||||
];
|
||||
}
|
||||
|
||||
//update all sidebar pages
|
||||
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(queryKey, (old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page,index) => {
|
||||
if (index === old.pages.length - 1) {
|
||||
return {
|
||||
...page,
|
||||
items: [...page.items, newPage],
|
||||
};
|
||||
}
|
||||
return page;
|
||||
}),
|
||||
};
|
||||
});
|
||||
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(
|
||||
queryKey,
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page, index) => {
|
||||
if (index === old.pages.length - 1) {
|
||||
return {
|
||||
...page,
|
||||
items: [...page.items, newPage],
|
||||
};
|
||||
}
|
||||
return page;
|
||||
}),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
//update sidebar haschildren
|
||||
if (data.parentPageId!==null){
|
||||
if (data.parentPageId !== null) {
|
||||
//update sub sidebar pages haschildern
|
||||
const subSideBarMatches = queryClient.getQueriesData({
|
||||
queryKey: ['sidebar-pages'],
|
||||
queryKey: ["sidebar-pages"],
|
||||
exact: false,
|
||||
});
|
||||
|
||||
@@ -241,8 +379,10 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.map((sidebarPage: IPage) =>
|
||||
sidebarPage.id === data.parentPageId ? { ...sidebarPage, hasChildren: true } : sidebarPage
|
||||
)
|
||||
sidebarPage.id === data.parentPageId
|
||||
? { ...sidebarPage, hasChildren: true }
|
||||
: sidebarPage,
|
||||
),
|
||||
})),
|
||||
};
|
||||
});
|
||||
@@ -250,7 +390,7 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
||||
|
||||
//update root sidebar pages haschildern
|
||||
const rootSideBarMatches = queryClient.getQueriesData({
|
||||
queryKey: ['root-sidebar-pages', data.spaceId],
|
||||
queryKey: ["root-sidebar-pages", data.spaceId],
|
||||
exact: false,
|
||||
});
|
||||
|
||||
@@ -262,8 +402,10 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.map((sidebarPage: IPage) =>
|
||||
sidebarPage.id === data.parentPageId ? { ...sidebarPage, hasChildren: true } : sidebarPage
|
||||
)
|
||||
sidebarPage.id === data.parentPageId
|
||||
? { ...sidebarPage, hasChildren: true }
|
||||
: sidebarPage,
|
||||
),
|
||||
})),
|
||||
};
|
||||
});
|
||||
@@ -276,27 +418,38 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
||||
});
|
||||
}
|
||||
|
||||
export function invalidateOnUpdatePage(spaceId: string, parentPageId: string, id: string, title: string, icon: string) {
|
||||
export function invalidateOnUpdatePage(
|
||||
spaceId: string,
|
||||
parentPageId: string,
|
||||
id: string,
|
||||
title: string,
|
||||
icon: string,
|
||||
) {
|
||||
let queryKey: QueryKey = null;
|
||||
if(parentPageId===null){
|
||||
queryKey = ['root-sidebar-pages', spaceId];
|
||||
}else{
|
||||
queryKey = ['sidebar-pages', {pageId: parentPageId, spaceId: spaceId}]
|
||||
if (parentPageId === null) {
|
||||
queryKey = ["root-sidebar-pages", spaceId];
|
||||
} else {
|
||||
queryKey = ["sidebar-pages", { pageId: parentPageId, spaceId: spaceId }];
|
||||
}
|
||||
//update all sidebar pages
|
||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(queryKey, (old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.map((sidebarPage: IPage) =>
|
||||
sidebarPage.id === id ? { ...sidebarPage, title: title, icon: icon } : sidebarPage
|
||||
)
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
|
||||
queryKey,
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.map((sidebarPage: IPage) =>
|
||||
sidebarPage.id === id
|
||||
? { ...sidebarPage, title: title, icon: icon }
|
||||
: sidebarPage,
|
||||
),
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
//update recent changes
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["recent-changes", spaceId],
|
||||
@@ -311,7 +464,7 @@ export function invalidateOnMovePage() {
|
||||
});
|
||||
//invalidate all sub sidebar pages
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['sidebar-pages'],
|
||||
queryKey: ["sidebar-pages"],
|
||||
});
|
||||
// ---
|
||||
}
|
||||
@@ -320,7 +473,8 @@ export function invalidateOnDeletePage(pageId: string) {
|
||||
//update all sidebar pages
|
||||
const allSideBarMatches = queryClient.getQueriesData({
|
||||
predicate: (query) =>
|
||||
query.queryKey[0] === 'root-sidebar-pages' || query.queryKey[0] === 'sidebar-pages',
|
||||
query.queryKey[0] === "root-sidebar-pages" ||
|
||||
query.queryKey[0] === "sidebar-pages",
|
||||
});
|
||||
|
||||
allSideBarMatches.forEach(([key, d]) => {
|
||||
@@ -330,14 +484,16 @@ export function invalidateOnDeletePage(pageId: string) {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.filter((sidebarPage: IPage) => sidebarPage.id !== pageId),
|
||||
items: page.items.filter(
|
||||
(sidebarPage: IPage) => sidebarPage.id !== pageId,
|
||||
),
|
||||
})),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
//update recent changes
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["recent-changes"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
IPageInput,
|
||||
SidebarPagesParams,
|
||||
} from '@/features/page/types/page.types';
|
||||
import { QueryParams } from "@/lib/types";
|
||||
import { IAttachment, IPagination } from "@/lib/types.ts";
|
||||
import { saveAs } from "file-saver";
|
||||
import { InfiniteData } from "@tanstack/react-query";
|
||||
@@ -30,8 +31,21 @@ export async function updatePage(data: Partial<IPageInput>): Promise<IPage> {
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function deletePage(pageId: string): Promise<void> {
|
||||
await api.post("/pages/delete", { pageId });
|
||||
export async function deletePage(pageId: string, permanentlyDelete = false): Promise<void> {
|
||||
await api.post("/pages/delete", { pageId, permanentlyDelete });
|
||||
}
|
||||
|
||||
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> {
|
||||
@@ -42,8 +56,8 @@ export async function movePageToSpace(data: IMovePageToSpace): Promise<void> {
|
||||
await api.post<void>("/pages/move-to-space", data);
|
||||
}
|
||||
|
||||
export async function copyPageToSpace(data: ICopyPageToSpace): Promise<IPage> {
|
||||
const req = await api.post<IPage>("/pages/copy-to-space", data);
|
||||
export async function duplicatePage(data: ICopyPageToSpace): Promise<IPage> {
|
||||
const req = await api.post<IPage>("/pages/duplicate", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
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,4 +1,10 @@
|
||||
import { NodeApi, NodeRendererProps, Tree, TreeApi } from "react-arborist";
|
||||
import {
|
||||
NodeApi,
|
||||
NodeRendererProps,
|
||||
Tree,
|
||||
TreeApi,
|
||||
SimpleTree,
|
||||
} from "react-arborist";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
||||
import {
|
||||
@@ -66,6 +72,7 @@ import MovePageModal from "../../components/move-page-modal.tsx";
|
||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||
import CopyPageModal from "../../components/copy-page-modal.tsx";
|
||||
import { duplicatePage } from "../../services/page-service.ts";
|
||||
|
||||
interface SpaceTreeProps {
|
||||
spaceId: string;
|
||||
@@ -90,8 +97,14 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
||||
const treeApiRef = useRef<TreeApi<SpaceTreeNode>>();
|
||||
const [openTreeNodes, setOpenTreeNodes] = useAtom<OpenMap>(openTreeNodesAtom);
|
||||
const rootElement = useRef<HTMLDivElement>();
|
||||
const [isRootReady, setIsRootReady] = useState(false);
|
||||
const { ref: sizeRef, width, height } = useElementSize();
|
||||
const mergedRef = useMergedRef(rootElement, sizeRef);
|
||||
const mergedRef = useMergedRef((element) => {
|
||||
rootElement.current = element;
|
||||
if (element && !isRootReady) {
|
||||
setIsRootReady(true);
|
||||
}
|
||||
}, sizeRef);
|
||||
const [isDataLoaded, setIsDataLoaded] = useState(false);
|
||||
const { data: currentPage } = usePageQuery({
|
||||
pageId: extractPageSlugId(pageSlug),
|
||||
@@ -199,16 +212,17 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
||||
}
|
||||
}, [currentPage?.id]);
|
||||
|
||||
// Clean up tree API on unmount
|
||||
useEffect(() => {
|
||||
if (treeApiRef.current) {
|
||||
return () => {
|
||||
// @ts-ignore
|
||||
setTreeApi(treeApiRef.current);
|
||||
}
|
||||
}, [treeApiRef.current]);
|
||||
setTreeApi(null);
|
||||
};
|
||||
}, [setTreeApi]);
|
||||
|
||||
return (
|
||||
<div ref={mergedRef} className={classes.treeContainer}>
|
||||
{rootElement.current && (
|
||||
{isRootReady && rootElement.current && (
|
||||
<Tree
|
||||
data={data.filter((node) => node?.spaceId === spaceId)}
|
||||
disableDrag={readOnly}
|
||||
@@ -217,7 +231,13 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
||||
{...controllers}
|
||||
width={width}
|
||||
height={rootElement.current.clientHeight}
|
||||
ref={treeApiRef}
|
||||
ref={(ref) => {
|
||||
treeApiRef.current = ref;
|
||||
if (ref) {
|
||||
//@ts-ignore
|
||||
setTreeApi(ref);
|
||||
}
|
||||
}}
|
||||
openByDefault={false}
|
||||
disableMultiSelection={true}
|
||||
className={classes.tree}
|
||||
@@ -383,7 +403,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
||||
<span className={classes.text}>{node.data.name || t("untitled")}</span>
|
||||
|
||||
<div className={classes.actions}>
|
||||
<NodeMenu node={node} treeApi={tree} />
|
||||
<NodeMenu node={node} treeApi={tree} spaceId={node.data.spaceId} />
|
||||
|
||||
{!tree.props.disableEdit && (
|
||||
<CreateNode
|
||||
@@ -436,13 +456,16 @@ function CreateNode({ node, treeApi, onExpandTree }: CreateNodeProps) {
|
||||
interface NodeMenuProps {
|
||||
node: NodeApi<SpaceTreeNode>;
|
||||
treeApi: TreeApi<SpaceTreeNode>;
|
||||
spaceId: string;
|
||||
}
|
||||
|
||||
function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
||||
function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const clipboard = useClipboard({ timeout: 500 });
|
||||
const { spaceSlug } = useParams();
|
||||
const { openDeleteModal } = useDeletePageModal();
|
||||
const [data, setData] = useAtom(treeDataAtom);
|
||||
const emit = useQueryEmit();
|
||||
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||
useDisclosure(false);
|
||||
const [
|
||||
@@ -461,6 +484,68 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
||||
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 (
|
||||
<>
|
||||
<Menu shadow="md" width={200}>
|
||||
@@ -505,6 +590,17 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
||||
|
||||
{!(treeApi.props.disableEdit as boolean) && (
|
||||
<>
|
||||
<Menu.Item
|
||||
leftSection={<IconCopy size={16} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleDuplicatePage();
|
||||
}}
|
||||
>
|
||||
{t("Duplicate")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconArrowRight size={16} />}
|
||||
onClick={(e) => {
|
||||
@@ -524,7 +620,7 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
||||
openCopyPageModal();
|
||||
}}
|
||||
>
|
||||
{t("Copy")}
|
||||
{t("Copy to space")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
@@ -537,7 +633,7 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
||||
openDeleteModal({ onConfirm: () => treeApi?.delete(node) });
|
||||
}}
|
||||
>
|
||||
{t("Delete")}
|
||||
{t("Move to trash")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { IMovePage, IPage } from "@/features/page/types/page.types.ts";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
useCreatePageMutation,
|
||||
useDeletePageMutation,
|
||||
useRemovePageMutation,
|
||||
useMovePageMutation,
|
||||
useUpdatePageMutation,
|
||||
} 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 createPageMutation = useCreatePageMutation();
|
||||
const updatePageMutation = useUpdatePageMutation();
|
||||
const deletePageMutation = useDeletePageMutation();
|
||||
const removePageMutation = useRemovePageMutation();
|
||||
const movePageMutation = useMovePageMutation();
|
||||
const navigate = useNavigate();
|
||||
const { spaceSlug } = useParams();
|
||||
@@ -225,7 +225,7 @@ export function useTreeMutation<T>(spaceId: string) {
|
||||
|
||||
const onDelete: DeleteHandler<T> = async (args: { ids: string[] }) => {
|
||||
try {
|
||||
await deletePageMutation.mutateAsync(args.ids[0]);
|
||||
await removePageMutation.mutateAsync(args.ids[0]);
|
||||
|
||||
const node = tree.find(args.ids[0]);
|
||||
if (!node) {
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface IPage {
|
||||
hasChildren: boolean;
|
||||
creator: ICreator;
|
||||
lastUpdatedBy: ILastUpdatedBy;
|
||||
deletedBy: IDeletedBy;
|
||||
space: Partial<ISpace>;
|
||||
}
|
||||
|
||||
@@ -34,6 +35,12 @@ interface ILastUpdatedBy {
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
interface IDeletedBy {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
export interface IMovePage {
|
||||
pageId: string;
|
||||
position?: string;
|
||||
@@ -49,7 +56,7 @@ export interface IMovePageToSpace {
|
||||
|
||||
export interface ICopyPageToSpace {
|
||||
pageId: string;
|
||||
spaceId: string;
|
||||
spaceId?: string;
|
||||
}
|
||||
|
||||
export interface SidebarPagesParams {
|
||||
|
||||
@@ -61,47 +61,26 @@ export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) {
|
||||
type: "group",
|
||||
}));
|
||||
|
||||
// Function to merge items into groups without duplicates
|
||||
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),
|
||||
);
|
||||
|
||||
const updatedGroups = existingGroups.map((group) => {
|
||||
if (group.group === groupName) {
|
||||
return { ...group, items: [...group.items, ...newItemsFiltered] };
|
||||
}
|
||||
return group;
|
||||
// Create fresh data structure based on current search results
|
||||
const newData = [];
|
||||
|
||||
if (userItems && userItems.length > 0) {
|
||||
newData.push({
|
||||
group: t("Select a user"),
|
||||
items: userItems,
|
||||
});
|
||||
}
|
||||
|
||||
if (groupItems && groupItems.length > 0) {
|
||||
newData.push({
|
||||
group: t("Select a group"),
|
||||
items: groupItems,
|
||||
});
|
||||
}
|
||||
|
||||
// Use spread syntax to avoid mutation
|
||||
return updatedGroups.some((group) => group.group === groupName)
|
||||
? updatedGroups
|
||||
: [...updatedGroups, { group: groupName, items: newItemsFiltered }];
|
||||
};
|
||||
|
||||
// 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);
|
||||
setData(newData);
|
||||
}
|
||||
}, [suggestion, data]);
|
||||
}, [suggestion, t]);
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
@@ -114,6 +93,7 @@ export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) {
|
||||
searchable
|
||||
searchValue={searchValue}
|
||||
onSearchChange={setSearchValue}
|
||||
filter={({ options }) => options}
|
||||
clearable
|
||||
variant="filled"
|
||||
onChange={onChange}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
IconSettings,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import classes from "./space-sidebar.module.css";
|
||||
import React from "react";
|
||||
@@ -206,6 +207,7 @@ interface SpaceMenuProps {
|
||||
}
|
||||
function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const { spaceSlug } = useParams();
|
||||
const [importOpened, { open: openImportModal, close: closeImportModal }] =
|
||||
useDisclosure(false);
|
||||
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||
@@ -253,6 +255,14 @@ function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
|
||||
>
|
||||
{t("Space settings")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
to={`/s/${spaceSlug}/trash`}
|
||||
leftSection={<IconTrash size={16} />}
|
||||
>
|
||||
{t("Trash")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Text, Avatar, SimpleGrid, Card, rem } from "@mantine/core";
|
||||
import { Text, Avatar, SimpleGrid, Card, rem, Group, Button } from "@mantine/core";
|
||||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
prefetchSpace,
|
||||
@@ -9,12 +9,13 @@ import { Link } from "react-router-dom";
|
||||
import classes from "./space-grid.module.css";
|
||||
import { formatMemberCount } from "@/lib";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconArrowRight } from "@tabler/icons-react";
|
||||
|
||||
export default function SpaceGrid() {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = useGetSpacesQuery({ page: 1 });
|
||||
const { data, isLoading } = useGetSpacesQuery({ page: 1, limit: 10 });
|
||||
|
||||
const cards = data?.items.map((space, index) => (
|
||||
const cards = data?.items.slice(0, 9).map((space, index) => (
|
||||
<Card
|
||||
key={space.id}
|
||||
p="xs"
|
||||
@@ -46,11 +47,27 @@ export default function SpaceGrid() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text fz="sm" fw={500} mb={"md"}>
|
||||
{t("Spaces you belong to")}
|
||||
</Text>
|
||||
<Group justify="space-between" align="center" mb="md">
|
||||
<Text fz="sm" fw={500}>
|
||||
{t("Spaces you belong to")}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
.spaceLink {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as AllSpacesList } from "./all-spaces-list";
|
||||
@@ -1,15 +1,6 @@
|
||||
import React from "react";
|
||||
import { isCloud } from "@/lib/config";
|
||||
import { useLicense } from "@/ee/hooks/use-license";
|
||||
import { MfaSettings } from "@/ee/mfa";
|
||||
|
||||
export function AccountMfaSection() {
|
||||
const { hasLicenseKey } = useLicense();
|
||||
const showMfa = isCloud() || hasLicenseKey;
|
||||
|
||||
if (!showMfa) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <MfaSettings />;
|
||||
}
|
||||
|
||||
@@ -63,6 +63,20 @@ export type RefetchRootTreeNodeEvent = {
|
||||
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 =
|
||||
| InvalidateEvent
|
||||
| InvalidateCommentsEvent
|
||||
@@ -71,4 +85,5 @@ export type WebSocketEvent =
|
||||
| AddTreeNodeEvent
|
||||
| MoveTreeNodeEvent
|
||||
| DeleteTreeNodeEvent
|
||||
| RefetchRootTreeNodeEvent;
|
||||
| RefetchRootTreeNodeEvent
|
||||
| ResolveCommentEvent;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "../page/queries/page-query";
|
||||
import { RQ_KEY } from "../comment/queries/comment-query";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { IComment } from "@/features/comment/types/comment.types";
|
||||
|
||||
export const useQuerySubscription = () => {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -96,6 +97,30 @@ export const useQuerySubscription = () => {
|
||||
});
|
||||
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]);
|
||||
|
||||
+4
-46
@@ -1,41 +1,23 @@
|
||||
import { Menu, ActionIcon, Text } from "@mantine/core";
|
||||
import React from "react";
|
||||
import { IconDots, IconTrash, IconShieldOff } from "@tabler/icons-react";
|
||||
import { IconDots, IconTrash } from "@tabler/icons-react";
|
||||
import { modals } from "@mantine/modals";
|
||||
import {
|
||||
useDeleteWorkspaceMemberMutation,
|
||||
useResetUserMfaMutation
|
||||
} from "@/features/workspace/queries/workspace-query.ts";
|
||||
import { useDeleteWorkspaceMemberMutation } from "@/features/workspace/queries/workspace-query.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 {
|
||||
userId: string;
|
||||
userRole: string;
|
||||
}
|
||||
export default function MemberActionMenu({ userId, userRole }: Props) {
|
||||
export default function MemberActionMenu({ userId }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const deleteWorkspaceMemberMutation = useDeleteWorkspaceMemberMutation();
|
||||
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 { isAdmin } = useUserRole();
|
||||
|
||||
const onRevoke = async () => {
|
||||
await deleteWorkspaceMemberMutation.mutateAsync({ userId });
|
||||
};
|
||||
|
||||
const onResetMfa = async () => {
|
||||
await resetUserMfaMutation.mutateAsync({ userId });
|
||||
};
|
||||
|
||||
const openRevokeModal = () =>
|
||||
modals.openConfirmModal({
|
||||
title: t("Delete member"),
|
||||
@@ -52,22 +34,6 @@ export default function MemberActionMenu({ userId, userRole }: Props) {
|
||||
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 (
|
||||
<>
|
||||
<Menu
|
||||
@@ -85,14 +51,6 @@ export default function MemberActionMenu({ userId, userRole }: Props) {
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
{showMfaReset && (
|
||||
<Menu.Item
|
||||
onClick={openResetMfaModal}
|
||||
leftSection={<IconShieldOff size={16} />}
|
||||
>
|
||||
{t("Reset MFA")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
c="red"
|
||||
onClick={openRevokeModal}
|
||||
|
||||
+1
-1
@@ -98,7 +98,7 @@ export default function WorkspaceMembersTable() {
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{isAdmin && <MemberActionMenu userId={user.id} userRole={user.role} />}
|
||||
{isAdmin && <MemberActionMenu userId={user.id} />}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
getAppVersion,
|
||||
deleteWorkspaceMember,
|
||||
} from "@/features/workspace/services/workspace-service";
|
||||
import { resetUserMfa } from "@/ee/mfa";
|
||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
@@ -193,29 +192,3 @@ export function useAppVersion(
|
||||
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" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { isCloud } from "@/lib/config";
|
||||
import { useLicense } from "@/ee/hooks/use-license";
|
||||
|
||||
export const useIsCloudEE = () => {
|
||||
const { hasLicenseKey } = useLicense();
|
||||
return isCloud() || !!hasLicenseKey;
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
const APP_ROUTE = {
|
||||
HOME: "/home",
|
||||
SPACES: "/spaces",
|
||||
AUTH: {
|
||||
LOGIN: "/login",
|
||||
SIGNUP: "/signup",
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
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 SpaceTrash() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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",
|
||||
"version": "0.21.0",
|
||||
"version": "0.22.2",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@@ -50,7 +50,7 @@
|
||||
"@nestjs/schedule": "^6.0.0",
|
||||
"@nestjs/terminus": "^11.0.0",
|
||||
"@nestjs/websockets": "^11.1.3",
|
||||
"@node-saml/passport-saml": "^5.0.1",
|
||||
"@node-saml/passport-saml": "^5.1.0",
|
||||
"@react-email/components": "0.0.28",
|
||||
"@react-email/render": "1.0.2",
|
||||
"@socket.io/redis-adapter": "^8.3.0",
|
||||
@@ -72,6 +72,7 @@
|
||||
"nodemailer": "^7.0.3",
|
||||
"openid-client": "^5.7.1",
|
||||
"otpauth": "^9.4.0",
|
||||
"p-limit": "^6.2.0",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pg": "^8.16.0",
|
||||
|
||||
@@ -10,7 +10,6 @@ import { Typography } from '@tiptap/extension-typography';
|
||||
import { TextStyle } from '@tiptap/extension-text-style';
|
||||
import { Color } from '@tiptap/extension-color';
|
||||
import { Youtube } from '@tiptap/extension-youtube';
|
||||
import Table from '@tiptap/extension-table';
|
||||
import {
|
||||
Callout,
|
||||
Comment,
|
||||
@@ -24,6 +23,7 @@ import {
|
||||
TableHeader,
|
||||
TableCell,
|
||||
TableRow,
|
||||
CustomTable,
|
||||
TiptapImage,
|
||||
TiptapVideo,
|
||||
TrailingNode,
|
||||
@@ -65,7 +65,7 @@ export const tiptapExtensions = [
|
||||
Details,
|
||||
DetailsContent,
|
||||
DetailsSummary,
|
||||
Table,
|
||||
CustomTable,
|
||||
TableCell,
|
||||
TableRow,
|
||||
TableHeader,
|
||||
|
||||
@@ -76,6 +76,11 @@ export function sanitizeFileName(fileName: string): string {
|
||||
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(
|
||||
request: FastifyRequest,
|
||||
): string | undefined {
|
||||
|
||||
@@ -50,6 +50,7 @@ import { validate as isValidUUID } from 'uuid';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
import { TokenService } from '../auth/services/token.service';
|
||||
import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload';
|
||||
import * as path from 'path';
|
||||
|
||||
@Controller()
|
||||
export class AttachmentController {
|
||||
@@ -356,6 +357,11 @@ export class AttachmentController {
|
||||
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}`;
|
||||
|
||||
try {
|
||||
|
||||
@@ -12,7 +12,7 @@ export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
super();
|
||||
}
|
||||
|
||||
async process(job: Job<Space, void>): Promise<void> {
|
||||
async process(job: Job<any, void>): Promise<void> {
|
||||
try {
|
||||
if (job.name === QueueJob.DELETE_SPACE_ATTACHMENTS) {
|
||||
await this.attachmentService.handleDeleteSpaceAttachments(job.data.id);
|
||||
@@ -20,6 +20,11 @@ export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
if (job.name === QueueJob.DELETE_USER_AVATARS) {
|
||||
await this.attachmentService.handleDeleteUserAvatars(job.data.id);
|
||||
}
|
||||
if (job.name === QueueJob.DELETE_PAGE_ATTACHMENTS) {
|
||||
await this.attachmentService.handleDeletePageAttachments(
|
||||
job.data.pageId,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -321,4 +321,50 @@ export class AttachmentService {
|
||||
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,
|
||||
) {
|
||||
const page = await this.pageRepo.findById(createCommentDto.pageId);
|
||||
if (!page) {
|
||||
if (!page || page.deletedAt) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
@@ -53,9 +53,11 @@ export class CommentController {
|
||||
}
|
||||
|
||||
return this.commentService.create(
|
||||
user.id,
|
||||
page.id,
|
||||
workspace.id,
|
||||
{
|
||||
userId: user.id,
|
||||
page,
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
createCommentDto,
|
||||
);
|
||||
}
|
||||
@@ -67,7 +69,6 @@ export class CommentController {
|
||||
@Body()
|
||||
pagination: PaginationOptions,
|
||||
@AuthUser() user: User,
|
||||
// @AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const page = await this.pageRepo.findById(input.pageId);
|
||||
if (!page) {
|
||||
@@ -89,12 +90,10 @@ export class CommentController {
|
||||
throw new NotFoundException('Comment not found');
|
||||
}
|
||||
|
||||
const page = await this.pageRepo.findById(comment.pageId);
|
||||
if (!page) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||
const ability = await this.spaceAbility.createForUser(
|
||||
user,
|
||||
comment.spaceId,
|
||||
);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
@@ -103,19 +102,76 @@ export class CommentController {
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('update')
|
||||
update(@Body() updateCommentDto: UpdateCommentDto, @AuthUser() user: User) {
|
||||
//TODO: only comment creators can update their comments
|
||||
return this.commentService.update(
|
||||
updateCommentDto.commentId,
|
||||
updateCommentDto,
|
||||
async update(@Body() dto: UpdateCommentDto, @AuthUser() user: User) {
|
||||
const comment = await this.commentRepo.findById(dto.commentId);
|
||||
if (!comment) {
|
||||
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(
|
||||
'You must have space edit permission to edit comments',
|
||||
);
|
||||
}
|
||||
|
||||
return this.commentService.update(comment, dto, user);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('delete')
|
||||
remove(@Body() input: CommentIdDto, @AuthUser() user: User) {
|
||||
// TODO: only comment creators and admins can delete their comments
|
||||
return this.commentService.remove(input.commentId, user);
|
||||
async delete(@Body() input: CommentIdDto, @AuthUser() user: User) {
|
||||
const comment = await this.commentRepo.findById(input.commentId);
|
||||
if (!comment) {
|
||||
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,21 +7,24 @@ import {
|
||||
import { CreateCommentDto } from './dto/create-comment.dto';
|
||||
import { UpdateCommentDto } from './dto/update-comment.dto';
|
||||
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
|
||||
import { Comment, User } from '@docmost/db/types/entity.types';
|
||||
import { Comment, Page, User } from '@docmost/db/types/entity.types';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { PaginationResult } from '@docmost/db/pagination/pagination';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
|
||||
@Injectable()
|
||||
export class CommentService {
|
||||
constructor(
|
||||
private commentRepo: CommentRepo,
|
||||
private pageRepo: PageRepo,
|
||||
private spaceMemberRepo: SpaceMemberRepo,
|
||||
) {}
|
||||
|
||||
async findById(commentId: string) {
|
||||
const comment = await this.commentRepo.findById(commentId, {
|
||||
includeCreator: true,
|
||||
includeResolvedBy: true,
|
||||
});
|
||||
if (!comment) {
|
||||
throw new NotFoundException('Comment not found');
|
||||
@@ -30,11 +33,10 @@ export class CommentService {
|
||||
}
|
||||
|
||||
async create(
|
||||
userId: string,
|
||||
pageId: string,
|
||||
workspaceId: string,
|
||||
opts: { userId: string; page: Page; workspaceId: string },
|
||||
createCommentDto: CreateCommentDto,
|
||||
) {
|
||||
const { userId, page, workspaceId } = opts;
|
||||
const commentContent = JSON.parse(createCommentDto.content);
|
||||
|
||||
if (createCommentDto.parentCommentId) {
|
||||
@@ -42,7 +44,7 @@ export class CommentService {
|
||||
createCommentDto.parentCommentId,
|
||||
);
|
||||
|
||||
if (!parentComment || parentComment.pageId !== pageId) {
|
||||
if (!parentComment || parentComment.pageId !== page.id) {
|
||||
throw new BadRequestException('Parent comment not found');
|
||||
}
|
||||
|
||||
@@ -51,17 +53,16 @@ export class CommentService {
|
||||
}
|
||||
}
|
||||
|
||||
const createdComment = await this.commentRepo.insertComment({
|
||||
pageId: pageId,
|
||||
return await this.commentRepo.insertComment({
|
||||
pageId: page.id,
|
||||
content: commentContent,
|
||||
selection: createCommentDto?.selection?.substring(0, 250),
|
||||
type: 'inline',
|
||||
parentCommentId: createCommentDto?.parentCommentId,
|
||||
creatorId: userId,
|
||||
workspaceId: workspaceId,
|
||||
spaceId: page.spaceId,
|
||||
});
|
||||
|
||||
return createdComment;
|
||||
}
|
||||
|
||||
async findByPageId(
|
||||
@@ -74,26 +75,16 @@ export class CommentService {
|
||||
throw new BadRequestException('Page not found');
|
||||
}
|
||||
|
||||
const pageComments = await this.commentRepo.findPageComments(
|
||||
pageId,
|
||||
pagination,
|
||||
);
|
||||
|
||||
return pageComments;
|
||||
return await this.commentRepo.findPageComments(pageId, pagination);
|
||||
}
|
||||
|
||||
async update(
|
||||
commentId: string,
|
||||
comment: Comment,
|
||||
updateCommentDto: UpdateCommentDto,
|
||||
authUser: User,
|
||||
): Promise<Comment> {
|
||||
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) {
|
||||
throw new ForbiddenException('You can only edit your own comments');
|
||||
}
|
||||
@@ -104,26 +95,14 @@ export class CommentService {
|
||||
{
|
||||
content: commentContent,
|
||||
editedAt: editedAt,
|
||||
updatedAt: editedAt,
|
||||
},
|
||||
commentId,
|
||||
comment.id,
|
||||
);
|
||||
comment.content = commentContent;
|
||||
comment.editedAt = editedAt;
|
||||
comment.updatedAt = editedAt;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class DeletedPageDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
spaceId: string;
|
||||
}
|
||||
+4
-4
@@ -1,13 +1,13 @@
|
||||
import { IsString, IsNotEmpty } from 'class-validator';
|
||||
import { IsString, IsNotEmpty, IsOptional } from 'class-validator';
|
||||
|
||||
export class CopyPageToSpaceDto {
|
||||
export class DuplicatePageDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
pageId: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
spaceId: string;
|
||||
spaceId?: string;
|
||||
}
|
||||
|
||||
export type CopyPageMapEntry = {
|
||||
@@ -31,3 +31,9 @@ export class PageInfoDto extends PageIdDto {
|
||||
@IsBoolean()
|
||||
includeContent: boolean;
|
||||
}
|
||||
|
||||
export class DeletePageDto extends PageIdDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
permanentlyDelete?: boolean;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,12 @@ import { PageService } from './services/page.service';
|
||||
import { CreatePageDto } from './dto/create-page.dto';
|
||||
import { UpdatePageDto } from './dto/update-page.dto';
|
||||
import { MovePageDto, MovePageToSpaceDto } from './dto/move-page.dto';
|
||||
import { PageHistoryIdDto, PageIdDto, PageInfoDto } from './dto/page.dto';
|
||||
import {
|
||||
PageHistoryIdDto,
|
||||
PageIdDto,
|
||||
PageInfoDto,
|
||||
DeletePageDto,
|
||||
} from './dto/page.dto';
|
||||
import { PageHistoryService } from './services/page-history.service';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||
@@ -28,7 +33,8 @@ import {
|
||||
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { RecentPageDto } from './dto/recent-page.dto';
|
||||
import { CopyPageToSpaceDto } from './dto/copy-page.dto';
|
||||
import { DuplicatePageDto } from './dto/duplicate-page.dto';
|
||||
import { DeletedPageDto } from './dto/deleted-page.dto';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('pages')
|
||||
@@ -100,7 +106,35 @@ export class PageController {
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('delete')
|
||||
async delete(@Body() pageIdDto: PageIdDto, @AuthUser() user: User) {
|
||||
async delete(@Body() deletePageDto: DeletePageDto, @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);
|
||||
|
||||
if (!page) {
|
||||
@@ -111,13 +145,14 @@ export class PageController {
|
||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
await this.pageService.forceDelete(pageIdDto.pageId);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('restore')
|
||||
async restore(@Body() pageIdDto: PageIdDto) {
|
||||
// await this.pageService.restore(deletePageDto.id);
|
||||
await this.pageRepo.restorePage(pageIdDto.pageId);
|
||||
|
||||
// Return the restored page data with hasChildren info
|
||||
const restoredPage = await this.pageRepo.findById(pageIdDto.pageId, {
|
||||
includeHasChildren: true,
|
||||
});
|
||||
return restoredPage;
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -146,6 +181,31 @@ export class PageController {
|
||||
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.Read, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.pageService.getDeletedSpacePages(
|
||||
deletedPageDto.spaceId,
|
||||
pagination,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: scope to workspaces
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('/history')
|
||||
async getPageHistory(
|
||||
@@ -242,33 +302,41 @@ export class PageController {
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('copy-to-space')
|
||||
async copyPageToSpace(
|
||||
@Body() dto: CopyPageToSpaceDto,
|
||||
@AuthUser() user: User,
|
||||
) {
|
||||
@Post('duplicate')
|
||||
async duplicatePage(@Body() dto: DuplicatePageDto, @AuthUser() user: User) {
|
||||
const copiedPage = await this.pageRepo.findById(dto.pageId);
|
||||
if (!copiedPage) {
|
||||
throw new NotFoundException('Page to copy not found');
|
||||
}
|
||||
if (copiedPage.spaceId === dto.spaceId) {
|
||||
throw new BadRequestException('Page is already in this space');
|
||||
|
||||
// If spaceId is provided, it's a copy to different 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)
|
||||
|
||||
@@ -2,11 +2,12 @@ import { Module } from '@nestjs/common';
|
||||
import { PageService } from './services/page.service';
|
||||
import { PageController } from './page.controller';
|
||||
import { PageHistoryService } from './services/page-history.service';
|
||||
import { TrashCleanupService } from './services/trash-cleanup.service';
|
||||
import { StorageModule } from '../../integrations/storage/storage.module';
|
||||
|
||||
@Module({
|
||||
controllers: [PageController],
|
||||
providers: [PageService, PageHistoryService],
|
||||
providers: [PageService, PageHistoryService, TrashCleanupService],
|
||||
exports: [PageService, PageHistoryService],
|
||||
imports: [StorageModule]
|
||||
})
|
||||
|
||||
@@ -17,8 +17,6 @@ import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
||||
import { MovePageDto } from '../dto/move-page.dto';
|
||||
import { ExpressionBuilder } from 'kysely';
|
||||
import { DB } from '@docmost/db/types/db';
|
||||
import { generateSlugId } from '../../../common/helpers';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
|
||||
@@ -31,9 +29,15 @@ import {
|
||||
removeMarkTypeFromDoc,
|
||||
} from '../../../common/helpers/prosemirror/utils';
|
||||
import { jsonToNode, jsonToText } from 'src/collaboration/collaboration.util';
|
||||
import { CopyPageMapEntry, ICopyPageAttachment } from '../dto/copy-page.dto';
|
||||
import {
|
||||
CopyPageMapEntry,
|
||||
ICopyPageAttachment,
|
||||
} from '../dto/duplicate-page.dto';
|
||||
import { Node as PMNode } from '@tiptap/pm/model';
|
||||
import { StorageService } from '../../../integrations/storage/storage.service';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
||||
|
||||
@Injectable()
|
||||
export class PageService {
|
||||
@@ -44,6 +48,7 @@ export class PageService {
|
||||
private attachmentRepo: AttachmentRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly storageService: StorageService,
|
||||
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
||||
) {}
|
||||
|
||||
async findById(
|
||||
@@ -166,23 +171,6 @@ 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(
|
||||
spaceId: string,
|
||||
pagination: PaginationOptions,
|
||||
@@ -199,9 +187,11 @@ export class PageService {
|
||||
'parentPageId',
|
||||
'spaceId',
|
||||
'creatorId',
|
||||
'deletedAt',
|
||||
])
|
||||
.select((eb) => this.withHasChildren(eb))
|
||||
.select((eb) => this.pageRepo.withHasChildren(eb))
|
||||
.orderBy('position', 'asc')
|
||||
.where('deletedAt', 'is', null)
|
||||
.where('spaceId', '=', spaceId);
|
||||
|
||||
if (pageId) {
|
||||
@@ -258,11 +248,24 @@ export class PageService {
|
||||
});
|
||||
}
|
||||
|
||||
async copyPageToSpace(rootPage: Page, spaceId: string, authUser: User) {
|
||||
//TODO:
|
||||
// i. maintain internal links within copied pages
|
||||
async duplicatePage(
|
||||
rootPage: Page,
|
||||
targetSpaceId: string | undefined,
|
||||
authUser: User,
|
||||
) {
|
||||
const spaceId = targetSpaceId || rootPage.spaceId;
|
||||
const isDuplicateInSameSpace =
|
||||
!targetSpaceId || targetSpaceId === rootPage.spaceId;
|
||||
|
||||
const nextPosition = await this.nextPagePosition(spaceId);
|
||||
let nextPosition: string;
|
||||
|
||||
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, {
|
||||
includeContent: true,
|
||||
@@ -326,12 +329,38 @@ 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();
|
||||
|
||||
// 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 {
|
||||
id: pageFromMap.newPageId,
|
||||
slugId: pageFromMap.newSlugId,
|
||||
title: page.title,
|
||||
title: title,
|
||||
icon: page.icon,
|
||||
content: prosemirrorJson,
|
||||
textContent: jsonToText(prosemirrorJson),
|
||||
@@ -377,33 +406,50 @@ export class PageService {
|
||||
attachment.id,
|
||||
newAttachmentId,
|
||||
);
|
||||
await this.storageService.copy(attachment.filePath, newPathFile);
|
||||
await this.db
|
||||
.insertInto('attachments')
|
||||
.values({
|
||||
id: newAttachmentId,
|
||||
type: attachment.type,
|
||||
filePath: newPathFile,
|
||||
fileName: attachment.fileName,
|
||||
fileSize: attachment.fileSize,
|
||||
mimeType: attachment.mimeType,
|
||||
fileExt: attachment.fileExt,
|
||||
creatorId: attachment.creatorId,
|
||||
workspaceId: attachment.workspaceId,
|
||||
pageId: newPageId,
|
||||
spaceId: spaceId,
|
||||
})
|
||||
.execute();
|
||||
|
||||
try {
|
||||
await this.storageService.copy(attachment.filePath, newPathFile);
|
||||
|
||||
await this.db
|
||||
.insertInto('attachments')
|
||||
.values({
|
||||
id: newAttachmentId,
|
||||
type: attachment.type,
|
||||
filePath: newPathFile,
|
||||
fileName: attachment.fileName,
|
||||
fileSize: attachment.fileSize,
|
||||
mimeType: attachment.mimeType,
|
||||
fileExt: attachment.fileExt,
|
||||
creatorId: attachment.creatorId,
|
||||
workspaceId: attachment.workspaceId,
|
||||
pageId: newPageId,
|
||||
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) {
|
||||
this.logger.log(err);
|
||||
this.logger.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newPageId = pageMap.get(rootPage.id).newPageId;
|
||||
return await this.pageRepo.findById(newPageId, {
|
||||
const duplicatedPage = await this.pageRepo.findById(newPageId, {
|
||||
includeSpace: true,
|
||||
});
|
||||
|
||||
const hasChildren = pages.length > 1;
|
||||
|
||||
return {
|
||||
...duplicatedPage,
|
||||
hasChildren,
|
||||
};
|
||||
}
|
||||
|
||||
async movePage(dto: MovePageDto, movedPage: Page) {
|
||||
@@ -450,9 +496,11 @@ export class PageService {
|
||||
'position',
|
||||
'parentPageId',
|
||||
'spaceId',
|
||||
'deletedAt',
|
||||
])
|
||||
.select((eb) => this.withHasChildren(eb))
|
||||
.select((eb) => this.pageRepo.withHasChildren(eb))
|
||||
.where('id', '=', childPageId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.unionAll((exp) =>
|
||||
exp
|
||||
.selectFrom('pages as p')
|
||||
@@ -464,6 +512,7 @@ export class PageService {
|
||||
'p.position',
|
||||
'p.parentPageId',
|
||||
'p.spaceId',
|
||||
'p.deletedAt',
|
||||
])
|
||||
.select(
|
||||
exp
|
||||
@@ -478,11 +527,13 @@ export class PageService {
|
||||
.as('count'),
|
||||
)
|
||||
.whereRef('child.parentPageId', '=', 'id')
|
||||
.where('child.deletedAt', 'is', null)
|
||||
.limit(1)
|
||||
.as('hasChildren'),
|
||||
)
|
||||
//.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')
|
||||
@@ -506,98 +557,58 @@ export class PageService {
|
||||
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> {
|
||||
await this.pageRepo.deletePage(pageId);
|
||||
// Get all descendant IDs (including the page itself) using recursive CTE
|
||||
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 });
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
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,15 +44,22 @@ export class SearchService {
|
||||
'creatorId',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
sql<number>`ts_rank(tsv, to_tsquery(${searchQuery}))`.as('rank'),
|
||||
sql<string>`ts_headline('english', text_content, to_tsquery(${searchQuery}),'MinWords=9, MaxWords=10, MaxFragments=3')`.as(
|
||||
sql<number>`ts_rank(tsv, to_tsquery('english', f_unaccent(${searchQuery})))`.as(
|
||||
'rank',
|
||||
),
|
||||
sql<string>`ts_headline('english', text_content, to_tsquery('english', f_unaccent(${searchQuery})),'MinWords=9, MaxWords=10, MaxFragments=3')`.as(
|
||||
'highlight',
|
||||
),
|
||||
])
|
||||
.where('tsv', '@@', sql<string>`to_tsquery(${searchQuery})`)
|
||||
.where(
|
||||
'tsv',
|
||||
'@@',
|
||||
sql<string>`to_tsquery('english', f_unaccent(${searchQuery}))`,
|
||||
)
|
||||
.$if(Boolean(searchParams.creatorId), (qb) =>
|
||||
qb.where('creatorId', '=', searchParams.creatorId),
|
||||
)
|
||||
.where('deletedAt', 'is', null)
|
||||
.orderBy('rank', 'desc')
|
||||
.limit(searchParams.limit | 20)
|
||||
.offset(searchParams.offset || 0);
|
||||
@@ -138,21 +145,37 @@ export class SearchService {
|
||||
const query = suggestion.query.toLowerCase().trim();
|
||||
|
||||
if (suggestion.includeUsers) {
|
||||
users = await this.db
|
||||
const userQuery = this.db
|
||||
.selectFrom('users')
|
||||
.select(['id', 'name', 'email', 'avatarUrl'])
|
||||
.where((eb) => eb(sql`LOWER(users.name)`, 'like', `%${query}%`))
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.limit(limit)
|
||||
.execute();
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
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) {
|
||||
groups = await this.db
|
||||
.selectFrom('groups')
|
||||
.select(['id', 'name', 'description'])
|
||||
.where((eb) => eb(sql`LOWER(groups.name)`, 'like', `%${query}%`))
|
||||
.where((eb) =>
|
||||
eb(
|
||||
sql`LOWER(f_unaccent(groups.name))`,
|
||||
'like',
|
||||
sql`LOWER(f_unaccent(${`%${query}%`}))`,
|
||||
),
|
||||
)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.limit(limit)
|
||||
.execute();
|
||||
@@ -162,7 +185,14 @@ export class SearchService {
|
||||
let pageSearch = this.db
|
||||
.selectFrom('pages')
|
||||
.select(['id', 'slugId', 'title', 'icon', 'spaceId'])
|
||||
.where((eb) => eb(sql`LOWER(pages.title)`, 'like', `%${query}%`))
|
||||
.where((eb) =>
|
||||
eb(
|
||||
sql`LOWER(f_unaccent(pages.title))`,
|
||||
'like',
|
||||
sql`LOWER(f_unaccent(${`%${query}%`}))`,
|
||||
),
|
||||
)
|
||||
.where('deletedAt', 'is', null)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.limit(limit);
|
||||
|
||||
|
||||
@@ -108,12 +108,12 @@ export class ShareService {
|
||||
includeCreator: true,
|
||||
});
|
||||
|
||||
page.content = await this.updatePublicAttachments(page);
|
||||
|
||||
if (!page) {
|
||||
if (!page || page.deletedAt) {
|
||||
throw new NotFoundException('Shared page not found');
|
||||
}
|
||||
|
||||
page.content = await this.updatePublicAttachments(page);
|
||||
|
||||
return { page, share };
|
||||
}
|
||||
|
||||
@@ -132,6 +132,7 @@ export class ShareService {
|
||||
sql`0`.as('level'),
|
||||
])
|
||||
.where(isValidUUID(pageId) ? 'id' : 'slugId', '=', pageId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.unionAll((union) =>
|
||||
union
|
||||
.selectFrom('pages as p')
|
||||
@@ -144,7 +145,8 @@ export class ShareService {
|
||||
// Increase the level by 1 for each ancestor.
|
||||
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')
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AcceptInviteDto, InviteUserDto } from '../dto/invitation.dto';
|
||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { sql } from 'kysely';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import {
|
||||
Group,
|
||||
@@ -55,7 +56,11 @@ export class WorkspaceInvitationService {
|
||||
|
||||
if (pagination.query) {
|
||||
query = query.where((eb) =>
|
||||
eb('email', 'ilike', `%${pagination.query}%`),
|
||||
eb(
|
||||
sql`email`,
|
||||
'ilike',
|
||||
sql`f_unaccent(${'%' + pagination.query + '%'})`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
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
@@ -0,0 +1,50 @@
|
||||
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,12 +20,13 @@ export class CommentRepo {
|
||||
// todo, add workspaceId
|
||||
async findById(
|
||||
commentId: string,
|
||||
opts?: { includeCreator: boolean },
|
||||
opts?: { includeCreator: boolean; includeResolvedBy: boolean },
|
||||
): Promise<Comment> {
|
||||
return await this.db
|
||||
.selectFrom('comments')
|
||||
.selectAll('comments')
|
||||
.$if(opts?.includeCreator, (qb) => qb.select(this.withCreator))
|
||||
.$if(opts?.includeResolvedBy, (qb) => qb.select(this.withResolvedBy))
|
||||
.where('id', '=', commentId)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
@@ -35,6 +36,7 @@ export class CommentRepo {
|
||||
.selectFrom('comments')
|
||||
.selectAll('comments')
|
||||
.select((eb) => this.withCreator(eb))
|
||||
.select((eb) => this.withResolvedBy(eb))
|
||||
.where('pageId', '=', pageId)
|
||||
.orderBy('createdAt', 'asc');
|
||||
|
||||
@@ -80,7 +82,37 @@ export class CommentRepo {
|
||||
).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> {
|
||||
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,6 +6,7 @@ import {
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { dbOrTx, executeTx } from '@docmost/db/utils';
|
||||
import { sql } from 'kysely';
|
||||
import { GroupUser, InsertableGroupUser } from '@docmost/db/types/entity.types';
|
||||
import { PaginationOptions } from '../../pagination/pagination-options';
|
||||
import { executeWithPagination } from '@docmost/db/pagination/pagination';
|
||||
@@ -56,7 +57,7 @@ export class GroupUserRepo {
|
||||
|
||||
if (pagination.query) {
|
||||
query = query.where((eb) =>
|
||||
eb('users.name', 'ilike', `%${pagination.query}%`),
|
||||
eb(sql`f_unaccent(users.name)`, 'ilike', sql`f_unaccent(${'%' + pagination.query + '%'})`),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -114,10 +114,10 @@ export class GroupRepo {
|
||||
|
||||
if (pagination.query) {
|
||||
query = query.where((eb) =>
|
||||
eb('name', 'ilike', `%${pagination.query}%`).or(
|
||||
'description',
|
||||
eb(sql`f_unaccent(name)`, 'ilike', sql`f_unaccent(${'%' + pagination.query + '%'})`).or(
|
||||
sql`f_unaccent(description)`,
|
||||
'ilike',
|
||||
`%${pagination.query}%`,
|
||||
sql`f_unaccent(${'%' + pagination.query + '%'})`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
|
||||
import { dbOrTx } from '../../utils';
|
||||
import { dbOrTx, executeTx } from '../../utils';
|
||||
import {
|
||||
InsertablePage,
|
||||
Page,
|
||||
@@ -22,6 +22,24 @@ export class PageRepo {
|
||||
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> = [
|
||||
'id',
|
||||
'slugId',
|
||||
@@ -50,6 +68,7 @@ export class PageRepo {
|
||||
includeCreator?: boolean;
|
||||
includeLastUpdatedBy?: boolean;
|
||||
includeContributors?: boolean;
|
||||
includeHasChildren?: boolean;
|
||||
withLock?: boolean;
|
||||
trx?: KyselyTransaction;
|
||||
},
|
||||
@@ -60,7 +79,10 @@ export class PageRepo {
|
||||
.selectFrom('pages')
|
||||
.select(this.baseFields)
|
||||
.$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) {
|
||||
query = query.select((eb) => this.withCreator(eb));
|
||||
@@ -139,12 +161,113 @@ export class PageRepo {
|
||||
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) {
|
||||
const query = this.db
|
||||
.selectFrom('pages')
|
||||
.select(this.baseFields)
|
||||
.select((eb) => this.withSpace(eb))
|
||||
.where('spaceId', '=', spaceId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.orderBy('updatedAt', 'desc');
|
||||
|
||||
const result = executeWithPagination(query, {
|
||||
@@ -163,6 +286,7 @@ export class PageRepo {
|
||||
.select(this.baseFields)
|
||||
.select((eb) => this.withSpace(eb))
|
||||
.where('spaceId', 'in', userSpaceIds)
|
||||
.where('deletedAt', 'is', null)
|
||||
.orderBy('updatedAt', 'desc');
|
||||
|
||||
const hasEmptyIds = userSpaceIds.length === 0;
|
||||
@@ -175,6 +299,41 @@ export class PageRepo {
|
||||
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'>) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
@@ -202,6 +361,15 @@ export class PageRepo {
|
||||
).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'>) {
|
||||
return jsonArrayFrom(
|
||||
eb
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { dbOrTx } from '@docmost/db/utils';
|
||||
import { sql } from 'kysely';
|
||||
import {
|
||||
InsertableSpaceMember,
|
||||
SpaceMember,
|
||||
@@ -119,9 +120,21 @@ export class SpaceMemberRepo {
|
||||
|
||||
if (pagination.query) {
|
||||
query = query.where((eb) =>
|
||||
eb('users.name', 'ilike', `%${pagination.query}%`)
|
||||
.or('users.email', 'ilike', `%${pagination.query}%`)
|
||||
.or('groups.name', 'ilike', `%${pagination.query}%`),
|
||||
eb(
|
||||
sql`f_unaccent(users.name)`,
|
||||
'ilike',
|
||||
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 + '%'})`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -228,10 +241,14 @@ export class SpaceMemberRepo {
|
||||
|
||||
if (pagination.query) {
|
||||
query = query.where((eb) =>
|
||||
eb('name', 'ilike', `%${pagination.query}%`).or(
|
||||
'description',
|
||||
eb(
|
||||
sql`f_unaccent(name)`,
|
||||
'ilike',
|
||||
`%${pagination.query}%`,
|
||||
sql`f_unaccent(${'%' + pagination.query + '%'})`,
|
||||
).or(
|
||||
sql`f_unaccent(description)`,
|
||||
'ilike',
|
||||
sql`f_unaccent(${'%' + pagination.query + '%'})`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -110,10 +110,10 @@ export class SpaceRepo {
|
||||
|
||||
if (pagination.query) {
|
||||
query = query.where((eb) =>
|
||||
eb('name', 'ilike', `%${pagination.query}%`).or(
|
||||
'description',
|
||||
eb(sql`f_unaccent(name)`, 'ilike', sql`f_unaccent(${'%' + pagination.query + '%'})`).or(
|
||||
sql`f_unaccent(description)`,
|
||||
'ilike',
|
||||
`%${pagination.query}%`,
|
||||
sql`f_unaccent(${'%' + pagination.query + '%'})`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -149,10 +149,14 @@ export class UserRepo {
|
||||
|
||||
if (pagination.query) {
|
||||
query = query.where((eb) =>
|
||||
eb('users.name', 'ilike', `%${pagination.query}%`).or(
|
||||
'users.email',
|
||||
eb(
|
||||
sql`f_unaccent(users.name)`,
|
||||
'ilike',
|
||||
`%${pagination.query}%`,
|
||||
sql`f_unaccent(${'%' + pagination.query + '%'})`,
|
||||
).or(
|
||||
sql`users.email`,
|
||||
'ilike',
|
||||
sql`f_unaccent(${'%' + pagination.query + '%'})`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+4
@@ -119,11 +119,15 @@ export interface Comments {
|
||||
deletedAt: Timestamp | null;
|
||||
editedAt: Timestamp | null;
|
||||
id: Generated<string>;
|
||||
lastEditedById: string | null;
|
||||
pageId: string;
|
||||
parentCommentId: string | null;
|
||||
resolvedAt: Timestamp | null;
|
||||
resolvedById: string | null;
|
||||
selection: string | null;
|
||||
spaceId: string;
|
||||
type: string | null;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: 2f4ed49609...d39ddc4b5e
@@ -14,10 +14,14 @@ import { AttachmentType } from '../../../core/attachment/attachment.constants';
|
||||
import { unwrapFromParagraph } from '../utils/import-formatter';
|
||||
import { resolveRelativeAttachmentPath } from '../utils/import.utils';
|
||||
import { load } from 'cheerio';
|
||||
import pLimit from 'p-limit';
|
||||
|
||||
@Injectable()
|
||||
export class ImportAttachmentService {
|
||||
private readonly logger = new Logger(ImportAttachmentService.name);
|
||||
private readonly CONCURRENT_UPLOADS = 3;
|
||||
private readonly MAX_RETRIES = 2;
|
||||
private readonly RETRY_DELAY = 2000;
|
||||
|
||||
constructor(
|
||||
private readonly storageService: StorageService,
|
||||
@@ -41,7 +45,14 @@ export class ImportAttachmentService {
|
||||
attachmentCandidates,
|
||||
} = opts;
|
||||
|
||||
const attachmentTasks: Promise<void>[] = [];
|
||||
const attachmentTasks: (() => Promise<void>)[] = [];
|
||||
const limit = pLimit(this.CONCURRENT_UPLOADS);
|
||||
const uploadStats = {
|
||||
total: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
failedFiles: [] as string[],
|
||||
};
|
||||
|
||||
/**
|
||||
* Cache keyed by the *relative* path that appears in the HTML.
|
||||
@@ -74,30 +85,16 @@ export class ImportAttachmentService {
|
||||
|
||||
const apiFilePath = `/api/files/${attachmentId}/${fileNameWithExt}`;
|
||||
|
||||
attachmentTasks.push(
|
||||
(async () => {
|
||||
const fileStream = createReadStream(abs);
|
||||
await this.storageService.uploadStream(storageFilePath, fileStream);
|
||||
const stat = await fs.stat(abs);
|
||||
|
||||
await this.db
|
||||
.insertInto('attachments')
|
||||
.values({
|
||||
id: attachmentId,
|
||||
filePath: storageFilePath,
|
||||
fileName: fileNameWithExt,
|
||||
fileSize: stat.size,
|
||||
mimeType: getMimeType(fileNameWithExt),
|
||||
type: 'file',
|
||||
fileExt: ext,
|
||||
creatorId: fileTask.creatorId,
|
||||
workspaceId: fileTask.workspaceId,
|
||||
pageId,
|
||||
spaceId: fileTask.spaceId,
|
||||
})
|
||||
.execute();
|
||||
})(),
|
||||
);
|
||||
attachmentTasks.push(() => this.uploadWithRetry({
|
||||
abs,
|
||||
storageFilePath,
|
||||
attachmentId,
|
||||
fileNameWithExt,
|
||||
ext,
|
||||
pageId,
|
||||
fileTask,
|
||||
uploadStats,
|
||||
}));
|
||||
|
||||
return {
|
||||
attachmentId,
|
||||
@@ -292,12 +289,113 @@ export class ImportAttachmentService {
|
||||
}
|
||||
|
||||
// wait for all uploads & DB inserts
|
||||
try {
|
||||
await Promise.all(attachmentTasks);
|
||||
} catch (err) {
|
||||
this.logger.log('Import attachment upload error', err);
|
||||
uploadStats.total = attachmentTasks.length;
|
||||
|
||||
if (uploadStats.total > 0) {
|
||||
this.logger.debug(`Starting upload of ${uploadStats.total} attachments...`);
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
attachmentTasks.map(task => limit(task))
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error('Import attachment upload error', err);
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`Upload completed: ${uploadStats.completed}/${uploadStats.total} successful, ${uploadStats.failed} failed`
|
||||
);
|
||||
|
||||
if (uploadStats.failed > 0) {
|
||||
this.logger.warn(
|
||||
`Failed to upload ${uploadStats.failed} files:`,
|
||||
uploadStats.failedFiles
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $.root().html() || '';
|
||||
}
|
||||
|
||||
private async uploadWithRetry(opts: {
|
||||
abs: string;
|
||||
storageFilePath: string;
|
||||
attachmentId: string;
|
||||
fileNameWithExt: string;
|
||||
ext: string;
|
||||
pageId: string;
|
||||
fileTask: FileTask;
|
||||
uploadStats: {
|
||||
total: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
failedFiles: string[];
|
||||
};
|
||||
}): Promise<void> {
|
||||
const {
|
||||
abs,
|
||||
storageFilePath,
|
||||
attachmentId,
|
||||
fileNameWithExt,
|
||||
ext,
|
||||
pageId,
|
||||
fileTask,
|
||||
uploadStats,
|
||||
} = opts;
|
||||
|
||||
let lastError: Error;
|
||||
|
||||
for (let attempt = 1; attempt <= this.MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const fileStream = createReadStream(abs);
|
||||
await this.storageService.uploadStream(storageFilePath, fileStream);
|
||||
const stat = await fs.stat(abs);
|
||||
|
||||
await this.db
|
||||
.insertInto('attachments')
|
||||
.values({
|
||||
id: attachmentId,
|
||||
filePath: storageFilePath,
|
||||
fileName: fileNameWithExt,
|
||||
fileSize: stat.size,
|
||||
mimeType: getMimeType(fileNameWithExt),
|
||||
type: 'file',
|
||||
fileExt: ext,
|
||||
creatorId: fileTask.creatorId,
|
||||
workspaceId: fileTask.workspaceId,
|
||||
pageId,
|
||||
spaceId: fileTask.spaceId,
|
||||
})
|
||||
.execute();
|
||||
|
||||
uploadStats.completed++;
|
||||
|
||||
if (uploadStats.completed % 10 === 0) {
|
||||
this.logger.debug(
|
||||
`Upload progress: ${uploadStats.completed}/${uploadStats.total}`
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
this.logger.warn(
|
||||
`Upload attempt ${attempt}/${this.MAX_RETRIES} failed for ${fileNameWithExt}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
|
||||
if (attempt < this.MAX_RETRIES) {
|
||||
await new Promise(resolve =>
|
||||
setTimeout(resolve, this.RETRY_DELAY * attempt)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
uploadStats.failed++;
|
||||
uploadStats.failedFiles.push(fileNameWithExt);
|
||||
this.logger.error(
|
||||
`Failed to upload ${fileNameWithExt} after ${this.MAX_RETRIES} attempts:`,
|
||||
lastError
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "docmost",
|
||||
"homepage": "https://docmost.com",
|
||||
"version": "0.21.0",
|
||||
"version": "0.22.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nx run-many -t build",
|
||||
@@ -18,6 +18,7 @@
|
||||
"dev": "pnpm concurrently -n \"frontend,backend\" -c \"cyan,green\" \"pnpm run client:dev\" \"pnpm run server:dev\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@braintree/sanitize-url": "^7.1.0",
|
||||
"@docmost/editor-ext": "workspace:*",
|
||||
"@hocuspocus/extension-redis": "^2.15.2",
|
||||
"@hocuspocus/provider": "^2.15.2",
|
||||
@@ -68,7 +69,7 @@
|
||||
"fractional-indexing-jittered": "^1.0.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"jszip": "^3.10.1",
|
||||
"linkifyjs": "^4.2.0",
|
||||
"linkifyjs": "^4.3.2",
|
||||
"marked": "13.0.3",
|
||||
"ms": "3.0.0-canary.1",
|
||||
"qrcode": "^1.5.4",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Mark, mergeAttributes } from "@tiptap/core";
|
||||
import { commentDecoration } from "./comment-decoration";
|
||||
import { Plugin } from "@tiptap/pm/state";
|
||||
|
||||
export interface ICommentOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
@@ -19,6 +20,7 @@ declare module "@tiptap/core" {
|
||||
unsetCommentDecoration: () => ReturnType;
|
||||
setComment: (commentId: string) => ReturnType;
|
||||
unsetComment: (commentId: string) => ReturnType;
|
||||
setCommentResolved: (commentId: string, resolved: boolean) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -53,6 +55,17 @@ export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
|
||||
};
|
||||
},
|
||||
},
|
||||
resolved: {
|
||||
default: false,
|
||||
parseHTML: (element) => element.hasAttribute("data-resolved"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.resolved) return {};
|
||||
|
||||
return {
|
||||
"data-resolved": "true",
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -60,9 +73,18 @@ export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
|
||||
return [
|
||||
{
|
||||
tag: "span[data-comment-id]",
|
||||
getAttrs: (el) =>
|
||||
!!(el as HTMLSpanElement).getAttribute("data-comment-id")?.trim() &&
|
||||
null,
|
||||
getAttrs: (el) => {
|
||||
const element = el as HTMLSpanElement;
|
||||
const commentId = element.getAttribute("data-comment-id")?.trim();
|
||||
const resolved = element.hasAttribute("data-resolved");
|
||||
|
||||
if (!commentId) return false;
|
||||
|
||||
return {
|
||||
commentId,
|
||||
resolved,
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
@@ -87,7 +109,8 @@ export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
|
||||
(commentId) =>
|
||||
({ commands }) => {
|
||||
if (!commentId) return false;
|
||||
return commands.setMark(this.name, { commentId });
|
||||
// Just add the new mark, do not remove existing ones
|
||||
return commands.setMark(this.name, { commentId, resolved: false });
|
||||
},
|
||||
unsetComment:
|
||||
(commentId) =>
|
||||
@@ -101,7 +124,7 @@ export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
|
||||
const commentMark = node.marks.find(
|
||||
(mark) =>
|
||||
mark.type.name === this.name &&
|
||||
mark.attrs.commentId === commentId,
|
||||
mark.attrs.commentId === commentId
|
||||
);
|
||||
|
||||
if (commentMark) {
|
||||
@@ -109,6 +132,37 @@ export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
|
||||
}
|
||||
});
|
||||
|
||||
return dispatch?.(tr);
|
||||
},
|
||||
setCommentResolved:
|
||||
(commentId, resolved) =>
|
||||
({ tr, dispatch }) => {
|
||||
if (!commentId) return false;
|
||||
|
||||
tr.doc.descendants((node, pos) => {
|
||||
const from = pos;
|
||||
const to = pos + node.nodeSize;
|
||||
|
||||
const commentMark = node.marks.find(
|
||||
(mark) =>
|
||||
mark.type.name === this.name &&
|
||||
mark.attrs.commentId === commentId
|
||||
);
|
||||
|
||||
if (commentMark) {
|
||||
// Remove the existing mark and add a new one with updated resolved state
|
||||
tr = tr.removeMark(from, to, commentMark);
|
||||
tr = tr.addMark(
|
||||
from,
|
||||
to,
|
||||
this.type.create({
|
||||
commentId: commentMark.attrs.commentId,
|
||||
resolved: resolved,
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return dispatch?.(tr);
|
||||
},
|
||||
};
|
||||
@@ -116,13 +170,15 @@ export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const commentId = HTMLAttributes?.["data-comment-id"] || null;
|
||||
const resolved = HTMLAttributes?.["data-resolved"] || false;
|
||||
|
||||
if (typeof window === "undefined" || typeof document === "undefined") {
|
||||
return [
|
||||
"span",
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||
class: 'comment-mark',
|
||||
class: resolved ? "comment-mark resolved" : "comment-mark",
|
||||
"data-comment-id": commentId,
|
||||
...(resolved && { "data-resolved": "true" }),
|
||||
}),
|
||||
0,
|
||||
];
|
||||
@@ -131,9 +187,14 @@ export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
|
||||
const elem = document.createElement("span");
|
||||
|
||||
Object.entries(
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)
|
||||
).forEach(([attr, val]) => elem.setAttribute(attr, val));
|
||||
|
||||
// Add resolved class if the comment is resolved
|
||||
if (resolved) {
|
||||
elem.classList.add("resolved");
|
||||
}
|
||||
|
||||
elem.addEventListener("click", (e) => {
|
||||
const selection = document.getSelection();
|
||||
if (selection.type === "Range") return;
|
||||
@@ -141,7 +202,7 @@ export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
|
||||
this.storage.activeCommentId = commentId;
|
||||
const commentEventClick = new CustomEvent("ACTIVE_COMMENT_EVENT", {
|
||||
bubbles: true,
|
||||
detail: { commentId },
|
||||
detail: { commentId, resolved },
|
||||
});
|
||||
|
||||
elem.dispatchEvent(commentEventClick);
|
||||
@@ -150,9 +211,7 @@ export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
|
||||
return elem;
|
||||
},
|
||||
|
||||
// @ts-ignore
|
||||
addProseMirrorPlugins(): Plugin[] {
|
||||
// @ts-ignore
|
||||
return [commentDecoration()];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
import { ReactNodeViewRenderer } from '@tiptap/react';
|
||||
import { sanitizeUrl } from './utils';
|
||||
|
||||
export interface EmbedOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
@@ -40,9 +41,12 @@ export const Embed = Node.create<EmbedOptions>({
|
||||
return {
|
||||
src: {
|
||||
default: '',
|
||||
parseHTML: (element) => element.getAttribute('data-src'),
|
||||
parseHTML: (element) => {
|
||||
const src = element.getAttribute('data-src');
|
||||
return sanitizeUrl(src);
|
||||
},
|
||||
renderHTML: (attributes: EmbedAttributes) => ({
|
||||
'data-src': attributes.src,
|
||||
'data-src': sanitizeUrl(attributes.src),
|
||||
}),
|
||||
},
|
||||
provider: {
|
||||
@@ -85,6 +89,9 @@ export const Embed = Node.create<EmbedOptions>({
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const src = HTMLAttributes["data-src"];
|
||||
const safeHref = sanitizeUrl(src);
|
||||
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(
|
||||
@@ -95,10 +102,10 @@ export const Embed = Node.create<EmbedOptions>({
|
||||
[
|
||||
"a",
|
||||
{
|
||||
href: HTMLAttributes["data-src"],
|
||||
href: safeHref,
|
||||
target: "blank",
|
||||
},
|
||||
`${HTMLAttributes["data-src"]}`,
|
||||
safeHref,
|
||||
],
|
||||
];
|
||||
},
|
||||
@@ -108,9 +115,15 @@ export const Embed = Node.create<EmbedOptions>({
|
||||
setEmbed:
|
||||
(attrs: EmbedAttributes) =>
|
||||
({ commands }) => {
|
||||
// Validate the URL before inserting
|
||||
const validatedAttrs = {
|
||||
...attrs,
|
||||
src: sanitizeUrl(attrs.src),
|
||||
};
|
||||
|
||||
return commands.insertContent({
|
||||
type: 'embed',
|
||||
attrs: attrs,
|
||||
attrs: validatedAttrs,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { TableCell as TiptapTableCell } from "@tiptap/extension-table-cell";
|
||||
|
||||
export const TableCell = TiptapTableCell.extend({
|
||||
name: "tableCell",
|
||||
content: "paragraph+",
|
||||
content: "(paragraph | heading | bulletList | orderedList | taskList | blockquote | callout | image | video | attachment | mathBlock | details | codeBlock)+",
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { TableHeader as TiptapTableHeader } from "@tiptap/extension-table-header
|
||||
|
||||
export const TableHeader = TiptapTableHeader.extend({
|
||||
name: "tableHeader",
|
||||
content: "paragraph+",
|
||||
content: "(paragraph | heading | bulletList | orderedList | taskList | blockquote | callout | image | video | attachment | mathBlock | details | codeBlock)+",
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./row";
|
||||
export * from "./cell";
|
||||
export * from "./header";
|
||||
export * from "./table";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user