diff --git a/apps/client/package.json b/apps/client/package.json index 751bfa43..617bf447 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -1,7 +1,7 @@ { "name": "client", "private": true, - "version": "0.25.0-beta.1", + "version": "0.25.3", "scripts": { "dev": "vite", "build": "tsc && vite build", @@ -14,18 +14,19 @@ "@docmost/editor-ext": "workspace:*", "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", - "@excalidraw/excalidraw": "0.18.0-c158187", - "@mantine/core": "^8.3.12", - "@mantine/dates": "^8.3.12", - "@mantine/form": "^8.3.12", - "@mantine/hooks": "^8.3.12", - "@mantine/modals": "^8.3.12", - "@mantine/notifications": "^8.3.12", - "@mantine/spotlight": "^8.3.12", + "@excalidraw/excalidraw": "0.18.0-3a5ef40", + "@mantine/core": "^8.3.14", + "@mantine/dates": "^8.3.14", + "@mantine/form": "^8.3.14", + "@mantine/hooks": "^8.3.14", + "@mantine/modals": "^8.3.14", + "@mantine/notifications": "^8.3.14", + "@mantine/spotlight": "^8.3.14", "@tabler/icons-react": "^3.36.1", "@tanstack/react-query": "^5.90.17", "alfaaz": "^1.1.0", - "axios": "^1.13.2", + "axios": "^1.13.5", + "blueimp-load-image": "^5.16.0", "clsx": "^2.1.1", "emoji-mart": "^5.6.0", "file-saver": "^2.0.5", @@ -41,7 +42,7 @@ "mantine-form-zod-resolver": "^1.3.0", "mermaid": "^11.12.2", "mitt": "^3.0.1", - "posthog-js": "^1.255.1", + "posthog-js": "1.345.5", "react": "^18.3.1", "react-arborist": "3.4.0", "react-clear-modal": "^2.0.17", @@ -59,6 +60,7 @@ "devDependencies": { "@eslint/js": "^9.16.0", "@tanstack/eslint-plugin-query": "^5.62.1", + "@types/blueimp-load-image": "^5.16.0", "@types/file-saver": "^2.0.7", "@types/js-cookie": "^3.0.6", "@types/katex": "^0.16.7", @@ -66,7 +68,7 @@ "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^5.1.1", - "eslint": "^9.15.0", + "eslint": "^9.39.2", "eslint-plugin-react": "^7.37.2", "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.16", diff --git a/apps/client/public/locales/de-DE/translation.json b/apps/client/public/locales/de-DE/translation.json index 93c6f265..0822a65e 100644 --- a/apps/client/public/locales/de-DE/translation.json +++ b/apps/client/public/locales/de-DE/translation.json @@ -41,7 +41,7 @@ "Date": "Datum", "Delete": "Löschen", "Delete group": "Gruppe löschen", - "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Sind Sie sicher, dass Sie diese Seite löschen möchten? Dadurch werden ihre Unterseiten und die Seitengeschichte gelöscht. Diese Aktion ist unwiderruflich.", + "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Sind Sie sicher, dass Sie diese Seite löschen möchten? Dabei werden auch alle Unterseiten und der Seitenverlauf gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.", "Description": "Beschreibung", "Details": "Details", "e.g ACME": "z.B. ACME", @@ -66,7 +66,7 @@ "Enter your new preferred email": "Geben Sie Ihre neue bevorzugte E-Mail ein", "Enter your password": "Geben Sie Ihr Passwort ein", "Error fetching page data.": "Fehler beim Abrufen der Seitendaten.", - "Error loading page history.": "Fehler beim Laden der Seitengeschichte.", + "Error loading page history.": "Fehler beim Laden des Seitenverlaufs.", "Export": "Exportieren", "Failed to create page": "Erstellung der Seite fehlgeschlagen", "Failed to delete page": "Löschen der Seite fehlgeschlagen", @@ -114,7 +114,7 @@ "New page": "Neue Seite", "New password": "Neues Passwort", "No group found": "Keine Gruppe gefunden", - "No page history saved yet.": "Es wurde noch keine Seitengeschichte gespeichert.", + "No page history saved yet.": "Es wurde noch kein Seitenverlauf gespeichert.", "No pages yet": "Noch keine Seiten", "No results found...": "Keine Ergebnisse gefunden...", "No user found": "Kein Benutzer gefunden", @@ -122,7 +122,9 @@ "Owner": "Besitzer", "page": "Seite", "Page deleted successfully": "Seite erfolgreich gelöscht", - "Page history": "Seitengeschichte", + "Page history": "Seitenverlauf", + "Select version": "Version auswählen", + "Highlight changes": "Änderungen hervorheben", "Page import is in progress. Please do not close this tab.": "Der Seitenimport läuft. Bitte schließen Sie diesen Tab nicht.", "Pages": "Seiten", "pages": "Seiten", @@ -353,6 +355,11 @@ "Insert current date": "Aktuelles Datum einfügen", "Draw and sketch excalidraw diagrams": "Excalidraw-Diagramme zeichnen und skizzieren", "Multiple": "Mehrere", + "Turn into": "In verwandeln", + "Text align": "Text ausrichten", + "This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.", + "Go to homepage": "Go to homepage", + "Pages you create will show up here.": "Pages you create will show up here.", "Heading {{level}}": "Überschrift {{level}}", "Toggle title": "Titel umschalten", "Write anything. Enter \"/\" for commands": "Schreiben Sie irgendetwas. Geben Sie \"/\" für Befehle ein", @@ -405,6 +412,21 @@ "Share deleted successfully": "Freigabe erfolgreich gelöscht", "Share not found": "Freigabe nicht gefunden", "Failed to share page": "Fehler beim Teilen der Seite", + "Disable public sharing": "Öffentliches Teilen deaktivieren", + "Prevent members from sharing pages publicly.": "Verhindern Sie, dass Mitglieder Seiten öffentlich teilen.", + "Toggle public sharing": "Öffentliches Teilen umschalten", + "Toggle space public sharing": "Öffentliches Teilen im Bereich umschalten", + "Public sharing is disabled at the workspace level": "Öffentliches Teilen ist auf der Arbeitsbereichsebene deaktiviert", + "Prevent pages in this space from being shared publicly.": "Verhindern Sie, dass Seiten in diesem Bereich öffentlich geteilt werden.", + "Requires an enterprise license": "Erfordert eine Unternehmenslizenz", + "Enable public sharing": "Öffentliches Teilen aktivieren", + "Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Sind Sie sicher, dass Sie das öffentliche Teilen aktivieren möchten? Mitglieder können Seiten öffentlich teilen.", + "Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Sind Sie sicher, dass Sie das öffentliche Teilen deaktivieren möchten? Alle bestehenden Freigabelinks in diesem Arbeitsbereich werden gelöscht.", + "Are you sure you want to enable public sharing for this space?": "Sind Sie sicher, dass Sie das öffentliche Teilen für diesen Bereich aktivieren möchten?", + "Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Sind Sie sicher, dass Sie das öffentliche Teilen deaktivieren möchten? Alle bestehenden Freigabelinks in diesem Bereich werden gelöscht.", + "Public sharing is disabled": "Öffentliches Teilen ist deaktiviert", + "Public sharing has been disabled at the workspace level.": "Das öffentliche Teilen wurde auf der Arbeitsbereichsebene deaktiviert.", + "Public sharing has been disabled for this space.": "Das öffentliche Teilen wurde für diesen Bereich deaktiviert.", "Copy page": "Seite kopieren", "Copy page to a different space.": "Seite in einen anderen Bereich kopieren.", "Page copied successfully": "Seite erfolgreich kopiert", @@ -565,13 +587,33 @@ "Ask AI": "KI fragen", "AI is thinking...": "Die KI überlegt...", "Ask a question...": "Fragen stellen...", - "AI-powered search (Ask AI)": "KI-gestützte Suche (KI fragen)", + "AI Answers": "KI-Antworten", + "AI-powered search (AI Answers)": "KI-unterstützte Suche (KI-Antworten)", "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Die KI-Suche verwendet Vektor-Einbettungen, um semantische Suchfunktionen in Ihrem Arbeitsbereich bereitzustellen.", "Toggle AI search": "KI-Suche umschalten", + "Generative AI (Ask AI)": "Generative KI (KI fragen)", + "Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Aktivieren Sie die KI-unterstützte Inhaltserstellung im Editor. Ermöglicht Benutzern das Erzeugen, Verbessern, Übersetzen und Transformieren von Text.", + "Toggle generative AI": "Generative KI umschalten", "Sources": "Quellen", - "Ask AI not available for attachments": "KI fragen nicht für Anhänge verfügbar", + "AI Answers not available for attachments": "KI-Antworten sind für Anhänge nicht verfügbar", "No answer available": "Keine Antwort verfügbar", "Background color": "Hintergrundfarbe", "Highlight color": "Hervorhebungsfarbe", - "Remove color": "Farbe entfernen" + "Remove color": "Farbe entfernen", + "Notifications": "Benachrichtigungen", + "No notifications": "Keine Benachrichtigungen", + "No unread notifications": "Keine ungelesenen Benachrichtigungen", + "All notifications": "Alle Benachrichtigungen", + "Unread only": "Nur ungelesen", + "Mark all as read": "Alle als gelesen markieren", + "Mark as read": "Als gelesen markieren", + "More options": "Weitere Optionen", + "mentioned you in a comment": "hat Sie in einem Kommentar erwähnt", + "commented on a page": "hat auf einer Seite kommentiert", + "resolved a comment": "hat einen Kommentar gelöst", + "mentioned you on a page": "hat Sie auf einer Seite erwähnt", + "Today": "Heute", + "Yesterday": "Gestern", + "This week": "Diese Woche", + "Older": "Älter" } diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index c0578d2b..e46dd2c8 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -123,6 +123,8 @@ "page": "page", "Page deleted successfully": "Page deleted successfully", "Page history": "Page history", + "Select version": "Select version", + "Highlight changes": "Highlight changes", "Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.", "Pages": "Pages", "pages": "pages", @@ -353,6 +355,11 @@ "Insert current date": "Insert current date", "Draw and sketch excalidraw diagrams": "Draw and sketch excalidraw diagrams", "Multiple": "Multiple", + "Turn into": "Turn into", + "Text align": "Text align", + "This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.", + "Go to homepage": "Go to homepage", + "Pages you create will show up here.": "Pages you create will show up here.", "Heading {{level}}": "Heading {{level}}", "Toggle title": "Toggle title", "Write anything. Enter \"/\" for commands": "Write anything. Enter \"/\" for commands", @@ -405,6 +412,21 @@ "Share deleted successfully": "Share deleted successfully", "Share not found": "Share not found", "Failed to share page": "Failed to share page", + "Disable public sharing": "Disable public sharing", + "Prevent members from sharing pages publicly.": "Prevent members from sharing pages publicly.", + "Toggle public sharing": "Toggle public sharing", + "Toggle space public sharing": "Toggle space public sharing", + "Public sharing is disabled at the workspace level": "Public sharing is disabled at the workspace level", + "Prevent pages in this space from being shared publicly.": "Prevent pages in this space from being shared publicly.", + "Requires an enterprise license": "Requires an enterprise license", + "Enable public sharing": "Enable public sharing", + "Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Are you sure you want to enable public sharing? Members will be able to share pages publicly.", + "Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.", + "Are you sure you want to enable public sharing for this space?": "Are you sure you want to enable public sharing for this space?", + "Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.", + "Public sharing is disabled": "Public sharing is disabled", + "Public sharing has been disabled at the workspace level.": "Public sharing has been disabled at the workspace level.", + "Public sharing has been disabled for this space.": "Public sharing has been disabled for this space.", "Copy page": "Copy page", "Copy page to a different space.": "Copy page to a different space.", "Page copied successfully": "Page copied successfully", @@ -565,13 +587,33 @@ "Ask AI": "Ask AI", "AI is thinking...": "AI is thinking...", "Ask a question...": "Ask a question...", - "AI-powered search (Ask AI)": "AI-powered search (Ask AI)", + "AI Answers": "AI Answers", + "AI-powered search (AI Answers)": "AI-powered search (AI Answers)", "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.", "Toggle AI search": "Toggle AI search", + "Generative AI (Ask AI)": "Generative AI (Ask AI)", + "Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.", + "Toggle generative AI": "Toggle generative AI", "Sources": "Sources", - "Ask AI not available for attachments": "Ask AI not available for attachments", + "AI Answers not available for attachments": "AI Answers not available for attachments", "No answer available": "No answer available", "Background color": "Background color", "Highlight color": "Highlight color", - "Remove color": "Remove color" + "Remove color": "Remove color", + "Notifications": "Notifications", + "No notifications": "No notifications", + "No unread notifications": "No unread notifications", + "All notifications": "All notifications", + "Unread only": "Unread only", + "Mark all as read": "Mark all as read", + "Mark as read": "Mark as read", + "More options": "More options", + "mentioned you in a comment": "mentioned you in a comment", + "commented on a page": "commented on a page", + "resolved a comment": "resolved a comment", + "mentioned you on a page": "mentioned you on a page", + "Today": "Today", + "Yesterday": "Yesterday", + "This week": "This week", + "Older": "Older" } diff --git a/apps/client/public/locales/es-ES/translation.json b/apps/client/public/locales/es-ES/translation.json index af02c493..b9d4f359 100644 --- a/apps/client/public/locales/es-ES/translation.json +++ b/apps/client/public/locales/es-ES/translation.json @@ -123,6 +123,8 @@ "page": "página", "Page deleted successfully": "Página eliminada con éxito", "Page history": "Historial de la página", + "Select version": "Seleccionar versión", + "Highlight changes": "Resaltar cambios", "Page import is in progress. Please do not close this tab.": "La importación de la página está en curso. Por favor, no cierre esta pestaña.", "Pages": "Páginas", "pages": "páginas", @@ -353,6 +355,11 @@ "Insert current date": "Insertar fecha actual", "Draw and sketch excalidraw diagrams": "Dibujar y esbozar diagramas de Excalidraw", "Multiple": "Múltiple", + "Turn into": "Convertir en", + "Text align": "Alineación del texto", + "This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.", + "Go to homepage": "Go to homepage", + "Pages you create will show up here.": "Pages you create will show up here.", "Heading {{level}}": "Encabezado {{level}}", "Toggle title": "Alternar título", "Write anything. Enter \"/\" for commands": "Escribe cualquier cosa. Ingresa \"/\" para comandos", @@ -405,6 +412,21 @@ "Share deleted successfully": "Compartición eliminada con éxito", "Share not found": "Compartición no encontrada", "Failed to share page": "Error al compartir la página", + "Disable public sharing": "Desactivar el uso compartido público", + "Prevent members from sharing pages publicly.": "Evitar que los miembros compartan páginas públicamente.", + "Toggle public sharing": "Alternar el uso compartido público", + "Toggle space public sharing": "Alternar el uso compartido público del espacio", + "Public sharing is disabled at the workspace level": "El uso compartido público está desactivado a nivel de espacio de trabajo", + "Prevent pages in this space from being shared publicly.": "Evitar que las páginas en este espacio se compartan públicamente.", + "Requires an enterprise license": "Requiere una licencia empresarial", + "Enable public sharing": "Activar el uso compartido público", + "Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "¿Está seguro de que desea activar el uso compartido público? Los miembros podrán compartir páginas públicamente.", + "Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "¿Está seguro de que desea desactivar el uso compartido público? Todos los enlaces compartidos existentes en este espacio de trabajo se eliminarán.", + "Are you sure you want to enable public sharing for this space?": "¿Está seguro de que desea activar el uso compartido público para este espacio?", + "Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "¿Está seguro de que desea desactivar el uso compartido público? Todos los enlaces compartidos existentes en este espacio se eliminarán.", + "Public sharing is disabled": "El uso compartido público está desactivado", + "Public sharing has been disabled at the workspace level.": "El uso compartido público se ha desactivado a nivel de espacio de trabajo.", + "Public sharing has been disabled for this space.": "El uso compartido público se ha desactivado para este espacio.", "Copy page": "Copiar página", "Copy page to a different space.": "Copiar página en otro espacio", "Page copied successfully": "Página copiada exitosamente", @@ -565,13 +587,33 @@ "Ask AI": "Preguntar a IA", "AI is thinking...": "IA está pensando...", "Ask a question...": "Haz una pregunta...", - "AI-powered search (Ask AI)": "Búsqueda impulsada por IA (Preguntar a IA)", + "AI Answers": "Respuestas de IA", + "AI-powered search (AI Answers)": "Búsqueda impulsada por IA (Respuestas de IA)", "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La búsqueda de IA utiliza incrustaciones vectoriales para proporcionar capacidades de búsqueda semántica en todo el contenido de su espacio de trabajo.", "Toggle AI search": "Alternar búsqueda de IA", + "Generative AI (Ask AI)": "IA generativa (Preguntar a la IA)", + "Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Habilitar la generación de contenido impulsada por IA en el editor. Permite a los usuarios generar, mejorar, traducir y transformar texto.", + "Toggle generative AI": "Activar IA generativa", "Sources": "Fuentes", - "Ask AI not available for attachments": "Preguntar a IA no está disponible para adjuntos", + "AI Answers not available for attachments": "Respuestas de IA no disponibles para archivos adjuntos", "No answer available": "No hay respuesta disponible", "Background color": "Color de fondo", "Highlight color": "Color de resaltado", - "Remove color": "Eliminar color" + "Remove color": "Eliminar color", + "Notifications": "Notificaciones", + "No notifications": "Sin notificaciones", + "No unread notifications": "No hay notificaciones no leídas", + "All notifications": "Todas las notificaciones", + "Unread only": "Solo no leídas", + "Mark all as read": "Marcar todo como leído", + "Mark as read": "Marcar como leído", + "More options": "Más opciones", + "mentioned you in a comment": "te mencionó en un comentario", + "commented on a page": "comentó en una página", + "resolved a comment": "resolvió un comentario", + "mentioned you on a page": "te mencionó en una página", + "Today": "Hoy", + "Yesterday": "Ayer", + "This week": "Esta semana", + "Older": "Más antiguo" } diff --git a/apps/client/public/locales/fr-FR/translation.json b/apps/client/public/locales/fr-FR/translation.json index 40a1e68a..77d5db91 100644 --- a/apps/client/public/locales/fr-FR/translation.json +++ b/apps/client/public/locales/fr-FR/translation.json @@ -123,6 +123,8 @@ "page": "page", "Page deleted successfully": "Page supprimée avec succès", "Page history": "Historique de la page", + "Select version": "Sélectionner la version", + "Highlight changes": "Mettre en évidence les changements", "Page import is in progress. Please do not close this tab.": "L'importation de la page est en cours. Veuillez ne pas fermer cet onglet.", "Pages": "Pages", "pages": "pages", @@ -353,6 +355,11 @@ "Insert current date": "Insérer la date actuelle", "Draw and sketch excalidraw diagrams": "Dessiner et esquisser des diagrammes Excalidraw", "Multiple": "Multiple", + "Turn into": "Transformer en", + "Text align": "Alignement du texte", + "This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.", + "Go to homepage": "Go to homepage", + "Pages you create will show up here.": "Pages you create will show up here.", "Heading {{level}}": "Titre {{level}}", "Toggle title": "Basculer le titre", "Write anything. Enter \"/\" for commands": "Écrivez n'importe quoi. Entrez \"/\" pour les commandes", @@ -405,6 +412,21 @@ "Share deleted successfully": "Partage supprimé avec succès", "Share not found": "Partage non trouvé", "Failed to share page": "Échec du partage de la page", + "Disable public sharing": "Désactiver le partage public", + "Prevent members from sharing pages publicly.": "Empêcher les membres de partager des pages publiquement.", + "Toggle public sharing": "Basculer le partage public", + "Toggle space public sharing": "Basculer le partage public de l'espace", + "Public sharing is disabled at the workspace level": "Le partage public est désactivé au niveau de l'espace de travail", + "Prevent pages in this space from being shared publicly.": "Empêcher les pages de cet espace d'être partagées publiquement.", + "Requires an enterprise license": "Nécessite une licence d'entreprise", + "Enable public sharing": "Activer le partage public", + "Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Êtes-vous sûr de vouloir activer le partage public ? Les membres pourront partager des pages publiquement.", + "Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Êtes-vous sûr de vouloir désactiver le partage public ? Tous les liens partagés existants dans cet espace de travail seront supprimés.", + "Are you sure you want to enable public sharing for this space?": "Êtes-vous sûr de vouloir activer le partage public pour cet espace ?", + "Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Êtes-vous sûr de vouloir désactiver le partage public ? Tous les liens partagés existants dans cet espace seront supprimés.", + "Public sharing is disabled": "Le partage public est désactivé", + "Public sharing has been disabled at the workspace level.": "Le partage public a été désactivé au niveau de l'espace de travail.", + "Public sharing has been disabled for this space.": "Le partage public a été désactivé pour cet espace.", "Copy page": "Copier la page", "Copy page to a different space.": "Copier la page dans un autre espace.", "Page copied successfully": "Page copiée avec succès", @@ -565,13 +587,33 @@ "Ask AI": "Demander à l'IA", "AI is thinking...": "L'IA réfléchit...", "Ask a question...": "Posez une question...", - "AI-powered search (Ask AI)": "Recherche assistée par l'IA (Demander à l'IA)", + "AI Answers": "Réponses IA", + "AI-powered search (AI Answers)": "Recherche propulsée par IA (Réponses IA)", "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La recherche IA utilise des incorporations vectorielles pour fournir des capacités de recherche sémantique à travers le contenu de votre espace de travail.", "Toggle AI search": "Basculer la recherche IA", + "Generative AI (Ask AI)": "IA générative (Demandez à l'IA)", + "Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Activer la génération de contenu assistée par IA dans l'éditeur. Permet aux utilisateurs de générer, améliorer, traduire et transformer du texte.", + "Toggle generative AI": "Activer/désactiver l'IA générative", "Sources": "Sources", - "Ask AI not available for attachments": "Demande à l'IA non disponible pour les pièces jointes", + "AI Answers not available for attachments": "Réponses IA non disponibles pour les pièces jointes", "No answer available": "Pas de réponse disponible", "Background color": "Couleur de fond", "Highlight color": "Couleur de surbrillance", - "Remove color": "Supprimer la couleur" + "Remove color": "Supprimer la couleur", + "Notifications": "Notifications", + "No notifications": "Aucune notification", + "No unread notifications": "Aucune notification non lue", + "All notifications": "Toutes les notifications", + "Unread only": "Non lues uniquement", + "Mark all as read": "Tout marquer comme lu", + "Mark as read": "Marquer comme lu", + "More options": "Plus d'options", + "mentioned you in a comment": "vous a mentionné dans un commentaire", + "commented on a page": "a commenté une page", + "resolved a comment": "a résolu un commentaire", + "mentioned you on a page": "vous a mentionné sur une page", + "Today": "Aujourd'hui", + "Yesterday": "Hier", + "This week": "Cette semaine", + "Older": "Plus ancien" } diff --git a/apps/client/public/locales/it-IT/translation.json b/apps/client/public/locales/it-IT/translation.json index ff80df0f..dc357932 100644 --- a/apps/client/public/locales/it-IT/translation.json +++ b/apps/client/public/locales/it-IT/translation.json @@ -123,6 +123,8 @@ "page": "pagina", "Page deleted successfully": "Pagina eliminata con successo", "Page history": "Cronologia della pagina", + "Select version": "Seleziona versione", + "Highlight changes": "Evidenzia modifiche", "Page import is in progress. Please do not close this tab.": "L'importazione della pagina è in corso. Si prega di non chiudere questa scheda.", "Pages": "Pagine", "pages": "pagine", @@ -353,6 +355,11 @@ "Insert current date": "Inserisci la data corrente", "Draw and sketch excalidraw diagrams": "Disegna e schizza diagrammi excalidraw", "Multiple": "Multiplo", + "Turn into": "Trasforma in", + "Text align": "Allinea testo", + "This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.", + "Go to homepage": "Go to homepage", + "Pages you create will show up here.": "Pages you create will show up here.", "Heading {{level}}": "Intestazione {{level}}", "Toggle title": "Attiva/disattiva titolo", "Write anything. Enter \"/\" for commands": "Scrivi qualcosa. Digita \"/\" per i comandi", @@ -405,6 +412,21 @@ "Share deleted successfully": "Condivisione eliminata con successo", "Share not found": "Condivisione non trovata", "Failed to share page": "Condivisione della pagina fallita", + "Disable public sharing": "Disabilita la condivisione pubblica", + "Prevent members from sharing pages publicly.": "Impedisci ai membri di condividere pubblicamente le pagine.", + "Toggle public sharing": "Attiva/disattiva la condivisione pubblica", + "Toggle space public sharing": "Attiva/disattiva la condivisione pubblica nello spazio", + "Public sharing is disabled at the workspace level": "La condivisione pubblica è disabilitata a livello di area di lavoro", + "Prevent pages in this space from being shared publicly.": "Impedisci che le pagine in questo spazio vengano condivise pubblicamente.", + "Requires an enterprise license": "Richiede una licenza enterprise", + "Enable public sharing": "Abilita la condivisione pubblica", + "Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Sei sicuro di voler abilitare la condivisione pubblica? I membri potranno condividere le pagine pubblicamente.", + "Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Sei sicuro di voler disabilitare la condivisione pubblica? Tutti i link condivisi esistenti in questa area di lavoro verranno eliminati.", + "Are you sure you want to enable public sharing for this space?": "Sei sicuro di voler abilitare la condivisione pubblica per questo spazio?", + "Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Sei sicuro di voler disabilitare la condivisione pubblica? Tutti i link condivisi esistenti in questo spazio verranno eliminati.", + "Public sharing is disabled": "La condivisione pubblica è disabilitata", + "Public sharing has been disabled at the workspace level.": "La condivisione pubblica è stata disabilitata a livello di area di lavoro.", + "Public sharing has been disabled for this space.": "La condivisione pubblica è stata disabilitata per questo spazio.", "Copy page": "Copia pagina", "Copy page to a different space.": "Copia pagina in un altro spazio.", "Page copied successfully": "Pagina copiata con successo", @@ -565,13 +587,33 @@ "Ask AI": "Chiedi all'AI", "AI is thinking...": "L'AI sta pensando...", "Ask a question...": "Fai una domanda...", - "AI-powered search (Ask AI)": "Ricerca potenziata dall'AI (Chiedi all'AI)", + "AI Answers": "Risposte AI", + "AI-powered search (AI Answers)": "Ricerca con AI (Risposte AI)", "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La ricerca AI utilizza embeddings vettoriali per fornire capacità di ricerca semantica nel contenuto della tua area di lavoro.", "Toggle AI search": "Attiva/disattiva ricerca AI", + "Generative AI (Ask AI)": "AI generativa (Chiedi AI)", + "Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Abilita la generazione di contenuti con AI nell'editor. Consente agli utenti di generare, migliorare, tradurre e trasformare il testo.", + "Toggle generative AI": "Attiva/Disattiva AI generativa", "Sources": "Fonti", - "Ask AI not available for attachments": "Chiedere all'AI non è disponibile per gli allegati", + "AI Answers not available for attachments": "Risposte AI non disponibili per gli allegati", "No answer available": "Nessuna risposta disponibile", "Background color": "Colore di sfondo", "Highlight color": "Colore evidenziato", - "Remove color": "Rimuovi colore" + "Remove color": "Rimuovi colore", + "Notifications": "Notifiche", + "No notifications": "Nessuna notifica", + "No unread notifications": "Nessuna notifica non letta", + "All notifications": "Tutte le notifiche", + "Unread only": "Solo non lette", + "Mark all as read": "Segna tutto come letto", + "Mark as read": "Segna come letto", + "More options": "Altre opzioni", + "mentioned you in a comment": "ti ha menzionato in un commento", + "commented on a page": "ha commentato una pagina", + "resolved a comment": "ha risolto un commento", + "mentioned you on a page": "ti ha menzionato in una pagina", + "Today": "Oggi", + "Yesterday": "Ieri", + "This week": "Questa settimana", + "Older": "Più vecchie" } diff --git a/apps/client/public/locales/ja-JP/translation.json b/apps/client/public/locales/ja-JP/translation.json index 4d18e074..1c884add 100644 --- a/apps/client/public/locales/ja-JP/translation.json +++ b/apps/client/public/locales/ja-JP/translation.json @@ -123,6 +123,8 @@ "page": "ページ", "Page deleted successfully": "ページを削除しました", "Page history": "ページ履歴", + "Select version": "バージョンを選択", + "Highlight changes": "変更を強調表示", "Page import is in progress. Please do not close this tab.": "ページをインポート中です。このタブを閉じないでください", "Pages": "ページ", "pages": "ページ", @@ -353,6 +355,11 @@ "Insert current date": "現在の日付を挿入します", "Draw and sketch excalidraw diagrams": "Excalidraw 図を挿入します", "Multiple": "複数", + "Turn into": "変換する", + "Text align": "テキストの配置", + "This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.", + "Go to homepage": "Go to homepage", + "Pages you create will show up here.": "Pages you create will show up here.", "Heading {{level}}": "見出し {{level}}", "Toggle title": "タイトルの表示/非表示を切り替える", "Write anything. Enter \"/\" for commands": "文字を入力するか、「/」でコマンドを呼び出します", @@ -405,6 +412,21 @@ "Share deleted successfully": "共有を削除しました", "Share not found": "共有が見つかりません", "Failed to share page": "ページの共有に失敗しました", + "Disable public sharing": "公開共有を無効にする", + "Prevent members from sharing pages publicly.": "メンバーがページを公開で共有するのを防ぐ。", + "Toggle public sharing": "公開共有を切り替える", + "Toggle space public sharing": "スペースの公開共有を切り替える", + "Public sharing is disabled at the workspace level": "ワークスペースレベルで公開共有が無効になっています", + "Prevent pages in this space from being shared publicly.": "このスペース内のページが公開で共有されるのを防ぐ。", + "Requires an enterprise license": "エンタープライズライセンスが必要です", + "Enable public sharing": "公開共有を有効にする", + "Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "本当に公開共有を有効にしますか?メンバーはページを公開で共有できるようになります。", + "Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "本当に公開共有を無効にしますか?このワークスペース内のすべての既存の共有リンクが削除されます。", + "Are you sure you want to enable public sharing for this space?": "本当にこのスペースの公開共有を有効にしますか?", + "Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "本当に公開共有を無効にしますか?このスペースのすべての既存の共有リンクが削除されます。", + "Public sharing is disabled": "公開共有が無効になっています", + "Public sharing has been disabled at the workspace level.": "ワークスペースレベルで公開共有が無効になりました。", + "Public sharing has been disabled for this space.": "このスペースで公開共有が無効になりました。", "Copy page": "ページをコピー", "Copy page to a different space.": "ページを別のスペースにコピーします", "Page copied successfully": "ページをコピーしました", @@ -565,13 +587,33 @@ "Ask AI": "AIに質問する", "AI is thinking...": "AIが考え中...", "Ask a question...": "質問を入力...", - "AI-powered search (Ask AI)": "AIによる検索(AIに質問)", + "AI Answers": "AI回答", + "AI-powered search (AI Answers)": "AI搭載検索 (AI回答)", "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI検索はベクター埋め込みを使用してワークスペース全体の意味検索を実現します", "Toggle AI search": "AI検索を切り替え", + "Generative AI (Ask AI)": "生成AI (Ask AI)", + "Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "エディターでAIを活用したコンテンツ生成を有効にします。ユーザーがテキストの生成、改善、翻訳、および変換を行うことができます。", + "Toggle generative AI": "生成AIを切り替える", "Sources": "ソース", - "Ask AI not available for attachments": "添付ファイルにはAI質問は利用できません", + "AI Answers not available for attachments": "添付ファイルにはAI回答を利用できません", "No answer available": "回答がありません", "Background color": "背景色", "Highlight color": "ハイライト色", - "Remove color": "色を削除" + "Remove color": "色を削除", + "Notifications": "通知", + "No notifications": "通知なし", + "No unread notifications": "未読の通知はありません", + "All notifications": "すべての通知", + "Unread only": "未読のみ", + "Mark all as read": "すべてを既読にする", + "Mark as read": "既読にする", + "More options": "その他のオプション", + "mentioned you in a comment": "コメントであなたに言及しました", + "commented on a page": "ページにコメントしました", + "resolved a comment": "コメントを解決しました", + "mentioned you on a page": "ページ上であなたに言及しました", + "Today": "今日", + "Yesterday": "昨日", + "This week": "今週", + "Older": "以前のもの" } diff --git a/apps/client/public/locales/ko-KR/translation.json b/apps/client/public/locales/ko-KR/translation.json index d9b48b04..ba6a93eb 100644 --- a/apps/client/public/locales/ko-KR/translation.json +++ b/apps/client/public/locales/ko-KR/translation.json @@ -123,6 +123,8 @@ "page": "페이지", "Page deleted successfully": "페이지 삭제 완료", "Page history": "페이지 기록", + "Select version": "버전 선택", + "Highlight changes": "변경 사항 강조", "Page import is in progress. Please do not close this tab.": "페이지 가져오기가 진행 중입니다. 이 탭을 닫지 마세요.", "Pages": "페이지", "pages": "페이지", @@ -353,6 +355,11 @@ "Insert current date": "현재 날짜 삽입", "Draw and sketch excalidraw diagrams": "Excalidraw diagram 그리기 및 스케치", "Multiple": "복제", + "Turn into": "변경하기", + "Text align": "텍스트 정렬", + "This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.", + "Go to homepage": "Go to homepage", + "Pages you create will show up here.": "Pages you create will show up here.", "Heading {{level}}": "제목 {{level}}", "Toggle title": "제목 토글", "Write anything. Enter \"/\" for commands": "아무거나 입력하세요. 명령어를 사용하려면 \"/\"를 입력하세요", @@ -405,6 +412,21 @@ "Share deleted successfully": "공유가 성공적으로 삭제되었습니다", "Share not found": "공유를 찾을 수 없습니다", "Failed to share page": "페이지 공유에 실패했습니다", + "Disable public sharing": "공유 비활성화", + "Prevent members from sharing pages publicly.": "멤버들이 페이지를 공개적으로 공유하지 못하도록 방지하십시오.", + "Toggle public sharing": "공유 전환", + "Toggle space public sharing": "공간 공유 전환", + "Public sharing is disabled at the workspace level": "워크스페이스 수준에서 공유가 비활성화되었습니다.", + "Prevent pages in this space from being shared publicly.": "이 공간의 페이지가 공개적으로 공유되지 않도록 방지하십시오.", + "Requires an enterprise license": "기업 라이센스가 필요합니다.", + "Enable public sharing": "공유 활성화", + "Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "공유를 활성화하시겠습니까? 멤버들이 페이지를 공개적으로 공유할 수 있게 됩니다.", + "Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "정말로 공유를 비활성화하시겠습니까? 이 워크스페이스의 모든 기존 공유 링크가 삭제됩니다.", + "Are you sure you want to enable public sharing for this space?": "이 공간의 공유를 활성화하시겠습니까?", + "Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "정말로 공유를 비활성화하시겠습니까? 이 공간의 모든 기존 공유 링크가 삭제됩니다.", + "Public sharing is disabled": "공유가 비활성화되었습니다.", + "Public sharing has been disabled at the workspace level.": "워크스페이스 수준에서 공유가 비활성화되었습니다.", + "Public sharing has been disabled for this space.": "이 공간의 공유가 비활성화되었습니다.", "Copy page": "페이지 복사하기", "Copy page to a different space.": "다른 공간으로 페이지 복사하기.", "Page copied successfully": "페이지가 성공적으로 복사되었습니다", @@ -565,13 +587,33 @@ "Ask AI": "AI에게 묻기", "AI is thinking...": "AI가 생각 중입니다...", "Ask a question...": "질문하세요...", - "AI-powered search (Ask AI)": "AI 지원 검색 (AI에게 묻기)", + "AI Answers": "AI 답변", + "AI-powered search (AI Answers)": "AI 구동 검색 (AI 답변)", "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI 검색은 벡터 임베딩을 사용하여 작업공간 콘텐츠에 대한 의미 검색 기능을 제공합니다.", "Toggle AI search": "AI 검색 전환", + "Generative AI (Ask AI)": "생성 AI (Ask AI)", + "Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "편집기에서 AI 구동 콘텐츠 생성을 활성화합니다. 사용자가 텍스트를 생성, 개선, 번역 및 변환할 수 있습니다.", + "Toggle generative AI": "생성 AI 토글", "Sources": "출처", - "Ask AI not available for attachments": "AI에게 묻기 기능은 첨부 파일에 대해 사용할 수 없습니다", + "AI Answers not available for attachments": "첨부 파일에 대해 AI 답변을 사용할 수 없습니다", "No answer available": "답변을 제공할 수 없습니다", "Background color": "배경 색", "Highlight color": "강조 색", - "Remove color": "색 제거" + "Remove color": "색 제거", + "Notifications": "알림", + "No notifications": "알림 없음", + "No unread notifications": "읽지 않은 알림 없음", + "All notifications": "모든 알림", + "Unread only": "읽지 않음만", + "Mark all as read": "모두 읽음으로 표시", + "Mark as read": "읽음으로 표시", + "More options": "추가 옵션", + "mentioned you in a comment": "댓글에서 당신을 언급했습니다", + "commented on a page": "페이지에 댓글을 달았습니다", + "resolved a comment": "댓글을 해결했습니다", + "mentioned you on a page": "페이지에서 당신을 언급했습니다", + "Today": "오늘", + "Yesterday": "어제", + "This week": "이번 주", + "Older": "이전" } diff --git a/apps/client/public/locales/nl-NL/translation.json b/apps/client/public/locales/nl-NL/translation.json index a7923b98..3f0136fc 100644 --- a/apps/client/public/locales/nl-NL/translation.json +++ b/apps/client/public/locales/nl-NL/translation.json @@ -123,6 +123,8 @@ "page": "pagina", "Page deleted successfully": "Pagina succesvol verwijderd", "Page history": "Pagina geschiedenis", + "Select version": "Selecteer versie", + "Highlight changes": "Wijzigingen markeren", "Page import is in progress. Please do not close this tab.": "Importeren van pagina's is bezig. Sluit dit tabblad niet.", "Pages": "Pagina's", "pages": "pagina's", @@ -353,6 +355,11 @@ "Insert current date": "Huidige datum invoeren", "Draw and sketch excalidraw diagrams": "Teken en schets excalidraw diagrammen", "Multiple": "Meerdere", + "Turn into": "Omzetten naar", + "Text align": "Tekstuitlijning", + "This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.", + "Go to homepage": "Go to homepage", + "Pages you create will show up here.": "Pages you create will show up here.", "Heading {{level}}": "Kop {{level}}", "Toggle title": "Schakel titel in/uit", "Write anything. Enter \"/\" for commands": "Schrijf iets. Voer \"/\" in voor commando's", @@ -405,6 +412,21 @@ "Share deleted successfully": "Delen succesvol verwijderd", "Share not found": "Delen niet gevonden", "Failed to share page": "Pagina delen mislukt", + "Disable public sharing": "Openbaar delen uitschakelen", + "Prevent members from sharing pages publicly.": "Voorkom dat leden pagina's openbaar delen.", + "Toggle public sharing": "Wissel openbaar delen", + "Toggle space public sharing": "Wissel openbaar delen van ruimte", + "Public sharing is disabled at the workspace level": "Openbaar delen is uitgeschakeld op werkruimteniveau", + "Prevent pages in this space from being shared publicly.": "Voorkom dat pagina's in deze ruimte openbaar worden gedeeld.", + "Requires an enterprise license": "Vereist een bedrijfslicentie", + "Enable public sharing": "Openbaar delen inschakelen", + "Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Weet je zeker dat je openbaar delen wilt inschakelen? Leden kunnen pagina's openbaar delen.", + "Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Weet je zeker dat je openbaar delen wilt uitschakelen? Alle bestaande gedeelde links in deze werkruimte zullen worden verwijderd.", + "Are you sure you want to enable public sharing for this space?": "Weet je zeker dat je openbaar delen voor deze ruimte wilt inschakelen?", + "Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Weet je zeker dat je openbaar delen wilt uitschakelen? Alle bestaande gedeelde links in deze ruimte zullen worden verwijderd.", + "Public sharing is disabled": "Openbaar delen is uitgeschakeld", + "Public sharing has been disabled at the workspace level.": "Openbaar delen is uitgeschakeld op werkruimteniveau.", + "Public sharing has been disabled for this space.": "Openbaar delen is uitgeschakeld voor deze ruimte.", "Copy page": "Pagina kopiëren", "Copy page to a different space.": "Kopieer pagina naar een andere ruimte.", "Page copied successfully": "Pagina succesvol gekopieerd", @@ -565,13 +587,33 @@ "Ask AI": "Vraag AI", "AI is thinking...": "AI is aan het nadenken...", "Ask a question...": "Stel een vraag...", - "AI-powered search (Ask AI)": "AI-ondersteunde zoekopdracht (Vraag AI)", + "AI Answers": "AI Antwoorden", + "AI-powered search (AI Answers)": "AI-gestuurde zoekopdracht (AI Antwoorden)", "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI-zoekopdracht maakt gebruik van vectorembeddings om semantische zoekmogelijkheden te bieden in uw werkruimte-inhoud.", "Toggle AI search": "Schakel AI-zoekopdracht in/uit", + "Generative AI (Ask AI)": "Generatieve AI (Vraag het AI)", + "Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Schakel AI-gestuurde inhoudsgeneratie in de editor in. Hiermee kunnen gebruikers tekst genereren, verbeteren, vertalen en transformeren.", + "Toggle generative AI": "Generatieve AI schakelen", "Sources": "Bronnen", - "Ask AI not available for attachments": "Vraag AI is niet beschikbaar voor bijlages", + "AI Answers not available for attachments": "AI Antwoorden niet beschikbaar voor bijlagen", "No answer available": "Geen antwoord beschikbaar", "Background color": "Achtergrondkleur", "Highlight color": "Markeerkleur", - "Remove color": "Kleur verwijderen" + "Remove color": "Kleur verwijderen", + "Notifications": "Meldingen", + "No notifications": "Geen meldingen", + "No unread notifications": "Geen ongelezen meldingen", + "All notifications": "Alle meldingen", + "Unread only": "Alleen ongelezen", + "Mark all as read": "Markeer alles als gelezen", + "Mark as read": "Markeer als gelezen", + "More options": "Meer opties", + "mentioned you in a comment": "noemde je in een reactie", + "commented on a page": "reageerde op een pagina", + "resolved a comment": "heeft een opmerking opgelost", + "mentioned you on a page": "noemde je op een pagina", + "Today": "Vandaag", + "Yesterday": "Gisteren", + "This week": "Deze week", + "Older": "Ouder" } diff --git a/apps/client/public/locales/pt-BR/translation.json b/apps/client/public/locales/pt-BR/translation.json index 30cc0b21..5fb2000f 100644 --- a/apps/client/public/locales/pt-BR/translation.json +++ b/apps/client/public/locales/pt-BR/translation.json @@ -123,6 +123,8 @@ "page": "página", "Page deleted successfully": "Página excluída com sucesso", "Page history": "Histórico da página", + "Select version": "Selecionar versão", + "Highlight changes": "Destacar alterações", "Page import is in progress. Please do not close this tab.": "A importação da página está em andamento. Por favor, não feche esta aba.", "Pages": "Páginas", "pages": "páginas", @@ -353,6 +355,11 @@ "Insert current date": "Insira a data atual", "Draw and sketch excalidraw diagrams": "Desenhe e esboce diagramas Excalidraw", "Multiple": "Múltiplo", + "Turn into": "Transformar em", + "Text align": "Alinhar texto", + "This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.", + "Go to homepage": "Go to homepage", + "Pages you create will show up here.": "Pages you create will show up here.", "Heading {{level}}": "Título {{level}}", "Toggle title": "Alternar título", "Write anything. Enter \"/\" for commands": "Escreva qualquer coisa. Digite \"/\" para comandos", @@ -405,6 +412,21 @@ "Share deleted successfully": "Compartilhamento excluído com sucesso", "Share not found": "Compartilhamento não encontrado", "Failed to share page": "Falha ao compartilhar página", + "Disable public sharing": "Desativar compartilhamento público", + "Prevent members from sharing pages publicly.": "Impedir que os membros compartilhem páginas publicamente.", + "Toggle public sharing": "Alternar compartilhamento público", + "Toggle space public sharing": "Alternar compartilhamento público do espaço", + "Public sharing is disabled at the workspace level": "O compartilhamento público está desativado no nível do espaço de trabalho", + "Prevent pages in this space from being shared publicly.": "Impedir que as páginas neste espaço sejam compartilhadas publicamente.", + "Requires an enterprise license": "Requer uma licença empresarial", + "Enable public sharing": "Ativar compartilhamento público", + "Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Tem certeza de que deseja ativar o compartilhamento público? Os membros poderão compartilhar páginas publicamente.", + "Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Tem certeza de que deseja desativar o compartilhamento público? Todos os links compartilhados existentes neste espaço de trabalho serão excluídos.", + "Are you sure you want to enable public sharing for this space?": "Tem certeza de que deseja ativar o compartilhamento público para este espaço?", + "Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Tem certeza de que deseja desativar o compartilhamento público? Todos os links compartilhados existentes neste espaço serão excluídos.", + "Public sharing is disabled": "Compartilhamento público está desativado", + "Public sharing has been disabled at the workspace level.": "O compartilhamento público foi desativado no nível do espaço de trabalho.", + "Public sharing has been disabled for this space.": "O compartilhamento público foi desativado para este espaço.", "Copy page": "Copiar página", "Copy page to a different space.": "Copiar página para um espaço diferente.", "Page copied successfully": "Página copiada com sucesso", @@ -565,13 +587,33 @@ "Ask AI": "Pergunte à IA", "AI is thinking...": "IA está pensando...", "Ask a question...": "Faça uma pergunta...", - "AI-powered search (Ask AI)": "Pesquisa com IA (Pergunte à IA)", + "AI Answers": "Respostas de IA", + "AI-powered search (AI Answers)": "Pesquisa com IA (Respostas de IA)", "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "A pesquisa IA usa vetores de incorporação para fornecer capacidades de pesquisa semântica em todo o conteúdo do seu espaço de trabalho.", "Toggle AI search": "Alternar pesquisa de IA", + "Generative AI (Ask AI)": "IA generativa (Perguntar à IA)", + "Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Habilitar geração de conteúdo com IA no editor. Permite aos usuários gerar, melhorar, traduzir e transformar texto.", + "Toggle generative AI": "Alternar IA generativa", "Sources": "Fontes", - "Ask AI not available for attachments": "Perguntar à IA não está disponível para anexos", + "AI Answers not available for attachments": "Respostas de IA não disponíveis para anexos", "No answer available": "Nenhuma resposta disponível", "Background color": "Cor de fundo", "Highlight color": "Cor de destaque", - "Remove color": "Remover cor" + "Remove color": "Remover cor", + "Notifications": "Notificações", + "No notifications": "Sem notificações", + "No unread notifications": "Sem notificações não lidas", + "All notifications": "Todas as notificações", + "Unread only": "Somente não lidas", + "Mark all as read": "Marcar todas como lidas", + "Mark as read": "Marcar como lida", + "More options": "Mais opções", + "mentioned you in a comment": "mencionou você em um comentário", + "commented on a page": "comentou em uma página", + "resolved a comment": "resolveu um comentário", + "mentioned you on a page": "mencionou você em uma página", + "Today": "Hoje", + "Yesterday": "Ontem", + "This week": "Esta semana", + "Older": "Mais antigo" } diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index 88e1f701..4c50f9e7 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -123,6 +123,8 @@ "page": "страница", "Page deleted successfully": "Страница успешно удалена", "Page history": "История страницы", + "Select version": "Выбрать версию", + "Highlight changes": "Выделить изменения", "Page import is in progress. Please do not close this tab.": "Импорт страницы в процессе. Пожалуйста, не закрывайте эту вкладку.", "Pages": "Страницы", "pages": "страницы", @@ -353,6 +355,11 @@ "Insert current date": "Вставить текущую дату", "Draw and sketch excalidraw diagrams": "Вставить и рисовать диаграммы Excalidraw", "Multiple": "Несколько", + "Turn into": "Преобразовать в", + "Text align": "Выравнивание текста", + "This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.", + "Go to homepage": "Go to homepage", + "Pages you create will show up here.": "Pages you create will show up here.", "Heading {{level}}": "Заголовок {{level}}", "Toggle title": "Переключить заголовок", "Write anything. Enter \"/\" for commands": "Начните писать. Введите \"/\" для списка команд", @@ -405,6 +412,21 @@ "Share deleted successfully": "Общий доступ успешно удален", "Share not found": "Общий доступ не найден", "Failed to share page": "Не удалось поделиться страницей", + "Disable public sharing": "Отключить общий доступ", + "Prevent members from sharing pages publicly.": "Запретить участникам делиться страницами публично.", + "Toggle public sharing": "Переключить общий доступ", + "Toggle space public sharing": "Переключить общий доступ для пространства", + "Public sharing is disabled at the workspace level": "Общий доступ отключен на уровне рабочего пространства", + "Prevent pages in this space from being shared publicly.": "Запретить делиться страницами в этом пространстве публично.", + "Requires an enterprise license": "Требуется корпоративная лицензия", + "Enable public sharing": "Включить общий доступ", + "Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Вы уверены, что хотите включить общий доступ? Участники смогут делиться страницами публично.", + "Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Вы уверены, что хотите отключить общий доступ? Все существующие ссылки в этом рабочем пространстве будут удалены.", + "Are you sure you want to enable public sharing for this space?": "Вы уверены, что хотите включить общий доступ для этого пространства?", + "Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Вы уверены, что хотите отключить общий доступ? Все существующие ссылки в этом пространстве будут удалены.", + "Public sharing is disabled": "Общий доступ отключен", + "Public sharing has been disabled at the workspace level.": "Общий доступ был отключен на уровне рабочего пространства.", + "Public sharing has been disabled for this space.": "Общий доступ был отключен для этого пространства.", "Copy page": "Копировать страницу", "Copy page to a different space.": "Копировать страницу в другое пространство.", "Page copied successfully": "Страница успешно скопирована", @@ -565,13 +587,33 @@ "Ask AI": "Спросить ИИ", "AI is thinking...": "ИИ обрабатывает запрос...", "Ask a question...": "Задайте вопрос...", - "AI-powered search (Ask AI)": "Поиск на базе ИИ (Спросить ИИ)", + "AI Answers": "Ответы ИИ", + "AI-powered search (AI Answers)": "Поиск на базе ИИ (Ответы ИИ)", "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Поиск ИИ использует векторные встраивания для обеспечения семантического поиска по содержимому вашего рабочего пространства.", "Toggle AI search": "Переключить поиск ИИ", + "Generative AI (Ask AI)": "Генеративный ИИ (Спросить ИИ)", + "Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Включите создание контента на базе ИИ в редакторе. Позволяет пользователям генерировать, улучшать, переводить и преобразовывать текст.", + "Toggle generative AI": "Переключить генеративный ИИ", "Sources": "Источники", - "Ask AI not available for attachments": "Функция \"Спросить ИИ\" недоступна для вложений", + "AI Answers not available for attachments": "Ответы ИИ недоступны для вложений", "No answer available": "Ответ недоступен", "Background color": "Цвет фона", "Highlight color": "Цвет выделения", - "Remove color": "Удалить цвет" + "Remove color": "Удалить цвет", + "Notifications": "Уведомления", + "No notifications": "Нет уведомлений", + "No unread notifications": "Нет непрочитанных уведомлений", + "All notifications": "Все уведомления", + "Unread only": "Только непрочитанные", + "Mark all as read": "Отметить все как прочитанные", + "Mark as read": "Отметить как прочитанное", + "More options": "Больше возможностей", + "mentioned you in a comment": "упомянул вас в комментарии", + "commented on a page": "прокомментировал на странице", + "resolved a comment": "разрешил комментарий", + "mentioned you on a page": "упомянул вас на странице", + "Today": "Сегодня", + "Yesterday": "Вчера", + "This week": "На этой неделе", + "Older": "Старше" } diff --git a/apps/client/public/locales/uk-UA/translation.json b/apps/client/public/locales/uk-UA/translation.json index e5cdaa40..84cc1428 100644 --- a/apps/client/public/locales/uk-UA/translation.json +++ b/apps/client/public/locales/uk-UA/translation.json @@ -123,6 +123,8 @@ "page": "сторінка", "Page deleted successfully": "Сторінку успішно видалено", "Page history": "Історія сторінки", + "Select version": "Вибрати версію", + "Highlight changes": "Підсвітити зміни", "Page import is in progress. Please do not close this tab.": "Імпорт сторінки в процесі. Будь ласка, не закривайте цю вкладку.", "Pages": "Сторінки", "pages": "сторінки", @@ -353,6 +355,11 @@ "Insert current date": "Вставити поточну дату", "Draw and sketch excalidraw diagrams": "Вставити та малювати діаграми Excalidraw", "Multiple": "Декілька", + "Turn into": "Перетворити", + "Text align": "Вирівнювання тексту", + "This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.", + "Go to homepage": "Go to homepage", + "Pages you create will show up here.": "Pages you create will show up here.", "Heading {{level}}": "Заголовок {{level}}", "Toggle title": "Перемкнути заголовок", "Write anything. Enter \"/\" for commands": "Почніть писати. Введіть \"/\" для списку команд", @@ -405,6 +412,21 @@ "Share deleted successfully": "Спільний доступ успішно видалено", "Share not found": "Спільний доступ не знайдено", "Failed to share page": "Не вдалося поділитися сторінкою", + "Disable public sharing": "Вимкнути публічний доступ", + "Prevent members from sharing pages publicly.": "Перешкодити учасникам публічно ділитися сторінками.", + "Toggle public sharing": "Перемикання публічного доступу", + "Toggle space public sharing": "Перемикання публічного доступу до просторів", + "Public sharing is disabled at the workspace level": "Публічний доступ вимкнуто на рівні робочого простору", + "Prevent pages in this space from being shared publicly.": "Перешкодити публічному поширенню сторінок у цьому просторі.", + "Requires an enterprise license": "Потребує корпоративної ліцензії", + "Enable public sharing": "Увімкнути публічний доступ", + "Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Ви впевнені, що хочете увімкнути публічний доступ? Учасники зможуть публічно ділитися сторінками.", + "Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Ви впевнені, що хочете вимкнути публічний доступ? Усі існуючі посилання для спільного доступу в цьому робочому просторі будуть видалені.", + "Are you sure you want to enable public sharing for this space?": "Ви впевнені, що хочете увімкнути публічний доступ для цього простору?", + "Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Ви впевнені, що хочете вимкнути публічний доступ? Усі існуючі посилання для спільного доступу в цьому просторі будуть видалені.", + "Public sharing is disabled": "Публічний доступ вимкнуто", + "Public sharing has been disabled at the workspace level.": "Публічний доступ було вимкнено на рівні робочого простору.", + "Public sharing has been disabled for this space.": "Публічний доступ було вимкнено для цього простору.", "Copy page": "Копіювати сторінки", "Copy page to a different space.": "Скопіювати сторінку в інший простір.", "Page copied successfully": "Сторінку успішно скопійовано", @@ -565,13 +587,33 @@ "Ask AI": "Запитати ШІ", "AI is thinking...": "ШІ думає...", "Ask a question...": "Задайте питання...", - "AI-powered search (Ask AI)": "Пошук на базі ШІ (Запитати ШІ)", + "AI Answers": "Відповіді ШІ", + "AI-powered search (AI Answers)": "Пошук на базі ШІ (Відповіді ШІ)", "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Пошук з ШІ використовує векторні вбудовування для надання можливостей семантичного пошуку у вашому робочому вмісті.", "Toggle AI search": "Переключити пошук з ШІ", + "Generative AI (Ask AI)": "Генеративний ШІ (Запитати ШІ)", + "Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Увімкнути генерацію контенту за допомогою ШІ в редакторі. Дозволяє користувачам генерувати, покращувати, перекладати та трансформувати текст.", + "Toggle generative AI": "Переключити генеративний ШІ", "Sources": "Джерела", - "Ask AI not available for attachments": "Запитати ШІ недоступно для вкладень", + "AI Answers not available for attachments": "Відповіді ШІ недоступні для вкладень", "No answer available": "Відповідь недоступна", "Background color": "Колір фону", "Highlight color": "Колір підсвічування", - "Remove color": "Видалити колір" + "Remove color": "Видалити колір", + "Notifications": "Сповіщення", + "No notifications": "Немає сповіщень", + "No unread notifications": "Немає непрочитаних сповіщень", + "All notifications": "Усі сповіщення", + "Unread only": "Тільки непрочитані", + "Mark all as read": "Позначити все як прочитане", + "Mark as read": "Позначити як прочитане", + "More options": "Більше опцій", + "mentioned you in a comment": "згадали вас у коментарі", + "commented on a page": "прокоментували на сторінці", + "resolved a comment": "вирішили коментар", + "mentioned you on a page": "згадали вас на сторінці", + "Today": "Сьогодні", + "Yesterday": "Вчора", + "This week": "Цього тижня", + "Older": "Старіші" } diff --git a/apps/client/public/locales/zh-CN/translation.json b/apps/client/public/locales/zh-CN/translation.json index a5eb84f1..44cbe767 100644 --- a/apps/client/public/locales/zh-CN/translation.json +++ b/apps/client/public/locales/zh-CN/translation.json @@ -123,6 +123,8 @@ "page": "个页面", "Page deleted successfully": "页面已成功删除", "Page history": "页面历史", + "Select version": "选择版本", + "Highlight changes": "突出显示更改", "Page import is in progress. Please do not close this tab.": "页面导入正在进行中。请不要关闭此标签页。", "Pages": "页面", "pages": "个页面", @@ -353,6 +355,11 @@ "Insert current date": "插入当前日期", "Draw and sketch excalidraw diagrams": "绘制 Excalidraw 图表", "Multiple": "多个", + "Turn into": "变成", + "Text align": "文本对齐", + "This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.", + "Go to homepage": "Go to homepage", + "Pages you create will show up here.": "Pages you create will show up here.", "Heading {{level}}": "{{level}} 级标题", "Toggle title": "切换标题", "Write anything. Enter \"/\" for commands": "开始编写内容,输入 \"/\" 以使用指令", @@ -405,6 +412,21 @@ "Share deleted successfully": "分享已成功删除", "Share not found": "未找到分享", "Failed to share page": "页面分享失败", + "Disable public sharing": "禁用公开分享", + "Prevent members from sharing pages publicly.": "阻止成员公开分享页面。", + "Toggle public sharing": "切换公开分享", + "Toggle space public sharing": "切换空间公开分享", + "Public sharing is disabled at the workspace level": "公开分享在工作区级别被禁用", + "Prevent pages in this space from being shared publicly.": "阻止此空间中的页面被公开分享。", + "Requires an enterprise license": "需要企业许可证", + "Enable public sharing": "启用公开分享", + "Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "您确定要启用公开分享吗?成员将能够公开分享页面。", + "Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "您确定要禁用公开分享吗?此工作区中的所有现有共享链接都将被删除。", + "Are you sure you want to enable public sharing for this space?": "您确定要为此空间启用公开分享吗?", + "Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "您确定要禁用公开分享吗?此空间中的所有现有共享链接都将被删除。", + "Public sharing is disabled": "公开分享已被禁用", + "Public sharing has been disabled at the workspace level.": "公开分享已在工作区级别被禁用。", + "Public sharing has been disabled for this space.": "此空间的公开分享已被禁用。", "Copy page": "复制页面", "Copy page to a different space.": "将页面复制到不同的空间。", "Page copied successfully": "页面复制成功", @@ -565,13 +587,33 @@ "Ask AI": "询问AI", "AI is thinking...": "AI正在思考...", "Ask a question...": "提问...", - "AI-powered search (Ask AI)": "AI驱动的搜索(询问AI)", + "AI Answers": "AI答案", + "AI-powered search (AI Answers)": "AI驱动的搜索 (AI答案)", "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI搜索使用向量嵌入提供跨工作空间内容的语义搜索功能。", "Toggle AI search": "切换AI搜索", + "Generative AI (Ask AI)": "生成型AI (询问AI)", + "Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "在编辑器中启用AI驱动的内容生成。允许用户生成、改进、翻译和转换文本。", + "Toggle generative AI": "切换生成型AI", "Sources": "来源", - "Ask AI not available for attachments": "附件不支持询问AI", + "AI Answers not available for attachments": "AI答案不适用于附件", "No answer available": "无可用答案", "Background color": "背景颜色", "Highlight color": "突出显示颜色", - "Remove color": "移除颜色" + "Remove color": "移除颜色", + "Notifications": "通知", + "No notifications": "没有通知", + "No unread notifications": "没有未读通知", + "All notifications": "所有通知", + "Unread only": "仅未读", + "Mark all as read": "标记所有为已读", + "Mark as read": "标记为已读", + "More options": "更多选项", + "mentioned you in a comment": "在评论中提到你", + "commented on a page": "在页面上评论", + "resolved a comment": "解决了一个评论", + "mentioned you on a page": "在页面上提到你", + "Today": "今天", + "Yesterday": "昨天", + "This week": "本周", + "Older": "较早" } diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index e0df67a7..438ffde8 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -14,7 +14,6 @@ import AccountPreferences from "@/pages/settings/account/account-preferences.tsx import SpaceHome from "@/pages/space/space-home.tsx"; import PageRedirect from "@/pages/page/page-redirect.tsx"; import Layout from "@/components/layouts/global/layout.tsx"; -import { ErrorBoundary } from "react-error-boundary"; import InviteSignup from "@/pages/auth/invite-signup.tsx"; import ForgotPassword from "@/pages/auth/forgot-password.tsx"; import PasswordReset from "./pages/auth/password-reset"; @@ -84,13 +83,7 @@ export default function App() { } /> {t("Failed to load page. An error occurred.")}} - > - - - } + element={} /> diff --git a/apps/client/src/components/common/copy-button.tsx b/apps/client/src/components/common/copy-button.tsx new file mode 100644 index 00000000..eb0721d7 --- /dev/null +++ b/apps/client/src/components/common/copy-button.tsx @@ -0,0 +1,33 @@ +// Source: https://github.com/mantinedev/mantine/blob/master/packages/@mantine/core/src/components/CopyButton/CopyButton.tsx - MIT +// modified to use the polyfilled clipboard api +import React from "react"; +import { useClipboard } from "@/hooks/use-clipboard"; +import { useProps } from "@mantine/core"; + +interface CopyButtonProps { + /** Children callback, provides current status and copy function as an argument */ + children: (payload: { copied: boolean; copy: () => void }) => React.ReactNode; + + /** Value that is copied to the clipboard when the button is clicked */ + value: string; + + /** Copied status timeout in ms @default `1000` */ + timeout?: number; +} + +const defaultProps = { + timeout: 1000, +} satisfies Partial; + +export function CopyButton(props: CopyButtonProps) { + const { children, timeout, value, ...others } = useProps( + "CopyButton", + defaultProps, + props, + ); + const clipboard = useClipboard({ timeout }); + const copy = () => clipboard.copy(value); + return <>{children({ copy, copied: clipboard.copied, ...others })}; +} + +CopyButton.displayName = "@mantine/core/CopyButton"; diff --git a/apps/client/src/components/common/copy.tsx b/apps/client/src/components/common/copy.tsx index efae5750..81a70771 100644 --- a/apps/client/src/components/common/copy.tsx +++ b/apps/client/src/components/common/copy.tsx @@ -1,4 +1,5 @@ -import { ActionIcon, CopyButton, Tooltip } from "@mantine/core"; +import { ActionIcon, Tooltip } from "@mantine/core"; +import { CopyButton } from "@/components/common/copy-button"; import { IconCheck, IconCopy } from "@tabler/icons-react"; import React from "react"; import { useTranslation } from "react-i18next"; diff --git a/apps/client/src/components/common/recent-changes.tsx b/apps/client/src/components/common/recent-changes.tsx index 81fdc899..b0bdfec7 100644 --- a/apps/client/src/components/common/recent-changes.tsx +++ b/apps/client/src/components/common/recent-changes.tsx @@ -11,7 +11,8 @@ import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx"; import { buildPageUrl } from "@/features/page/page.utils.ts"; import { formattedDate } from "@/lib/time.ts"; import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts"; -import { IconFileDescription } from "@tabler/icons-react"; +import { IconFileDescription, IconFiles } from "@tabler/icons-react"; +import { EmptyState } from "@/components/ui/empty-state.tsx"; import { getSpaceUrl } from "@/lib/config.ts"; import { useTranslation } from "react-i18next"; import { getInitialsColor } from "@/lib/get-initials-color.ts"; @@ -85,8 +86,10 @@ export default function RecentChanges({ spaceId }: Props) { ) : ( - - {t("No pages yet")} - + ); } diff --git a/apps/client/src/components/layouts/global/app-header.tsx b/apps/client/src/components/layouts/global/app-header.tsx index eb1ca74f..58b76b71 100644 --- a/apps/client/src/components/layouts/global/app-header.tsx +++ b/apps/client/src/components/layouts/global/app-header.tsx @@ -22,6 +22,7 @@ import { searchSpotlight, shareSearchSpotlight, } from "@/features/search/constants.ts"; +import { NotificationPopover } from "@/features/notification/components/notification-popover.tsx"; const links = [{ link: APP_ROUTE.HOME, label: "Home" }]; @@ -97,6 +98,7 @@ export function AppHeader() { + {isCloud() && isTrial && trialDaysLeft !== 0 && ( + + + + {title} + + {description && ( + + {description} + + )} + {action} + + + ); +} diff --git a/apps/client/src/ee/ai/components/editor/ai-menu/ai-menu.module.css b/apps/client/src/ee/ai/components/editor/ai-menu/ai-menu.module.css new file mode 100644 index 00000000..a0293f98 --- /dev/null +++ b/apps/client/src/ee/ai/components/editor/ai-menu/ai-menu.module.css @@ -0,0 +1,61 @@ +.aiMenu { + display: flex; + flex-direction: column; + width: 100%; + max-width: 600px; + min-height: 2.25rem; +} + +.aiInput { + width: 100%; + + & input { + height: 44px; + border-radius: 22px; + padding-left: 20px; + padding-right: 40px; + border: 1px solid + light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + font-size: var(--mantine-font-size-sm); + + &:focus { + border-color: light-dark( + var(--mantine-color-gray-4), + var(--mantine-color-dark-3) + ); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); + } + } +} +.menuItemSelected { + background-color: var(--mantine-color-gray-1); + + @mixin dark { + background-color: var(--mantine-color-dark-5); + } +} + +.resultPreview { + background-color: light-dark( + var(--mantine-color-white), + var(--mantine-color-dark-6) + ); + border: 1px solid + light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); + overflow: hidden; +} + +.resultPreviewWrapper { + font-size: var(--mantine-font-size-md); + line-height: 1.6; + padding: var(--mantine-spacing-md); + + *:first-child { + margin-top: 0; + } + *:last-child { + margin-bottom: 0; + } +} diff --git a/apps/client/src/ee/ai/components/editor/ai-menu/ai-menu.tsx b/apps/client/src/ee/ai/components/editor/ai-menu/ai-menu.tsx new file mode 100644 index 00000000..6f7578c2 --- /dev/null +++ b/apps/client/src/ee/ai/components/editor/ai-menu/ai-menu.tsx @@ -0,0 +1,325 @@ +import { Editor } from "@tiptap/react"; +import { ActionIcon, TextInput, Tooltip } from "@mantine/core"; +import { useDebouncedCallback, useMediaQuery } from "@mantine/hooks"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { useAtom } from "jotai"; +import { IconArrowUp } from "@tabler/icons-react"; +import { showAiMenuAtom } from "@/features/editor/atoms/editor-atoms.ts"; +import { useAiGenerateStreamMutation } from "@/ee/ai/queries/ai-query.ts"; +import { AiAction } from "@/ee/ai/types/ai.types.ts"; +import { CommandItem, commandItems, CommandSet } from "./command-items.ts"; +import { CommandSelector } from "./command-selector.tsx"; +import { ResultPreview } from "./result-preview.tsx"; +import classes from "./ai-menu.module.css"; +import { marked } from "marked"; +import { DOMSerializer } from "@tiptap/pm/model"; +import { htmlToMarkdown } from "@docmost/editor-ext"; +import { useLocation } from "react-router-dom"; + +interface EditorAiMenuProps { + editor: Editor | null; +} + +const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => { + const aiGenerateStreamMutation = useAiGenerateStreamMutation(); + const location = useLocation(); + const isSmBreakpoint = useMediaQuery("(max-width: 48em)"); + const [showAiMenu, setShowAiMenu] = useAtom(showAiMenuAtom); + const containerRef = useRef(null); + const inputRef = useRef(null); + const [prompt, setPrompt] = useState(""); + const [output, setOutput] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(-1); + const [activeCommandSet, setActiveCommandSet] = useState("main"); + const [lastAction, setLastAction] = useState(null); + const [menuPlacement, setMenuPlacement] = useState<{ + top: number; + left: number; + width: number; + }>({ + top: 0, + left: 0, + width: 0, + }); + const currentItems = useMemo(() => { + return commandItems[activeCommandSet].filter((item) => { + return item.name.toLowerCase().includes(prompt.toLowerCase()); + }); + }, [prompt, output, activeCommandSet]); + const updateMenuPlacement = useCallback(() => { + if (!editor || !showAiMenu) return; + + const { view } = editor; + const { to } = editor.state.selection; + const editorRect = view.dom.getBoundingClientRect(); + const cursorCoords = view.coordsAtPos(to); + const topOffset = 8; + const editorPadding = isSmBreakpoint ? 16 : 48; + + setMenuPlacement({ + top: cursorCoords.bottom + topOffset + window.scrollY, + left: editorRect.left + editorPadding + window.scrollX, + width: editorRect.width - editorPadding * 2, + }); + }, [editor, showAiMenu, isSmBreakpoint]); + const resetMenu = useCallback(() => { + setPrompt(""); + setOutput(""); + setActiveCommandSet("main"); + setLastAction(null); + aiGenerateStreamMutation.reset(); + }, [aiGenerateStreamMutation.reset]); + const debouncedUpdateMenuPlacement = useDebouncedCallback( + updateMenuPlacement, + 60, + ); + const handleGenerate = useCallback( + (item?: CommandItem) => { + if (!editor || isLoading) return; + + let command: CommandItem | null = item || null; + + if (!command) { + if (!prompt) return; + + command = { + id: "custom", + name: "Custom", + action: AiAction.CUSTOM, + prompt, + }; + } + + const { from, to } = editor.state.selection; + const slice = editor.state.doc.slice(from, to); + const serializer = DOMSerializer.fromSchema(editor.schema); + const fragment = serializer.serializeFragment(slice.content); + const wrapper = document.createElement("div"); + wrapper.appendChild(fragment); + const content = htmlToMarkdown(wrapper.innerHTML); + + setOutput(""); + setIsLoading(true); + aiGenerateStreamMutation.mutate({ + action: command.action, + prompt: command.prompt, + content, + onChunk: (chunk) => { + setOutput((output) => output + chunk.content); + }, + onComplete: () => { + setIsLoading(false); + setActiveCommandSet("result"); + }, + onError: () => { + setIsLoading(false); + resetMenu(); + }, + }); + setLastAction(command); + }, + [ + editor, + prompt, + isLoading, + aiGenerateStreamMutation.mutateAsync, + resetMenu, + ], + ); + const handleCommand = useCallback( + (item?: CommandItem) => { + setPrompt(""); + + if (!item) { + return handleGenerate(); + } + if (item.id === "back") { + return setActiveCommandSet("main"); + } + if (item.id === "result-replace") { + const chain = editor.chain().focus(); + + if (lastAction.action === AiAction.CONTINUE_WRITING) { + chain.setTextSelection(editor.state.selection.to); + } + + const html = (marked.parse(output) as string).trim(); + // Strip

wrapper for single-paragraph output to preserve inline context + const content = + html.startsWith("

") && + html.endsWith("

") && + html.lastIndexOf("

") === 0 + ? html.slice(3, -4) + : html; + + chain.insertContent(content).run(); + + return setShowAiMenu(false); + } + if (item.id === "result-insert-below") { + editor + .chain() + .focus() + .setTextSelection(editor.state.selection.to) + .insertContent(marked.parse(output)) + .run(); + + return setShowAiMenu(false); + } + if (item.id === "result-copy") { + navigator.clipboard.writeText(output); + + return setShowAiMenu(false); + } + if (item.id === "result-discard") { + setOutput(""); + + return resetMenu(); + } + if (item.id === "result-try-again" && lastAction) { + return handleGenerate(lastAction); + } + if (item.subCommandSet) { + return setActiveCommandSet(item.subCommandSet); + } + + return handleGenerate(item); + }, + [editor, output, lastAction, handleGenerate, resetMenu], + ); + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + const totalItems = currentItems.length; + const cycleSize = totalItems + 1; + + if (event.key === "Escape") { + return setShowAiMenu(false); + } + + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + + return setSelectedIndex((selectedIndex) => { + const direction = event.key === "ArrowDown" ? 1 : -1; + const newIndex = selectedIndex + direction; + + if (newIndex < -1) return cycleSize - 1; + if (newIndex >= cycleSize) return 0; + + return newIndex; + }); + } + + if (event.key === "Enter") { + event.preventDefault(); + + return handleCommand(currentItems[selectedIndex]); + } + }, + [currentItems, selectedIndex], + ); + + useEffect(() => { + if (!editor) return; + + const handleClose = () => setShowAiMenu(false); + const observer = new ResizeObserver(() => { + debouncedUpdateMenuPlacement(); + }); + + updateMenuPlacement(); + editor.on("focus", handleClose); + editor.on("blur", handleClose); + window.addEventListener("resize", debouncedUpdateMenuPlacement); + window.addEventListener("scroll", debouncedUpdateMenuPlacement, true); + observer.observe(editor.view.dom); + + return () => { + editor.off("focus", handleClose); + editor.off("blur", handleClose); + window.removeEventListener("resize", debouncedUpdateMenuPlacement); + window.removeEventListener("scroll", debouncedUpdateMenuPlacement, true); + observer.disconnect(); + }; + }, [editor, updateMenuPlacement, debouncedUpdateMenuPlacement]); + + useEffect(() => { + setShowAiMenu(false); + }, [location]); + useEffect(() => { + if (showAiMenu) { + resetMenu(); + } + }, [showAiMenu, resetMenu]); + useEffect(() => { + // Focus input when menu opens or command set changes + requestAnimationFrame(() => { + inputRef.current?.focus({ preventScroll: true }); + }); + }, [showAiMenu, isLoading, currentItems]); + useEffect(() => { + if (!currentItems.length) { + setSelectedIndex(-1); + } + setSelectedIndex(prompt || activeCommandSet !== "main" ? 0 : -1); + }, [prompt, activeCommandSet, currentItems]); + + if (!showAiMenu) return null; + + return createPortal( +

+
+ + + setPrompt(e.currentTarget.value)} + rightSection={ + handleGenerate()} + > + + + } + onKeyDown={handleKeyDown} + /> + +
+
, + document.body, + ); +}; + +export { EditorAiMenu }; diff --git a/apps/client/src/ee/ai/components/editor/ai-menu/command-items.ts b/apps/client/src/ee/ai/components/editor/ai-menu/command-items.ts new file mode 100644 index 00000000..71eaa9cb --- /dev/null +++ b/apps/client/src/ee/ai/components/editor/ai-menu/command-items.ts @@ -0,0 +1,219 @@ +import { AiAction } from "@/ee/ai/types/ai.types.ts"; +import { + IconSparkles, + IconArrowsMaximize, + IconArrowsMinimize, + IconWriting, + IconHelp, + IconList, + IconMoodSmile, + IconLanguage, + IconTrash, + IconRefresh, + IconChevronLeft, + IconCheck, + IconArrowDownLeft, + IconCopy, + IconTextPlus, + IconAlignJustified, +} from "@tabler/icons-react"; + +interface CommandItem { + name: string; + id: string; + icon?: typeof IconSparkles; + action?: AiAction; + prompt?: string; + subCommandSet?: CommandSet; +} + +type CommandSet = "main" | "tone" | "translate" | "result"; + +const mainItems: CommandItem[] = [ + { + id: "improve-writing", + name: "Improve writing", + icon: IconSparkles, + action: AiAction.IMPROVE_WRITING, + }, + { + id: "fix-spelling-grammar", + name: "Fix spelling & grammar", + icon: IconCheck, + action: AiAction.FIX_SPELLING_GRAMMAR, + }, + { + id: "make-longer", + name: "Make longer", + icon: IconTextPlus, + action: AiAction.MAKE_LONGER, + }, + { + id: "make-shorter", + name: "Make shorter", + icon: IconAlignJustified, + action: AiAction.MAKE_SHORTER, + }, + { + id: "continue-writing", + name: "Continue writing", + icon: IconWriting, + action: AiAction.CONTINUE_WRITING, + }, + { + id: "explain", + name: "Explain", + icon: IconHelp, + action: AiAction.EXPLAIN, + }, + { + id: "summarize", + name: "Summarize", + icon: IconList, + action: AiAction.SUMMARIZE, + }, + { + id: "change-tone", + name: "Change tone", + icon: IconMoodSmile, + subCommandSet: "tone", + }, + { + id: "translate", + name: "Translate", + icon: IconLanguage, + subCommandSet: "translate", + }, +]; +const toneItems: CommandItem[] = [ + { + id: "back", + name: "Back", + icon: IconChevronLeft, + }, + { + id: "tone-professional", + name: "Professional", + icon: IconMoodSmile, + action: AiAction.CHANGE_TONE, + prompt: "Professional", + }, + { + id: "tone-casual", + name: "Casual", + icon: IconMoodSmile, + action: AiAction.CHANGE_TONE, + prompt: "Casual", + }, + { + id: "tone-friendly", + name: "Friendly", + icon: IconMoodSmile, + action: AiAction.CHANGE_TONE, + prompt: "Friendly", + }, +]; +const translateItems: CommandItem[] = [ + { + id: "back", + name: "Back", + icon: IconChevronLeft, + }, + { + id: "translate-english", + name: "English", + icon: IconLanguage, + action: AiAction.TRANSLATE, + prompt: "English", + }, + { + id: "translate-spanish", + name: "Spanish", + icon: IconLanguage, + action: AiAction.TRANSLATE, + prompt: "Spanish", + }, + { + id: "translate-german", + name: "German", + icon: IconLanguage, + action: AiAction.TRANSLATE, + prompt: "German", + }, + { + id: "translate-french", + name: "French", + icon: IconLanguage, + action: AiAction.TRANSLATE, + prompt: "French", + }, + { + id: "translate-dutch", + name: "Dutch", + icon: IconLanguage, + action: AiAction.TRANSLATE, + prompt: "Dutch", + }, + { + id: "translate-portuguese", + name: "Portuguese", + icon: IconLanguage, + action: AiAction.TRANSLATE, + prompt: "Portuguese", + }, + { + id: "translate-italian", + name: "Italian", + icon: IconLanguage, + action: AiAction.TRANSLATE, + prompt: "Italian", + }, + { + id: "translate-japanese", + name: "Japanese", + icon: IconLanguage, + action: AiAction.TRANSLATE, + prompt: "Japanese", + }, + { + id: "translate-korean", + name: "Korean", + icon: IconLanguage, + action: AiAction.TRANSLATE, + prompt: "Korean", + }, + { + id: "translate-swedish", + name: "Swedish", + icon: IconLanguage, + action: AiAction.TRANSLATE, + prompt: "Swedish", + }, + { + id: "translate-chinese", + name: "Chinese (Simplified)", + icon: IconLanguage, + action: AiAction.TRANSLATE, + prompt: "Simplified Chinese", + }, +]; +const resultItems: CommandItem[] = [ + { id: "result-replace", name: "Replace", icon: IconCheck }, + { id: "result-insert-below", name: "Insert below", icon: IconArrowDownLeft }, + { id: "result-copy", name: "Copy", icon: IconCopy }, + { id: "result-discard", name: "Discard", icon: IconTrash }, + { + id: "result-try-again", + name: "Try again", + icon: IconRefresh, + }, +]; +const commandItems: Record = { + main: mainItems, + tone: toneItems, + translate: translateItems, + result: resultItems, +}; + +export type { CommandItem, CommandSet }; +export { commandItems }; diff --git a/apps/client/src/ee/ai/components/editor/ai-menu/command-selector.tsx b/apps/client/src/ee/ai/components/editor/ai-menu/command-selector.tsx new file mode 100644 index 00000000..8e66bee0 --- /dev/null +++ b/apps/client/src/ee/ai/components/editor/ai-menu/command-selector.tsx @@ -0,0 +1,72 @@ +import { Loader, Menu, ScrollArea } from "@mantine/core"; +import { IconChevronRight } from "@tabler/icons-react"; +import { ReactNode } from "react"; +import { CommandItem } from "./command-items.ts"; +import classes from "./ai-menu.module.css"; + +interface CommandSelectorProps { + selectedIndex: number; + + isLoading: boolean; + output: string; + currentItems: CommandItem[]; + children: ReactNode; + handleCommand(item: CommandItem): void; +} + +const CommandSelector = ({ + selectedIndex, + children, + isLoading, + output, + currentItems, + handleCommand, +}: CommandSelectorProps) => { + return ( + 0} + middlewares={{ flip: false }} + position="bottom-start" + offset={4} + width={250} + trapFocus={false} + shadow="lg" + > + {children} + + + {currentItems.map((item, index) => { + const isSelected = selectedIndex === index; + const showLoader = + isLoading && output === "" && !item.subCommandSet; + + return ( + + ) : item.icon ? ( + + ) : undefined + } + rightSection={ + item.subCommandSet ? ( + + ) : undefined + } + onClick={() => handleCommand(item)} + disabled={isLoading} + > + {item.name} + + ); + })} + + + + ); +}; + +export { CommandSelector }; diff --git a/apps/client/src/ee/ai/components/editor/ai-menu/result-preview.tsx b/apps/client/src/ee/ai/components/editor/ai-menu/result-preview.tsx new file mode 100644 index 00000000..d34682e3 --- /dev/null +++ b/apps/client/src/ee/ai/components/editor/ai-menu/result-preview.tsx @@ -0,0 +1,32 @@ +import { Loader, Paper, ScrollArea } from "@mantine/core"; +import DOMPurify from "dompurify"; +import { marked } from "marked"; +import { memo } from "react"; +import classes from "./ai-menu.module.css"; + +interface ResultPreviewProps { + output: string; + isLoading: boolean; +} +const ResultPreview = memo(({ output, isLoading }: ResultPreviewProps) => { + if (!output && !isLoading) return; + + const parsedOutput = `${marked.parse(output)}`; + + return ( + + +
+ {parsedOutput && ( +
+ )} + {isLoading && } +
+ + + ); +}); + +export { ResultPreview }; diff --git a/apps/client/src/ee/ai/components/enable-ai-search.tsx b/apps/client/src/ee/ai/components/enable-ai-search.tsx index 53b0a9bd..91242804 100644 --- a/apps/client/src/ee/ai/components/enable-ai-search.tsx +++ b/apps/client/src/ee/ai/components/enable-ai-search.tsx @@ -15,7 +15,7 @@ export default function EnableAiSearch() { <>
- {t("AI-powered search (Ask AI)")} + {t("AI-powered search (AI Answers)")} {t( "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.", diff --git a/apps/client/src/ee/ai/components/enable-generative-ai.tsx b/apps/client/src/ee/ai/components/enable-generative-ai.tsx new file mode 100644 index 00000000..9e09f4f0 --- /dev/null +++ b/apps/client/src/ee/ai/components/enable-generative-ai.tsx @@ -0,0 +1,48 @@ +import { Group, Text, Switch } from "@mantine/core"; +import { useAtom } from "jotai"; +import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; +import { notifications } from "@mantine/notifications"; +import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx"; + +export default function EnableGenerativeAi() { + const { t } = useTranslation(); + const [workspace, setWorkspace] = useAtom(workspaceAtom); + const [checked, setChecked] = useState(workspace?.settings?.ai?.generative); + const hasAccess = useIsCloudEE(); + + const handleChange = async (event: React.ChangeEvent) => { + const value = event.currentTarget.checked; + try { + const updatedWorkspace = await updateWorkspace({ generativeAi: value }); + setChecked(value); + setWorkspace(updatedWorkspace); + } catch (err) { + notifications.show({ + message: err?.response?.data?.message, + color: "red", + }); + } + }; + + return ( + +
+ {t("Generative AI (Ask AI)")} + + {t( + "Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.", + )} + +
+ + +
+ ); +} diff --git a/apps/client/src/ee/ai/hooks/use-ai-search.ts b/apps/client/src/ee/ai/hooks/use-ai-search.ts index f9c5aa88..03b24424 100644 --- a/apps/client/src/ee/ai/hooks/use-ai-search.ts +++ b/apps/client/src/ee/ai/hooks/use-ai-search.ts @@ -1,6 +1,6 @@ import { useMutation, UseMutationResult } from "@tanstack/react-query"; import { useState, useCallback } from "react"; -import { askAi, IAiSearchResponse } from "@/ee/ai/services/ai-search-service.ts"; +import { aiAnswers, IAiSearchResponse } from "@/ee/ai/services/ai-search-service.ts"; import { IPageSearchParams } from "@/features/search/types/search.types.ts"; // @ts-ignore @@ -26,7 +26,7 @@ export function useAiSearch(): UseAiSearchResult { const { contentType, ...apiParams } = params; - return await askAi(apiParams, (chunk) => { + return await aiAnswers(apiParams, (chunk) => { if (chunk.content) { setStreamingAnswer((prev) => prev + chunk.content); } diff --git a/apps/client/src/ee/ai/pages/ai-settings.tsx b/apps/client/src/ee/ai/pages/ai-settings.tsx index b9ab516d..441f91b9 100644 --- a/apps/client/src/ee/ai/pages/ai-settings.tsx +++ b/apps/client/src/ee/ai/pages/ai-settings.tsx @@ -1,25 +1,25 @@ import { Helmet } from "react-helmet-async"; -import { getAppName, isCloud } from "@/lib/config.ts"; +import { getAppName } from "@/lib/config.ts"; import SettingsTitle from "@/components/settings/settings-title.tsx"; import React from "react"; import useUserRole from "@/hooks/use-user-role.tsx"; import { useTranslation } from "react-i18next"; -import useLicense from "@/ee/hooks/use-license.tsx"; import EnableAiSearch from "@/ee/ai/components/enable-ai-search.tsx"; -import { Alert } from "@mantine/core"; +import EnableGenerativeAi from "@/ee/ai/components/enable-generative-ai.tsx"; +import { Alert, Stack } from "@mantine/core"; import { IconInfoCircle } from "@tabler/icons-react"; +import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx"; +import { isCloud } from "@/lib/config.ts"; export default function AiSettings() { const { t } = useTranslation(); const { isAdmin } = useUserRole(); - const { hasLicenseKey } = useLicense(); + const hasAccess = useIsCloudEE(); if (!isAdmin) { return null; } - const hasAccess = isCloud() || (!isCloud() && hasLicenseKey); - return ( <> @@ -40,7 +40,10 @@ export default function AiSettings() { )} - + + {!isCloud() && } + + ); } diff --git a/apps/client/src/ee/ai/services/ai-search-service.ts b/apps/client/src/ee/ai/services/ai-search-service.ts index 759a104a..8c2af64a 100644 --- a/apps/client/src/ee/ai/services/ai-search-service.ts +++ b/apps/client/src/ee/ai/services/ai-search-service.ts @@ -15,11 +15,11 @@ export interface IAiSearchResponse { }>; } -export async function askAi( +export async function aiAnswers( params: IPageSearchParams, onChunk?: (chunk: { content?: string; sources?: any[] }) => void, ): Promise { - const response = await fetch("/api/ai/ask", { + const response = await fetch("/api/ai/answers", { method: "POST", headers: { "Content-Type": "application/json", diff --git a/apps/client/src/ee/ai/services/ai-service.ts b/apps/client/src/ee/ai/services/ai-service.ts index f3634d59..88557ff1 100644 --- a/apps/client/src/ee/ai/services/ai-service.ts +++ b/apps/client/src/ee/ai/services/ai-service.ts @@ -43,13 +43,16 @@ export async function generateAiContentStream( } const processStream = async () => { + let buffer = ""; try { while (true) { const { done, value } = await reader.read(); if (done) break; - const chunk = decoder.decode(value, { stream: true }); - const lines = chunk.split("\n"); + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + + buffer = lines.pop() || ""; for (const line of lines) { if (line.startsWith("data: ")) { @@ -66,7 +69,7 @@ export async function generateAiContentStream( onChunk(parsed); } } catch (e) { - // Ignore parse errors for incomplete chunks + // Skip invalid JSON } } } diff --git a/apps/client/src/ee/ai/types/ai.types.ts b/apps/client/src/ee/ai/types/ai.types.ts index a5fbc253..54778563 100644 --- a/apps/client/src/ee/ai/types/ai.types.ts +++ b/apps/client/src/ee/ai/types/ai.types.ts @@ -6,6 +6,7 @@ export enum AiAction { SIMPLIFY = "simplify", CHANGE_TONE = "change_tone", SUMMARIZE = "summarize", + EXPLAIN = "explain", CONTINUE_WRITING = "continue_writing", TRANSLATE = "translate", CUSTOM = "custom", diff --git a/apps/client/src/ee/components/ldap-login-modal.tsx b/apps/client/src/ee/components/ldap-login-modal.tsx index 9360651d..0a456946 100644 --- a/apps/client/src/ee/components/ldap-login-modal.tsx +++ b/apps/client/src/ee/components/ldap-login-modal.tsx @@ -7,7 +7,7 @@ import { notifications } from "@mantine/notifications"; import { useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { IAuthProvider } from "@/ee/security/types/security.types"; -import APP_ROUTE from "@/lib/app-route"; +import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route"; import { ldapLogin } from "@/ee/security/services/ldap-auth-service"; const formSchema = z.object({ @@ -59,13 +59,13 @@ export function LdapLoginModal({ // Handle MFA like the regular login if (response?.userHasMfa) { onClose(); - navigate(APP_ROUTE.AUTH.MFA_CHALLENGE); + navigate(APP_ROUTE.AUTH.MFA_CHALLENGE + window.location.search); } else if (response?.requiresMfaSetup) { onClose(); - navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED); + navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED + window.location.search); } else { onClose(); - navigate(APP_ROUTE.HOME); + navigate(getPostLoginRedirect()); } } catch (err: any) { setIsLoading(false); diff --git a/apps/client/src/ee/hooks/use-enterprise-access.tsx b/apps/client/src/ee/hooks/use-enterprise-access.tsx new file mode 100644 index 00000000..b7746d6f --- /dev/null +++ b/apps/client/src/ee/hooks/use-enterprise-access.tsx @@ -0,0 +1,12 @@ +import { isCloud } from "@/lib/config"; +import useLicense from "@/ee/hooks/use-license"; +import usePlan from "@/ee/hooks/use-plan"; + +const useEnterpriseAccess = () => { + const { hasLicenseKey } = useLicense(); + const { isBusiness } = usePlan(); + + return (isCloud() && isBusiness) || (!isCloud() && hasLicenseKey); +}; + +export default useEnterpriseAccess; diff --git a/apps/client/src/ee/mfa/components/mfa-backup-codes-modal.tsx b/apps/client/src/ee/mfa/components/mfa-backup-codes-modal.tsx index c24638fe..6b439ef5 100644 --- a/apps/client/src/ee/mfa/components/mfa-backup-codes-modal.tsx +++ b/apps/client/src/ee/mfa/components/mfa-backup-codes-modal.tsx @@ -8,10 +8,10 @@ import { Group, List, Code, - CopyButton, Alert, PasswordInput, } from "@mantine/core"; +import { CopyButton } from "@/components/common/copy-button"; import { IconRefresh, IconCopy, diff --git a/apps/client/src/ee/mfa/components/mfa-challenge.tsx b/apps/client/src/ee/mfa/components/mfa-challenge.tsx index 8a9bef53..413494ef 100644 --- a/apps/client/src/ee/mfa/components/mfa-challenge.tsx +++ b/apps/client/src/ee/mfa/components/mfa-challenge.tsx @@ -18,7 +18,7 @@ import { useNavigate } from "react-router-dom"; import { notifications } from "@mantine/notifications"; import classes from "./mfa-challenge.module.css"; import { verifyMfa } from "@/ee/mfa"; -import APP_ROUTE from "@/lib/app-route"; +import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route"; import { useTranslation } from "react-i18next"; import * as z from "zod"; import { MfaBackupCodeInput } from "./mfa-backup-code-input"; @@ -53,7 +53,7 @@ export function MfaChallenge() { setIsLoading(true); try { await verifyMfa(values.code); - navigate(APP_ROUTE.HOME); + navigate(getPostLoginRedirect()); } catch (error: any) { setIsLoading(false); notifications.show({ diff --git a/apps/client/src/ee/mfa/components/mfa-setup-modal.tsx b/apps/client/src/ee/mfa/components/mfa-setup-modal.tsx index d01f2c9f..89d479d7 100644 --- a/apps/client/src/ee/mfa/components/mfa-setup-modal.tsx +++ b/apps/client/src/ee/mfa/components/mfa-setup-modal.tsx @@ -11,7 +11,6 @@ import { PinInput, Alert, List, - CopyButton, ActionIcon, Tooltip, Paper, @@ -20,6 +19,7 @@ import { Collapse, UnstyledButton, } from "@mantine/core"; +import { CopyButton } from "@/components/common/copy-button"; import { IconQrcode, IconShieldCheck, diff --git a/apps/client/src/ee/mfa/components/mfa-setup-required.tsx b/apps/client/src/ee/mfa/components/mfa-setup-required.tsx index c657abe9..ab327c4d 100644 --- a/apps/client/src/ee/mfa/components/mfa-setup-required.tsx +++ b/apps/client/src/ee/mfa/components/mfa-setup-required.tsx @@ -3,7 +3,7 @@ import { Container, Paper, Title, Text, Alert, Stack } from "@mantine/core"; import { IconAlertCircle } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { MfaSetupModal } from "@/ee/mfa"; -import APP_ROUTE from "@/lib/app-route.ts"; +import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts"; import { useNavigate } from "react-router-dom"; export default function MfaSetupRequired() { @@ -11,7 +11,7 @@ export default function MfaSetupRequired() { const navigate = useNavigate(); const handleSetupComplete = () => { - navigate(APP_ROUTE.HOME); + navigate(getPostLoginRedirect()); }; return ( diff --git a/apps/client/src/ee/mfa/hooks/use-mfa-page-protection.ts b/apps/client/src/ee/mfa/hooks/use-mfa-page-protection.ts index 9200cac7..30b27427 100644 --- a/apps/client/src/ee/mfa/hooks/use-mfa-page-protection.ts +++ b/apps/client/src/ee/mfa/hooks/use-mfa-page-protection.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { useNavigate, useLocation } from "react-router-dom"; -import APP_ROUTE from "@/lib/app-route"; +import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route"; import { validateMfaAccess } from "@/ee/mfa"; export function useMfaPageProtection() { @@ -13,8 +13,10 @@ export function useMfaPageProtection() { const checkAccess = async () => { const result = await validateMfaAccess(); + const search = location.search; + if (!result.valid) { - navigate(APP_ROUTE.AUTH.LOGIN); + navigate(APP_ROUTE.AUTH.LOGIN + search); return; } @@ -26,17 +28,17 @@ export function useMfaPageProtection() { if (result.requiresMfaSetup && !isOnSetupPage) { // User needs to set up MFA but is on challenge page - navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED); + navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED + search); } else if ( !result.requiresMfaSetup && result.userHasMfa && !isOnChallengePage ) { // User has MFA and should be on challenge page - navigate(APP_ROUTE.AUTH.MFA_CHALLENGE); + navigate(APP_ROUTE.AUTH.MFA_CHALLENGE + search); } else if (!result.isTransferToken) { // User has a regular auth token, shouldn't be on MFA pages - navigate(APP_ROUTE.HOME); + navigate(getPostLoginRedirect()); } else { setIsValid(true); } diff --git a/apps/client/src/ee/security/components/disable-public-sharing.tsx b/apps/client/src/ee/security/components/disable-public-sharing.tsx new file mode 100644 index 00000000..a5d9f34c --- /dev/null +++ b/apps/client/src/ee/security/components/disable-public-sharing.tsx @@ -0,0 +1,88 @@ +import { Group, Text, Switch, Tooltip } from "@mantine/core"; +import { modals } from "@mantine/modals"; +import { useAtom } from "jotai"; +import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; +import { notifications } from "@mantine/notifications"; +import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx"; + +export default function DisablePublicSharing() { + const { t } = useTranslation(); + + return ( + +
+ {t("Disable public sharing")} + + {t("Prevent members from sharing pages publicly.")} + +
+ + +
+ ); +} + +function DisablePublicSharingToggle() { + const { t } = useTranslation(); + const [workspace, setWorkspace] = useAtom(workspaceAtom); + const [checked, setChecked] = useState( + workspace?.settings?.sharing?.disabled === true, + ); + const hasAccess = useEnterpriseAccess(); + + const applyChange = async (value: boolean) => { + try { + const updatedWorkspace = await updateWorkspace({ + disablePublicSharing: value, + }); + setChecked(value); + setWorkspace(updatedWorkspace); + } catch (err) { + notifications.show({ + message: err?.response?.data?.message, + color: "red", + }); + } + }; + + const handleChange = (event: React.ChangeEvent) => { + const value = event.currentTarget.checked; + + modals.openConfirmModal({ + title: value ? t("Disable public sharing") : t("Enable public sharing"), + children: ( + + {value + ? t( + "Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.", + ) + : t( + "Are you sure you want to enable public sharing? Members will be able to share pages publicly.", + )} + + ), + centered: true, + labels: { confirm: t("Confirm"), cancel: t("Cancel") }, + confirmProps: value ? { color: "red" } : {}, + onConfirm: () => applyChange(value), + }); + }; + + return ( + + + + ); +} diff --git a/apps/client/src/ee/security/components/enforce-mfa.tsx b/apps/client/src/ee/security/components/enforce-mfa.tsx index 37cf5152..b716e200 100644 --- a/apps/client/src/ee/security/components/enforce-mfa.tsx +++ b/apps/client/src/ee/security/components/enforce-mfa.tsx @@ -10,23 +10,18 @@ export default function EnforceMfa() { const { t } = useTranslation(); return ( - <> - - MFA - - -
- {t("Enforce two-factor authentication")} - - {t( - "Once enforced, all members must enable two-factor authentication to access the workspace.", - )} - -
+ +
+ {t("Enforce two-factor authentication")} + + {t( + "Once enforced, all members must enable two-factor authentication to access the workspace.", + )} + +
- -
- + +
); } diff --git a/apps/client/src/ee/security/components/space-public-sharing-toggle.tsx b/apps/client/src/ee/security/components/space-public-sharing-toggle.tsx new file mode 100644 index 00000000..f03d18f2 --- /dev/null +++ b/apps/client/src/ee/security/components/space-public-sharing-toggle.tsx @@ -0,0 +1,84 @@ +import { Group, Text, Switch, Tooltip } from "@mantine/core"; +import { modals } from "@mantine/modals"; +import { useAtom } from "jotai"; +import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ISpace } from "@/features/space/types/space.types.ts"; +import { useUpdateSpaceMutation } from "@/features/space/queries/space-query.ts"; + +type SpacePublicSharingToggleProps = { + space: ISpace; +}; + +export default function SpacePublicSharingToggle({ + space, +}: SpacePublicSharingToggleProps) { + const { t } = useTranslation(); + const [workspace] = useAtom(workspaceAtom); + const workspaceDisabled = workspace?.settings?.sharing?.disabled === true; + const [checked, setChecked] = useState( + space.settings?.sharing?.disabled === true, + ); + const updateSpaceMutation = useUpdateSpaceMutation(); + + const applyChange = async (value: boolean) => { + try { + await updateSpaceMutation.mutateAsync({ + spaceId: space.id, + disablePublicSharing: value, + }); + setChecked(value); + } catch { + // error handled by mutation + } + }; + + const handleChange = (event: React.ChangeEvent) => { + const value = event.currentTarget.checked; + + modals.openConfirmModal({ + title: value ? t("Disable public sharing") : t("Enable public sharing"), + children: ( + + {value + ? t( + "Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.", + ) + : t( + "Are you sure you want to enable public sharing for this space?", + )} + + ), + centered: true, + labels: { confirm: t("Confirm"), cancel: t("Cancel") }, + confirmProps: value ? { color: "red" } : {}, + onConfirm: () => applyChange(value), + }); + }; + + return ( + +
+ {t("Disable public sharing")} + + {workspaceDisabled + ? t("Public sharing is disabled at the workspace level") + : t("Prevent pages in this space from being shared publicly.")} + +
+ + + +
+ ); +} diff --git a/apps/client/src/ee/security/pages/security.tsx b/apps/client/src/ee/security/pages/security.tsx index 82d8640f..a32c5867 100644 --- a/apps/client/src/ee/security/pages/security.tsx +++ b/apps/client/src/ee/security/pages/security.tsx @@ -9,15 +9,16 @@ import CreateSsoProvider from "@/ee/security/components/create-sso-provider.tsx" import EnforceSso from "@/ee/security/components/enforce-sso.tsx"; import AllowedDomains from "@/ee/security/components/allowed-domains.tsx"; import { useTranslation } from "react-i18next"; -import useLicense from "@/ee/hooks/use-license.tsx"; -import usePlan from "@/ee/hooks/use-plan.tsx"; import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx"; +import DisablePublicSharing from "@/ee/security/components/disable-public-sharing.tsx"; +import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx"; +import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx"; export default function Security() { const { t } = useTranslation(); const { isAdmin } = useUserRole(); - const { hasLicenseKey } = useLicense(); - const { isBusiness } = usePlan(); + const hasEnterpriseAccess = useEnterpriseAccess(); + const isCloudEE = useIsCloudEE(); if (!isAdmin) { return null; @@ -30,26 +31,41 @@ export default function Security() {
- - - - + {(!isCloud() || hasEnterpriseAccess) && ( + <> + + + + )} + Single sign-on (SSO) - {(isCloud() && isBusiness) || (!isCloud() && hasLicenseKey) ? ( + {hasEnterpriseAccess && ( <> + + )} + + {isCloudEE && ( + <> + + + + )} + + {hasEnterpriseAccess && ( + <> - ) : null} + )} diff --git a/apps/client/src/features/attachments/services/attachment-service.ts b/apps/client/src/features/attachments/services/attachment-service.ts index 9550e06d..fa43da3c 100644 --- a/apps/client/src/features/attachments/services/attachment-service.ts +++ b/apps/client/src/features/attachments/services/attachment-service.ts @@ -1,20 +1,62 @@ import api from "@/lib/api-client"; +import loadImage from "blueimp-load-image"; import { AvatarIconType, IAttachment, } from "@/features/attachments/types/attachment.types.ts"; +async function compressAndResizeIcon( + file: File, + type: AvatarIconType, +): Promise { + const isPng = file.type === "image/png"; + + const { image: canvas } = await loadImage(file, { + maxWidth: 300, + maxHeight: 300, + canvas: true, + orientation: true, + imageSmoothingQuality: "high", + }); + + if (type === AvatarIconType.AVATAR || !isPng) { + const ctx = (canvas as HTMLCanvasElement).getContext("2d")!; + ctx.globalCompositeOperation = "destination-over"; + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.globalCompositeOperation = "source-over"; + } + + const outputType = isPng ? "image/png" : "image/jpeg"; + + return new Promise((resolve, reject) => { + (canvas as HTMLCanvasElement).toBlob( + (blob) => { + if (!blob) { + reject(new Error("Failed to compress image")); + return; + } + resolve(new File([blob], file.name, { type: outputType })); + }, + outputType, + isPng ? undefined : 0.85, + ); + }); +} + export async function uploadIcon( file: File, type: AvatarIconType, spaceId?: string, ): Promise { + const processed = await compressAndResizeIcon(file, type); + const formData = new FormData(); formData.append("type", type); if (spaceId) { formData.append("spaceId", spaceId); } - formData.append("image", file); + formData.append("image", processed); return await api.post("/attachments/upload-image", formData, { headers: { diff --git a/apps/client/src/features/auth/hooks/use-auth.ts b/apps/client/src/features/auth/hooks/use-auth.ts index decb393f..6e1b4e34 100644 --- a/apps/client/src/features/auth/hooks/use-auth.ts +++ b/apps/client/src/features/auth/hooks/use-auth.ts @@ -23,7 +23,7 @@ import { acceptInvitation, createWorkspace, } from "@/features/workspace/services/workspace-service.ts"; -import APP_ROUTE from "@/lib/app-route.ts"; +import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts"; import { RESET } from "jotai/utils"; import { useTranslation } from "react-i18next"; import { isCloud } from "@/lib/config.ts"; @@ -44,11 +44,11 @@ export default function useAuth() { // Check if MFA is required if (response?.userHasMfa) { - navigate(APP_ROUTE.AUTH.MFA_CHALLENGE); + navigate(APP_ROUTE.AUTH.MFA_CHALLENGE + window.location.search); } else if (response?.requiresMfaSetup) { - navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED); + navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED + window.location.search); } else { - navigate(APP_ROUTE.HOME); + navigate(getPostLoginRedirect()); } } catch (err) { setIsLoading(false); diff --git a/apps/client/src/features/auth/hooks/use-redirect-if-authenticated.ts b/apps/client/src/features/auth/hooks/use-redirect-if-authenticated.ts index 8961ea93..10c76bd3 100644 --- a/apps/client/src/features/auth/hooks/use-redirect-if-authenticated.ts +++ b/apps/client/src/features/auth/hooks/use-redirect-if-authenticated.ts @@ -1,6 +1,6 @@ import { useEffect } from "react"; import useCurrentUser from "@/features/user/hooks/use-current-user.ts"; -import APP_ROUTE from "@/lib/app-route.ts"; +import { getPostLoginRedirect } from "@/lib/app-route.ts"; import { useNavigate } from "react-router-dom"; export function useRedirectIfAuthenticated() { @@ -9,7 +9,7 @@ export function useRedirectIfAuthenticated() { useEffect(() => { if (data && data?.user) { - navigate(APP_ROUTE.HOME); + navigate(getPostLoginRedirect()); } }, [isLoading, data]); } diff --git a/apps/client/src/features/comment/components/comment-dialog.tsx b/apps/client/src/features/comment/components/comment-dialog.tsx index 7a4b55aa..7b0bb7bf 100644 --- a/apps/client/src/features/comment/components/comment-dialog.tsx +++ b/apps/client/src/features/comment/components/comment-dialog.tsx @@ -31,6 +31,7 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) { const [currentUser] = useAtom(currentUserAtom); const [, setAsideState] = useAtom(asideStateAtom); const useClickOutsideRef = useClickOutside(() => { + if (document.querySelector("#mention")) return; handleDialogClose(); }); const createCommentMutation = useCreateCommentMutation(); @@ -105,6 +106,7 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) { position={{ bottom: 500, right: 50 }} withCloseButton withBorder + data-comment-dialog > diff --git a/apps/client/src/features/comment/components/comment-editor.tsx b/apps/client/src/features/comment/components/comment-editor.tsx index a0489cdc..067391f4 100644 --- a/apps/client/src/features/comment/components/comment-editor.tsx +++ b/apps/client/src/features/comment/components/comment-editor.tsx @@ -1,14 +1,15 @@ -import { EditorContent, useEditor } from "@tiptap/react"; +import { EditorContent, ReactNodeViewRenderer, useEditor } from "@tiptap/react"; import { Placeholder } from "@tiptap/extension-placeholder"; -import { Underline } from "@tiptap/extension-underline"; -import { Link } from "@tiptap/extension-link"; import { StarterKit } from "@tiptap/starter-kit"; +import { Mention, LinkExtension } from "@docmost/editor-ext"; import classes from "./comment.module.css"; import { useFocusWithin } from "@mantine/hooks"; import clsx from "clsx"; import { forwardRef, useEffect, useImperativeHandle } from "react"; import { useTranslation } from "react-i18next"; import EmojiCommand from "@/features/editor/extensions/emoji-command"; +import mentionRenderItems from "@/features/editor/components/mention/mention-suggestion"; +import MentionView from "@/features/editor/components/mention/mention-view"; interface CommentEditorProps { defaultContent?: any; @@ -39,13 +40,29 @@ const CommentEditor = forwardRef( StarterKit.configure({ gapcursor: false, dropcursor: false, + link: false, }), Placeholder.configure({ placeholder: placeholder || t("Reply..."), }), - Underline, - Link, + LinkExtension, EmojiCommand, + Mention.configure({ + suggestion: { + allowSpaces: true, + items: () => [], + // @ts-ignore + render: mentionRenderItems, + }, + HTMLAttributes: { + class: "mention", + }, + }).extend({ + addNodeView() { + this.editor.isInitialized = true; + return ReactNodeViewRenderer(MentionView); + }, + }), ], editorProps: { handleDOMEvents: { @@ -60,7 +77,8 @@ const CommentEditor = forwardRef( ].includes(event.key) ) { const emojiCommand = document.querySelector("#emoji-command"); - if (emojiCommand) { + const mentionPopup = document.querySelector("#mention"); + if (emojiCommand || mentionPopup) { return true; } } @@ -84,9 +102,14 @@ const CommentEditor = forwardRef( autofocus: (autofocus && "end") || false, }); + // Sync content from props for read-only editors (e.g. when updated via + // websocket on another browser). Skip for editable editors to avoid + // resetting the cursor position on every keystroke. useEffect(() => { - commentEditor.commands.setContent(defaultContent); - }, [defaultContent]); + if (!editable && commentEditor && defaultContent) { + commentEditor.commands.setContent(defaultContent); + } + }, [defaultContent, editable, commentEditor]); useEffect(() => { setTimeout(() => { @@ -103,7 +126,11 @@ const CommentEditor = forwardRef( })); return ( -
+
(comment.content); + const editContentRef = useRef(null); const updateCommentMutation = useUpdateCommentMutation(); const deleteCommentMutation = useDeleteCommentMutation(comment.pageId); const resolveCommentMutation = useResolveCommentMutation(); @@ -56,9 +57,13 @@ function CommentListItem({ setIsLoading(true); const commentToUpdate = { commentId: comment.id, - content: JSON.stringify(content), + content: JSON.stringify(editContentRef.current ?? content), }; await updateCommentMutation.mutateAsync(commentToUpdate); + if (editContentRef.current) { + setContent(editContentRef.current); + editContentRef.current = null; + } setIsEditing(false); emit({ @@ -128,6 +133,7 @@ function CommentListItem({ setIsEditing(true); } function cancelEdit() { + editContentRef.current = null; setIsEditing(false); } @@ -194,7 +200,7 @@ function CommentListItem({ setContent(newContent)} + onUpdate={(newContent: any) => { editContentRef.current = newContent; }} onSave={handleUpdateComment} autofocus={true} /> diff --git a/apps/client/src/features/comment/components/comment.module.css b/apps/client/src/features/comment/components/comment.module.css index fd4ac5be..59032499 100644 --- a/apps/client/src/features/comment/components/comment.module.css +++ b/apps/client/src/features/comment/components/comment.module.css @@ -32,11 +32,14 @@ max-width: 100%; white-space: pre-wrap; word-break: break-word; - max-height: 20vh; padding-left: 6px; padding-right: 6px; margin-top: 10px; margin-bottom: 2px; + } + + &[data-editable] .ProseMirror :global(.ProseMirror){ + max-height: 50vh; overflow: hidden auto; } diff --git a/apps/client/src/features/editor/atoms/editor-atoms.ts b/apps/client/src/features/editor/atoms/editor-atoms.ts index d4f133f7..25d9332b 100644 --- a/apps/client/src/features/editor/atoms/editor-atoms.ts +++ b/apps/client/src/features/editor/atoms/editor-atoms.ts @@ -8,3 +8,5 @@ export const titleEditorAtom = atom(null); export const readOnlyEditorAtom = atom(null); export const yjsConnectionStatusAtom = atom(""); + +export const showAiMenuAtom = atom(false); diff --git a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.module.css b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.module.css index e43c1714..facaf7ff 100644 --- a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.module.css +++ b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.module.css @@ -1,11 +1,53 @@ .bubbleMenu { display: flex; + flex-wrap: nowrap; + overflow-x: auto; + max-width: 100vw; width: fit-content; - border-radius: 2px; + box-shadow: 0 4px 12px light-dark(#cfcfcf, #0f0f0f); + border-radius: 6px; border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-gray-8)); + background-color: light-dark( + var(--mantine-color-white), + var(--mantine-color-dark-6) + ); + + > * { + flex-shrink: 0; + } + + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } .active { color: light-dark(var(--mantine-color-blue-8), var(--mantine-color-gray-5)); } } + +.buttonRoot { + height: 34px; + padding-left: rem(8); + padding-right: rem(4); + border: none; + border-radius: 6px; +} + +.buttonSeparator { + border-right: 1px solid + light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)) !important; +} + +.divider { + width: 1px; + height: 16px; + align-self: center; + margin: 0 4px; + background-color: light-dark( + var(--mantine-color-gray-3), + var(--mantine-color-dark-3) + ); +} diff --git a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx index a6d143ff..a5d3f8a5 100644 --- a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx +++ b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx @@ -9,10 +9,11 @@ import { IconStrikethrough, IconUnderline, IconMessage, + IconSparkles, } from "@tabler/icons-react"; import clsx from "clsx"; import classes from "./bubble-menu.module.css"; -import { ActionIcon, rem, Tooltip } from "@mantine/core"; +import { ActionIcon, Button, rem, Tooltip } from "@mantine/core"; import { ColorSelector } from "./color-selector"; import { NodeSelector } from "./node-selector"; import { TextAlignmentSelector } from "./text-alignment-selector"; @@ -20,11 +21,13 @@ import { draftCommentIdAtom, showCommentPopupAtom, } from "@/features/comment/atoms/comment-atom"; -import { useAtom } from "jotai"; +import { useAtom, useAtomValue } from "jotai"; import { v7 as uuid7 } from "uuid"; import { isCellSelection, isTextSelected } from "@docmost/editor-ext"; import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx"; import { useTranslation } from "react-i18next"; +import { showAiMenuAtom } from "@/features/editor/atoms/editor-atoms"; +import { workspaceAtom } from "@/features/user/atoms/current-user-atom"; export interface BubbleMenuItem { name: string; @@ -39,14 +42,22 @@ type EditorBubbleMenuProps = Omit & { export const EditorBubbleMenu: FC = (props) => { const { t } = useTranslation(); + const [showAiMenu, setShowAiMenu] = useAtom(showAiMenuAtom); const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom); + const workspace = useAtomValue(workspaceAtom); + const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true; const [, setDraftCommentId] = useAtom(draftCommentIdAtom); const showCommentPopupRef = useRef(showCommentPopup); + const showAiMenuRef = useRef(showAiMenu); useEffect(() => { showCommentPopupRef.current = showCommentPopup; }, [showCommentPopup]); + useEffect(() => { + showAiMenuRef.current = showAiMenu; + }, [showAiMenu]); + const editorState = useEditorState({ editor: props.editor, selector: (ctx) => { @@ -123,6 +134,7 @@ export const EditorBubbleMenu: FC = (props) => { empty || isNodeSelection(selection) || isCellSelection(selection) || + showAiMenuRef.current || showCommentPopupRef?.current ) { return false; @@ -146,9 +158,31 @@ export const EditorBubbleMenu: FC = (props) => { const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false); const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false); + // Hide the bubble menu immediately when AI menu is shown + if (showAiMenu) return; + return ( - +
+ {isGenerativeAiEnabled && ( + <> + +
+ + )} = (props) => { }} /> - - - + + + + +
); diff --git a/apps/client/src/features/editor/components/bubble-menu/color-selector.tsx b/apps/client/src/features/editor/components/bubble-menu/color-selector.tsx index d0907b81..0d4d80c0 100644 --- a/apps/client/src/features/editor/components/bubble-menu/color-selector.tsx +++ b/apps/client/src/features/editor/components/bubble-menu/color-selector.tsx @@ -1,7 +1,6 @@ import React, { Dispatch, FC, SetStateAction } from "react"; -import { IconCheck, IconChevronDown, IconPalette } from "@tabler/icons-react"; +import { IconCheck, IconChevronDown } from "@tabler/icons-react"; import { - ActionIcon, Button, Popover, rem, @@ -15,6 +14,8 @@ import { import type { Editor } from "@tiptap/react"; import { useEditorState } from "@tiptap/react"; import { useTranslation } from "react-i18next"; +import clsx from "clsx"; +import classes from "./bubble-menu.module.css"; export interface BubbleColorMenuItem { name: string; @@ -166,14 +167,10 @@ export const ColorSelector: FC = ({ onClick={() => setIsOpen(!isOpen)} data-text-color={activeColorItem?.color || ""} data-highlight-color={activeHighlightItem?.color || ""} - className="color-selector-trigger" + className={clsx(["color-selector-trigger", classes.buttonRoot])} style={{ - height: "34px", - border: "none", fontWeight: 500, fontSize: rem(16), - paddingLeft: rem(8), - paddingRight: rem(4), }} > A diff --git a/apps/client/src/features/editor/components/bubble-menu/node-selector.tsx b/apps/client/src/features/editor/components/bubble-menu/node-selector.tsx index 13b2117f..7fecff9e 100644 --- a/apps/client/src/features/editor/components/bubble-menu/node-selector.tsx +++ b/apps/client/src/features/editor/components/bubble-menu/node-selector.tsx @@ -1,6 +1,7 @@ import React, { Dispatch, FC, SetStateAction } from "react"; import { IconBlockquote, + IconCaretRightFilled, IconCheck, IconCheckbox, IconChevronDown, @@ -8,14 +9,16 @@ import { IconH1, IconH2, IconH3, + IconInfoCircle, IconList, IconListNumbers, IconTypography, } from "@tabler/icons-react"; -import { Popover, Button, ScrollArea } from "@mantine/core"; +import { Popover, Button, ScrollArea, Tooltip } from "@mantine/core"; import type { Editor } from "@tiptap/react"; import { useEditorState } from "@tiptap/react"; import { useTranslation } from "react-i18next"; +import classes from "./bubble-menu.module.css"; interface NodeSelectorProps { editor: Editor | null; @@ -54,6 +57,8 @@ export const NodeSelector: FC = ({ isTaskItem: ctx.editor.isActive("taskItem"), isBlockquote: ctx.editor.isActive("blockquote"), isCodeBlock: ctx.editor.isActive("codeBlock"), + isCallout: ctx.editor.isActive("callout"), + isDetails: ctx.editor.isActive("details"), }; }, }); @@ -123,6 +128,18 @@ export const NodeSelector: FC = ({ command: () => editor.chain().focus().toggleCodeBlock().run(), isActive: () => editorState?.isCodeBlock, }, + { + name: "Callout", + icon: IconInfoCircle, + command: () => editor.chain().focus().toggleCallout().run(), + isActive: () => editorState?.isCallout, + }, + { + name: "Toggle block", + icon: IconCaretRightFilled, + command: () => editor.chain().focus().setDetails().run(), + isActive: () => editorState?.isDetails, + }, ]; const activeItem = items.filter((item) => item.isActive()).pop() ?? { @@ -132,15 +149,18 @@ export const NodeSelector: FC = ({ return ( - + + + diff --git a/apps/client/src/features/editor/components/bubble-menu/text-alignment-selector.tsx b/apps/client/src/features/editor/components/bubble-menu/text-alignment-selector.tsx index b5277651..f8dba1c9 100644 --- a/apps/client/src/features/editor/components/bubble-menu/text-alignment-selector.tsx +++ b/apps/client/src/features/editor/components/bubble-menu/text-alignment-selector.tsx @@ -7,7 +7,7 @@ import { IconCheck, IconChevronDown, } from "@tabler/icons-react"; -import { Popover, Button, ScrollArea, rem } from "@mantine/core"; +import { Popover, Button, ScrollArea, Tooltip, rem } from "@mantine/core"; import type { Editor } from "@tiptap/react"; import { useEditorState } from "@tiptap/react"; import { useTranslation } from "react-i18next"; @@ -84,16 +84,18 @@ export const TextAlignmentSelector: FC = ({ return ( - + + + diff --git a/apps/client/src/features/editor/components/code-block/code-block-view.tsx b/apps/client/src/features/editor/components/code-block/code-block-view.tsx index 130016a3..0ff2fe36 100644 --- a/apps/client/src/features/editor/components/code-block/code-block-view.tsx +++ b/apps/client/src/features/editor/components/code-block/code-block-view.tsx @@ -1,5 +1,6 @@ import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react"; -import { ActionIcon, CopyButton, Group, Select, Tooltip } from "@mantine/core"; +import { ActionIcon, Group, Select, Tooltip } from "@mantine/core"; +import { CopyButton } from "@/components/common/copy-button"; import { useEffect, useState } from "react"; import { IconCheck, IconCopy } from "@tabler/icons-react"; import classes from "./code-block.module.css"; diff --git a/apps/client/src/features/editor/components/mention/mention-list.tsx b/apps/client/src/features/editor/components/mention/mention-list.tsx index 32959146..f71e39eb 100644 --- a/apps/client/src/features/editor/components/mention/mention-list.tsx +++ b/apps/client/src/features/editor/components/mention/mention-list.tsx @@ -10,6 +10,7 @@ import React, { import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query.ts"; import { ActionIcon, + Divider, Group, Paper, ScrollArea, @@ -51,6 +52,7 @@ const MentionList = forwardRef((props, ref) => { const tree = useMemo(() => new SimpleTree(data), [data]); const createPageMutation = useCreatePageMutation(); const emit = useQueryEmit(); + const isInCommentContext = props.isInCommentContext ?? false; const { data: suggestion, isLoading } = useSearchSuggestionsQuery({ query: props.query, @@ -58,6 +60,7 @@ const MentionList = forwardRef((props, ref) => { includePages: true, spaceId: space.id, limit: 10, + preload: true, }); const createPageItem = (label: string) : MentionSuggestionItem => { @@ -102,7 +105,9 @@ const MentionList = forwardRef((props, ref) => { })), ); } - items.push(createPageItem(props.query)); + if (!isInCommentContext && props.query) { + items.push(createPageItem(props.query)); + } setRenderItems(items); // update editor storage @@ -250,35 +255,51 @@ const MentionList = forwardRef((props, ref) => { } } - // if no results and enter what to do? - useEffect(() => { viewportRef.current ?.querySelector(`[data-item-index="${selectedIndex}"]`) ?.scrollIntoView({ block: "nearest" }); }, [selectedIndex]); + const popupWidth = isInCommentContext ? 280 : 320; + if (renderItems.length === 0) { return ( - - { t("No results") } + + + { t("No results") } + ); } + const hasUsers = renderItems.some((item) => item.entityType === "user"); + const hasPages = renderItems.some((item) => item.entityType === "page" && item.id !== null); + const createPageItemData = renderItems.find((item) => item.entityType === "page" && item.id === null); + return ( - + {renderItems?.map((item, index) => { if (item.entityType === "header") { + const isFirst = index === 0; return (
- + {!isFirst && } + {item.label}
@@ -292,8 +313,9 @@ const MentionList = forwardRef((props, ref) => { className={clsx(classes.menuBtn, { [classes.selectedItem]: index === selectedIndex, })} + px="sm" > - + ((props, ref) => { ); - } else if (item.entityType === "page") { + } else if (item.entityType === "page" && item.id !== null) { return ( ((props, ref) => { className={clsx(classes.menuBtn, { [classes.selectedItem]: index === selectedIndex, })} + px="sm" > - + {item.icon || ( - - { (item.id) ? : } - + )} -
- - { (item.id) ? item.label : t("Create page") + ': ' + item.label } +
+ + {item.label}
@@ -348,6 +366,37 @@ const MentionList = forwardRef((props, ref) => { return null; } })} + + {createPageItemData && !isInCommentContext && ( + <> + {(hasUsers || hasPages) && } + selectItem(renderItems.indexOf(createPageItemData))} + className={clsx(classes.menuBtn, { + [classes.selectedItem]: renderItems.indexOf(createPageItemData) === selectedIndex, + })} + px="sm" + > + + + + + +
+ + {t("Create page")}: {createPageItemData.label} + +
+
+
+ + )} ); diff --git a/apps/client/src/features/editor/components/mention/mention-suggestion.ts b/apps/client/src/features/editor/components/mention/mention-suggestion.ts index d53c422c..2a4fec1f 100644 --- a/apps/client/src/features/editor/components/mention/mention-suggestion.ts +++ b/apps/client/src/features/editor/components/mention/mention-suggestion.ts @@ -17,8 +17,13 @@ const mentionRenderItems = () => { let component: ReactRenderer | null = null; let activeClientRect: (() => DOMRect) | null = null; let updatePositionCleanup: (() => void) | null = null; + let outsideClickHandler: ((e: MouseEvent) => void) | null = null; const destroy = () => { + if (outsideClickHandler) { + document.removeEventListener("pointerdown", outsideClickHandler); + outsideClickHandler = null; + } updatePositionCleanup?.(); updatePositionCleanup = null; component?.destroy(); @@ -45,8 +50,14 @@ const mentionRenderItems = () => { return; } + const editorDom = props.editor?.view?.dom; + const asideEl = editorDom?.closest(".mantine-AppShell-aside"); + const dialogEl = editorDom?.closest("[data-comment-dialog]"); + const isInCommentContext = !!(asideEl || dialogEl); + // const isInCommentContext = !!asideEl; + component = new ReactRenderer(MentionList, { - props, + props: { ...props, isInCommentContext }, editor: props.editor, }); @@ -59,6 +70,18 @@ const mentionRenderItems = () => { const { element } = component; document.body.appendChild(element); + outsideClickHandler = (e: MouseEvent) => { + const target = e.target as Node; + if (element && !element.contains(target)) { + destroy(); + } + }; + document.addEventListener("pointerdown", outsideClickHandler); + + const shiftMiddleware = asideEl + ? shift({ boundary: asideEl, crossAxis: true, padding: 8 }) + : shift(); + updatePositionCleanup = autoUpdate( { getBoundingClientRect: () => @@ -76,7 +99,7 @@ const mentionRenderItems = () => { element, { placement: "bottom-start", - middleware: [offset(0), flip(), shift()], + middleware: [offset(4), flip(), shiftMiddleware], }, ).then(({ x, y }) => { Object.assign(element.style, { diff --git a/apps/client/src/features/editor/components/mention/mention.module.css b/apps/client/src/features/editor/components/mention/mention.module.css index 691fc71a..0ebd09ef 100644 --- a/apps/client/src/features/editor/components/mention/mention.module.css +++ b/apps/client/src/features/editor/components/mention/mention.module.css @@ -31,14 +31,14 @@ .menuBtn { width: 100%; - padding: 4px; - margin-bottom: 2px; + padding: 6px 4px; + margin-bottom: 1px; color: var(--mantine-color-text); border-radius: var(--mantine-radius-sm); &:hover { @mixin light { - background: var(--mantine-color-gray-2); + background: var(--mantine-color-gray-1); } @mixin dark { @@ -49,7 +49,7 @@ .selectedItem { @mixin light { - background: var(--mantine-color-gray-2); + background: var(--mantine-color-gray-1); } @mixin dark { diff --git a/apps/client/src/features/editor/components/mention/mention.type.ts b/apps/client/src/features/editor/components/mention/mention.type.ts index e837629a..efbe27f8 100644 --- a/apps/client/src/features/editor/components/mention/mention.type.ts +++ b/apps/client/src/features/editor/components/mention/mention.type.ts @@ -7,6 +7,7 @@ export interface MentionListProps { range: Range; text: string; editor: Editor; + isInCommentContext?: boolean; } export type MentionSuggestionItem = diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.ts index bebefed4..27793f62 100644 --- a/apps/client/src/features/editor/components/slash-menu/menu-items.ts +++ b/apps/client/src/features/editor/components/slash-menu/menu-items.ts @@ -170,6 +170,8 @@ const CommandGroups: SlashMenuGroupedItemsType = { input.type = "file"; input.accept = "image/*"; input.multiple = true; + input.style.display = "none"; + document.body.appendChild(input); input.onchange = async () => { if (input.files?.length) { for (const file of input.files) { @@ -179,8 +181,7 @@ const CommandGroups: SlashMenuGroupedItemsType = { } } - // Reset the input value to allow uploading the same file again if needed - input.value = ""; + input.remove(); }; input.click(); }, @@ -202,6 +203,8 @@ const CommandGroups: SlashMenuGroupedItemsType = { input.type = "file"; input.accept = "video/*"; input.multiple = true; + input.style.display = "none"; + document.body.appendChild(input); input.onchange = async () => { if (input.files?.length) { for (const file of input.files) { @@ -211,8 +214,7 @@ const CommandGroups: SlashMenuGroupedItemsType = { } } - // Reset the input value to allow uploading the same file again if needed - input.value = ""; + input.remove(); }; input.click(); }, @@ -234,6 +236,8 @@ const CommandGroups: SlashMenuGroupedItemsType = { input.type = "file"; input.accept = ""; input.multiple = true; + input.style.display = "none"; + document.body.appendChild(input); input.onchange = async () => { if (input.files?.length) { for (const file of input.files) { @@ -243,8 +247,7 @@ const CommandGroups: SlashMenuGroupedItemsType = { } } - // Reset the input value to allow uploading the same file again if needed - input.value = ""; + input.remove(); }; input.click(); }, diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index da8bd84a..ed7ccecd 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -66,6 +66,7 @@ import { PageEditMode } from "@/features/user/types/user.types.ts"; import { jwtDecode } from "jwt-decode"; import { searchSpotlight } from "@/features/search/constants.ts"; import { useEditorScroll } from "./hooks/use-editor-scroll"; +import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu"; interface PageEditorProps { pageId: string; @@ -405,6 +406,7 @@ export default function PageEditor({ {editor && editorIsEditable && (
+ diff --git a/apps/client/src/features/editor/readonly-page-editor.tsx b/apps/client/src/features/editor/readonly-page-editor.tsx index 77496fcd..b2ab829a 100644 --- a/apps/client/src/features/editor/readonly-page-editor.tsx +++ b/apps/client/src/features/editor/readonly-page-editor.tsx @@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef } from "react"; import { EditorProvider } from "@tiptap/react"; import { mainExtensions } from "@/features/editor/extensions/extensions"; import { Document } from "@tiptap/extension-document"; -import { Heading, generateNodeId, UniqueID } from "@docmost/editor-ext"; +import { Heading, UniqueID } from "@docmost/editor-ext"; import { Text } from "@tiptap/extension-text"; import { Placeholder } from "@tiptap/extension-placeholder"; import { useAtom } from "jotai"; diff --git a/apps/client/src/features/editor/styles/print.css b/apps/client/src/features/editor/styles/print.css index c63e376b..5cf32a30 100644 --- a/apps/client/src/features/editor/styles/print.css +++ b/apps/client/src/features/editor/styles/print.css @@ -8,7 +8,7 @@ } .mantine-AppShell-main { - padding-top: 0 !important; + padding: 0 !important; min-height: auto !important; } diff --git a/apps/client/src/features/editor/title-editor.tsx b/apps/client/src/features/editor/title-editor.tsx index c3610394..31318af5 100644 --- a/apps/client/src/features/editor/title-editor.tsx +++ b/apps/client/src/features/editor/title-editor.tsx @@ -157,8 +157,10 @@ export function TitleEditor({ useEffect(() => { setTimeout(() => { - titleEditor?.commands.focus("end"); - }, 500); + // guard against Cannot access view['hasFocus'] error + if (!titleEditor?.isInitialized) return; + titleEditor?.commands?.focus("end"); + }, 300); }, [titleEditor]); useEffect(() => { diff --git a/apps/client/src/features/notification/components/notification-item.tsx b/apps/client/src/features/notification/components/notification-item.tsx new file mode 100644 index 00000000..f9510be6 --- /dev/null +++ b/apps/client/src/features/notification/components/notification-item.tsx @@ -0,0 +1,148 @@ +import { + ActionIcon, + Group, + Text, + Tooltip, + UnstyledButton, +} from "@mantine/core"; +import { + IconCheck, + IconFileDescription, + IconPointFilled, +} from "@tabler/icons-react"; +import { CustomAvatar } from "@/components/ui/custom-avatar"; +import { INotification } from "../types/notification.types"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { useState } from "react"; +import { useMarkReadMutation } from "../queries/notification-query"; +import { buildPageUrl } from "@/features/page/page.utils"; +import { formatRelativeTime } from "../notification.utils"; +import classes from "../notification.module.css"; + +type NotificationItemProps = { + notification: INotification; + onNavigate: () => void; +}; + +export function NotificationItem({ + notification, + onNavigate, +}: NotificationItemProps) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const markRead = useMarkReadMutation(); + const [hovered, setHovered] = useState(false); + + const isUnread = !notification.readAt; + + const getNotificationMessage = (): string => { + switch (notification.type) { + case "comment.user_mention": + return t("mentioned you in a comment"); + case "comment.created": + return t("commented on a page"); + case "comment.resolved": + return t("resolved a comment"); + case "page.user_mention": + return t("mentioned you on a page"); + default: + return ""; + } + }; + + const handleClick = () => { + if (notification.page && notification.space) { + if (isUnread) { + markRead.mutate([notification.id]); + } + navigate( + buildPageUrl( + notification.space.slug, + notification.page.slugId, + notification.page.title, + ), + ); + onNavigate(); + } + }; + + const handleMarkRead = (e: React.MouseEvent) => { + e.stopPropagation(); + if (isUnread) { + markRead.mutate([notification.id]); + } + }; + + return ( + setHovered(true)} + onMouseLeave={() => setHovered(false)} + w="100%" + className={classes.notificationItem} + > + + + +
+ + + {notification.actor?.name} + {" "} + {getNotificationMessage()} + + + {notification.page && ( + + {notification.page.icon ? ( + + {notification.page.icon} + + ) : ( + + )} + + {notification.page.title || t("Untitled")} + + + )} +
+ + + {hovered && isUnread ? ( + + + + + + ) : ( + + {formatRelativeTime(notification.createdAt)} + + )} + + {isUnread && ( + + )} + +
+
+ ); +} diff --git a/apps/client/src/features/notification/components/notification-list.tsx b/apps/client/src/features/notification/components/notification-list.tsx new file mode 100644 index 00000000..4c992c57 --- /dev/null +++ b/apps/client/src/features/notification/components/notification-list.tsx @@ -0,0 +1,115 @@ +import { Center, Divider, Loader, Stack, Text } from "@mantine/core"; +import { IconBellOff } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { useEffect, useRef } from "react"; +import { NotificationItem } from "./notification-item"; +import { INotification, NotificationFilter } from "../types/notification.types"; +import { groupNotificationsByTime } from "../notification.utils"; +import { useNotificationsQuery } from "../queries/notification-query"; +import classes from "../notification.module.css"; + +type NotificationListProps = { + filter: NotificationFilter; + onNavigate: () => void; +}; + +export function NotificationList({ + filter, + onNavigate, +}: NotificationListProps) { + const { t } = useTranslation(); + const { + data, + isLoading, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + } = useNotificationsQuery(); + + const sentinelRef = useRef(null); + + useEffect(() => { + const sentinel = sentinelRef.current; + if (!sentinel) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, + { threshold: 0.1 }, + ); + + observer.observe(sentinel); + return () => observer.disconnect(); + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + const allNotifications = + data?.pages.flatMap((page) => page.items) ?? []; + + const filtered = + filter === "unread" + ? allNotifications.filter((n) => !n.readAt) + : allNotifications; + + if (filtered.length === 0) { + return ( +
+ + + + {filter === "unread" + ? t("No unread notifications") + : t("No notifications")} + + +
+ ); + } + + const timeGroupLabels = { + today: t("Today"), + yesterday: t("Yesterday"), + this_week: t("This week"), + older: t("Older"), + }; + + const groups = groupNotificationsByTime(filtered, timeGroupLabels); + + return ( + + {groups.map((group, groupIndex) => ( +
+ {groupIndex > 0 && } + + {group.label} + + {group.notifications.map((notification: INotification) => ( + + ))} +
+ ))} + +
+ + {isFetchingNextPage && ( +
+ +
+ )} + + ); +} diff --git a/apps/client/src/features/notification/components/notification-popover.tsx b/apps/client/src/features/notification/components/notification-popover.tsx new file mode 100644 index 00000000..8ebfedad --- /dev/null +++ b/apps/client/src/features/notification/components/notification-popover.tsx @@ -0,0 +1,142 @@ +import { useState } from "react"; +import { + ActionIcon, + Group, + Indicator, + Menu, + Popover, + ScrollArea, + Text, + Tooltip, +} from "@mantine/core"; +import { + IconBell, + IconCheck, + IconChecks, + IconDots, + IconFilter, +} from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { NotificationList } from "./notification-list"; +import { NotificationFilter } from "../types/notification.types"; +import { + useMarkAllReadMutation, + useUnreadCountQuery, +} from "../queries/notification-query"; + +export function NotificationPopover() { + const { t } = useTranslation(); + const [opened, setOpened] = useState(false); + const [filter, setFilter] = useState("all"); + + const { data: unreadData } = useUnreadCountQuery(); + const markAllRead = useMarkAllReadMutation(); + + const unreadCount = unreadData?.count ?? 0; + + const handleMarkAllRead = () => { + markAllRead.mutate(); + }; + + return ( + + + + setOpened((o) => !o)} + > + + + + + + + + + + + {t("Notifications")} + + + + + + + + + + + + {t("Filter")} + setFilter("all")} + rightSection={ + filter === "all" ? : null + } + > + {t("All notifications")} + + setFilter("unread")} + rightSection={ + filter === "unread" ? : null + } + > + {t("Unread only")} + + + + + + + + + + + + + + } + onClick={handleMarkAllRead} + disabled={unreadCount === 0} + > + {t("Mark all as read")} + + + + + + + + setOpened(false)} + /> + + + + ); +} diff --git a/apps/client/src/features/notification/hooks/use-notification-socket.ts b/apps/client/src/features/notification/hooks/use-notification-socket.ts new file mode 100644 index 00000000..796c504a --- /dev/null +++ b/apps/client/src/features/notification/hooks/use-notification-socket.ts @@ -0,0 +1,23 @@ +import { useEffect } from "react"; +import { useAtom } from "jotai"; +import { useQueryClient } from "@tanstack/react-query"; +import { socketAtom } from "@/features/websocket/atoms/socket-atom"; +import { NOTIFICATION_KEY } from "../queries/notification-query"; + +export function useNotificationSocket() { + const queryClient = useQueryClient(); + const [socket] = useAtom(socketAtom); + + useEffect(() => { + if (!socket) return; + + const handler = () => { + queryClient.invalidateQueries({ queryKey: NOTIFICATION_KEY }); + }; + + socket.on("notification", handler); + return () => { + socket.off("notification", handler); + }; + }, [socket, queryClient]); +} diff --git a/apps/client/src/features/notification/notification.module.css b/apps/client/src/features/notification/notification.module.css new file mode 100644 index 00000000..8a80bcf8 --- /dev/null +++ b/apps/client/src/features/notification/notification.module.css @@ -0,0 +1,13 @@ +.notificationItem { + padding: 8px 12px; + border-radius: 4px; + cursor: pointer; +} + +.notificationItem:hover { + background-color: var(--mantine-color-default-hover); +} + +.divider { + border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5)); +} diff --git a/apps/client/src/features/notification/notification.utils.ts b/apps/client/src/features/notification/notification.utils.ts new file mode 100644 index 00000000..266bfc27 --- /dev/null +++ b/apps/client/src/features/notification/notification.utils.ts @@ -0,0 +1,75 @@ +import { INotification } from "./types/notification.types"; + +export function formatRelativeTime(dateStr: string): string { + const date = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMin = Math.floor(diffMs / 60_000); + const diffHours = Math.floor(diffMs / 3_600_000); + const diffDays = Math.floor(diffMs / 86_400_000); + + if (diffMin < 1) return "now"; + if (diffMin < 60) return `${diffMin}m`; + if (diffHours < 24) return `${diffHours}h`; + if (diffDays < 7) return `${diffDays}d`; + + return date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); +} + +type TimeGroup = "today" | "yesterday" | "this_week" | "older"; + +export function getTimeGroup(dateStr: string): TimeGroup { + const date = new Date(dateStr); + const now = new Date(); + + const startOfToday = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + ); + const startOfYesterday = new Date(startOfToday); + startOfYesterday.setDate(startOfYesterday.getDate() - 1); + const startOfWeek = new Date(startOfToday); + startOfWeek.setDate(startOfWeek.getDate() - 7); + + if (date >= startOfToday) return "today"; + if (date >= startOfYesterday) return "yesterday"; + if (date >= startOfWeek) return "this_week"; + return "older"; +} + +export type GroupedNotifications = { + key: TimeGroup; + label: string; + notifications: INotification[]; +}; + +export function groupNotificationsByTime( + notifications: INotification[], + labels: Record, +): GroupedNotifications[] { + const groups: Record = { + today: [], + yesterday: [], + this_week: [], + older: [], + }; + + for (const notification of notifications) { + const group = getTimeGroup(notification.createdAt); + groups[group].push(notification); + } + + const order: TimeGroup[] = ["today", "yesterday", "this_week", "older"]; + + return order + .filter((key) => groups[key].length > 0) + .map((key) => ({ + key, + label: labels[key], + notifications: groups[key], + })); +} diff --git a/apps/client/src/features/notification/queries/notification-query.ts b/apps/client/src/features/notification/queries/notification-query.ts new file mode 100644 index 00000000..363482b1 --- /dev/null +++ b/apps/client/src/features/notification/queries/notification-query.ts @@ -0,0 +1,59 @@ +import { + keepPreviousData, + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { + getNotifications, + getUnreadCount, + markNotificationsRead, + markAllNotificationsRead, +} from "../services/notification-service"; + +export const NOTIFICATION_KEY = ["notifications"]; +export const UNREAD_COUNT_KEY = ["notifications", "unread-count"]; + +export function useNotificationsQuery() { + return useInfiniteQuery({ + queryKey: NOTIFICATION_KEY, + queryFn: ({ pageParam }) => getNotifications({ cursor: pageParam }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => + lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined, + staleTime: 0, + gcTime: 0, + placeholderData: keepPreviousData, + }); +} + +export function useUnreadCountQuery() { + return useQuery({ + queryKey: UNREAD_COUNT_KEY, + queryFn: getUnreadCount, + }); +} + +export function useMarkReadMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (notificationIds: string[]) => + markNotificationsRead(notificationIds), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: NOTIFICATION_KEY }); + }, + }); +} + +export function useMarkAllReadMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: markAllNotificationsRead, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: NOTIFICATION_KEY }); + }, + }); +} diff --git a/apps/client/src/features/notification/services/notification-service.ts b/apps/client/src/features/notification/services/notification-service.ts new file mode 100644 index 00000000..8adf4909 --- /dev/null +++ b/apps/client/src/features/notification/services/notification-service.ts @@ -0,0 +1,31 @@ +import api from "@/lib/api-client"; +import { INotification } from "../types/notification.types"; +import { IPagination } from "@/lib/types"; + +export async function getNotifications(params: { + limit?: number; + cursor?: string; +}): Promise> { + const req = await api.post>( + "/notifications", + params, + ); + return req.data; +} + +export async function getUnreadCount(): Promise<{ count: number }> { + const req = await api.post<{ count: number }>( + "/notifications/unread-count", + ); + return req.data; +} + +export async function markNotificationsRead( + notificationIds: string[], +): Promise { + await api.post("/notifications/mark-read", { notificationIds }); +} + +export async function markAllNotificationsRead(): Promise { + await api.post("/notifications/mark-all-read"); +} diff --git a/apps/client/src/features/notification/types/notification.types.ts b/apps/client/src/features/notification/types/notification.types.ts new file mode 100644 index 00000000..2961f5fc --- /dev/null +++ b/apps/client/src/features/notification/types/notification.types.ts @@ -0,0 +1,39 @@ +export type NotificationType = + | "comment.user_mention" + | "comment.created" + | "comment.resolved" + | "page.user_mention"; + +export type INotification = { + id: string; + userId: string; + workspaceId: string; + type: NotificationType; + actorId: string | null; + pageId: string | null; + spaceId: string | null; + commentId: string | null; + data: Record | null; + readAt: string | null; + emailedAt: string | null; + archivedAt: string | null; + createdAt: string; + actor: { + id: string; + name: string; + avatarUrl: string | null; + } | null; + page: { + id: string; + title: string; + slugId: string; + icon: string | null; + } | null; + space: { + id: string; + name: string; + slug: string; + } | null; +}; + +export type NotificationFilter = "all" | "unread"; diff --git a/apps/client/src/features/page-history/atoms/history-atoms.ts b/apps/client/src/features/page-history/atoms/history-atoms.ts index 023aaa36..2acf163d 100644 --- a/apps/client/src/features/page-history/atoms/history-atoms.ts +++ b/apps/client/src/features/page-history/atoms/history-atoms.ts @@ -1,4 +1,9 @@ import { atom } from "jotai"; export const historyAtoms = atom(false); -export const activeHistoryIdAtom = atom(''); +export const activeHistoryIdAtom = atom(""); +export const activeHistoryPrevIdAtom = atom(""); +export const highlightChangesAtom = atom(true); + +export type DiffCounts = { added: number; deleted: number; total: number }; +export const diffCountsAtom = atom(null); diff --git a/apps/client/src/features/page-history/components/css/history-mobile.module.css b/apps/client/src/features/page-history/components/css/history-mobile.module.css new file mode 100644 index 00000000..2db6d10c --- /dev/null +++ b/apps/client/src/features/page-history/components/css/history-mobile.module.css @@ -0,0 +1,69 @@ +.container { + display: flex; + flex-direction: column; + height: calc(100vh - 60px); + position: relative; + overflow: hidden; +} + +.selectorWrapper { + padding: var(--mantine-spacing-sm); + border-bottom: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); + flex-shrink: 0; +} + +.selector { + width: 100%; + text-align: left; + background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6)); + padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm); + cursor: pointer; + + &:hover { + background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5)); + } +} + +.dropdown { + max-height: rem(300px); +} + +.option { + padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm); + + &[data-combobox-selected] { + background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5)); + } + + &:hover { + background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5)); + } +} + +.editorArea { + flex: 1; + min-height: 0; +} + +.editorContent { + padding: var(--mantine-spacing-md); + padding-bottom: rem(60px); +} + +.actionButtons { + padding: var(--mantine-spacing-sm) var(--mantine-spacing-md); + padding-bottom: rem(70px); + border-top: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); + background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7)); + flex-shrink: 0; +} + +.floatingBar { + position: fixed; + bottom: var(--mantine-spacing-md); + left: 50%; + transform: translateX(-50%); + z-index: 100; + background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6)); + white-space: nowrap; +} diff --git a/apps/client/src/features/page-history/components/css/history.module.css b/apps/client/src/features/page-history/components/css/history.module.css new file mode 100644 index 00000000..a4be3819 --- /dev/null +++ b/apps/client/src/features/page-history/components/css/history.module.css @@ -0,0 +1,79 @@ +.history { + display: block; + width: 100%; + padding: var(--mantine-spacing-md); + color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0)); + + @mixin hover { + background-color: light-dark( + var(--mantine-color-gray-2), + var(--mantine-color-dark-8) + ); + } +} + +.historyEditor { + :global(.ProseMirror) { + padding: 0 !important; + } + + & :global(.history-diff-added) { + background: light-dark(#e1f3f2, #01654a) !important; + color: light-dark(#007b69, #cafff7) !important; + -webkit-box-decoration-break: clone; + box-decoration-break: clone; + } + + & :global(.history-diff-deleted) { + text-decoration: line-through; + color: light-dark(var(--mantine-color-red-7), var(--mantine-color-red-4)); + background: light-dark(var(--mantine-color-red-0), rgba(255, 0, 0, 0.1)); + border-radius: rem(2px); + padding: 0 rem(2px); + } + + & :global(.history-diff-node-added) { + outline: rem(2px) solid + light-dark(var(--mantine-color-teal-5), var(--mantine-color-teal-7)); + outline-offset: rem(2px); + border-radius: rem(4px); + } + + & :global(.history-diff-node-deleted) { + opacity: 0.5; + outline: rem(2px) dashed + light-dark(var(--mantine-color-red-4), var(--mantine-color-red-6)); + outline-offset: rem(4px); + border-radius: rem(4px); + } +} + +.active { + background-color: light-dark( + var(--mantine-color-gray-2), + var(--mantine-color-dark-8) + ); +} + +.sidebar { + max-height: rem(700px); + width: rem(250px); + padding: var(--mantine-spacing-sm); + display: flex; + flex-direction: column; + border-right: rem(1px) solid + light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); +} + +.sidebarFlex { + display: flex; +} + +.sidebarMain { + flex: 1; +} + +.sidebarRightSection { + flex: 1; + padding: rem(16px) rem(40px); +} diff --git a/apps/client/src/features/page-history/components/history-editor.tsx b/apps/client/src/features/page-history/components/history-editor.tsx index 5fa8cf42..d071abc3 100644 --- a/apps/client/src/features/page-history/components/history-editor.tsx +++ b/apps/client/src/features/page-history/components/history-editor.tsx @@ -1,36 +1,203 @@ import "@/features/editor/styles/index.css"; -import React, { useEffect } from "react"; +import { useEffect } from "react"; import { EditorContent, useEditor } from "@tiptap/react"; import { mainExtensions } from "@/features/editor/extensions/extensions"; import { Title } from "@mantine/core"; -import classes from "./history.module.css"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import historyClasses from "./css/history.module.css"; +import { recreateTransform } from "@docmost/editor-ext"; +import { DOMSerializer, Node } from "@tiptap/pm/model"; +import { ChangeSet, simplifyChanges } from "@tiptap/pm/changeset"; +import { useAtom } from "jotai"; +import { + diffCountsAtom, + highlightChangesAtom, +} from "@/features/page-history/atoms/history-atoms"; export interface HistoryEditorProps { title: string; content: any; + previousContent?: any; } -export function HistoryEditor({ title, content }: HistoryEditorProps) { +export function HistoryEditor({ + title, + content, + previousContent, +}: HistoryEditorProps) { + const [highlightChanges] = useAtom(highlightChangesAtom); + const [, setDiffCounts] = useAtom(diffCountsAtom); + const editor = useEditor({ extensions: mainExtensions, editable: false, }); useEffect(() => { - if (editor && content) { + if (!editor || !content) return; + + let decorationSet = DecorationSet.empty; + let addedCount = 0; + let deletedCount = 0; + + if (previousContent) { + try { + const schema = editor.schema; + const oldContent = Node.fromJSON(schema, previousContent); + const newContent = Node.fromJSON(schema, content); + + const tr = recreateTransform(oldContent, newContent, { + complexSteps: false, + wordDiffs: true, + simplifyDiff: true, + }); + + const changeSet = ChangeSet.create(oldContent).addSteps( + tr.doc, + tr.mapping.maps, + [], + ); + const changes = simplifyChanges(changeSet.changes, newContent); + + editor.commands.setContent(content); + + const specialNodeTypes = new Set([ + "image", + "attachment", + "video", + "excalidraw", + "drawio", + "mermaid", + "mathBlock", + "mathInline", + "table", + "details", + "callout", + ]); + + const decorations: Decoration[] = []; + let changeIndex = 0; + + for (const change of changes) { + if (change.toB > change.fromB) { + changeIndex++; + const currentIndex = changeIndex; + let foundSpecialNode: { node: Node; pos: number } | null = null; + newContent.nodesBetween(change.fromB, change.toB, (node, pos) => { + if (specialNodeTypes.has(node.type.name)) { + const nodeEnd = pos + node.nodeSize; + if (change.fromB <= pos && change.toB >= nodeEnd) { + foundSpecialNode = { node, pos }; + return false; + } + } + }); + + if (foundSpecialNode) { + const nodeEnd = + foundSpecialNode.pos + foundSpecialNode.node.nodeSize; + decorations.push( + Decoration.node(foundSpecialNode.pos, nodeEnd, { + class: "history-diff-node-added", + "data-diff-index": String(currentIndex), + }), + ); + } else { + decorations.push( + Decoration.inline(change.fromB, change.toB, { + class: "history-diff-added", + "data-diff-index": String(currentIndex), + }), + ); + } + addedCount += 1; + } + if (change.toA > change.fromA) { + changeIndex++; + const currentIndex = changeIndex; + let foundDeletedNode: { node: Node; pos: number } | null = null; + oldContent.nodesBetween(change.fromA, change.toA, (node, pos) => { + if (specialNodeTypes.has(node.type.name)) { + const nodeEnd = pos + node.nodeSize; + if (change.fromA <= pos && change.toA >= nodeEnd) { + foundDeletedNode = { node, pos }; + return false; + } + } + }); + + if (foundDeletedNode) { + decorations.push( + Decoration.widget(change.fromB, () => { + const wrapper = document.createElement("div"); + wrapper.className = "history-diff-node-deleted"; + wrapper.setAttribute("data-diff-index", String(currentIndex)); + const serializer = DOMSerializer.fromSchema(schema); + const dom = serializer.serializeNode(foundDeletedNode!.node); + wrapper.appendChild(dom); + return wrapper; + }), + ); + } else { + const deletedText = oldContent.textBetween( + change.fromA, + change.toA, + "", + ); + if (deletedText) { + decorations.push( + Decoration.widget(change.fromB, () => { + const span = document.createElement("span"); + span.className = "history-diff-deleted"; + span.setAttribute("data-diff-index", String(currentIndex)); + span.textContent = deletedText; + return span; + }), + ); + } + } + deletedCount += 1; + } + } + + decorationSet = DecorationSet.create(newContent, decorations); + } catch (e) { + console.error("History diff failed:", e); + editor.commands.setContent(content); + } + } else { editor.commands.setContent(content); } - }, [title, content, editor]); + + const total = addedCount + deletedCount; + // @ts-ignore + setDiffCounts({ added: addedCount, deleted: deletedCount, total }); + + editor.setOptions({ + editorProps: { + ...editor.options.editorProps, + decorations: () => + highlightChanges ? decorationSet : DecorationSet.empty, + }, + }); + }, [ + title, + content, + editor, + previousContent, + highlightChanges, + setDiffCounts, + ]); return ( - <> -
- {title} - - {editor && ( - - )} -
- +
+ {title} + {editor && ( + + )} +
); } diff --git a/apps/client/src/features/page-history/components/history-item.tsx b/apps/client/src/features/page-history/components/history-item.tsx index eb348bd6..cc56b191 100644 --- a/apps/client/src/features/page-history/components/history-item.tsx +++ b/apps/client/src/features/page-history/components/history-item.tsx @@ -1,44 +1,100 @@ -import { Text, Group, UnstyledButton } from "@mantine/core"; +import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { formattedDate } from "@/lib/time"; -import classes from "./history.module.css"; +import classes from "./css/history.module.css"; import clsx from "clsx"; +import { IPageHistory } from "@/features/page-history/types/page.types"; +import { memo, useCallback } from "react"; + +const MAX_VISIBLE_AVATARS = 5; interface HistoryItemProps { - historyItem: any; - onSelect: (id: string) => void; + historyItem: IPageHistory; + index: number; + onSelect: (id: string, index: number) => void; + onHover?: (id: string, index: number) => void; + onHoverEnd?: () => void; isActive: boolean; } -function HistoryItem({ historyItem, onSelect, isActive }: HistoryItemProps) { +const HistoryItem = memo(function HistoryItem({ + historyItem, + index, + onSelect, + onHover, + onHoverEnd, + isActive, +}: HistoryItemProps) { + const handleClick = useCallback(() => { + onSelect(historyItem.id, index); + }, [onSelect, historyItem.id, index]); + + const handleMouseEnter = useCallback(() => { + onHover?.(historyItem.id, index); + }, [onHover, historyItem.id, index]); + + const contributors = historyItem.contributors; + const hasContributors = contributors && contributors.length > 0; + return ( onSelect(historyItem.id)} + onClick={handleClick} + onMouseEnter={handleMouseEnter} + onMouseLeave={onHoverEnd} className={clsx(classes.history, { [classes.active]: isActive })} > - -
- - {formattedDate(new Date(historyItem.createdAt))} - + {formattedDate(new Date(historyItem.createdAt))} -
- - + + {hasContributors ? ( + <> + + + {contributors.slice(0, MAX_VISIBLE_AVATARS).map((contributor) => ( + + + + ))} + {contributors.length > MAX_VISIBLE_AVATARS && ( + ( +
{c.name}
+ ))} + > + + +{contributors.length - MAX_VISIBLE_AVATARS} + +
+ )} +
+
+ {contributors.length === 1 && ( - {historyItem.lastUpdatedBy.name} + {contributors[0].name} -
-
-
+ )} + + ) : ( + <> + + + {historyItem.lastUpdatedBy?.name} + + + )}
); -} +}); export default HistoryItem; diff --git a/apps/client/src/features/page-history/components/history-list.tsx b/apps/client/src/features/page-history/components/history-list.tsx index 7b0d9ea2..4024901b 100644 --- a/apps/client/src/features/page-history/components/history-list.tsx +++ b/apps/client/src/features/page-history/components/history-list.tsx @@ -1,29 +1,27 @@ import { usePageHistoryListQuery, - usePageHistoryQuery, + prefetchPageHistory, } from "@/features/page-history/queries/page-history-query"; import HistoryItem from "@/features/page-history/components/history-item"; import { activeHistoryIdAtom, + activeHistoryPrevIdAtom, historyAtoms, } from "@/features/page-history/atoms/history-atoms"; -import { useAtom } from "jotai"; -import { useCallback, useEffect } from "react"; -import { Button, ScrollArea, Group, Divider, Text } from "@mantine/core"; +import { useAtom, useSetAtom } from "jotai"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { - pageEditorAtom, - titleEditorAtom, -} from "@/features/editor/atoms/editor-atoms"; -import { modals } from "@mantine/modals"; -import { notifications } from "@mantine/notifications"; + Button, + ScrollArea, + Group, + Divider, + Loader, + Center, +} from "@mantine/core"; import { useTranslation } from "react-i18next"; -import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts"; -import { useSpaceQuery } from "@/features/space/queries/space-query.ts"; -import { useParams } from "react-router-dom"; -import { - SpaceCaslAction, - SpaceCaslSubject, -} from "@/features/space/permissions/permissions.type.ts"; +import { useHistoryRestore } from "@/features/page-history/hooks"; + +const PREFETCH_DELAY_MS = 150; interface Props { pageId: string; @@ -32,62 +30,89 @@ interface Props { function HistoryList({ pageId }: Props) { const { t } = useTranslation(); const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom); + const setActiveHistoryPrevId = useSetAtom(activeHistoryPrevIdAtom); + const setHistoryModalOpen = useSetAtom(historyAtoms); + const { - data: pageHistoryList, + data: pageHistoryData, isLoading, isError, + fetchNextPage, + hasNextPage, + isFetchingNextPage, } = usePageHistoryListQuery(pageId); - const { data: activeHistoryData } = usePageHistoryQuery(activeHistoryId); - const [mainEditor] = useAtom(pageEditorAtom); - const [mainEditorTitle] = useAtom(titleEditorAtom); - const [, setHistoryModalOpen] = useAtom(historyAtoms); + const historyItems = useMemo( + () => pageHistoryData?.pages.flatMap((page) => page.items) ?? [], + [pageHistoryData], + ); - const { spaceSlug } = useParams(); - const { data: space } = useSpaceQuery(spaceSlug); - const spaceRules = space?.membership?.permissions; - const spaceAbility = useSpaceAbility(spaceRules); + const loadMoreRef = useRef(null); + const prefetchTimeoutRef = useRef | null>(null); - const confirmModal = () => - modals.openConfirmModal({ - title: t("Please confirm your action"), - children: ( - - {t( - "Are you sure you want to restore this version? Any changes not versioned will be lost.", - )} - - ), - labels: { confirm: t("Confirm"), cancel: t("Cancel") }, - onConfirm: handleRestore, - }); + const { canRestore, confirmRestore } = useHistoryRestore(); - const handleRestore = useCallback(() => { - if (activeHistoryData) { - mainEditorTitle - .chain() - .clearContent() - .setContent(activeHistoryData.title, { emitUpdate: true }) - .run(); - mainEditor - .chain() - .clearContent() - .setContent(activeHistoryData.content) - .run(); - setHistoryModalOpen(false); - notifications.show({ message: t("Successfully restored") }); + const clearPrefetchTimeout = useCallback(() => { + if (prefetchTimeoutRef.current) { + clearTimeout(prefetchTimeoutRef.current); + prefetchTimeoutRef.current = null; } - }, [activeHistoryData]); + }, []); + + const handleHover = useCallback( + (historyId: string, index: number) => { + clearPrefetchTimeout(); + prefetchTimeoutRef.current = setTimeout(() => { + prefetchPageHistory(historyId); + const prevId = historyItems[index + 1]?.id; + if (prevId) { + prefetchPageHistory(prevId); + } + }, PREFETCH_DELAY_MS); + }, + [clearPrefetchTimeout, historyItems], + ); useEffect(() => { - if ( - pageHistoryList && - pageHistoryList.items.length > 0 && - !activeHistoryId - ) { - setActiveHistoryId(pageHistoryList.items[0].id); + return clearPrefetchTimeout; + }, [clearPrefetchTimeout]); + + const handleSelect = useCallback( + (id: string, index: number) => { + setActiveHistoryId(id); + setActiveHistoryPrevId(historyItems[index + 1]?.id ?? ""); + }, + [historyItems, setActiveHistoryId, setActiveHistoryPrevId], + ); + + useEffect(() => { + if (historyItems.length > 0 && !activeHistoryId) { + setActiveHistoryId(historyItems[0].id); + setActiveHistoryPrevId(historyItems[1]?.id ?? ""); } - }, [pageHistoryList]); + }, [ + historyItems, + activeHistoryId, + setActiveHistoryId, + setActiveHistoryPrevId, + ]); + + useEffect(() => { + const sentinel = loadMoreRef.current; + if (!sentinel || !hasNextPage) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && !isFetchingNextPage) { + fetchNextPage(); + } + }, + { threshold: 0.1 }, + ); + + observer.observe(sentinel); + return () => observer.disconnect(); + }, [fetchNextPage, hasNextPage, isFetchingNextPage]); if (isLoading) { return <>; @@ -97,34 +122,36 @@ function HistoryList({ pageId }: Props) { return
{t("Error loading page history.")}
; } - if (!pageHistoryList || pageHistoryList.items.length === 0) { + if (historyItems.length === 0) { return <>{t("No page history saved yet.")}; } return (
- {pageHistoryList && - pageHistoryList.items.map((historyItem, index) => ( - - ))} + {historyItems.map((historyItem, index) => ( + + ))} + {hasNextPage &&
} + {isFetchingNextPage && ( +
+ +
+ )} - {spaceAbility.cannot( - SpaceCaslAction.Manage, - SpaceCaslSubject.Page, - ) ? null : ( + {canRestore && ( <> - + )} diff --git a/apps/client/src/features/page-history/components/history-modal-body.tsx b/apps/client/src/features/page-history/components/history-modal-body.tsx index 199601fc..5673c82a 100644 --- a/apps/client/src/features/page-history/components/history-modal-body.tsx +++ b/apps/client/src/features/page-history/components/history-modal-body.tsx @@ -1,21 +1,45 @@ -import { ScrollArea } from "@mantine/core"; +import { + ActionIcon, + Group, + Paper, + ScrollArea, + Switch, + Text, +} from "@mantine/core"; import HistoryList from "@/features/page-history/components/history-list"; -import classes from "./history.module.css"; -import { useAtom } from "jotai"; -import { activeHistoryIdAtom } from "@/features/page-history/atoms/history-atoms"; +import classes from "./css/history.module.css"; +import { useAtom, useAtomValue } from "jotai"; +import { + activeHistoryIdAtom, + activeHistoryPrevIdAtom, + diffCountsAtom, + highlightChangesAtom, +} from "@/features/page-history/atoms/history-atoms"; import HistoryView from "@/features/page-history/components/history-view"; -import { useEffect } from "react"; +import { useRef } from "react"; +import { IconChevronUp, IconChevronDown } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { + useDiffNavigation, + useHistoryReset, +} from "@/features/page-history/hooks"; interface Props { pageId: string; } export default function HistoryModalBody({ pageId }: Props) { - const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom); + const { t } = useTranslation(); + const scrollViewportRef = useRef(null); - useEffect(() => { - setActiveHistoryId(""); - }, [pageId]); + const activeHistoryId = useAtomValue(activeHistoryIdAtom); + const activeHistoryPrevId = useAtomValue(activeHistoryPrevIdAtom); + const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom); + const diffCounts = useAtomValue(diffCountsAtom); + + useHistoryReset(pageId); + const { currentChangeIndex, handlePrevChange, handleNextChange } = + useDiffNavigation(scrollViewportRef); return (
@@ -25,11 +49,63 @@ export default function HistoryModalBody({ pageId }: Props) {
- -
- {activeHistoryId && } -
-
+
+ +
+ {activeHistoryId && } +
+
+ + {activeHistoryId && activeHistoryPrevId && ( + + + setHighlightChanges(e.currentTarget.checked)} + styles={{ label: { userSelect: "none", whiteSpace: "nowrap" } }} + /> + {highlightChanges && diffCounts && diffCounts.total > 0 && ( + + + {currentChangeIndex} of {diffCounts.total} + + + + + + + + + )} + + + )} +
); } diff --git a/apps/client/src/features/page-history/components/history-modal-mobile.tsx b/apps/client/src/features/page-history/components/history-modal-mobile.tsx new file mode 100644 index 00000000..b73695da --- /dev/null +++ b/apps/client/src/features/page-history/components/history-modal-mobile.tsx @@ -0,0 +1,215 @@ +import { + ActionIcon, + Box, + Button, + Group, + Paper, + ScrollArea, + Select, + Switch, + Text, +} from "@mantine/core"; +import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import { + activeHistoryIdAtom, + activeHistoryPrevIdAtom, + diffCountsAtom, + highlightChangesAtom, + historyAtoms, +} from "@/features/page-history/atoms/history-atoms"; +import HistoryView from "@/features/page-history/components/history-view"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { IconCheck, IconChevronDown, IconChevronUp } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { usePageHistoryListQuery } from "@/features/page-history/queries/page-history-query"; +import { formattedDate } from "@/lib/time"; +import { + useDiffNavigation, + useHistoryReset, + useHistoryRestore, +} from "@/features/page-history/hooks"; +import classes from "./css/history-mobile.module.css"; + +interface Props { + pageId: string; + pageTitle?: string; +} + +export default function HistoryModalMobile({ pageId, pageTitle }: Props) { + const { t } = useTranslation(); + + const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom); + const setActiveHistoryPrevId = useSetAtom(activeHistoryPrevIdAtom); + const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom); + const diffCounts = useAtomValue(diffCountsAtom); + const setHistoryModalOpen = useSetAtom(historyAtoms); + + const scrollViewportRef = useRef(null); + const dropdownViewportRef = useRef(null); + + const { + data: pageHistoryData, + isLoading, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = usePageHistoryListQuery(pageId); + + const historyItems = useMemo( + () => pageHistoryData?.pages.flatMap((page) => page.items) ?? [], + [pageHistoryData], + ); + + const selectData = useMemo( + () => + historyItems.map((item) => { + const contributors = item.contributors; + const hasContributors = contributors && contributors.length > 0; + const names = hasContributors + ? contributors.map((c) => c.name).join(", ") + : item.lastUpdatedBy?.name; + return { + value: item.id, + label: formattedDate(new Date(item.createdAt)), + userName: names, + }; + }), + [historyItems], + ); + + useHistoryReset(pageId); + const { canRestore, confirmRestore } = useHistoryRestore(); + const { currentChangeIndex, handlePrevChange, handleNextChange } = + useDiffNavigation(scrollViewportRef); + + useEffect(() => { + if (historyItems.length > 0 && !activeHistoryId) { + setActiveHistoryId(historyItems[0].id); + setActiveHistoryPrevId(historyItems[1]?.id ?? ""); + } + }, [ + historyItems, + activeHistoryId, + setActiveHistoryId, + setActiveHistoryPrevId, + ]); + + const handleDropdownScroll = useCallback(() => { + const viewport = dropdownViewportRef.current; + if (!viewport || !hasNextPage || isFetchingNextPage) return; + + const { scrollTop, scrollHeight, clientHeight } = viewport; + const isNearBottom = scrollTop + clientHeight >= scrollHeight - 50; + + if (isNearBottom) { + fetchNextPage(); + } + }, [fetchNextPage, hasNextPage, isFetchingNextPage]); + + const handleSelectVersion = useCallback( + (value: string | null) => { + if (!value) return; + const index = historyItems.findIndex((item) => item.id === value); + if (index >= 0) { + setActiveHistoryId(value); + setActiveHistoryPrevId(historyItems[index + 1]?.id ?? ""); + } + }, + [historyItems, setActiveHistoryId, setActiveHistoryPrevId], + ); + + if (isLoading) { + return null; + } + + return ( + + +