mirror of
https://github.com/docmost/docmost.git
synced 2026-08-29 18:14:58 +08:00
Merge branch 'main' into perm-x
This commit is contained in:
+10
-10
@@ -14,14 +14,14 @@
|
|||||||
"@docmost/editor-ext": "workspace:*",
|
"@docmost/editor-ext": "workspace:*",
|
||||||
"@emoji-mart/data": "^1.2.1",
|
"@emoji-mart/data": "^1.2.1",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@excalidraw/excalidraw": "0.18.0-c158187",
|
"@excalidraw/excalidraw": "0.18.0-3a5ef40",
|
||||||
"@mantine/core": "^8.3.12",
|
"@mantine/core": "^8.3.14",
|
||||||
"@mantine/dates": "^8.3.12",
|
"@mantine/dates": "^8.3.14",
|
||||||
"@mantine/form": "^8.3.12",
|
"@mantine/form": "^8.3.14",
|
||||||
"@mantine/hooks": "^8.3.12",
|
"@mantine/hooks": "^8.3.14",
|
||||||
"@mantine/modals": "^8.3.12",
|
"@mantine/modals": "^8.3.14",
|
||||||
"@mantine/notifications": "^8.3.12",
|
"@mantine/notifications": "^8.3.14",
|
||||||
"@mantine/spotlight": "^8.3.12",
|
"@mantine/spotlight": "^8.3.14",
|
||||||
"@tabler/icons-react": "^3.36.1",
|
"@tabler/icons-react": "^3.36.1",
|
||||||
"@tanstack/react-query": "^5.90.17",
|
"@tanstack/react-query": "^5.90.17",
|
||||||
"alfaaz": "^1.1.0",
|
"alfaaz": "^1.1.0",
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
"mantine-form-zod-resolver": "^1.3.0",
|
"mantine-form-zod-resolver": "^1.3.0",
|
||||||
"mermaid": "^11.12.2",
|
"mermaid": "^11.12.2",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"posthog-js": "^1.255.1",
|
"posthog-js": "1.345.5",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-arborist": "3.4.0",
|
"react-arborist": "3.4.0",
|
||||||
"react-clear-modal": "^2.0.17",
|
"react-clear-modal": "^2.0.17",
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
"eslint": "^9.15.0",
|
"eslint": "^9.39.2",
|
||||||
"eslint-plugin-react": "^7.37.2",
|
"eslint-plugin-react": "^7.37.2",
|
||||||
"eslint-plugin-react-hooks": "^5.1.0",
|
"eslint-plugin-react-hooks": "^5.1.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.16",
|
"eslint-plugin-react-refresh": "^0.4.16",
|
||||||
|
|||||||
@@ -355,6 +355,8 @@
|
|||||||
"Insert current date": "Aktuelles Datum einfügen",
|
"Insert current date": "Aktuelles Datum einfügen",
|
||||||
"Draw and sketch excalidraw diagrams": "Excalidraw-Diagramme zeichnen und skizzieren",
|
"Draw and sketch excalidraw diagrams": "Excalidraw-Diagramme zeichnen und skizzieren",
|
||||||
"Multiple": "Mehrere",
|
"Multiple": "Mehrere",
|
||||||
|
"Turn into": "In verwandeln",
|
||||||
|
"Text align": "Text ausrichten",
|
||||||
"Heading {{level}}": "Überschrift {{level}}",
|
"Heading {{level}}": "Überschrift {{level}}",
|
||||||
"Toggle title": "Titel umschalten",
|
"Toggle title": "Titel umschalten",
|
||||||
"Write anything. Enter \"/\" for commands": "Schreiben Sie irgendetwas. Geben Sie \"/\" für Befehle ein",
|
"Write anything. Enter \"/\" for commands": "Schreiben Sie irgendetwas. Geben Sie \"/\" für Befehle ein",
|
||||||
@@ -582,13 +584,33 @@
|
|||||||
"Ask AI": "KI fragen",
|
"Ask AI": "KI fragen",
|
||||||
"AI is thinking...": "Die KI überlegt...",
|
"AI is thinking...": "Die KI überlegt...",
|
||||||
"Ask a question...": "Fragen stellen...",
|
"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.",
|
"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",
|
"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",
|
"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",
|
"No answer available": "Keine Antwort verfügbar",
|
||||||
"Background color": "Hintergrundfarbe",
|
"Background color": "Hintergrundfarbe",
|
||||||
"Highlight color": "Hervorhebungsfarbe",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -355,6 +355,8 @@
|
|||||||
"Insert current date": "Insert current date",
|
"Insert current date": "Insert current date",
|
||||||
"Draw and sketch excalidraw diagrams": "Draw and sketch excalidraw diagrams",
|
"Draw and sketch excalidraw diagrams": "Draw and sketch excalidraw diagrams",
|
||||||
"Multiple": "Multiple",
|
"Multiple": "Multiple",
|
||||||
|
"Turn into": "Turn into",
|
||||||
|
"Text align": "Text align",
|
||||||
"Heading {{level}}": "Heading {{level}}",
|
"Heading {{level}}": "Heading {{level}}",
|
||||||
"Toggle title": "Toggle title",
|
"Toggle title": "Toggle title",
|
||||||
"Write anything. Enter \"/\" for commands": "Write anything. Enter \"/\" for commands",
|
"Write anything. Enter \"/\" for commands": "Write anything. Enter \"/\" for commands",
|
||||||
@@ -582,13 +584,33 @@
|
|||||||
"Ask AI": "Ask AI",
|
"Ask AI": "Ask AI",
|
||||||
"AI is thinking...": "AI is thinking...",
|
"AI is thinking...": "AI is thinking...",
|
||||||
"Ask a question...": "Ask a question...",
|
"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.",
|
"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",
|
"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",
|
"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",
|
"No answer available": "No answer available",
|
||||||
"Background color": "Background color",
|
"Background color": "Background color",
|
||||||
"Highlight color": "Highlight 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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -355,6 +355,8 @@
|
|||||||
"Insert current date": "Insertar fecha actual",
|
"Insert current date": "Insertar fecha actual",
|
||||||
"Draw and sketch excalidraw diagrams": "Dibujar y esbozar diagramas de Excalidraw",
|
"Draw and sketch excalidraw diagrams": "Dibujar y esbozar diagramas de Excalidraw",
|
||||||
"Multiple": "Múltiple",
|
"Multiple": "Múltiple",
|
||||||
|
"Turn into": "Convertir en",
|
||||||
|
"Text align": "Alineación del texto",
|
||||||
"Heading {{level}}": "Encabezado {{level}}",
|
"Heading {{level}}": "Encabezado {{level}}",
|
||||||
"Toggle title": "Alternar título",
|
"Toggle title": "Alternar título",
|
||||||
"Write anything. Enter \"/\" for commands": "Escribe cualquier cosa. Ingresa \"/\" para comandos",
|
"Write anything. Enter \"/\" for commands": "Escribe cualquier cosa. Ingresa \"/\" para comandos",
|
||||||
@@ -582,13 +584,33 @@
|
|||||||
"Ask AI": "Preguntar a IA",
|
"Ask AI": "Preguntar a IA",
|
||||||
"AI is thinking...": "IA está pensando...",
|
"AI is thinking...": "IA está pensando...",
|
||||||
"Ask a question...": "Haz una pregunta...",
|
"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.",
|
"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",
|
"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",
|
"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",
|
"No answer available": "No hay respuesta disponible",
|
||||||
"Background color": "Color de fondo",
|
"Background color": "Color de fondo",
|
||||||
"Highlight color": "Color de resaltado",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -355,6 +355,8 @@
|
|||||||
"Insert current date": "Insérer la date actuelle",
|
"Insert current date": "Insérer la date actuelle",
|
||||||
"Draw and sketch excalidraw diagrams": "Dessiner et esquisser des diagrammes Excalidraw",
|
"Draw and sketch excalidraw diagrams": "Dessiner et esquisser des diagrammes Excalidraw",
|
||||||
"Multiple": "Multiple",
|
"Multiple": "Multiple",
|
||||||
|
"Turn into": "Transformer en",
|
||||||
|
"Text align": "Alignement du texte",
|
||||||
"Heading {{level}}": "Titre {{level}}",
|
"Heading {{level}}": "Titre {{level}}",
|
||||||
"Toggle title": "Basculer le titre",
|
"Toggle title": "Basculer le titre",
|
||||||
"Write anything. Enter \"/\" for commands": "Écrivez n'importe quoi. Entrez \"/\" pour les commandes",
|
"Write anything. Enter \"/\" for commands": "Écrivez n'importe quoi. Entrez \"/\" pour les commandes",
|
||||||
@@ -582,13 +584,33 @@
|
|||||||
"Ask AI": "Demander à l'IA",
|
"Ask AI": "Demander à l'IA",
|
||||||
"AI is thinking...": "L'IA réfléchit...",
|
"AI is thinking...": "L'IA réfléchit...",
|
||||||
"Ask a question...": "Posez une question...",
|
"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.",
|
"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",
|
"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",
|
"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",
|
"No answer available": "Pas de réponse disponible",
|
||||||
"Background color": "Couleur de fond",
|
"Background color": "Couleur de fond",
|
||||||
"Highlight color": "Couleur de surbrillance",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -355,6 +355,8 @@
|
|||||||
"Insert current date": "Inserisci la data corrente",
|
"Insert current date": "Inserisci la data corrente",
|
||||||
"Draw and sketch excalidraw diagrams": "Disegna e schizza diagrammi excalidraw",
|
"Draw and sketch excalidraw diagrams": "Disegna e schizza diagrammi excalidraw",
|
||||||
"Multiple": "Multiplo",
|
"Multiple": "Multiplo",
|
||||||
|
"Turn into": "Trasforma in",
|
||||||
|
"Text align": "Allinea testo",
|
||||||
"Heading {{level}}": "Intestazione {{level}}",
|
"Heading {{level}}": "Intestazione {{level}}",
|
||||||
"Toggle title": "Attiva/disattiva titolo",
|
"Toggle title": "Attiva/disattiva titolo",
|
||||||
"Write anything. Enter \"/\" for commands": "Scrivi qualcosa. Digita \"/\" per i comandi",
|
"Write anything. Enter \"/\" for commands": "Scrivi qualcosa. Digita \"/\" per i comandi",
|
||||||
@@ -582,13 +584,33 @@
|
|||||||
"Ask AI": "Chiedi all'AI",
|
"Ask AI": "Chiedi all'AI",
|
||||||
"AI is thinking...": "L'AI sta pensando...",
|
"AI is thinking...": "L'AI sta pensando...",
|
||||||
"Ask a question...": "Fai una domanda...",
|
"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.",
|
"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",
|
"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",
|
"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",
|
"No answer available": "Nessuna risposta disponibile",
|
||||||
"Background color": "Colore di sfondo",
|
"Background color": "Colore di sfondo",
|
||||||
"Highlight color": "Colore evidenziato",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -355,6 +355,8 @@
|
|||||||
"Insert current date": "現在の日付を挿入します",
|
"Insert current date": "現在の日付を挿入します",
|
||||||
"Draw and sketch excalidraw diagrams": "Excalidraw 図を挿入します",
|
"Draw and sketch excalidraw diagrams": "Excalidraw 図を挿入します",
|
||||||
"Multiple": "複数",
|
"Multiple": "複数",
|
||||||
|
"Turn into": "変換する",
|
||||||
|
"Text align": "テキストの配置",
|
||||||
"Heading {{level}}": "見出し {{level}}",
|
"Heading {{level}}": "見出し {{level}}",
|
||||||
"Toggle title": "タイトルの表示/非表示を切り替える",
|
"Toggle title": "タイトルの表示/非表示を切り替える",
|
||||||
"Write anything. Enter \"/\" for commands": "文字を入力するか、「/」でコマンドを呼び出します",
|
"Write anything. Enter \"/\" for commands": "文字を入力するか、「/」でコマンドを呼び出します",
|
||||||
@@ -582,13 +584,33 @@
|
|||||||
"Ask AI": "AIに質問する",
|
"Ask AI": "AIに質問する",
|
||||||
"AI is thinking...": "AIが考え中...",
|
"AI is thinking...": "AIが考え中...",
|
||||||
"Ask a question...": "質問を入力...",
|
"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検索はベクター埋め込みを使用してワークスペース全体の意味検索を実現します",
|
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI検索はベクター埋め込みを使用してワークスペース全体の意味検索を実現します",
|
||||||
"Toggle AI search": "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": "ソース",
|
"Sources": "ソース",
|
||||||
"Ask AI not available for attachments": "添付ファイルにはAI質問は利用できません",
|
"AI Answers not available for attachments": "添付ファイルにはAI回答を利用できません",
|
||||||
"No answer available": "回答がありません",
|
"No answer available": "回答がありません",
|
||||||
"Background color": "背景色",
|
"Background color": "背景色",
|
||||||
"Highlight 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": "以前のもの"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -355,6 +355,8 @@
|
|||||||
"Insert current date": "현재 날짜 삽입",
|
"Insert current date": "현재 날짜 삽입",
|
||||||
"Draw and sketch excalidraw diagrams": "Excalidraw diagram 그리기 및 스케치",
|
"Draw and sketch excalidraw diagrams": "Excalidraw diagram 그리기 및 스케치",
|
||||||
"Multiple": "복제",
|
"Multiple": "복제",
|
||||||
|
"Turn into": "변경하기",
|
||||||
|
"Text align": "텍스트 정렬",
|
||||||
"Heading {{level}}": "제목 {{level}}",
|
"Heading {{level}}": "제목 {{level}}",
|
||||||
"Toggle title": "제목 토글",
|
"Toggle title": "제목 토글",
|
||||||
"Write anything. Enter \"/\" for commands": "아무거나 입력하세요. 명령어를 사용하려면 \"/\"를 입력하세요",
|
"Write anything. Enter \"/\" for commands": "아무거나 입력하세요. 명령어를 사용하려면 \"/\"를 입력하세요",
|
||||||
@@ -582,13 +584,33 @@
|
|||||||
"Ask AI": "AI에게 묻기",
|
"Ask AI": "AI에게 묻기",
|
||||||
"AI is thinking...": "AI가 생각 중입니다...",
|
"AI is thinking...": "AI가 생각 중입니다...",
|
||||||
"Ask a question...": "질문하세요...",
|
"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 검색은 벡터 임베딩을 사용하여 작업공간 콘텐츠에 대한 의미 검색 기능을 제공합니다.",
|
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI 검색은 벡터 임베딩을 사용하여 작업공간 콘텐츠에 대한 의미 검색 기능을 제공합니다.",
|
||||||
"Toggle AI search": "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": "출처",
|
"Sources": "출처",
|
||||||
"Ask AI not available for attachments": "AI에게 묻기 기능은 첨부 파일에 대해 사용할 수 없습니다",
|
"AI Answers not available for attachments": "첨부 파일에 대해 AI 답변을 사용할 수 없습니다",
|
||||||
"No answer available": "답변을 제공할 수 없습니다",
|
"No answer available": "답변을 제공할 수 없습니다",
|
||||||
"Background color": "배경 색",
|
"Background color": "배경 색",
|
||||||
"Highlight 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": "이전"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -355,6 +355,8 @@
|
|||||||
"Insert current date": "Huidige datum invoeren",
|
"Insert current date": "Huidige datum invoeren",
|
||||||
"Draw and sketch excalidraw diagrams": "Teken en schets excalidraw diagrammen",
|
"Draw and sketch excalidraw diagrams": "Teken en schets excalidraw diagrammen",
|
||||||
"Multiple": "Meerdere",
|
"Multiple": "Meerdere",
|
||||||
|
"Turn into": "Omzetten naar",
|
||||||
|
"Text align": "Tekstuitlijning",
|
||||||
"Heading {{level}}": "Kop {{level}}",
|
"Heading {{level}}": "Kop {{level}}",
|
||||||
"Toggle title": "Schakel titel in/uit",
|
"Toggle title": "Schakel titel in/uit",
|
||||||
"Write anything. Enter \"/\" for commands": "Schrijf iets. Voer \"/\" in voor commando's",
|
"Write anything. Enter \"/\" for commands": "Schrijf iets. Voer \"/\" in voor commando's",
|
||||||
@@ -582,13 +584,33 @@
|
|||||||
"Ask AI": "Vraag AI",
|
"Ask AI": "Vraag AI",
|
||||||
"AI is thinking...": "AI is aan het nadenken...",
|
"AI is thinking...": "AI is aan het nadenken...",
|
||||||
"Ask a question...": "Stel een vraag...",
|
"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.",
|
"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",
|
"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",
|
"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",
|
"No answer available": "Geen antwoord beschikbaar",
|
||||||
"Background color": "Achtergrondkleur",
|
"Background color": "Achtergrondkleur",
|
||||||
"Highlight color": "Markeerkleur",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -355,6 +355,8 @@
|
|||||||
"Insert current date": "Insira a data atual",
|
"Insert current date": "Insira a data atual",
|
||||||
"Draw and sketch excalidraw diagrams": "Desenhe e esboce diagramas Excalidraw",
|
"Draw and sketch excalidraw diagrams": "Desenhe e esboce diagramas Excalidraw",
|
||||||
"Multiple": "Múltiplo",
|
"Multiple": "Múltiplo",
|
||||||
|
"Turn into": "Transformar em",
|
||||||
|
"Text align": "Alinhar texto",
|
||||||
"Heading {{level}}": "Título {{level}}",
|
"Heading {{level}}": "Título {{level}}",
|
||||||
"Toggle title": "Alternar título",
|
"Toggle title": "Alternar título",
|
||||||
"Write anything. Enter \"/\" for commands": "Escreva qualquer coisa. Digite \"/\" para comandos",
|
"Write anything. Enter \"/\" for commands": "Escreva qualquer coisa. Digite \"/\" para comandos",
|
||||||
@@ -582,13 +584,33 @@
|
|||||||
"Ask AI": "Pergunte à IA",
|
"Ask AI": "Pergunte à IA",
|
||||||
"AI is thinking...": "IA está pensando...",
|
"AI is thinking...": "IA está pensando...",
|
||||||
"Ask a question...": "Faça uma pergunta...",
|
"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.",
|
"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",
|
"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",
|
"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",
|
"No answer available": "Nenhuma resposta disponível",
|
||||||
"Background color": "Cor de fundo",
|
"Background color": "Cor de fundo",
|
||||||
"Highlight color": "Cor de destaque",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -355,6 +355,8 @@
|
|||||||
"Insert current date": "Вставить текущую дату",
|
"Insert current date": "Вставить текущую дату",
|
||||||
"Draw and sketch excalidraw diagrams": "Вставить и рисовать диаграммы Excalidraw",
|
"Draw and sketch excalidraw diagrams": "Вставить и рисовать диаграммы Excalidraw",
|
||||||
"Multiple": "Несколько",
|
"Multiple": "Несколько",
|
||||||
|
"Turn into": "Преобразовать в",
|
||||||
|
"Text align": "Выравнивание текста",
|
||||||
"Heading {{level}}": "Заголовок {{level}}",
|
"Heading {{level}}": "Заголовок {{level}}",
|
||||||
"Toggle title": "Переключить заголовок",
|
"Toggle title": "Переключить заголовок",
|
||||||
"Write anything. Enter \"/\" for commands": "Начните писать. Введите \"/\" для списка команд",
|
"Write anything. Enter \"/\" for commands": "Начните писать. Введите \"/\" для списка команд",
|
||||||
@@ -582,13 +584,33 @@
|
|||||||
"Ask AI": "Спросить ИИ",
|
"Ask AI": "Спросить ИИ",
|
||||||
"AI is thinking...": "ИИ обрабатывает запрос...",
|
"AI is thinking...": "ИИ обрабатывает запрос...",
|
||||||
"Ask a question...": "Задайте вопрос...",
|
"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.": "Поиск ИИ использует векторные встраивания для обеспечения семантического поиска по содержимому вашего рабочего пространства.",
|
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Поиск ИИ использует векторные встраивания для обеспечения семантического поиска по содержимому вашего рабочего пространства.",
|
||||||
"Toggle AI search": "Переключить поиск ИИ",
|
"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": "Источники",
|
"Sources": "Источники",
|
||||||
"Ask AI 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": "Удалить цвет",
|
||||||
|
"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": "Старше"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -355,6 +355,8 @@
|
|||||||
"Insert current date": "Вставити поточну дату",
|
"Insert current date": "Вставити поточну дату",
|
||||||
"Draw and sketch excalidraw diagrams": "Вставити та малювати діаграми Excalidraw",
|
"Draw and sketch excalidraw diagrams": "Вставити та малювати діаграми Excalidraw",
|
||||||
"Multiple": "Декілька",
|
"Multiple": "Декілька",
|
||||||
|
"Turn into": "Перетворити",
|
||||||
|
"Text align": "Вирівнювання тексту",
|
||||||
"Heading {{level}}": "Заголовок {{level}}",
|
"Heading {{level}}": "Заголовок {{level}}",
|
||||||
"Toggle title": "Перемкнути заголовок",
|
"Toggle title": "Перемкнути заголовок",
|
||||||
"Write anything. Enter \"/\" for commands": "Почніть писати. Введіть \"/\" для списку команд",
|
"Write anything. Enter \"/\" for commands": "Почніть писати. Введіть \"/\" для списку команд",
|
||||||
@@ -582,13 +584,33 @@
|
|||||||
"Ask AI": "Запитати ШІ",
|
"Ask AI": "Запитати ШІ",
|
||||||
"AI is thinking...": "ШІ думає...",
|
"AI is thinking...": "ШІ думає...",
|
||||||
"Ask a question...": "Задайте питання...",
|
"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.": "Пошук з ШІ використовує векторні вбудовування для надання можливостей семантичного пошуку у вашому робочому вмісті.",
|
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Пошук з ШІ використовує векторні вбудовування для надання можливостей семантичного пошуку у вашому робочому вмісті.",
|
||||||
"Toggle AI search": "Переключити пошук з ШІ",
|
"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": "Джерела",
|
"Sources": "Джерела",
|
||||||
"Ask AI 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": "Видалити колір",
|
||||||
|
"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": "Старіші"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -355,6 +355,8 @@
|
|||||||
"Insert current date": "插入当前日期",
|
"Insert current date": "插入当前日期",
|
||||||
"Draw and sketch excalidraw diagrams": "绘制 Excalidraw 图表",
|
"Draw and sketch excalidraw diagrams": "绘制 Excalidraw 图表",
|
||||||
"Multiple": "多个",
|
"Multiple": "多个",
|
||||||
|
"Turn into": "变成",
|
||||||
|
"Text align": "文本对齐",
|
||||||
"Heading {{level}}": "{{level}} 级标题",
|
"Heading {{level}}": "{{level}} 级标题",
|
||||||
"Toggle title": "切换标题",
|
"Toggle title": "切换标题",
|
||||||
"Write anything. Enter \"/\" for commands": "开始编写内容,输入 \"/\" 以使用指令",
|
"Write anything. Enter \"/\" for commands": "开始编写内容,输入 \"/\" 以使用指令",
|
||||||
@@ -582,13 +584,33 @@
|
|||||||
"Ask AI": "询问AI",
|
"Ask AI": "询问AI",
|
||||||
"AI is thinking...": "AI正在思考...",
|
"AI is thinking...": "AI正在思考...",
|
||||||
"Ask a question...": "提问...",
|
"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搜索使用向量嵌入提供跨工作空间内容的语义搜索功能。",
|
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI搜索使用向量嵌入提供跨工作空间内容的语义搜索功能。",
|
||||||
"Toggle AI search": "切换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": "来源",
|
"Sources": "来源",
|
||||||
"Ask AI not available for attachments": "附件不支持询问AI",
|
"AI Answers not available for attachments": "AI答案不适用于附件",
|
||||||
"No answer available": "无可用答案",
|
"No answer available": "无可用答案",
|
||||||
"Background color": "背景颜色",
|
"Background color": "背景颜色",
|
||||||
"Highlight 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": "较早"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
searchSpotlight,
|
searchSpotlight,
|
||||||
shareSearchSpotlight,
|
shareSearchSpotlight,
|
||||||
} from "@/features/search/constants.ts";
|
} from "@/features/search/constants.ts";
|
||||||
|
import { NotificationPopover } from "@/features/notification/components/notification-popover.tsx";
|
||||||
|
|
||||||
const links = [{ link: APP_ROUTE.HOME, label: "Home" }];
|
const links = [{ link: APP_ROUTE.HOME, label: "Home" }];
|
||||||
|
|
||||||
@@ -97,6 +98,7 @@ export function AppHeader() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Group px={"xl"} wrap="nowrap">
|
<Group px={"xl"} wrap="nowrap">
|
||||||
|
<NotificationPopover />
|
||||||
{isCloud() && isTrial && trialDaysLeft !== 0 && (
|
{isCloud() && isTrial && trialDaysLeft !== 0 && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="light"
|
variant="light"
|
||||||
|
|||||||
@@ -115,7 +115,6 @@ const groupedData: DataGroup[] = [
|
|||||||
icon: IconSparkles,
|
icon: IconSparkles,
|
||||||
path: "/settings/ai",
|
path: "/settings/ai",
|
||||||
isAdmin: true,
|
isAdmin: true,
|
||||||
isSelfhosted: true,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<HTMLDivElement | null>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const [prompt, setPrompt] = useState("");
|
||||||
|
const [output, setOutput] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||||
|
const [activeCommandSet, setActiveCommandSet] = useState<CommandSet>("main");
|
||||||
|
const [lastAction, setLastAction] = useState<CommandItem | null>(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 <p> wrapper for single-paragraph output to preserve inline context
|
||||||
|
const content =
|
||||||
|
html.startsWith("<p>") &&
|
||||||
|
html.endsWith("</p>") &&
|
||||||
|
html.lastIndexOf("<p>") === 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<HTMLInputElement>) => {
|
||||||
|
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(
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
zIndex: 200,
|
||||||
|
position: "absolute",
|
||||||
|
top: menuPlacement.top,
|
||||||
|
left: menuPlacement.left,
|
||||||
|
width: menuPlacement.width,
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={classes.aiMenu}
|
||||||
|
style={{ pointerEvents: "auto" }}
|
||||||
|
tabIndex={0}
|
||||||
|
ref={containerRef}
|
||||||
|
>
|
||||||
|
<ResultPreview output={output} isLoading={isLoading} />
|
||||||
|
<CommandSelector
|
||||||
|
selectedIndex={selectedIndex}
|
||||||
|
isLoading={isLoading}
|
||||||
|
output={output}
|
||||||
|
currentItems={currentItems}
|
||||||
|
handleCommand={handleCommand}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
ref={inputRef}
|
||||||
|
className={classes.aiInput}
|
||||||
|
placeholder="Ask AI..."
|
||||||
|
data-autofocus
|
||||||
|
value={prompt}
|
||||||
|
disabled={isLoading}
|
||||||
|
onChange={(e) => setPrompt(e.currentTarget.value)}
|
||||||
|
rightSection={
|
||||||
|
<ActionIcon
|
||||||
|
disabled={!prompt || isLoading}
|
||||||
|
variant="filled"
|
||||||
|
color="blue"
|
||||||
|
radius="xl"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleGenerate()}
|
||||||
|
>
|
||||||
|
<IconArrowUp size={14} stroke={2.5} />
|
||||||
|
</ActionIcon>
|
||||||
|
}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
</CommandSelector>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { EditorAiMenu };
|
||||||
@@ -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<CommandSet, CommandItem[]> = {
|
||||||
|
main: mainItems,
|
||||||
|
tone: toneItems,
|
||||||
|
translate: translateItems,
|
||||||
|
result: resultItems,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type { CommandItem, CommandSet };
|
||||||
|
export { commandItems };
|
||||||
@@ -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 (
|
||||||
|
<Menu
|
||||||
|
opened={!isLoading && currentItems.length > 0}
|
||||||
|
middlewares={{ flip: false }}
|
||||||
|
position="bottom-start"
|
||||||
|
offset={4}
|
||||||
|
width={250}
|
||||||
|
trapFocus={false}
|
||||||
|
shadow="lg"
|
||||||
|
>
|
||||||
|
<Menu.Target>{children}</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<ScrollArea.Autosize type="scroll" scrollbarSize={5} mah={300}>
|
||||||
|
{currentItems.map((item, index) => {
|
||||||
|
const isSelected = selectedIndex === index;
|
||||||
|
const showLoader =
|
||||||
|
isLoading && output === "" && !item.subCommandSet;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu.Item
|
||||||
|
key={item.id}
|
||||||
|
className={isSelected ? classes.menuItemSelected : undefined}
|
||||||
|
leftSection={
|
||||||
|
showLoader ? (
|
||||||
|
<Loader size={14} />
|
||||||
|
) : item.icon ? (
|
||||||
|
<item.icon size={16} />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
rightSection={
|
||||||
|
item.subCommandSet ? (
|
||||||
|
<IconChevronRight size={14} />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
onClick={() => handleCommand(item)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Menu.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { CommandSelector };
|
||||||
@@ -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 (
|
||||||
|
<Paper mb={4} shadow="lg" radius="md" className={classes.resultPreview}>
|
||||||
|
<ScrollArea.Autosize mah={300} type="scroll" scrollbarSize={5}>
|
||||||
|
<div className={classes.resultPreviewWrapper}>
|
||||||
|
{parsedOutput && (
|
||||||
|
<div
|
||||||
|
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(parsedOutput) }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isLoading && <Loader size={12} ml="xs" display="inline-block" />}
|
||||||
|
</div>
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export { ResultPreview };
|
||||||
@@ -15,7 +15,7 @@ export default function EnableAiSearch() {
|
|||||||
<>
|
<>
|
||||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||||
<div>
|
<div>
|
||||||
<Text size="md">{t("AI-powered search (Ask AI)")}</Text>
|
<Text size="md">{t("AI-powered search (AI Answers)")}</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{t(
|
{t(
|
||||||
"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.",
|
||||||
|
|||||||
@@ -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<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||||
|
<div>
|
||||||
|
<Text size="md">{t("Generative AI (Ask AI)")}</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t(
|
||||||
|
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
defaultChecked={checked}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={!hasAccess}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMutation, UseMutationResult } from "@tanstack/react-query";
|
import { useMutation, UseMutationResult } from "@tanstack/react-query";
|
||||||
import { useState, useCallback } from "react";
|
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";
|
import { IPageSearchParams } from "@/features/search/types/search.types.ts";
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -26,7 +26,7 @@ export function useAiSearch(): UseAiSearchResult {
|
|||||||
|
|
||||||
const { contentType, ...apiParams } = params;
|
const { contentType, ...apiParams } = params;
|
||||||
|
|
||||||
return await askAi(apiParams, (chunk) => {
|
return await aiAnswers(apiParams, (chunk) => {
|
||||||
if (chunk.content) {
|
if (chunk.content) {
|
||||||
setStreamingAnswer((prev) => prev + chunk.content);
|
setStreamingAnswer((prev) => prev + chunk.content);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
import { Helmet } from "react-helmet-async";
|
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 SettingsTitle from "@/components/settings/settings-title.tsx";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import useLicense from "@/ee/hooks/use-license.tsx";
|
|
||||||
import EnableAiSearch from "@/ee/ai/components/enable-ai-search.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 { IconInfoCircle } from "@tabler/icons-react";
|
||||||
|
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx";
|
||||||
|
import { isCloud } from "@/lib/config.ts";
|
||||||
|
|
||||||
export default function AiSettings() {
|
export default function AiSettings() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isAdmin } = useUserRole();
|
const { isAdmin } = useUserRole();
|
||||||
const { hasLicenseKey } = useLicense();
|
const hasAccess = useIsCloudEE();
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasAccess = isCloud() || (!isCloud() && hasLicenseKey);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
@@ -40,7 +40,10 @@ export default function AiSettings() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<EnableAiSearch />
|
<Stack gap="md">
|
||||||
|
{!isCloud() && <EnableAiSearch />}
|
||||||
|
<EnableGenerativeAi />
|
||||||
|
</Stack>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ export interface IAiSearchResponse {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function askAi(
|
export async function aiAnswers(
|
||||||
params: IPageSearchParams,
|
params: IPageSearchParams,
|
||||||
onChunk?: (chunk: { content?: string; sources?: any[] }) => void,
|
onChunk?: (chunk: { content?: string; sources?: any[] }) => void,
|
||||||
): Promise<IAiSearchResponse> {
|
): Promise<IAiSearchResponse> {
|
||||||
const response = await fetch("/api/ai/ask", {
|
const response = await fetch("/api/ai/answers", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|||||||
@@ -43,13 +43,16 @@ export async function generateAiContentStream(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const processStream = async () => {
|
const processStream = async () => {
|
||||||
|
let buffer = "";
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
if (done) break;
|
if (done) break;
|
||||||
|
|
||||||
const chunk = decoder.decode(value, { stream: true });
|
buffer += decoder.decode(value, { stream: true });
|
||||||
const lines = chunk.split("\n");
|
const lines = buffer.split("\n");
|
||||||
|
|
||||||
|
buffer = lines.pop() || "";
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.startsWith("data: ")) {
|
if (line.startsWith("data: ")) {
|
||||||
@@ -66,7 +69,7 @@ export async function generateAiContentStream(
|
|||||||
onChunk(parsed);
|
onChunk(parsed);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Ignore parse errors for incomplete chunks
|
// Skip invalid JSON
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export enum AiAction {
|
|||||||
SIMPLIFY = "simplify",
|
SIMPLIFY = "simplify",
|
||||||
CHANGE_TONE = "change_tone",
|
CHANGE_TONE = "change_tone",
|
||||||
SUMMARIZE = "summarize",
|
SUMMARIZE = "summarize",
|
||||||
|
EXPLAIN = "explain",
|
||||||
CONTINUE_WRITING = "continue_writing",
|
CONTINUE_WRITING = "continue_writing",
|
||||||
TRANSLATE = "translate",
|
TRANSLATE = "translate",
|
||||||
CUSTOM = "custom",
|
CUSTOM = "custom",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
|||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
const [, setAsideState] = useAtom(asideStateAtom);
|
const [, setAsideState] = useAtom(asideStateAtom);
|
||||||
const useClickOutsideRef = useClickOutside(() => {
|
const useClickOutsideRef = useClickOutside(() => {
|
||||||
|
if (document.querySelector("#mention")) return;
|
||||||
handleDialogClose();
|
handleDialogClose();
|
||||||
});
|
});
|
||||||
const createCommentMutation = useCreateCommentMutation();
|
const createCommentMutation = useCreateCommentMutation();
|
||||||
@@ -105,6 +106,7 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
|||||||
position={{ bottom: 500, right: 50 }}
|
position={{ bottom: 500, right: 50 }}
|
||||||
withCloseButton
|
withCloseButton
|
||||||
withBorder
|
withBorder
|
||||||
|
data-comment-dialog
|
||||||
>
|
>
|
||||||
<Stack gap={2}>
|
<Stack gap={2}>
|
||||||
<Group>
|
<Group>
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { EditorContent, useEditor } from "@tiptap/react";
|
import { EditorContent, ReactNodeViewRenderer, useEditor } from "@tiptap/react";
|
||||||
import { Placeholder } from "@tiptap/extension-placeholder";
|
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 { StarterKit } from "@tiptap/starter-kit";
|
||||||
|
import { Mention, LinkExtension } from "@docmost/editor-ext";
|
||||||
import classes from "./comment.module.css";
|
import classes from "./comment.module.css";
|
||||||
import { useFocusWithin } from "@mantine/hooks";
|
import { useFocusWithin } from "@mantine/hooks";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { forwardRef, useEffect, useImperativeHandle } from "react";
|
import { forwardRef, useEffect, useImperativeHandle } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import EmojiCommand from "@/features/editor/extensions/emoji-command";
|
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 {
|
interface CommentEditorProps {
|
||||||
defaultContent?: any;
|
defaultContent?: any;
|
||||||
@@ -39,13 +40,29 @@ const CommentEditor = forwardRef(
|
|||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
gapcursor: false,
|
gapcursor: false,
|
||||||
dropcursor: false,
|
dropcursor: false,
|
||||||
|
link: false,
|
||||||
}),
|
}),
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder: placeholder || t("Reply..."),
|
placeholder: placeholder || t("Reply..."),
|
||||||
}),
|
}),
|
||||||
Underline,
|
LinkExtension,
|
||||||
Link,
|
|
||||||
EmojiCommand,
|
EmojiCommand,
|
||||||
|
Mention.configure({
|
||||||
|
suggestion: {
|
||||||
|
allowSpaces: true,
|
||||||
|
items: () => [],
|
||||||
|
// @ts-ignore
|
||||||
|
render: mentionRenderItems,
|
||||||
|
},
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: "mention",
|
||||||
|
},
|
||||||
|
}).extend({
|
||||||
|
addNodeView() {
|
||||||
|
this.editor.isInitialized = true;
|
||||||
|
return ReactNodeViewRenderer(MentionView);
|
||||||
|
},
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
editorProps: {
|
editorProps: {
|
||||||
handleDOMEvents: {
|
handleDOMEvents: {
|
||||||
@@ -60,7 +77,8 @@ const CommentEditor = forwardRef(
|
|||||||
].includes(event.key)
|
].includes(event.key)
|
||||||
) {
|
) {
|
||||||
const emojiCommand = document.querySelector("#emoji-command");
|
const emojiCommand = document.querySelector("#emoji-command");
|
||||||
if (emojiCommand) {
|
const mentionPopup = document.querySelector("#mention");
|
||||||
|
if (emojiCommand || mentionPopup) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,7 +126,11 @@ const CommentEditor = forwardRef(
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={focusRef} className={classes.commentEditor}>
|
<div
|
||||||
|
ref={focusRef}
|
||||||
|
className={classes.commentEditor}
|
||||||
|
data-editable={editable || undefined}
|
||||||
|
>
|
||||||
<EditorContent
|
<EditorContent
|
||||||
editor={commentEditor}
|
editor={commentEditor}
|
||||||
className={clsx(classes.ProseMirror, { [classes.focused]: focused })}
|
className={clsx(classes.ProseMirror, { [classes.focused]: focused })}
|
||||||
|
|||||||
@@ -32,11 +32,14 @@
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
max-height: 20vh;
|
|
||||||
padding-left: 6px;
|
padding-left: 6px;
|
||||||
padding-right: 6px;
|
padding-right: 6px;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-editable] .ProseMirror :global(.ProseMirror){
|
||||||
|
max-height: 50vh;
|
||||||
overflow: hidden auto;
|
overflow: hidden auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,3 +8,5 @@ export const titleEditorAtom = atom<Editor | null>(null);
|
|||||||
export const readOnlyEditorAtom = atom<Editor | null>(null);
|
export const readOnlyEditorAtom = atom<Editor | null>(null);
|
||||||
|
|
||||||
export const yjsConnectionStatusAtom = atom<string>("");
|
export const yjsConnectionStatusAtom = atom<string>("");
|
||||||
|
|
||||||
|
export const showAiMenuAtom = atom(false);
|
||||||
|
|||||||
@@ -1,11 +1,53 @@
|
|||||||
.bubbleMenu {
|
.bubbleMenu {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
max-width: 100vw;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
border-radius: 2px;
|
box-shadow: 0 4px 12px light-dark(#cfcfcf, #0f0f0f);
|
||||||
|
border-radius: 6px;
|
||||||
border: 1px solid
|
border: 1px solid
|
||||||
light-dark(var(--mantine-color-gray-3), var(--mantine-color-gray-8));
|
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 {
|
.active {
|
||||||
color: light-dark(var(--mantine-color-blue-8), var(--mantine-color-gray-5));
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ import {
|
|||||||
IconStrikethrough,
|
IconStrikethrough,
|
||||||
IconUnderline,
|
IconUnderline,
|
||||||
IconMessage,
|
IconMessage,
|
||||||
|
IconSparkles,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import classes from "./bubble-menu.module.css";
|
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 { ColorSelector } from "./color-selector";
|
||||||
import { NodeSelector } from "./node-selector";
|
import { NodeSelector } from "./node-selector";
|
||||||
import { TextAlignmentSelector } from "./text-alignment-selector";
|
import { TextAlignmentSelector } from "./text-alignment-selector";
|
||||||
@@ -20,11 +21,13 @@ import {
|
|||||||
draftCommentIdAtom,
|
draftCommentIdAtom,
|
||||||
showCommentPopupAtom,
|
showCommentPopupAtom,
|
||||||
} from "@/features/comment/atoms/comment-atom";
|
} from "@/features/comment/atoms/comment-atom";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { v7 as uuid7 } from "uuid";
|
import { v7 as uuid7 } from "uuid";
|
||||||
import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
|
import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
|
||||||
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
|
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
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 {
|
export interface BubbleMenuItem {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -39,14 +42,22 @@ type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
|
|||||||
|
|
||||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [showAiMenu, setShowAiMenu] = useAtom(showAiMenuAtom);
|
||||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||||
|
const workspace = useAtomValue(workspaceAtom);
|
||||||
|
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
|
||||||
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
|
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
|
||||||
const showCommentPopupRef = useRef(showCommentPopup);
|
const showCommentPopupRef = useRef(showCommentPopup);
|
||||||
|
const showAiMenuRef = useRef(showAiMenu);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
showCommentPopupRef.current = showCommentPopup;
|
showCommentPopupRef.current = showCommentPopup;
|
||||||
}, [showCommentPopup]);
|
}, [showCommentPopup]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
showAiMenuRef.current = showAiMenu;
|
||||||
|
}, [showAiMenu]);
|
||||||
|
|
||||||
const editorState = useEditorState({
|
const editorState = useEditorState({
|
||||||
editor: props.editor,
|
editor: props.editor,
|
||||||
selector: (ctx) => {
|
selector: (ctx) => {
|
||||||
@@ -123,6 +134,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
empty ||
|
empty ||
|
||||||
isNodeSelection(selection) ||
|
isNodeSelection(selection) ||
|
||||||
isCellSelection(selection) ||
|
isCellSelection(selection) ||
|
||||||
|
showAiMenuRef.current ||
|
||||||
showCommentPopupRef?.current
|
showCommentPopupRef?.current
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
@@ -146,9 +158,31 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
|
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
|
||||||
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
||||||
|
|
||||||
|
// Hide the bubble menu immediately when AI menu is shown
|
||||||
|
if (showAiMenu) return;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BubbleMenu {...bubbleMenuProps} style={{ zIndex: 200, position: "relative"}}>
|
<BubbleMenu
|
||||||
|
{...bubbleMenuProps}
|
||||||
|
style={{ zIndex: 200, position: "relative" }}
|
||||||
|
>
|
||||||
<div className={classes.bubbleMenu}>
|
<div className={classes.bubbleMenu}>
|
||||||
|
{isGenerativeAiEnabled && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
className={clsx(classes.buttonRoot)}
|
||||||
|
radius="0"
|
||||||
|
leftSection={<IconSparkles size={16} />}
|
||||||
|
onClick={() => {
|
||||||
|
setShowAiMenu(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("Ask AI")}
|
||||||
|
</Button>
|
||||||
|
<div className={classes.divider} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<NodeSelector
|
<NodeSelector
|
||||||
editor={props.editor}
|
editor={props.editor}
|
||||||
isOpen={isNodeSelectorOpen}
|
isOpen={isNodeSelectorOpen}
|
||||||
@@ -212,16 +246,18 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ActionIcon
|
<Tooltip label={t(commentItem.name)} withArrow withinPortal={false}>
|
||||||
variant="default"
|
<ActionIcon
|
||||||
size="lg"
|
variant="default"
|
||||||
radius="0"
|
size="lg"
|
||||||
aria-label={t(commentItem.name)}
|
radius="6px"
|
||||||
style={{ border: "none" }}
|
aria-label={t(commentItem.name)}
|
||||||
onClick={commentItem.command}
|
style={{ border: "none" }}
|
||||||
>
|
onClick={commentItem.command}
|
||||||
<IconMessage size={16} stroke={2} />
|
>
|
||||||
</ActionIcon>
|
<IconMessage size={16} stroke={2} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</BubbleMenu>
|
</BubbleMenu>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { Dispatch, FC, SetStateAction } from "react";
|
import React, { Dispatch, FC, SetStateAction } from "react";
|
||||||
import { IconCheck, IconChevronDown, IconPalette } from "@tabler/icons-react";
|
import { IconCheck, IconChevronDown } from "@tabler/icons-react";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
|
||||||
Button,
|
Button,
|
||||||
Popover,
|
Popover,
|
||||||
rem,
|
rem,
|
||||||
@@ -15,6 +14,8 @@ import {
|
|||||||
import type { Editor } from "@tiptap/react";
|
import type { Editor } from "@tiptap/react";
|
||||||
import { useEditorState } from "@tiptap/react";
|
import { useEditorState } from "@tiptap/react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import classes from "./bubble-menu.module.css";
|
||||||
|
|
||||||
export interface BubbleColorMenuItem {
|
export interface BubbleColorMenuItem {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -166,14 +167,10 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
|||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
data-text-color={activeColorItem?.color || ""}
|
data-text-color={activeColorItem?.color || ""}
|
||||||
data-highlight-color={activeHighlightItem?.color || ""}
|
data-highlight-color={activeHighlightItem?.color || ""}
|
||||||
className="color-selector-trigger"
|
className={clsx(["color-selector-trigger", classes.buttonRoot])}
|
||||||
style={{
|
style={{
|
||||||
height: "34px",
|
|
||||||
border: "none",
|
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
fontSize: rem(16),
|
fontSize: rem(16),
|
||||||
paddingLeft: rem(8),
|
|
||||||
paddingRight: rem(4),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
A
|
A
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { Dispatch, FC, SetStateAction } from "react";
|
import React, { Dispatch, FC, SetStateAction } from "react";
|
||||||
import {
|
import {
|
||||||
IconBlockquote,
|
IconBlockquote,
|
||||||
|
IconCaretRightFilled,
|
||||||
IconCheck,
|
IconCheck,
|
||||||
IconCheckbox,
|
IconCheckbox,
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
@@ -8,14 +9,16 @@ import {
|
|||||||
IconH1,
|
IconH1,
|
||||||
IconH2,
|
IconH2,
|
||||||
IconH3,
|
IconH3,
|
||||||
|
IconInfoCircle,
|
||||||
IconList,
|
IconList,
|
||||||
IconListNumbers,
|
IconListNumbers,
|
||||||
IconTypography,
|
IconTypography,
|
||||||
} from "@tabler/icons-react";
|
} 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 type { Editor } from "@tiptap/react";
|
||||||
import { useEditorState } from "@tiptap/react";
|
import { useEditorState } from "@tiptap/react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import classes from "./bubble-menu.module.css";
|
||||||
|
|
||||||
interface NodeSelectorProps {
|
interface NodeSelectorProps {
|
||||||
editor: Editor | null;
|
editor: Editor | null;
|
||||||
@@ -54,6 +57,8 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
|
|||||||
isTaskItem: ctx.editor.isActive("taskItem"),
|
isTaskItem: ctx.editor.isActive("taskItem"),
|
||||||
isBlockquote: ctx.editor.isActive("blockquote"),
|
isBlockquote: ctx.editor.isActive("blockquote"),
|
||||||
isCodeBlock: ctx.editor.isActive("codeBlock"),
|
isCodeBlock: ctx.editor.isActive("codeBlock"),
|
||||||
|
isCallout: ctx.editor.isActive("callout"),
|
||||||
|
isDetails: ctx.editor.isActive("details"),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -123,6 +128,18 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
|
|||||||
command: () => editor.chain().focus().toggleCodeBlock().run(),
|
command: () => editor.chain().focus().toggleCodeBlock().run(),
|
||||||
isActive: () => editorState?.isCodeBlock,
|
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() ?? {
|
const activeItem = items.filter((item) => item.isActive()).pop() ?? {
|
||||||
@@ -132,15 +149,18 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
|
|||||||
return (
|
return (
|
||||||
<Popover opened={isOpen} withArrow>
|
<Popover opened={isOpen} withArrow>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<Button
|
<Tooltip label={t("Turn into")} withArrow withinPortal={false} disabled={isOpen}>
|
||||||
variant="default"
|
<Button
|
||||||
style={{ border: "none", height: "34px" }}
|
className={classes.buttonRoot}
|
||||||
radius="0"
|
variant="default"
|
||||||
rightSection={<IconChevronDown size={16} />}
|
style={{ border: "none", height: "34px" }}
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
radius="0"
|
||||||
>
|
rightSection={<IconChevronDown size={16} />}
|
||||||
{t(activeItem?.name)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
</Button>
|
>
|
||||||
|
{t(activeItem?.name)}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
|
|
||||||
<Popover.Dropdown>
|
<Popover.Dropdown>
|
||||||
|
|||||||
+13
-11
@@ -7,7 +7,7 @@ import {
|
|||||||
IconCheck,
|
IconCheck,
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
} from "@tabler/icons-react";
|
} 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 type { Editor } from "@tiptap/react";
|
||||||
import { useEditorState } from "@tiptap/react";
|
import { useEditorState } from "@tiptap/react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -84,16 +84,18 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
|
|||||||
return (
|
return (
|
||||||
<Popover opened={isOpen} withArrow>
|
<Popover opened={isOpen} withArrow>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<Button
|
<Tooltip label={t("Text align")} withArrow withinPortal={false} disabled={isOpen}>
|
||||||
variant="default"
|
<Button
|
||||||
style={{ border: "none", height: "34px" }}
|
variant="default"
|
||||||
px="5"
|
style={{ border: "none", height: "34px" }}
|
||||||
radius="0"
|
px="5"
|
||||||
rightSection={<IconChevronDown size={16} />}
|
radius="0"
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
rightSection={<IconChevronDown size={16} />}
|
||||||
>
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
<activeItem.icon style={{ width: rem(16) }} stroke={2} />
|
>
|
||||||
</Button>
|
<activeItem.icon style={{ width: rem(16) }} stroke={2} />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
|
|
||||||
<Popover.Dropdown>
|
<Popover.Dropdown>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import React, {
|
|||||||
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query.ts";
|
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query.ts";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
|
Divider,
|
||||||
Group,
|
Group,
|
||||||
Paper,
|
Paper,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
@@ -51,6 +52,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
const tree = useMemo(() => new SimpleTree<SpaceTreeNode>(data), [data]);
|
const tree = useMemo(() => new SimpleTree<SpaceTreeNode>(data), [data]);
|
||||||
const createPageMutation = useCreatePageMutation();
|
const createPageMutation = useCreatePageMutation();
|
||||||
const emit = useQueryEmit();
|
const emit = useQueryEmit();
|
||||||
|
const isInCommentContext = props.isInCommentContext ?? false;
|
||||||
|
|
||||||
const { data: suggestion, isLoading } = useSearchSuggestionsQuery({
|
const { data: suggestion, isLoading } = useSearchSuggestionsQuery({
|
||||||
query: props.query,
|
query: props.query,
|
||||||
@@ -58,6 +60,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
includePages: true,
|
includePages: true,
|
||||||
spaceId: space.id,
|
spaceId: space.id,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
|
preload: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const createPageItem = (label: string) : MentionSuggestionItem => {
|
const createPageItem = (label: string) : MentionSuggestionItem => {
|
||||||
@@ -102,7 +105,9 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
items.push(createPageItem(props.query));
|
if (!isInCommentContext && props.query) {
|
||||||
|
items.push(createPageItem(props.query));
|
||||||
|
}
|
||||||
|
|
||||||
setRenderItems(items);
|
setRenderItems(items);
|
||||||
// update editor storage
|
// update editor storage
|
||||||
@@ -250,35 +255,51 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// if no results and enter what to do?
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
viewportRef.current
|
viewportRef.current
|
||||||
?.querySelector(`[data-item-index="${selectedIndex}"]`)
|
?.querySelector(`[data-item-index="${selectedIndex}"]`)
|
||||||
?.scrollIntoView({ block: "nearest" });
|
?.scrollIntoView({ block: "nearest" });
|
||||||
}, [selectedIndex]);
|
}, [selectedIndex]);
|
||||||
|
|
||||||
|
const popupWidth = isInCommentContext ? 280 : 320;
|
||||||
|
|
||||||
if (renderItems.length === 0) {
|
if (renderItems.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Paper shadow="md" p="xs" withBorder>
|
<Paper id="mention" shadow="md" py="xs" withBorder radius="md">
|
||||||
{ t("No results") }
|
<Text c="dimmed" size="sm" px="sm">
|
||||||
|
{ t("No results") }
|
||||||
|
</Text>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<Paper id="mention" shadow="md" p="xs" withBorder>
|
<Paper id="mention" shadow="md" withBorder radius="md" py={6}>
|
||||||
<ScrollArea.Autosize
|
<ScrollArea.Autosize
|
||||||
viewportRef={viewportRef}
|
viewportRef={viewportRef}
|
||||||
mah={350}
|
mah={350}
|
||||||
w={320}
|
w={popupWidth}
|
||||||
scrollbarSize={8}
|
scrollbarSize={6}
|
||||||
>
|
>
|
||||||
{renderItems?.map((item, index) => {
|
{renderItems?.map((item, index) => {
|
||||||
if (item.entityType === "header") {
|
if (item.entityType === "header") {
|
||||||
|
const isFirst = index === 0;
|
||||||
return (
|
return (
|
||||||
<div key={`${item.label}-${index}`}>
|
<div key={`${item.label}-${index}`}>
|
||||||
<Text c="dimmed" mb={4} tt="uppercase">
|
{!isFirst && <Divider my={6} />}
|
||||||
|
<Text
|
||||||
|
c="dimmed"
|
||||||
|
size="xs"
|
||||||
|
fw={500}
|
||||||
|
px="sm"
|
||||||
|
pt={isFirst ? 2 : 4}
|
||||||
|
pb={4}
|
||||||
|
tt="uppercase"
|
||||||
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
@@ -292,8 +313,9 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
className={clsx(classes.menuBtn, {
|
className={clsx(classes.menuBtn, {
|
||||||
[classes.selectedItem]: index === selectedIndex,
|
[classes.selectedItem]: index === selectedIndex,
|
||||||
})}
|
})}
|
||||||
|
px="sm"
|
||||||
>
|
>
|
||||||
<Group>
|
<Group gap="sm">
|
||||||
<CustomAvatar
|
<CustomAvatar
|
||||||
size={"sm"}
|
size={"sm"}
|
||||||
avatarUrl={item.avatarUrl}
|
avatarUrl={item.avatarUrl}
|
||||||
@@ -308,7 +330,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
</Group>
|
</Group>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
);
|
);
|
||||||
} else if (item.entityType === "page") {
|
} else if (item.entityType === "page" && item.id !== null) {
|
||||||
return (
|
return (
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
data-item-index={index}
|
data-item-index={index}
|
||||||
@@ -317,28 +339,24 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
className={clsx(classes.menuBtn, {
|
className={clsx(classes.menuBtn, {
|
||||||
[classes.selectedItem]: index === selectedIndex,
|
[classes.selectedItem]: index === selectedIndex,
|
||||||
})}
|
})}
|
||||||
|
px="sm"
|
||||||
>
|
>
|
||||||
<Group>
|
<Group gap="sm" wrap="nowrap">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="default"
|
variant="subtle"
|
||||||
component="div"
|
component="div"
|
||||||
aria-label={item.label}
|
aria-label={item.label}
|
||||||
|
color="gray"
|
||||||
|
size="sm"
|
||||||
>
|
>
|
||||||
{item.icon || (
|
{item.icon || (
|
||||||
<ActionIcon
|
<IconFileDescription size={18} stroke={1.5} />
|
||||||
component="span"
|
|
||||||
variant="transparent"
|
|
||||||
color="gray"
|
|
||||||
size={18}
|
|
||||||
>
|
|
||||||
{ (item.id) ? <IconFileDescription size={18} /> : <IconPlus size={18} /> }
|
|
||||||
</ActionIcon>
|
|
||||||
)}
|
)}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={500} truncate>
|
||||||
{ (item.id) ? item.label : t("Create page") + ': ' + item.label }
|
{item.label}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -348,6 +366,37 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{createPageItemData && !isInCommentContext && (
|
||||||
|
<>
|
||||||
|
{(hasUsers || hasPages) && <Divider my={6} />}
|
||||||
|
<UnstyledButton
|
||||||
|
data-item-index={renderItems.indexOf(createPageItemData)}
|
||||||
|
onClick={() => selectItem(renderItems.indexOf(createPageItemData))}
|
||||||
|
className={clsx(classes.menuBtn, {
|
||||||
|
[classes.selectedItem]: renderItems.indexOf(createPageItemData) === selectedIndex,
|
||||||
|
})}
|
||||||
|
px="sm"
|
||||||
|
>
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
component="div"
|
||||||
|
color="gray"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<IconPlus size={16} stroke={1.5} />
|
||||||
|
</ActionIcon>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text size="sm" fw={500} truncate>
|
||||||
|
{t("Create page")}: {createPageItemData.label}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</ScrollArea.Autosize>
|
</ScrollArea.Autosize>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,8 +17,13 @@ const mentionRenderItems = () => {
|
|||||||
let component: ReactRenderer | null = null;
|
let component: ReactRenderer | null = null;
|
||||||
let activeClientRect: (() => DOMRect) | null = null;
|
let activeClientRect: (() => DOMRect) | null = null;
|
||||||
let updatePositionCleanup: (() => void) | null = null;
|
let updatePositionCleanup: (() => void) | null = null;
|
||||||
|
let outsideClickHandler: ((e: MouseEvent) => void) | null = null;
|
||||||
|
|
||||||
const destroy = () => {
|
const destroy = () => {
|
||||||
|
if (outsideClickHandler) {
|
||||||
|
document.removeEventListener("pointerdown", outsideClickHandler);
|
||||||
|
outsideClickHandler = null;
|
||||||
|
}
|
||||||
updatePositionCleanup?.();
|
updatePositionCleanup?.();
|
||||||
updatePositionCleanup = null;
|
updatePositionCleanup = null;
|
||||||
component?.destroy();
|
component?.destroy();
|
||||||
@@ -45,8 +50,14 @@ const mentionRenderItems = () => {
|
|||||||
return;
|
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, {
|
component = new ReactRenderer(MentionList, {
|
||||||
props,
|
props: { ...props, isInCommentContext },
|
||||||
editor: props.editor,
|
editor: props.editor,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,6 +70,18 @@ const mentionRenderItems = () => {
|
|||||||
const { element } = component;
|
const { element } = component;
|
||||||
document.body.appendChild(element);
|
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(
|
updatePositionCleanup = autoUpdate(
|
||||||
{
|
{
|
||||||
getBoundingClientRect: () =>
|
getBoundingClientRect: () =>
|
||||||
@@ -76,7 +99,7 @@ const mentionRenderItems = () => {
|
|||||||
element,
|
element,
|
||||||
{
|
{
|
||||||
placement: "bottom-start",
|
placement: "bottom-start",
|
||||||
middleware: [offset(0), flip(), shift()],
|
middleware: [offset(4), flip(), shiftMiddleware],
|
||||||
},
|
},
|
||||||
).then(({ x, y }) => {
|
).then(({ x, y }) => {
|
||||||
Object.assign(element.style, {
|
Object.assign(element.style, {
|
||||||
|
|||||||
@@ -31,14 +31,14 @@
|
|||||||
|
|
||||||
.menuBtn {
|
.menuBtn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 4px;
|
padding: 6px 4px;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 1px;
|
||||||
color: var(--mantine-color-text);
|
color: var(--mantine-color-text);
|
||||||
border-radius: var(--mantine-radius-sm);
|
border-radius: var(--mantine-radius-sm);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@mixin light {
|
@mixin light {
|
||||||
background: var(--mantine-color-gray-2);
|
background: var(--mantine-color-gray-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@mixin dark {
|
@mixin dark {
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
|
|
||||||
.selectedItem {
|
.selectedItem {
|
||||||
@mixin light {
|
@mixin light {
|
||||||
background: var(--mantine-color-gray-2);
|
background: var(--mantine-color-gray-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@mixin dark {
|
@mixin dark {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface MentionListProps {
|
|||||||
range: Range;
|
range: Range;
|
||||||
text: string;
|
text: string;
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
|
isInCommentContext?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MentionSuggestionItem =
|
export type MentionSuggestionItem =
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ import { PageEditMode } from "@/features/user/types/user.types.ts";
|
|||||||
import { jwtDecode } from "jwt-decode";
|
import { jwtDecode } from "jwt-decode";
|
||||||
import { searchSpotlight } from "@/features/search/constants.ts";
|
import { searchSpotlight } from "@/features/search/constants.ts";
|
||||||
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
||||||
|
import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu";
|
||||||
|
|
||||||
interface PageEditorProps {
|
interface PageEditorProps {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@@ -405,6 +406,7 @@ export default function PageEditor({
|
|||||||
|
|
||||||
{editor && editorIsEditable && (
|
{editor && editorIsEditable && (
|
||||||
<div>
|
<div>
|
||||||
|
<EditorAiMenu editor={editor} />
|
||||||
<EditorBubbleMenu editor={editor} />
|
<EditorBubbleMenu editor={editor} />
|
||||||
<TableMenu editor={editor} />
|
<TableMenu editor={editor} />
|
||||||
<TableCellMenu editor={editor} appendTo={menuContainerRef} />
|
<TableCellMenu editor={editor} appendTo={menuContainerRef} />
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ export function TitleEditor({
|
|||||||
// guard against Cannot access view['hasFocus'] error
|
// guard against Cannot access view['hasFocus'] error
|
||||||
if (!titleEditor?.isInitialized) return;
|
if (!titleEditor?.isInitialized) return;
|
||||||
titleEditor?.commands?.focus("end");
|
titleEditor?.commands?.focus("end");
|
||||||
}, 500);
|
}, 300);
|
||||||
}, [titleEditor]);
|
}, [titleEditor]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<UnstyledButton
|
||||||
|
onClick={handleClick}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
w="100%"
|
||||||
|
className={classes.notificationItem}
|
||||||
|
>
|
||||||
|
<Group wrap="nowrap" align="flex-start" gap="sm">
|
||||||
|
<CustomAvatar
|
||||||
|
avatarUrl={notification.actor?.avatarUrl}
|
||||||
|
name={notification.actor?.name || "?"}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text size="sm" lineClamp={2}>
|
||||||
|
<Text span fw={600}>
|
||||||
|
{notification.actor?.name}
|
||||||
|
</Text>{" "}
|
||||||
|
{getNotificationMessage()}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{notification.page && (
|
||||||
|
<Group gap={4} mt={2} wrap="nowrap">
|
||||||
|
{notification.page.icon ? (
|
||||||
|
<Text size="xs" style={{ flexShrink: 0 }}>
|
||||||
|
{notification.page.icon}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<IconFileDescription
|
||||||
|
size={14}
|
||||||
|
stroke={1.5}
|
||||||
|
style={{ flexShrink: 0, color: "var(--mantine-color-dimmed)" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||||
|
{notification.page.title || t("Untitled")}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Group gap={4} wrap="nowrap" align="center" style={{ flexShrink: 0 }}>
|
||||||
|
{hovered && isUnread ? (
|
||||||
|
<Tooltip label={t("Mark as read")} withArrow>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleMarkRead}
|
||||||
|
>
|
||||||
|
<IconCheck size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{formatRelativeTime(notification.createdAt)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isUnread && (
|
||||||
|
<IconPointFilled
|
||||||
|
size={12}
|
||||||
|
color="var(--mantine-color-blue-filled)"
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<HTMLDivElement>(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 (
|
||||||
|
<Center py="xl">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allNotifications =
|
||||||
|
data?.pages.flatMap((page) => page.items) ?? [];
|
||||||
|
|
||||||
|
const filtered =
|
||||||
|
filter === "unread"
|
||||||
|
? allNotifications.filter((n) => !n.readAt)
|
||||||
|
: allNotifications;
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
return (
|
||||||
|
<Center py="xl">
|
||||||
|
<Stack align="center" gap="xs">
|
||||||
|
<IconBellOff size={32} stroke={1.5} color="var(--mantine-color-dimmed)" />
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{filter === "unread"
|
||||||
|
? t("No unread notifications")
|
||||||
|
: t("No notifications")}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeGroupLabels = {
|
||||||
|
today: t("Today"),
|
||||||
|
yesterday: t("Yesterday"),
|
||||||
|
this_week: t("This week"),
|
||||||
|
older: t("Older"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const groups = groupNotificationsByTime(filtered, timeGroupLabels);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap={0}>
|
||||||
|
{groups.map((group, groupIndex) => (
|
||||||
|
<div key={group.key}>
|
||||||
|
{groupIndex > 0 && <Divider className={classes.divider} />}
|
||||||
|
<Text size="xs" fw={600} c="dimmed" px="md" pt="sm" pb={4}>
|
||||||
|
{group.label}
|
||||||
|
</Text>
|
||||||
|
{group.notifications.map((notification: INotification) => (
|
||||||
|
<NotificationItem
|
||||||
|
key={notification.id}
|
||||||
|
notification={notification}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div ref={sentinelRef} style={{ height: 1 }} />
|
||||||
|
|
||||||
|
{isFetchingNextPage && (
|
||||||
|
<Center py="xs">
|
||||||
|
<Loader size="xs" />
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<NotificationFilter>("all");
|
||||||
|
|
||||||
|
const { data: unreadData } = useUnreadCountQuery();
|
||||||
|
const markAllRead = useMarkAllReadMutation();
|
||||||
|
|
||||||
|
const unreadCount = unreadData?.count ?? 0;
|
||||||
|
|
||||||
|
const handleMarkAllRead = () => {
|
||||||
|
markAllRead.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
position="bottom-end"
|
||||||
|
shadow="lg"
|
||||||
|
opened={opened}
|
||||||
|
onChange={setOpened}
|
||||||
|
withArrow
|
||||||
|
>
|
||||||
|
<Popover.Target>
|
||||||
|
<Tooltip label={t("Notifications")} withArrow>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="dark"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setOpened((o) => !o)}
|
||||||
|
>
|
||||||
|
<Indicator
|
||||||
|
offset={5}
|
||||||
|
color="red"
|
||||||
|
withBorder
|
||||||
|
disabled={unreadCount === 0}
|
||||||
|
>
|
||||||
|
<IconBell size={20} />
|
||||||
|
</Indicator>
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Popover.Target>
|
||||||
|
|
||||||
|
<Popover.Dropdown
|
||||||
|
p={0}
|
||||||
|
style={{ width: "min(420px, calc(100vw - 24px))" }}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" px="md" py="sm">
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
{t("Notifications")}
|
||||||
|
</Text>
|
||||||
|
<Group gap={4}>
|
||||||
|
<Menu position="bottom-end" withArrow withinPortal={false}>
|
||||||
|
<Menu.Target>
|
||||||
|
<Tooltip label={t("Filter")} withArrow>
|
||||||
|
<ActionIcon variant="subtle" color="dark" size="sm">
|
||||||
|
<IconFilter size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Menu.Label>{t("Filter")}</Menu.Label>
|
||||||
|
<Menu.Item
|
||||||
|
onClick={() => setFilter("all")}
|
||||||
|
rightSection={
|
||||||
|
filter === "all" ? <IconCheck size={14} /> : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("All notifications")}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
onClick={() => setFilter("unread")}
|
||||||
|
rightSection={
|
||||||
|
filter === "unread" ? <IconCheck size={14} /> : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("Unread only")}
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
<Menu position="bottom-end" withArrow withinPortal={false}>
|
||||||
|
<Menu.Target>
|
||||||
|
<Tooltip label={t("More options")} withArrow>
|
||||||
|
<ActionIcon variant="subtle" color="dark" size="sm">
|
||||||
|
<IconDots size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconChecks size={16} />}
|
||||||
|
onClick={handleMarkAllRead}
|
||||||
|
disabled={unreadCount === 0}
|
||||||
|
>
|
||||||
|
{t("Mark all as read")}
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<ScrollArea.Autosize
|
||||||
|
mah={500}
|
||||||
|
type="auto"
|
||||||
|
offsetScrollbars
|
||||||
|
scrollbarSize={6}
|
||||||
|
>
|
||||||
|
<NotificationList
|
||||||
|
filter={filter}
|
||||||
|
onNavigate={() => setOpened(false)}
|
||||||
|
/>
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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<TimeGroup, string>,
|
||||||
|
): GroupedNotifications[] {
|
||||||
|
const groups: Record<TimeGroup, INotification[]> = {
|
||||||
|
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],
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<IPagination<INotification>> {
|
||||||
|
const req = await api.post<IPagination<INotification>>(
|
||||||
|
"/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<void> {
|
||||||
|
await api.post("/notifications/mark-read", { notificationIds });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markAllNotificationsRead(): Promise<void> {
|
||||||
|
await api.post("/notifications/mark-all-read");
|
||||||
|
}
|
||||||
@@ -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<string, unknown> | 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";
|
||||||
@@ -82,8 +82,8 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
|||||||
|
|
||||||
<Tooltip label={t("Comments")} openDelay={250} withArrow>
|
<Tooltip label={t("Comments")} openDelay={250} withArrow>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="default"
|
variant="subtle"
|
||||||
style={{ border: "none" }}
|
color="dark"
|
||||||
onClick={() => toggleAside("comments")}
|
onClick={() => toggleAside("comments")}
|
||||||
>
|
>
|
||||||
<IconMessage size={20} stroke={2} />
|
<IconMessage size={20} stroke={2} />
|
||||||
@@ -92,8 +92,8 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
|||||||
|
|
||||||
<Tooltip label={t("Table of contents")} openDelay={250} withArrow>
|
<Tooltip label={t("Table of contents")} openDelay={250} withArrow>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="default"
|
variant="subtle"
|
||||||
style={{ border: "none" }}
|
color="dark"
|
||||||
onClick={() => toggleAside("toc")}
|
onClick={() => toggleAside("toc")}
|
||||||
>
|
>
|
||||||
<IconList size={20} stroke={2} />
|
<IconList size={20} stroke={2} />
|
||||||
@@ -169,7 +169,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
arrowPosition="center"
|
arrowPosition="center"
|
||||||
>
|
>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<ActionIcon variant="default" style={{ border: "none" }}>
|
<ActionIcon variant="subtle" color="dark">
|
||||||
<IconDots size={20} />
|
<IconDots size={20} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
|
|||||||
@@ -106,10 +106,16 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
|||||||
}
|
}
|
||||||
}, sizeRef);
|
}, sizeRef);
|
||||||
const [isDataLoaded, setIsDataLoaded] = useState(false);
|
const [isDataLoaded, setIsDataLoaded] = useState(false);
|
||||||
|
const spaceIdRef = useRef(spaceId);
|
||||||
|
spaceIdRef.current = spaceId;
|
||||||
const { data: currentPage } = usePageQuery({
|
const { data: currentPage } = usePageQuery({
|
||||||
pageId: extractPageSlugId(pageSlug),
|
pageId: extractPageSlugId(pageSlug),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsDataLoaded(false);
|
||||||
|
}, [spaceId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasNextPage && !isFetching) {
|
if (hasNextPage && !isFetching) {
|
||||||
fetchNextPage();
|
fetchNextPage();
|
||||||
@@ -130,12 +136,15 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// same space; append only missing roots
|
// same space; append only missing roots
|
||||||
|
setIsDataLoaded(true);
|
||||||
return mergeRootTrees(prev, treeData);
|
return mergeRootTrees(prev, treeData);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [pagesData, hasNextPage]);
|
}, [pagesData, hasNextPage, spaceId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const effectSpaceId = spaceId;
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
if (isDataLoaded && currentPage) {
|
if (isDataLoaded && currentPage) {
|
||||||
// check if pageId node is present in the tree
|
// check if pageId node is present in the tree
|
||||||
@@ -149,6 +158,8 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
|||||||
if (!currentPage.id) return;
|
if (!currentPage.id) return;
|
||||||
const ancestors = await getPageBreadcrumbs(currentPage.id);
|
const ancestors = await getPageBreadcrumbs(currentPage.id);
|
||||||
|
|
||||||
|
if (spaceIdRef.current !== effectSpaceId) return;
|
||||||
|
|
||||||
if (ancestors && ancestors?.length > 1) {
|
if (ancestors && ancestors?.length > 1) {
|
||||||
let flatTreeItems = [...buildTree(ancestors)];
|
let flatTreeItems = [...buildTree(ancestors)];
|
||||||
|
|
||||||
@@ -176,22 +187,22 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
|||||||
|
|
||||||
// Wait for all fetch operations to complete
|
// Wait for all fetch operations to complete
|
||||||
Promise.all(fetchPromises).then(() => {
|
Promise.all(fetchPromises).then(() => {
|
||||||
|
if (spaceIdRef.current !== effectSpaceId) return;
|
||||||
|
|
||||||
// build tree with children
|
// build tree with children
|
||||||
const ancestorsTree = buildTreeWithChildren(flatTreeItems);
|
const ancestorsTree = buildTreeWithChildren(flatTreeItems);
|
||||||
// child of root page we're attaching the built ancestors to
|
// child of root page we're attaching the built ancestors to
|
||||||
const rootChild = ancestorsTree[0];
|
const rootChild = ancestorsTree[0];
|
||||||
|
|
||||||
// attach built ancestors to tree
|
// attach built ancestors to tree using functional updater
|
||||||
const updatedTree = appendNodeChildren(
|
// to avoid stale closure overwriting the current tree data
|
||||||
data,
|
setData((currentData) =>
|
||||||
rootChild.id,
|
appendNodeChildren(currentData, rootChild.id, rootChild.children),
|
||||||
rootChild.children,
|
|
||||||
);
|
);
|
||||||
setData(updatedTree);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// focus on node and open all parents
|
// focus on node and open all parents
|
||||||
treeApiRef.current.select(currentPage.id);
|
treeApiRef.current?.select(currentPage.id);
|
||||||
}, 100);
|
}, 100);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ export function SearchMobileControl({ onSearch }: SearchMobileControlProps) {
|
|||||||
return (
|
return (
|
||||||
<Tooltip label={t("Search")} withArrow>
|
<Tooltip label={t("Search")} withArrow>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="default"
|
variant="subtle"
|
||||||
style={{ border: "none" }}
|
color="dark"
|
||||||
onClick={onSearch}
|
onClick={onSearch}
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ export function SearchSpotlightFilters({
|
|||||||
<Switch
|
<Switch
|
||||||
checked={isAiMode}
|
checked={isAiMode}
|
||||||
onChange={(event) => onAskClick()}
|
onChange={(event) => onAskClick()}
|
||||||
label={t("Ask AI")}
|
label={t("AI Answers")}
|
||||||
size="sm"
|
size="sm"
|
||||||
color="blue"
|
color="blue"
|
||||||
labelPosition="left"
|
labelPosition="left"
|
||||||
@@ -279,7 +279,7 @@ export function SearchSpotlightFilters({
|
|||||||
isAiMode &&
|
isAiMode &&
|
||||||
option.value === "attachment" && (
|
option.value === "attachment" && (
|
||||||
<Text size="xs" mt={4}>
|
<Text size="xs" mt={4}>
|
||||||
{t("Ask AI not available for attachments")}
|
{t("AI Answers not available for attachments")}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,13 +24,14 @@ export function usePageSearchQuery(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useSearchSuggestionsQuery(
|
export function useSearchSuggestionsQuery(
|
||||||
params: SearchSuggestionParams,
|
params: SearchSuggestionParams & { preload?: boolean },
|
||||||
): UseQueryResult<ISuggestionResult, Error> {
|
): UseQueryResult<ISuggestionResult, Error> {
|
||||||
|
const { preload, ...queryParams } = params;
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["search-suggestion", params.query],
|
queryKey: ["search-suggestion", params.query],
|
||||||
staleTime: 60 * 1000, // 1min
|
staleTime: 60 * 1000, // 1min
|
||||||
queryFn: () => searchSuggestions(params),
|
queryFn: () => searchSuggestions(queryParams),
|
||||||
enabled: !!params.query,
|
enabled: preload || !!params.query,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,8 +45,7 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
|
|||||||
const { isTrial } = useTrial();
|
const { isTrial } = useTrial();
|
||||||
const [workspace] = useAtom(workspaceAtom);
|
const [workspace] = useAtom(workspaceAtom);
|
||||||
const { data: space } = useSpaceQuery(spaceSlug);
|
const { data: space } = useSpaceQuery(spaceSlug);
|
||||||
const workspaceDisabled =
|
const workspaceDisabled = workspace?.settings?.sharing?.disabled === true;
|
||||||
workspace?.settings?.sharing?.disabled === true;
|
|
||||||
const spaceDisabled = space?.settings?.sharing?.disabled === true;
|
const spaceDisabled = space?.settings?.sharing?.disabled === true;
|
||||||
const sharingDisabled = workspaceDisabled || spaceDisabled;
|
const sharingDisabled = workspaceDisabled || spaceDisabled;
|
||||||
const createShareMutation = useCreateShareMutation();
|
const createShareMutation = useCreateShareMutation();
|
||||||
@@ -134,7 +133,6 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
|
|||||||
<Popover width={350} position="bottom" withArrow shadow="md">
|
<Popover width={350} position="bottom" withArrow shadow="md">
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<Button
|
<Button
|
||||||
style={{ border: "none" }}
|
|
||||||
size="compact-sm"
|
size="compact-sm"
|
||||||
leftSection={
|
leftSection={
|
||||||
<Indicator
|
<Indicator
|
||||||
@@ -146,7 +144,8 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
|
|||||||
<IconWorld size={20} stroke={1.5} />
|
<IconWorld size={20} stroke={1.5} />
|
||||||
</Indicator>
|
</Indicator>
|
||||||
}
|
}
|
||||||
variant="default"
|
color="dark"
|
||||||
|
variant="subtle"
|
||||||
>
|
>
|
||||||
{t("Share")}
|
{t("Share")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { io } from "socket.io-client";
|
|||||||
import { SOCKET_URL } from "@/features/websocket/types";
|
import { SOCKET_URL } from "@/features/websocket/types";
|
||||||
import { useQuerySubscription } from "@/features/websocket/use-query-subscription.ts";
|
import { useQuerySubscription } from "@/features/websocket/use-query-subscription.ts";
|
||||||
import { useTreeSocket } from "@/features/websocket/use-tree-socket.ts";
|
import { useTreeSocket } from "@/features/websocket/use-tree-socket.ts";
|
||||||
|
import { useNotificationSocket } from "@/features/notification/hooks/use-notification-socket.ts";
|
||||||
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||||
import { Error404 } from "@/components/ui/error-404.tsx";
|
import { Error404 } from "@/components/ui/error-404.tsx";
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ export function UserProvider({ children }: React.PropsWithChildren) {
|
|||||||
|
|
||||||
useQuerySubscription();
|
useQuerySubscription();
|
||||||
useTreeSocket();
|
useTreeSocket();
|
||||||
|
useNotificationSocket();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data && data.user && data.workspace) {
|
if (data && data.user && data.workspace) {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export interface IWorkspace {
|
|||||||
hasLicenseKey?: boolean;
|
hasLicenseKey?: boolean;
|
||||||
enforceMfa?: boolean;
|
enforceMfa?: boolean;
|
||||||
aiSearch?: boolean;
|
aiSearch?: boolean;
|
||||||
|
generativeAi?: boolean;
|
||||||
disablePublicSharing?: boolean;
|
disablePublicSharing?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ export interface IWorkspaceSettings {
|
|||||||
|
|
||||||
export interface IWorkspaceAiSettings {
|
export interface IWorkspaceAiSettings {
|
||||||
search?: boolean;
|
search?: boolean;
|
||||||
|
generative?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IWorkspaceSharingSettings {
|
export interface IWorkspaceSharingSettings {
|
||||||
|
|||||||
@@ -35,12 +35,35 @@ export const theme = createTheme({
|
|||||||
blue,
|
blue,
|
||||||
red,
|
red,
|
||||||
},
|
},
|
||||||
|
/***
|
||||||
|
components: {
|
||||||
|
ActionIcon: ActionIcon.extend({
|
||||||
|
vars: (_theme, props) => {
|
||||||
|
return {
|
||||||
|
root: {
|
||||||
|
...(props.variant === "subtle" &&
|
||||||
|
props.color === "dark" && {
|
||||||
|
"--ai-color": "var(--mantine-color-default-color)",
|
||||||
|
"--ai-hover": "var(--mantine-color-default-hover)",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
***/
|
||||||
});
|
});
|
||||||
|
|
||||||
export const mantineCssResolver: CSSVariablesResolver = (theme) => ({
|
export const mantineCssResolver: CSSVariablesResolver = (theme) => ({
|
||||||
variables: {
|
variables: {
|
||||||
"--input-error-size": theme.fontSizes.sm,
|
"--input-error-size": theme.fontSizes.sm,
|
||||||
},
|
},
|
||||||
light: {},
|
light: {
|
||||||
dark: {},
|
"--mantine-color-dark-light-color": "#4e5359",
|
||||||
|
"--mantine-color-dark-light-hover": "var(--mantine-color-gray-light-hover)",
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
"--mantine-color-dark-light-color": "var(--mantine-color-gray-4)",
|
||||||
|
"--mantine-color-dark-light-hover": "var(--mantine-color-default-hover)",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+11
-11
@@ -58,8 +58,8 @@
|
|||||||
"@nestjs/terminus": "^11.0.0",
|
"@nestjs/terminus": "^11.0.0",
|
||||||
"@nestjs/websockets": "^11.1.13",
|
"@nestjs/websockets": "^11.1.13",
|
||||||
"@node-saml/passport-saml": "^5.1.0",
|
"@node-saml/passport-saml": "^5.1.0",
|
||||||
"@react-email/components": "0.0.28",
|
"@react-email/components": "1.0.7",
|
||||||
"@react-email/render": "1.0.2",
|
"@react-email/render": "2.0.4",
|
||||||
"@socket.io/redis-adapter": "^8.3.0",
|
"@socket.io/redis-adapter": "^8.3.0",
|
||||||
"ai": "^6.0.37",
|
"ai": "^6.0.37",
|
||||||
"ai-sdk-ollama": "^3.1.1",
|
"ai-sdk-ollama": "^3.1.1",
|
||||||
@@ -113,32 +113,32 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.20.0",
|
"@eslint/js": "^9.20.0",
|
||||||
"@nestjs/cli": "^11.0.4",
|
"@nestjs/cli": "^11.0.16",
|
||||||
"@nestjs/schematics": "^11.0.1",
|
"@nestjs/schematics": "^11.0.1",
|
||||||
"@nestjs/testing": "^11.0.10",
|
"@nestjs/testing": "^11.0.10",
|
||||||
"@types/bcrypt": "^5.0.2",
|
"@types/bcrypt": "^5.0.2",
|
||||||
"@types/debounce": "^1.2.4",
|
"@types/debounce": "^1.2.4",
|
||||||
"@types/fs-extra": "^11.0.4",
|
"@types/fs-extra": "^11.0.4",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/mime-types": "^2.1.4",
|
"@types/mime-types": "^2.1.4",
|
||||||
"@types/node": "^22.13.4",
|
"@types/node": "^22.13.4",
|
||||||
"@types/nodemailer": "^6.4.17",
|
"@types/nodemailer": "^6.4.17",
|
||||||
"@types/passport-google-oauth20": "^2.0.16",
|
"@types/passport-google-oauth20": "^2.0.16",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.3",
|
||||||
"@types/ws": "^8.5.14",
|
"@types/ws": "^8.5.14",
|
||||||
"@types/yauzl": "^2.10.3",
|
"@types/yauzl": "^2.10.3",
|
||||||
"eslint": "^9.20.1",
|
"eslint": "^9.39.2",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
"globals": "^15.15.0",
|
"globals": "^15.15.0",
|
||||||
"jest": "^29.7.0",
|
"jest": "^30.2.0",
|
||||||
"kysely-codegen": "^0.19.0",
|
"kysely-codegen": "^0.19.0",
|
||||||
"prettier": "^3.5.1",
|
"prettier": "^3.5.1",
|
||||||
"react-email": "3.0.2",
|
"react-email": "5.2.8",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.2.2",
|
||||||
"ts-jest": "^29.2.5",
|
"ts-jest": "^29.4.6",
|
||||||
"ts-loader": "^9.5.2",
|
"ts-loader": "^9.5.4",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsconfig-paths": "^4.2.0",
|
"tsconfig-paths": "^4.2.0",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Hocuspocus, Document } from '@hocuspocus/server';
|
import { Hocuspocus, Document } from '@hocuspocus/server';
|
||||||
|
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||||
|
import {
|
||||||
|
prosemirrorNodeToYElement,
|
||||||
|
tiptapExtensions,
|
||||||
|
} from './collaboration.util';
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
import { User } from '@docmost/db/types/entity.types';
|
||||||
|
|
||||||
export type CollabEventHandlers = ReturnType<
|
export type CollabEventHandlers = ReturnType<
|
||||||
CollaborationHandler['getHandlers']
|
CollaborationHandler['getHandlers']
|
||||||
@@ -20,6 +27,44 @@ export class CollaborationHandler {
|
|||||||
// const fragment = doc.getXmlFragment('default');
|
// const fragment = doc.getXmlFragment('default');
|
||||||
//});
|
//});
|
||||||
},
|
},
|
||||||
|
updatePageContent: async (
|
||||||
|
documentName: string,
|
||||||
|
payload: {
|
||||||
|
prosemirrorJson: any;
|
||||||
|
operation: string;
|
||||||
|
user: User;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const { prosemirrorJson, operation, user } = payload;
|
||||||
|
this.logger.debug('Updating page content via yjs', documentName);
|
||||||
|
await this.withYdocConnection(
|
||||||
|
hocuspocus,
|
||||||
|
documentName,
|
||||||
|
{ user },
|
||||||
|
(doc) => {
|
||||||
|
const fragment = doc.getXmlFragment('default');
|
||||||
|
|
||||||
|
if (operation === 'replace') {
|
||||||
|
if (fragment.length > 0) {
|
||||||
|
fragment.delete(0, fragment.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newDoc = TiptapTransformer.toYdoc(
|
||||||
|
prosemirrorJson,
|
||||||
|
'default',
|
||||||
|
tiptapExtensions,
|
||||||
|
);
|
||||||
|
Y.applyUpdate(doc, Y.encodeStateAsUpdate(newDoc));
|
||||||
|
} else {
|
||||||
|
const newContent = prosemirrorJson.content || [];
|
||||||
|
const yElements = newContent.map(prosemirrorNodeToYElement);
|
||||||
|
const position =
|
||||||
|
operation === 'prepend' ? 0 : fragment.length;
|
||||||
|
fragment.insert(position, yElements);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { Logger, Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
import {
|
||||||
|
Global,
|
||||||
|
Logger,
|
||||||
|
Module,
|
||||||
|
OnModuleDestroy,
|
||||||
|
OnModuleInit,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { AuthenticationExtension } from './extensions/authentication.extension';
|
import { AuthenticationExtension } from './extensions/authentication.extension';
|
||||||
import { PersistenceExtension } from './extensions/persistence.extension';
|
import { PersistenceExtension } from './extensions/persistence.extension';
|
||||||
import { CollaborationGateway } from './collaboration.gateway';
|
import { CollaborationGateway } from './collaboration.gateway';
|
||||||
@@ -11,6 +17,7 @@ import { HistoryProcessor } from './processors/history.processor';
|
|||||||
import { LoggerExtension } from './extensions/logger.extension';
|
import { LoggerExtension } from './extensions/logger.extension';
|
||||||
import { CollaborationHandler } from './collaboration.handler';
|
import { CollaborationHandler } from './collaboration.handler';
|
||||||
import { CollabHistoryService } from './services/collab-history.service';
|
import { CollabHistoryService } from './services/collab-history.service';
|
||||||
|
import { WatcherModule } from '../core/watcher/watcher.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [
|
providers: [
|
||||||
@@ -23,7 +30,7 @@ import { CollabHistoryService } from './services/collab-history.service';
|
|||||||
CollaborationHandler,
|
CollaborationHandler,
|
||||||
],
|
],
|
||||||
exports: [CollaborationGateway],
|
exports: [CollaborationGateway],
|
||||||
imports: [TokenModule],
|
imports: [TokenModule, WatcherModule],
|
||||||
})
|
})
|
||||||
export class CollaborationModule implements OnModuleInit, OnModuleDestroy {
|
export class CollaborationModule implements OnModuleInit, OnModuleDestroy {
|
||||||
private readonly logger = new Logger(CollaborationModule.name);
|
private readonly logger = new Logger(CollaborationModule.name);
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
Highlight,
|
Highlight,
|
||||||
UniqueID,
|
UniqueID,
|
||||||
addUniqueIdsToDoc,
|
addUniqueIdsToDoc,
|
||||||
|
htmlToMarkdown,
|
||||||
} from '@docmost/editor-ext';
|
} from '@docmost/editor-ext';
|
||||||
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
||||||
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
||||||
@@ -42,6 +43,7 @@ import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
|||||||
// see:https://github.com/ueberdosis/tiptap/issues/4089
|
// see:https://github.com/ueberdosis/tiptap/issues/4089
|
||||||
//import { generateJSON } from '@tiptap/html';
|
//import { generateJSON } from '@tiptap/html';
|
||||||
import { Node, Schema } from '@tiptap/pm/model';
|
import { Node, Schema } from '@tiptap/pm/model';
|
||||||
|
import * as Y from 'yjs';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
|
|
||||||
export const tiptapExtensions = [
|
export const tiptapExtensions = [
|
||||||
@@ -161,3 +163,37 @@ function stripUnknownNodes(
|
|||||||
|
|
||||||
return json;
|
return json;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function prosemirrorNodeToYElement(node: any): Y.XmlElement | Y.XmlText {
|
||||||
|
if (node.type === 'text') {
|
||||||
|
const ytext = new Y.XmlText();
|
||||||
|
ytext.insert(0, node.text || '');
|
||||||
|
if (node.marks?.length > 0) {
|
||||||
|
const attrs: Record<string, any> = {};
|
||||||
|
for (const mark of node.marks) {
|
||||||
|
attrs[mark.type] = mark.attrs || true;
|
||||||
|
}
|
||||||
|
ytext.format(0, node.text?.length || 0, attrs);
|
||||||
|
}
|
||||||
|
return ytext;
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = new Y.XmlElement(node.type);
|
||||||
|
if (node.attrs) {
|
||||||
|
for (const [key, value] of Object.entries(node.attrs)) {
|
||||||
|
if (value !== null && value !== undefined) {
|
||||||
|
element.setAttribute(key, value as any);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (node.content?.length > 0) {
|
||||||
|
const children = node.content.map(prosemirrorNodeToYElement);
|
||||||
|
element.insert(0, children);
|
||||||
|
}
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jsonToMarkdown(tiptapJson: any): string {
|
||||||
|
const html = jsonToHtml(tiptapJson);
|
||||||
|
return htmlToMarkdown(html);
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,11 +19,13 @@ import { Queue } from 'bullmq';
|
|||||||
import {
|
import {
|
||||||
extractMentions,
|
extractMentions,
|
||||||
extractPageMentions,
|
extractPageMentions,
|
||||||
|
extractUserMentions,
|
||||||
} from '../../common/helpers/prosemirror/utils';
|
} from '../../common/helpers/prosemirror/utils';
|
||||||
import { isDeepStrictEqual } from 'node:util';
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
import {
|
import {
|
||||||
IPageBacklinkJob,
|
IPageBacklinkJob,
|
||||||
IPageHistoryJob,
|
IPageHistoryJob,
|
||||||
|
IPageMentionNotificationJob,
|
||||||
} from '../../integrations/queue/constants/queue.interface';
|
} from '../../integrations/queue/constants/queue.interface';
|
||||||
import { Page } from '@docmost/db/types/entity.types';
|
import { Page } from '@docmost/db/types/entity.types';
|
||||||
import { CollabHistoryService } from '../services/collab-history.service';
|
import { CollabHistoryService } from '../services/collab-history.service';
|
||||||
@@ -44,6 +46,7 @@ export class PersistenceExtension implements Extension {
|
|||||||
@InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
|
@InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
|
||||||
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
||||||
@InjectQueue(QueueName.HISTORY_QUEUE) private historyQueue: Queue,
|
@InjectQueue(QueueName.HISTORY_QUEUE) private historyQueue: Queue,
|
||||||
|
@InjectQueue(QueueName.NOTIFICATION_QUEUE) private notificationQueue: Queue,
|
||||||
private readonly collabHistory: CollabHistoryService,
|
private readonly collabHistory: CollabHistoryService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -170,6 +173,24 @@ export class PersistenceExtension implements Extension {
|
|||||||
mentions: pageMentions,
|
mentions: pageMentions,
|
||||||
} as IPageBacklinkJob);
|
} as IPageBacklinkJob);
|
||||||
|
|
||||||
|
const userMentions = extractUserMentions(mentions);
|
||||||
|
const oldMentions = page.content ? extractMentions(page.content) : [];
|
||||||
|
const oldMentionedUserIds = extractUserMentions(oldMentions).map((m) => m.entityId);
|
||||||
|
|
||||||
|
if (userMentions.length > 0) {
|
||||||
|
await this.notificationQueue.add(QueueJob.PAGE_MENTION_NOTIFICATION, {
|
||||||
|
userMentions: userMentions.map((m) => ({
|
||||||
|
userId: m.entityId,
|
||||||
|
mentionId: m.id,
|
||||||
|
creatorId: m.creatorId,
|
||||||
|
})),
|
||||||
|
oldMentionedUserIds,
|
||||||
|
pageId,
|
||||||
|
spaceId: page.spaceId,
|
||||||
|
workspaceId: page.workspaceId,
|
||||||
|
} as IPageMentionNotificationJob);
|
||||||
|
}
|
||||||
|
|
||||||
await this.aiQueue.add(QueueJob.PAGE_CONTENT_UPDATED, {
|
await this.aiQueue.add(QueueJob.PAGE_CONTENT_UPDATED, {
|
||||||
pageIds: [pageId],
|
pageIds: [pageId],
|
||||||
workspaceId: page.workspaceId,
|
workspaceId: page.workspaceId,
|
||||||
@@ -181,7 +202,8 @@ export class PersistenceExtension implements Extension {
|
|||||||
|
|
||||||
async onChange(data: onChangePayload) {
|
async onChange(data: onChangePayload) {
|
||||||
const documentName = data.documentName;
|
const documentName = data.documentName;
|
||||||
const userId = data.context?.user.id;
|
const userId = data.context?.user?.id;
|
||||||
|
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
|
|
||||||
if (!this.contributors.has(documentName)) {
|
if (!this.contributors.has(documentName)) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
|
|||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
import { isDeepStrictEqual } from 'node:util';
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
import { CollabHistoryService } from '../services/collab-history.service';
|
import { CollabHistoryService } from '../services/collab-history.service';
|
||||||
|
import { WatcherService } from '../../core/watcher/watcher.service';
|
||||||
|
|
||||||
@Processor(QueueName.HISTORY_QUEUE)
|
@Processor(QueueName.HISTORY_QUEUE)
|
||||||
export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
||||||
@@ -16,6 +17,7 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
|||||||
private readonly pageHistoryRepo: PageHistoryRepo,
|
private readonly pageHistoryRepo: PageHistoryRepo,
|
||||||
private readonly pageRepo: PageRepo,
|
private readonly pageRepo: PageRepo,
|
||||||
private readonly collabHistory: CollabHistoryService,
|
private readonly collabHistory: CollabHistoryService,
|
||||||
|
private readonly watcherService: WatcherService,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
@@ -49,6 +51,13 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
|||||||
await this.collabHistory.popContributors(pageId);
|
await this.collabHistory.popContributors(pageId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await this.watcherService.addPageWatchers(
|
||||||
|
contributorIds,
|
||||||
|
pageId,
|
||||||
|
page.spaceId,
|
||||||
|
page.workspaceId,
|
||||||
|
);
|
||||||
|
|
||||||
await this.pageHistoryRepo.saveHistory(page, { contributorIds });
|
await this.pageHistoryRepo.saveHistory(page, { contributorIds });
|
||||||
this.logger.debug(`History created for page: ${pageId}`);
|
this.logger.debug(`History created for page: ${pageId}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
import { TransformHttpResponseInterceptor } from '../../common/interceptors/http-response.interceptor';
|
import { TransformHttpResponseInterceptor } from '../../common/interceptors/http-response.interceptor';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { Logger as PinoLogger } from 'nestjs-pino';
|
import { Logger as PinoLogger } from 'nestjs-pino';
|
||||||
|
import { InternalLogFilter } from '../../common/logger/internal-log-filter';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create<NestFastifyApplication>(
|
const app = await NestFactory.create<NestFastifyApplication>(
|
||||||
@@ -19,7 +20,7 @@ async function bootstrap() {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
logger: false,
|
logger: new InternalLogFilter(),
|
||||||
bufferLogs: false,
|
bufferLogs: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import {
|
||||||
|
initProseMirrorDoc,
|
||||||
|
relativePositionToAbsolutePosition,
|
||||||
|
} from 'y-prosemirror';
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
import { Document } from '@hocuspocus/server';
|
||||||
|
import { getSchema } from '@tiptap/core';
|
||||||
|
import { tiptapExtensions } from './collaboration.util';
|
||||||
|
|
||||||
|
export type YjsSelection = {
|
||||||
|
anchor: any;
|
||||||
|
head: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function setYjsMark(
|
||||||
|
doc: Document,
|
||||||
|
fragment: Y.XmlFragment,
|
||||||
|
yjsSelection: YjsSelection,
|
||||||
|
markName: string,
|
||||||
|
markAttributes: Record<string, any>,
|
||||||
|
) {
|
||||||
|
const schema = getSchema(tiptapExtensions);
|
||||||
|
const { mapping } = initProseMirrorDoc(fragment, schema);
|
||||||
|
|
||||||
|
// Convert JSON positions to Y.js RelativePosition objects
|
||||||
|
const anchorRelPos = Y.createRelativePositionFromJSON(yjsSelection.anchor);
|
||||||
|
const headRelPos = Y.createRelativePositionFromJSON(yjsSelection.head);
|
||||||
|
|
||||||
|
const anchor = relativePositionToAbsolutePosition(
|
||||||
|
doc,
|
||||||
|
fragment,
|
||||||
|
anchorRelPos,
|
||||||
|
mapping,
|
||||||
|
);
|
||||||
|
const head = relativePositionToAbsolutePosition(
|
||||||
|
doc,
|
||||||
|
fragment,
|
||||||
|
headRelPos,
|
||||||
|
mapping,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (anchor === null || head === null) {
|
||||||
|
throw new Error(
|
||||||
|
'Could not resolve Y.js relative positions to absolute positions',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const from = Math.min(anchor, head);
|
||||||
|
const to = Math.max(anchor, head);
|
||||||
|
|
||||||
|
// Apply mark directly to Y.js XmlText nodes
|
||||||
|
// This bypasses updateYFragment which has compatibility issues
|
||||||
|
applyMarkToYFragment(fragment, from, to, markName, markAttributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMarkToYFragment(
|
||||||
|
fragment: Y.XmlFragment,
|
||||||
|
from: number,
|
||||||
|
to: number,
|
||||||
|
markName: string,
|
||||||
|
markAttributes: Record<string, any>,
|
||||||
|
) {
|
||||||
|
let pos = 0;
|
||||||
|
|
||||||
|
const processItem = (item: any): boolean => {
|
||||||
|
if (pos >= to) return false;
|
||||||
|
|
||||||
|
if (item instanceof Y.XmlText) {
|
||||||
|
const textLength = item.length;
|
||||||
|
const itemEnd = pos + textLength;
|
||||||
|
|
||||||
|
if (itemEnd > from && pos < to) {
|
||||||
|
const formatFrom = Math.max(0, from - pos);
|
||||||
|
const formatTo = Math.min(textLength, to - pos);
|
||||||
|
const formatLength = formatTo - formatFrom;
|
||||||
|
|
||||||
|
if (formatLength > 0) {
|
||||||
|
item.format(formatFrom, formatLength, { [markName]: markAttributes });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pos = itemEnd;
|
||||||
|
} else if (item instanceof Y.XmlElement) {
|
||||||
|
pos++; // Opening tag
|
||||||
|
for (let i = 0; i < item.length; i++) {
|
||||||
|
if (!processItem(item.get(i))) return false;
|
||||||
|
}
|
||||||
|
pos++; // Closing tag
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < fragment.length; i++) {
|
||||||
|
if (!processItem(fragment.get(i))) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a mark from all text in the fragment that has the specified attribute value.
|
||||||
|
* Useful for deleting comments by commentId.
|
||||||
|
*/
|
||||||
|
export function removeYjsMarkByAttribute(
|
||||||
|
fragment: Y.XmlFragment,
|
||||||
|
markName: string,
|
||||||
|
attributeName: string,
|
||||||
|
attributeValue: string,
|
||||||
|
) {
|
||||||
|
const processItem = (item: any) => {
|
||||||
|
if (item instanceof Y.XmlText) {
|
||||||
|
// Get all formatting deltas to find ranges with this mark
|
||||||
|
const deltas = item.toDelta();
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
for (const delta of deltas) {
|
||||||
|
const length = delta.insert?.length ?? 0;
|
||||||
|
const attributes = delta.attributes ?? {};
|
||||||
|
const markAttr = attributes[markName];
|
||||||
|
|
||||||
|
if (markAttr && markAttr[attributeName] === attributeValue) {
|
||||||
|
// Remove the mark by setting it to null
|
||||||
|
item.format(offset, length, { [markName]: null });
|
||||||
|
}
|
||||||
|
offset += length;
|
||||||
|
}
|
||||||
|
} else if (item instanceof Y.XmlElement) {
|
||||||
|
for (let i = 0; i < item.length; i++) {
|
||||||
|
processItem(item.get(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < fragment.length; i++) {
|
||||||
|
processItem(fragment.get(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates a mark's attributes for all text that has the specified attribute value.
|
||||||
|
* Useful for resolving/unresolving comments by commentId.
|
||||||
|
*/
|
||||||
|
export function updateYjsMarkAttribute(
|
||||||
|
fragment: Y.XmlFragment,
|
||||||
|
markName: string,
|
||||||
|
findByAttribute: { name: string; value: string },
|
||||||
|
newAttributes: Record<string, any>,
|
||||||
|
) {
|
||||||
|
const processItem = (item: any) => {
|
||||||
|
if (item instanceof Y.XmlText) {
|
||||||
|
const deltas = item.toDelta();
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
for (const delta of deltas) {
|
||||||
|
const length = delta.insert?.length ?? 0;
|
||||||
|
const attributes = delta.attributes ?? {};
|
||||||
|
const markAttr = attributes[markName];
|
||||||
|
|
||||||
|
if (
|
||||||
|
markAttr &&
|
||||||
|
markAttr[findByAttribute.name] === findByAttribute.value
|
||||||
|
) {
|
||||||
|
// Update the mark with new attributes (merge with existing)
|
||||||
|
item.format(offset, length, {
|
||||||
|
[markName]: { ...markAttr, ...newAttributes },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
offset += length;
|
||||||
|
}
|
||||||
|
} else if (item instanceof Y.XmlElement) {
|
||||||
|
for (let i = 0; i < item.length; i++) {
|
||||||
|
processItem(item.get(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < fragment.length; i++) {
|
||||||
|
processItem(fragment.get(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,3 +9,7 @@ export const LOCAL_STORAGE_PATH = path.resolve(
|
|||||||
'..',
|
'..',
|
||||||
LOCAL_STORAGE_DIR,
|
LOCAL_STORAGE_DIR,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export function getPageTitle(title: string | null | undefined): string {
|
||||||
|
return title || 'untitled';
|
||||||
|
}
|
||||||
|
|||||||
@@ -64,6 +64,30 @@ export function extractPageMentions(mentionList: MentionNode[]): MentionNode[] {
|
|||||||
return pageMentionList as MentionNode[];
|
return pageMentionList as MentionNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function extractUserMentionIdsFromJson(json: any): string[] {
|
||||||
|
const userIds: string[] = [];
|
||||||
|
|
||||||
|
function walk(node: any) {
|
||||||
|
if (!node) return;
|
||||||
|
if (
|
||||||
|
node.type === 'mention' &&
|
||||||
|
node.attrs?.entityType === 'user' &&
|
||||||
|
node.attrs?.entityId &&
|
||||||
|
!userIds.includes(node.attrs.entityId)
|
||||||
|
) {
|
||||||
|
userIds.push(node.attrs.entityId);
|
||||||
|
}
|
||||||
|
if (Array.isArray(node.content)) {
|
||||||
|
for (const child of node.content) {
|
||||||
|
walk(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
walk(json);
|
||||||
|
return userIds;
|
||||||
|
}
|
||||||
|
|
||||||
export function getProsemirrorContent(content: any) {
|
export function getProsemirrorContent(content: any) {
|
||||||
return (
|
return (
|
||||||
content ?? {
|
content ?? {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { ConsoleLogger } from '@nestjs/common';
|
import { ConsoleLogger, LogLevel } from '@nestjs/common';
|
||||||
|
|
||||||
export class InternalLogFilter extends ConsoleLogger {
|
export class InternalLogFilter extends ConsoleLogger {
|
||||||
static contextsToIgnore = [
|
static contextsToIgnore = [
|
||||||
|
'NestFactory',
|
||||||
'InstanceLoader',
|
'InstanceLoader',
|
||||||
'RoutesResolver',
|
'RoutesResolver',
|
||||||
'RouterExplorer',
|
'RouterExplorer',
|
||||||
@@ -11,14 +12,23 @@ export class InternalLogFilter extends ConsoleLogger {
|
|||||||
private allowedLogLevels: string[];
|
private allowedLogLevels: string[];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
super({
|
||||||
|
json: isProduction,
|
||||||
|
});
|
||||||
const isDebugMode = process.env.DEBUG_MODE === 'true';
|
const isDebugMode = process.env.DEBUG_MODE === 'true';
|
||||||
|
|
||||||
if (isProduction && !isDebugMode) {
|
if (isProduction && !isDebugMode) {
|
||||||
this.allowedLogLevels = ['log', 'error', 'fatal'];
|
this.allowedLogLevels = ['info', 'error', 'fatal'];
|
||||||
} else {
|
} else {
|
||||||
this.allowedLogLevels = ['log', 'debug', 'verbose', 'warn', 'error', 'fatal'];
|
this.allowedLogLevels = [
|
||||||
|
'info',
|
||||||
|
'debug',
|
||||||
|
'verbose',
|
||||||
|
'warn',
|
||||||
|
'error',
|
||||||
|
'fatal',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,9 +38,8 @@ export class InternalLogFilter extends ConsoleLogger {
|
|||||||
|
|
||||||
log(_: any, context?: string): void {
|
log(_: any, context?: string): void {
|
||||||
if (
|
if (
|
||||||
this.isLogLevelAllowed('log') &&
|
this.isLogLevelAllowed('info') &&
|
||||||
(process.env.NODE_ENV !== 'production' ||
|
!InternalLogFilter.contextsToIgnore.includes(context)
|
||||||
!InternalLogFilter.contextsToIgnore.includes(context))
|
|
||||||
) {
|
) {
|
||||||
super.log.apply(this, arguments);
|
super.log.apply(this, arguments);
|
||||||
}
|
}
|
||||||
@@ -59,4 +68,15 @@ export class InternalLogFilter extends ConsoleLogger {
|
|||||||
super.verbose.apply(this, arguments);
|
super.verbose.apply(this, arguments);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected printMessages(
|
||||||
|
messages: unknown[],
|
||||||
|
context?: string,
|
||||||
|
logLevel?: LogLevel,
|
||||||
|
writeStreamType?: 'stdout' | 'stderr',
|
||||||
|
errorStack?: unknown,
|
||||||
|
): void {
|
||||||
|
const level = logLevel === 'log' ? ('info' as LogLevel) : logLevel;
|
||||||
|
super.printMessages(messages, context, level, writeStreamType, errorStack);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { CommentService } from './comment.service';
|
|||||||
import { CommentController } from './comment.controller';
|
import { CommentController } from './comment.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [],
|
|
||||||
controllers: [CommentController],
|
controllers: [CommentController],
|
||||||
providers: [CommentService],
|
providers: [CommentService],
|
||||||
exports: [CommentService],
|
exports: [CommentService],
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
|
import { Queue } from 'bullmq';
|
||||||
import { CreateCommentDto } from './dto/create-comment.dto';
|
import { CreateCommentDto } from './dto/create-comment.dto';
|
||||||
import { UpdateCommentDto } from './dto/update-comment.dto';
|
import { UpdateCommentDto } from './dto/update-comment.dto';
|
||||||
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
|
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
|
||||||
@@ -11,12 +14,21 @@ import { Comment, Page, User } from '@docmost/db/types/entity.types';
|
|||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
||||||
|
import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
||||||
|
import { extractUserMentionIdsFromJson } from '../../common/helpers/prosemirror/utils';
|
||||||
|
import { ICommentNotificationJob } from '../../integrations/queue/constants/queue.interface';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CommentService {
|
export class CommentService {
|
||||||
|
private readonly logger = new Logger(CommentService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private commentRepo: CommentRepo,
|
private commentRepo: CommentRepo,
|
||||||
private pageRepo: PageRepo,
|
private pageRepo: PageRepo,
|
||||||
|
@InjectQueue(QueueName.GENERAL_QUEUE)
|
||||||
|
private generalQueue: Queue,
|
||||||
|
@InjectQueue(QueueName.NOTIFICATION_QUEUE)
|
||||||
|
private notificationQueue: Queue,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findById(commentId: string) {
|
async findById(commentId: string) {
|
||||||
@@ -51,7 +63,7 @@ export class CommentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.commentRepo.insertComment({
|
const comment = await this.commentRepo.insertComment({
|
||||||
pageId: page.id,
|
pageId: page.id,
|
||||||
content: commentContent,
|
content: commentContent,
|
||||||
selection: createCommentDto?.selection?.substring(0, 250),
|
selection: createCommentDto?.selection?.substring(0, 250),
|
||||||
@@ -61,6 +73,33 @@ export class CommentService {
|
|||||||
workspaceId: workspaceId,
|
workspaceId: workspaceId,
|
||||||
spaceId: page.spaceId,
|
spaceId: page.spaceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.generalQueue
|
||||||
|
.add(QueueJob.ADD_PAGE_WATCHERS, {
|
||||||
|
userIds: [userId],
|
||||||
|
pageId: page.id,
|
||||||
|
spaceId: page.spaceId,
|
||||||
|
workspaceId,
|
||||||
|
})
|
||||||
|
.catch((err) =>
|
||||||
|
this.logger.warn(`Failed to queue add-page-watchers: ${err.message}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
const isReply = !!createCommentDto.parentCommentId;
|
||||||
|
|
||||||
|
await this.queueCommentNotification(
|
||||||
|
commentContent,
|
||||||
|
[],
|
||||||
|
comment.id,
|
||||||
|
page.id,
|
||||||
|
page.spaceId,
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
!isReply,
|
||||||
|
createCommentDto.parentCommentId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return comment;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByPageId(
|
async findByPageId(
|
||||||
@@ -87,6 +126,8 @@ export class CommentService {
|
|||||||
throw new ForbiddenException('You can only edit your own comments');
|
throw new ForbiddenException('You can only edit your own comments');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const oldMentionIds = extractUserMentionIdsFromJson(comment.content);
|
||||||
|
|
||||||
const editedAt = new Date();
|
const editedAt = new Date();
|
||||||
|
|
||||||
await this.commentRepo.updateComment(
|
await this.commentRepo.updateComment(
|
||||||
@@ -97,10 +138,57 @@ export class CommentService {
|
|||||||
},
|
},
|
||||||
comment.id,
|
comment.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await this.queueCommentNotification(
|
||||||
|
commentContent,
|
||||||
|
oldMentionIds,
|
||||||
|
comment.id,
|
||||||
|
comment.pageId,
|
||||||
|
comment.spaceId,
|
||||||
|
comment.workspaceId,
|
||||||
|
authUser.id,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
comment.content = commentContent;
|
comment.content = commentContent;
|
||||||
comment.editedAt = editedAt;
|
comment.editedAt = editedAt;
|
||||||
comment.updatedAt = editedAt;
|
comment.updatedAt = editedAt;
|
||||||
|
|
||||||
return comment;
|
return comment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async queueCommentNotification(
|
||||||
|
content: any,
|
||||||
|
oldMentionIds: string[],
|
||||||
|
commentId: string,
|
||||||
|
pageId: string,
|
||||||
|
spaceId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
actorId: string,
|
||||||
|
notifyWatchers: boolean,
|
||||||
|
parentCommentId?: string,
|
||||||
|
) {
|
||||||
|
const mentionedUserIds = extractUserMentionIdsFromJson(content);
|
||||||
|
const newMentionIds = mentionedUserIds.filter(
|
||||||
|
(id) => id !== actorId && !oldMentionIds.includes(id),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (newMentionIds.length === 0 && !notifyWatchers && !parentCommentId) return;
|
||||||
|
|
||||||
|
const jobData: ICommentNotificationJob = {
|
||||||
|
commentId,
|
||||||
|
parentCommentId,
|
||||||
|
pageId,
|
||||||
|
spaceId,
|
||||||
|
workspaceId,
|
||||||
|
actorId,
|
||||||
|
mentionedUserIds: newMentionIds,
|
||||||
|
notifyWatchers,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.notificationQueue.add(
|
||||||
|
QueueJob.COMMENT_NOTIFICATION,
|
||||||
|
jobData,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import { CaslModule } from './casl/casl.module';
|
|||||||
import { PageAccessModule } from './page-access/page-access.module';
|
import { PageAccessModule } from './page-access/page-access.module';
|
||||||
import { DomainMiddleware } from '../common/middlewares/domain.middleware';
|
import { DomainMiddleware } from '../common/middlewares/domain.middleware';
|
||||||
import { ShareModule } from './share/share.module';
|
import { ShareModule } from './share/share.module';
|
||||||
|
import { NotificationModule } from './notification/notification.module';
|
||||||
|
import { WatcherModule } from './watcher/watcher.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -32,6 +34,8 @@ import { ShareModule } from './share/share.module';
|
|||||||
CaslModule,
|
CaslModule,
|
||||||
PageAccessModule,
|
PageAccessModule,
|
||||||
ShareModule,
|
ShareModule,
|
||||||
|
NotificationModule,
|
||||||
|
WatcherModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class CoreModule implements NestModule {
|
export class CoreModule implements NestModule {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { GroupController } from './group.controller';
|
|||||||
import { GroupUserService } from './services/group-user.service';
|
import { GroupUserService } from './services/group-user.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [],
|
||||||
controllers: [GroupController],
|
controllers: [GroupController],
|
||||||
providers: [GroupService, GroupUserService],
|
providers: [GroupService, GroupUserService],
|
||||||
exports: [GroupService, GroupUserService],
|
exports: [GroupService, GroupUserService],
|
||||||
|
|||||||
@@ -10,15 +10,20 @@ import { GroupService } from './group.service';
|
|||||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
|
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
|
||||||
|
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||||
|
import { executeTx } from '@docmost/db/utils';
|
||||||
|
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GroupUserService {
|
export class GroupUserService {
|
||||||
constructor(
|
constructor(
|
||||||
private groupUserRepo: GroupUserRepo,
|
private groupUserRepo: GroupUserRepo,
|
||||||
|
private spaceMemberRepo: SpaceMemberRepo,
|
||||||
private userRepo: UserRepo,
|
private userRepo: UserRepo,
|
||||||
@Inject(forwardRef(() => GroupService))
|
@Inject(forwardRef(() => GroupService))
|
||||||
private groupService: GroupService,
|
private groupService: GroupService,
|
||||||
|
private readonly watcherRepo: WatcherRepo,
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -100,6 +105,18 @@ export class GroupUserService {
|
|||||||
throw new BadRequestException('Group member not found');
|
throw new BadRequestException('Group member not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.groupUserRepo.delete(userId, groupId);
|
const spaceIds = await this.spaceMemberRepo.getSpaceIdsByGroupId(groupId);
|
||||||
|
|
||||||
|
// TODO: use queue instead
|
||||||
|
await executeTx(this.db, async (trx) => {
|
||||||
|
await this.groupUserRepo.delete(userId, groupId, { trx });
|
||||||
|
|
||||||
|
for (const spaceId of spaceIds) {
|
||||||
|
await this.watcherRepo.deleteByUsersWithoutSpaceAccess(
|
||||||
|
[userId],
|
||||||
|
spaceId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,18 +8,27 @@ import {
|
|||||||
import { CreateGroupDto, DefaultGroup } from '../dto/create-group.dto';
|
import { CreateGroupDto, DefaultGroup } from '../dto/create-group.dto';
|
||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
import { UpdateGroupDto } from '../dto/update-group.dto';
|
import { UpdateGroupDto } from '../dto/update-group.dto';
|
||||||
import { KyselyTransaction } from '@docmost/db/types/kysely.types';
|
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||||
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
||||||
|
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
|
||||||
|
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||||
import { Group, InsertableGroup, User } from '@docmost/db/types/entity.types';
|
import { Group, InsertableGroup, User } from '@docmost/db/types/entity.types';
|
||||||
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
||||||
import { GroupUserService } from './group-user.service';
|
import { GroupUserService } from './group-user.service';
|
||||||
|
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||||
|
import { executeTx } from '@docmost/db/utils';
|
||||||
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GroupService {
|
export class GroupService {
|
||||||
constructor(
|
constructor(
|
||||||
private groupRepo: GroupRepo,
|
private groupRepo: GroupRepo,
|
||||||
|
private groupUserRepo: GroupUserRepo,
|
||||||
|
private spaceMemberRepo: SpaceMemberRepo,
|
||||||
@Inject(forwardRef(() => GroupUserService))
|
@Inject(forwardRef(() => GroupUserService))
|
||||||
private groupUserService: GroupUserService,
|
private groupUserService: GroupUserService,
|
||||||
|
private readonly watcherRepo: WatcherRepo,
|
||||||
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getGroupInfo(groupId: string, workspaceId: string): Promise<Group> {
|
async getGroupInfo(groupId: string, workspaceId: string): Promise<Group> {
|
||||||
@@ -68,20 +77,6 @@ export class GroupService {
|
|||||||
return createdGroup;
|
return createdGroup;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createDefaultGroup(
|
|
||||||
workspaceId: string,
|
|
||||||
userId?: string,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<Group> {
|
|
||||||
const insertableGroup: InsertableGroup = {
|
|
||||||
name: DefaultGroup.EVERYONE,
|
|
||||||
isDefault: true,
|
|
||||||
creatorId: userId ?? null,
|
|
||||||
workspaceId: workspaceId,
|
|
||||||
};
|
|
||||||
return await this.groupRepo.insertGroup(insertableGroup, trx);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateGroup(
|
async updateGroup(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
updateGroupDto: UpdateGroupDto,
|
updateGroupDto: UpdateGroupDto,
|
||||||
@@ -141,7 +136,24 @@ export class GroupService {
|
|||||||
if (group.isDefault) {
|
if (group.isDefault) {
|
||||||
throw new BadRequestException('You cannot delete a default group');
|
throw new BadRequestException('You cannot delete a default group');
|
||||||
}
|
}
|
||||||
await this.groupRepo.delete(groupId, workspaceId);
|
|
||||||
|
const [userIds, spaceIds] = await Promise.all([
|
||||||
|
this.groupUserRepo.getUserIdsByGroupId(groupId),
|
||||||
|
this.spaceMemberRepo.getSpaceIdsByGroupId(groupId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// TODO: use queue instead
|
||||||
|
await executeTx(this.db, async (trx) => {
|
||||||
|
await this.groupRepo.delete(groupId, workspaceId, { trx });
|
||||||
|
|
||||||
|
for (const spaceId of spaceIds) {
|
||||||
|
await this.watcherRepo.deleteByUsersWithoutSpaceAccess(
|
||||||
|
userIds,
|
||||||
|
spaceId,
|
||||||
|
{ trx },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAndValidateGroup(
|
async findAndValidateGroup(
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { IsArray, IsOptional, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class NotificationIdDto {
|
||||||
|
@IsUUID()
|
||||||
|
notificationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MarkNotificationsReadDto {
|
||||||
|
@IsArray()
|
||||||
|
@IsUUID(undefined, { each: true })
|
||||||
|
@IsOptional()
|
||||||
|
notificationIds?: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export const NotificationType = {
|
||||||
|
COMMENT_USER_MENTION: 'comment.user_mention',
|
||||||
|
COMMENT_CREATED: 'comment.created',
|
||||||
|
COMMENT_RESOLVED: 'comment.resolved',
|
||||||
|
PAGE_USER_MENTION: 'page.user_mention',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type NotificationType =
|
||||||
|
(typeof NotificationType)[keyof typeof NotificationType];
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Post,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { NotificationService } from './notification.service';
|
||||||
|
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
|
import { User } from '@docmost/db/types/entity.types';
|
||||||
|
import { MarkNotificationsReadDto } from './dto/notification.dto';
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Controller('notifications')
|
||||||
|
export class NotificationController {
|
||||||
|
constructor(private readonly notificationService: NotificationService) {}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('/')
|
||||||
|
async getNotifications(
|
||||||
|
@Body() pagination: PaginationOptions,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
) {
|
||||||
|
return this.notificationService.findByUserId(user.id, pagination);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('unread-count')
|
||||||
|
async getUnreadCount(@AuthUser() user: User) {
|
||||||
|
const count = await this.notificationService.getUnreadCount(user.id);
|
||||||
|
return { count };
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('mark-read')
|
||||||
|
async markAsRead(
|
||||||
|
@Body() dto: MarkNotificationsReadDto,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
) {
|
||||||
|
if (dto.notificationIds?.length) {
|
||||||
|
await this.notificationService.markMultipleAsRead(
|
||||||
|
dto.notificationIds,
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('mark-all-read')
|
||||||
|
async markAllAsRead(@AuthUser() user: User) {
|
||||||
|
await this.notificationService.markAllAsRead(user.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { NotificationService } from './notification.service';
|
||||||
|
import { NotificationController } from './notification.controller';
|
||||||
|
import { NotificationProcessor } from './notification.processor';
|
||||||
|
import { CommentNotificationService } from './services/comment.notification';
|
||||||
|
import { PageNotificationService } from './services/page.notification';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [],
|
||||||
|
controllers: [NotificationController],
|
||||||
|
providers: [
|
||||||
|
NotificationService,
|
||||||
|
NotificationProcessor,
|
||||||
|
CommentNotificationService,
|
||||||
|
PageNotificationService,
|
||||||
|
],
|
||||||
|
exports: [NotificationService],
|
||||||
|
})
|
||||||
|
export class NotificationModule {}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { Logger, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
|
||||||
|
import { Job } from 'bullmq';
|
||||||
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
|
import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
||||||
|
import {
|
||||||
|
ICommentNotificationJob,
|
||||||
|
ICommentResolvedNotificationJob,
|
||||||
|
IPageMentionNotificationJob,
|
||||||
|
} from '../../integrations/queue/constants/queue.interface';
|
||||||
|
import { CommentNotificationService } from './services/comment.notification';
|
||||||
|
import { PageNotificationService } from './services/page.notification';
|
||||||
|
import { DomainService } from '../../integrations/environment/domain.service';
|
||||||
|
|
||||||
|
@Processor(QueueName.NOTIFICATION_QUEUE)
|
||||||
|
export class NotificationProcessor
|
||||||
|
extends WorkerHost
|
||||||
|
implements OnModuleDestroy
|
||||||
|
{
|
||||||
|
private readonly logger = new Logger(NotificationProcessor.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly commentNotificationService: CommentNotificationService,
|
||||||
|
private readonly pageNotificationService: PageNotificationService,
|
||||||
|
private readonly domainService: DomainService,
|
||||||
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async process(
|
||||||
|
job: Job<
|
||||||
|
| ICommentNotificationJob
|
||||||
|
| ICommentResolvedNotificationJob
|
||||||
|
| IPageMentionNotificationJob,
|
||||||
|
void
|
||||||
|
>,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const workspaceId = (job.data as { workspaceId: string }).workspaceId;
|
||||||
|
const appUrl = await this.getWorkspaceUrl(workspaceId);
|
||||||
|
|
||||||
|
switch (job.name) {
|
||||||
|
case QueueJob.COMMENT_NOTIFICATION: {
|
||||||
|
await this.commentNotificationService.processComment(
|
||||||
|
job.data as ICommentNotificationJob,
|
||||||
|
appUrl,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case QueueJob.COMMENT_RESOLVED_NOTIFICATION: {
|
||||||
|
await this.commentNotificationService.processResolved(
|
||||||
|
job.data as ICommentResolvedNotificationJob,
|
||||||
|
appUrl,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case QueueJob.PAGE_MENTION_NOTIFICATION: {
|
||||||
|
await this.pageNotificationService.processPageMention(
|
||||||
|
job.data as IPageMentionNotificationJob,
|
||||||
|
appUrl,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
this.logger.warn(`Unknown notification job: ${job.name}`);
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
this.logger.error(`Failed to process ${job.name}: ${message}`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getWorkspaceUrl(workspaceId: string): Promise<string> {
|
||||||
|
const workspace = await this.db
|
||||||
|
.selectFrom('workspaces')
|
||||||
|
.select('hostname')
|
||||||
|
.where('id', '=', workspaceId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
return this.domainService.getUrl(workspace?.hostname);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnWorkerEvent('failed')
|
||||||
|
onError(job: Job) {
|
||||||
|
this.logger.error(
|
||||||
|
`Error processing ${job.name} job. Reason: ${job.failedReason}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy(): Promise<void> {
|
||||||
|
if (this.worker) {
|
||||||
|
await this.worker.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
|
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
|
||||||
|
import { InsertableNotification } from '@docmost/db/types/entity.types';
|
||||||
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
|
import { WsGateway } from '../../ws/ws.gateway';
|
||||||
|
import { MailService } from '../../integrations/mail/mail.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NotificationService {
|
||||||
|
private readonly logger = new Logger(NotificationService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly notificationRepo: NotificationRepo,
|
||||||
|
private readonly wsGateway: WsGateway,
|
||||||
|
private readonly mailService: MailService,
|
||||||
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async create(data: InsertableNotification) {
|
||||||
|
const notification = await this.notificationRepo.insert(data);
|
||||||
|
|
||||||
|
this.wsGateway.server
|
||||||
|
.to(`user-${data.userId}`)
|
||||||
|
.emit('notification', { id: notification.id, type: notification.type });
|
||||||
|
|
||||||
|
return notification;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByUserId(userId: string, pagination: PaginationOptions) {
|
||||||
|
return this.notificationRepo.findByUserId(userId, pagination);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUnreadCount(userId: string) {
|
||||||
|
return this.notificationRepo.getUnreadCount(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAsRead(notificationId: string, userId: string) {
|
||||||
|
return this.notificationRepo.markAsRead(notificationId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async markMultipleAsRead(notificationIds: string[], userId: string) {
|
||||||
|
return this.notificationRepo.markMultipleAsRead(notificationIds, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAllAsRead(userId: string) {
|
||||||
|
return this.notificationRepo.markAllAsRead(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async queueEmail(
|
||||||
|
userId: string,
|
||||||
|
notificationId: string,
|
||||||
|
subject: string,
|
||||||
|
template: any,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const user = await this.db
|
||||||
|
.selectFrom('users')
|
||||||
|
.select(['email'])
|
||||||
|
.where('id', '=', userId)
|
||||||
|
.where('deletedAt', 'is', null)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (!user?.email) return;
|
||||||
|
|
||||||
|
await this.mailService.sendToQueue({
|
||||||
|
to: user.email,
|
||||||
|
subject,
|
||||||
|
template,
|
||||||
|
notificationId,
|
||||||
|
});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to queue email for notification ${notificationId}: ${message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
|
import {
|
||||||
|
ICommentNotificationJob,
|
||||||
|
ICommentResolvedNotificationJob,
|
||||||
|
} from '../../../integrations/queue/constants/queue.interface';
|
||||||
|
import { NotificationService } from '../notification.service';
|
||||||
|
import { NotificationType } from '../notification.constants';
|
||||||
|
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||||
|
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||||
|
import { CommentMentionEmail } from '@docmost/transactional/emails/comment-mention-email';
|
||||||
|
import { CommentCreateEmail } from '@docmost/transactional/emails/comment-created-email';
|
||||||
|
import { CommentResolvedEmail } from '@docmost/transactional/emails/comment-resolved-email';
|
||||||
|
import { getPageTitle } from '../../../common/helpers';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CommentNotificationService {
|
||||||
|
private readonly logger = new Logger(CommentNotificationService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
|
private readonly notificationService: NotificationService,
|
||||||
|
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||||
|
private readonly watcherRepo: WatcherRepo,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async processComment(data: ICommentNotificationJob, appUrl: string) {
|
||||||
|
const {
|
||||||
|
commentId,
|
||||||
|
parentCommentId,
|
||||||
|
pageId,
|
||||||
|
spaceId,
|
||||||
|
workspaceId,
|
||||||
|
actorId,
|
||||||
|
mentionedUserIds,
|
||||||
|
notifyWatchers,
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
const context = await this.getCommentContext(
|
||||||
|
actorId,
|
||||||
|
pageId,
|
||||||
|
spaceId,
|
||||||
|
commentId,
|
||||||
|
appUrl,
|
||||||
|
);
|
||||||
|
if (!context) return;
|
||||||
|
|
||||||
|
const { actor, pageTitle, pageUrl } = context;
|
||||||
|
const notifiedUserIds = new Set<string>();
|
||||||
|
notifiedUserIds.add(actorId);
|
||||||
|
|
||||||
|
const recipientIds = parentCommentId
|
||||||
|
? await this.getThreadParticipantIds(parentCommentId)
|
||||||
|
: notifyWatchers
|
||||||
|
? await this.watcherRepo.getPageWatcherIds(pageId)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const allCandidateIds = [
|
||||||
|
...new Set([...mentionedUserIds, ...recipientIds]),
|
||||||
|
];
|
||||||
|
const usersWithAccess =
|
||||||
|
await this.spaceMemberRepo.getUserIdsWithSpaceAccess(
|
||||||
|
allCandidateIds,
|
||||||
|
spaceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const userId of mentionedUserIds) {
|
||||||
|
if (!usersWithAccess.has(userId)) continue;
|
||||||
|
|
||||||
|
const notification = await this.notificationService.create({
|
||||||
|
userId,
|
||||||
|
workspaceId,
|
||||||
|
type: NotificationType.COMMENT_USER_MENTION,
|
||||||
|
actorId,
|
||||||
|
pageId,
|
||||||
|
spaceId,
|
||||||
|
commentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.notificationService.queueEmail(
|
||||||
|
userId,
|
||||||
|
notification.id,
|
||||||
|
`${actor.name} mentioned you in a comment`,
|
||||||
|
CommentMentionEmail({ actorName: actor.name, pageTitle, pageUrl }),
|
||||||
|
);
|
||||||
|
|
||||||
|
notifiedUserIds.add(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const recipientId of recipientIds) {
|
||||||
|
if (notifiedUserIds.has(recipientId)) continue;
|
||||||
|
if (!usersWithAccess.has(recipientId)) continue;
|
||||||
|
|
||||||
|
const notification = await this.notificationService.create({
|
||||||
|
userId: recipientId,
|
||||||
|
workspaceId,
|
||||||
|
type: NotificationType.COMMENT_CREATED,
|
||||||
|
actorId,
|
||||||
|
pageId,
|
||||||
|
spaceId,
|
||||||
|
commentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.notificationService.queueEmail(
|
||||||
|
recipientId,
|
||||||
|
notification.id,
|
||||||
|
`${actor.name} commented on ${pageTitle}`,
|
||||||
|
CommentCreateEmail({ actorName: actor.name, pageTitle, pageUrl }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async processResolved(data: ICommentResolvedNotificationJob, appUrl: string) {
|
||||||
|
const {
|
||||||
|
commentId,
|
||||||
|
commentCreatorId,
|
||||||
|
pageId,
|
||||||
|
spaceId,
|
||||||
|
workspaceId,
|
||||||
|
actorId,
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
if (commentCreatorId === actorId) return;
|
||||||
|
|
||||||
|
const context = await this.getCommentContext(
|
||||||
|
actorId,
|
||||||
|
pageId,
|
||||||
|
spaceId,
|
||||||
|
commentId,
|
||||||
|
appUrl,
|
||||||
|
);
|
||||||
|
if (!context) return;
|
||||||
|
|
||||||
|
const { actor, pageTitle, pageUrl } = context;
|
||||||
|
|
||||||
|
const roles = await this.spaceMemberRepo.getUserSpaceRoles(
|
||||||
|
commentCreatorId,
|
||||||
|
spaceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!roles) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Skipping resolved notification for user ${commentCreatorId}: no access to space ${spaceId}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const notification = await this.notificationService.create({
|
||||||
|
userId: commentCreatorId,
|
||||||
|
workspaceId,
|
||||||
|
type: NotificationType.COMMENT_RESOLVED,
|
||||||
|
actorId,
|
||||||
|
pageId,
|
||||||
|
spaceId,
|
||||||
|
commentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const subject = `${actor.name} resolved a comment on ${pageTitle}`;
|
||||||
|
|
||||||
|
await this.notificationService.queueEmail(
|
||||||
|
commentCreatorId,
|
||||||
|
notification.id,
|
||||||
|
subject,
|
||||||
|
CommentResolvedEmail({ actorName: actor.name, pageTitle, pageUrl }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getThreadParticipantIds(
|
||||||
|
parentCommentId: string,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const participants = await this.db
|
||||||
|
.selectFrom('comments')
|
||||||
|
.select('creatorId')
|
||||||
|
.where((eb) =>
|
||||||
|
eb.or([
|
||||||
|
eb('id', '=', parentCommentId),
|
||||||
|
eb('parentCommentId', '=', parentCommentId),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return [...new Set(participants.map((p) => p.creatorId))];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getCommentContext(
|
||||||
|
actorId: string,
|
||||||
|
pageId: string,
|
||||||
|
spaceId: string,
|
||||||
|
commentId: string,
|
||||||
|
appUrl: string,
|
||||||
|
) {
|
||||||
|
const [actor, page, space] = await Promise.all([
|
||||||
|
this.db
|
||||||
|
.selectFrom('users')
|
||||||
|
.select(['id', 'name'])
|
||||||
|
.where('id', '=', actorId)
|
||||||
|
.executeTakeFirst(),
|
||||||
|
this.db
|
||||||
|
.selectFrom('pages')
|
||||||
|
.select(['id', 'title', 'slugId'])
|
||||||
|
.where('id', '=', pageId)
|
||||||
|
.executeTakeFirst(),
|
||||||
|
this.db
|
||||||
|
.selectFrom('spaces')
|
||||||
|
.select(['id', 'slug'])
|
||||||
|
.where('id', '=', spaceId)
|
||||||
|
.executeTakeFirst(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!actor || !page || !space) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageUrl = `${appUrl}/s/${space.slug}/p/${page.slugId}`;
|
||||||
|
|
||||||
|
return { actor, pageTitle: getPageTitle(page.title), pageUrl };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
|
import { IPageMentionNotificationJob } from '../../../integrations/queue/constants/queue.interface';
|
||||||
|
import { NotificationService } from '../notification.service';
|
||||||
|
import { NotificationType } from '../notification.constants';
|
||||||
|
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||||
|
import { PageMentionEmail } from '@docmost/transactional/emails/page-mention-email';
|
||||||
|
import { getPageTitle } from '../../../common/helpers';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PageNotificationService {
|
||||||
|
constructor(
|
||||||
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
|
private readonly notificationService: NotificationService,
|
||||||
|
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async processPageMention(data: IPageMentionNotificationJob, appUrl: string) {
|
||||||
|
const { userMentions, oldMentionedUserIds, pageId, spaceId, workspaceId } =
|
||||||
|
data;
|
||||||
|
|
||||||
|
const oldIds = new Set(oldMentionedUserIds);
|
||||||
|
const newMentions = userMentions.filter(
|
||||||
|
(m) => !oldIds.has(m.userId) && m.creatorId !== m.userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (newMentions.length === 0) return;
|
||||||
|
|
||||||
|
const candidateUserIds = newMentions.map((m) => m.userId);
|
||||||
|
const usersWithAccess =
|
||||||
|
await this.spaceMemberRepo.getUserIdsWithSpaceAccess(
|
||||||
|
candidateUserIds,
|
||||||
|
spaceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const accessibleMentions = newMentions.filter((m) =>
|
||||||
|
usersWithAccess.has(m.userId),
|
||||||
|
);
|
||||||
|
if (accessibleMentions.length === 0) return;
|
||||||
|
|
||||||
|
const mentionsByCreator = new Map<
|
||||||
|
string,
|
||||||
|
{ userId: string; mentionId: string }[]
|
||||||
|
>();
|
||||||
|
for (const m of accessibleMentions) {
|
||||||
|
const list = mentionsByCreator.get(m.creatorId) || [];
|
||||||
|
list.push({ userId: m.userId, mentionId: m.mentionId });
|
||||||
|
mentionsByCreator.set(m.creatorId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [actorId, mentions] of mentionsByCreator) {
|
||||||
|
await this.notifyMentionedUsers(
|
||||||
|
mentions,
|
||||||
|
actorId,
|
||||||
|
pageId,
|
||||||
|
spaceId,
|
||||||
|
workspaceId,
|
||||||
|
appUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async notifyMentionedUsers(
|
||||||
|
mentions: { userId: string; mentionId: string }[],
|
||||||
|
actorId: string,
|
||||||
|
pageId: string,
|
||||||
|
spaceId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
appUrl: string,
|
||||||
|
) {
|
||||||
|
const context = await this.getPageContext(actorId, pageId, spaceId, appUrl);
|
||||||
|
if (!context) return;
|
||||||
|
|
||||||
|
const { actor, pageTitle, basePageUrl } = context;
|
||||||
|
|
||||||
|
for (const { userId, mentionId } of mentions) {
|
||||||
|
const notification = await this.notificationService.create({
|
||||||
|
userId,
|
||||||
|
workspaceId,
|
||||||
|
type: NotificationType.PAGE_USER_MENTION,
|
||||||
|
actorId,
|
||||||
|
pageId,
|
||||||
|
spaceId,
|
||||||
|
data: { mentionId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const pageUrl = `${basePageUrl}`;
|
||||||
|
const subject = `${actor.name} mentioned you in ${pageTitle}`;
|
||||||
|
|
||||||
|
await this.notificationService.queueEmail(
|
||||||
|
userId,
|
||||||
|
notification.id,
|
||||||
|
subject,
|
||||||
|
PageMentionEmail({ actorName: actor.name, pageTitle, pageUrl }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getPageContext(
|
||||||
|
actorId: string,
|
||||||
|
pageId: string,
|
||||||
|
spaceId: string,
|
||||||
|
appUrl: string,
|
||||||
|
) {
|
||||||
|
const [actor, page, space] = await Promise.all([
|
||||||
|
this.db
|
||||||
|
.selectFrom('users')
|
||||||
|
.select(['id', 'name'])
|
||||||
|
.where('id', '=', actorId)
|
||||||
|
.executeTakeFirst(),
|
||||||
|
this.db
|
||||||
|
.selectFrom('pages')
|
||||||
|
.select(['id', 'title', 'slugId'])
|
||||||
|
.where('id', '=', pageId)
|
||||||
|
.executeTakeFirst(),
|
||||||
|
this.db
|
||||||
|
.selectFrom('spaces')
|
||||||
|
.select(['id', 'slug'])
|
||||||
|
.where('id', '=', spaceId)
|
||||||
|
.executeTakeFirst(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!actor || !page || !space) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const basePageUrl = `${appUrl}/s/${space.slug}/p/${page.slugId}`;
|
||||||
|
|
||||||
|
return { actor, pageTitle: getPageTitle(page.title), basePageUrl };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,13 @@
|
|||||||
import { IsOptional, IsString, IsUUID } from 'class-validator';
|
import {
|
||||||
|
IsIn,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
ValidateIf,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
|
||||||
|
export type ContentFormat = 'json' | 'markdown' | 'html';
|
||||||
|
|
||||||
export class CreatePageDto {
|
export class CreatePageDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -15,4 +24,12 @@ export class CreatePageDto {
|
|||||||
|
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
content?: string | object;
|
||||||
|
|
||||||
|
@ValidateIf((o) => o.content !== undefined)
|
||||||
|
@Transform(({ value }) => value?.toLowerCase() ?? 'json')
|
||||||
|
@IsIn(['json', 'markdown', 'html'])
|
||||||
|
format?: ContentFormat;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
|
IsIn,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
IsUUID,
|
IsUUID,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
|
||||||
|
import { ContentFormat } from './create-page.dto';
|
||||||
|
|
||||||
export class PageIdDto {
|
export class PageIdDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -30,6 +34,11 @@ export class PageInfoDto extends PageIdDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
includeContent: boolean;
|
includeContent: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => value?.toLowerCase())
|
||||||
|
@IsIn(['json', 'markdown', 'html'])
|
||||||
|
format?: ContentFormat;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DeletePageDto extends PageIdDto {
|
export class DeletePageDto extends PageIdDto {
|
||||||
|
|||||||
@@ -1,8 +1,24 @@
|
|||||||
import { PartialType } from '@nestjs/mapped-types';
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
import { CreatePageDto } from './create-page.dto';
|
import { CreatePageDto, ContentFormat } from './create-page.dto';
|
||||||
import { IsString } from 'class-validator';
|
import { IsIn, IsOptional, IsString, ValidateIf } from 'class-validator';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
|
||||||
|
export type ContentOperation = 'append' | 'prepend' | 'replace';
|
||||||
|
|
||||||
export class UpdatePageDto extends PartialType(CreatePageDto) {
|
export class UpdatePageDto extends PartialType(CreatePageDto) {
|
||||||
@IsString()
|
@IsString()
|
||||||
pageId: string;
|
pageId: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
content?: string | object;
|
||||||
|
|
||||||
|
@ValidateIf((o) => o.content !== undefined)
|
||||||
|
@Transform(({ value }) => value?.toLowerCase())
|
||||||
|
@IsIn(['append', 'prepend', 'replace'])
|
||||||
|
operation?: ContentOperation;
|
||||||
|
|
||||||
|
@ValidateIf((o) => o.content !== undefined)
|
||||||
|
@Transform(({ value }) => value?.toLowerCase() ?? 'json')
|
||||||
|
@IsIn(['json', 'markdown', 'html'])
|
||||||
|
format?: ContentFormat;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
|||||||
import { RecentPageDto } from './dto/recent-page.dto';
|
import { RecentPageDto } from './dto/recent-page.dto';
|
||||||
import { DuplicatePageDto } from './dto/duplicate-page.dto';
|
import { DuplicatePageDto } from './dto/duplicate-page.dto';
|
||||||
import { DeletedPageDto } from './dto/deleted-page.dto';
|
import { DeletedPageDto } from './dto/deleted-page.dto';
|
||||||
|
import {
|
||||||
|
jsonToHtml,
|
||||||
|
jsonToMarkdown,
|
||||||
|
} from '../../collaboration/collaboration.util';
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('pages')
|
@Controller('pages')
|
||||||
@@ -65,6 +69,17 @@ export class PageController {
|
|||||||
|
|
||||||
await this.pageAccessService.validateCanView(page, user);
|
await this.pageAccessService.validateCanView(page, user);
|
||||||
|
|
||||||
|
if (dto.format && dto.format !== 'json' && page.content) {
|
||||||
|
const contentOutput =
|
||||||
|
dto.format === 'markdown'
|
||||||
|
? jsonToMarkdown(page.content)
|
||||||
|
: jsonToHtml(page.content);
|
||||||
|
return {
|
||||||
|
...page,
|
||||||
|
content: contentOutput,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +114,25 @@ export class PageController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.pageService.create(user.id, workspace.id, createPageDto);
|
const page = await this.pageService.create(
|
||||||
|
user.id,
|
||||||
|
workspace.id,
|
||||||
|
createPageDto,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
createPageDto.format &&
|
||||||
|
createPageDto.format !== 'json' &&
|
||||||
|
page.content
|
||||||
|
) {
|
||||||
|
const contentOutput =
|
||||||
|
createPageDto.format === 'markdown'
|
||||||
|
? jsonToMarkdown(page.content)
|
||||||
|
: jsonToHtml(page.content);
|
||||||
|
return { ...page, content: contentOutput };
|
||||||
|
}
|
||||||
|
|
||||||
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@@ -113,7 +146,25 @@ export class PageController {
|
|||||||
|
|
||||||
await this.pageAccessService.validateCanEdit(page, user);
|
await this.pageAccessService.validateCanEdit(page, user);
|
||||||
|
|
||||||
return this.pageService.update(page, updatePageDto, user.id);
|
const updatedPage = await this.pageService.update(
|
||||||
|
page,
|
||||||
|
updatePageDto,
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
updatePageDto.format &&
|
||||||
|
updatePageDto.format !== 'json' &&
|
||||||
|
updatedPage.content
|
||||||
|
) {
|
||||||
|
const contentOutput =
|
||||||
|
updatePageDto.format === 'markdown'
|
||||||
|
? jsonToMarkdown(updatedPage.content)
|
||||||
|
: jsonToHtml(updatedPage.content);
|
||||||
|
return { ...updatedPage, content: contentOutput };
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedPage;
|
||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { TrashCleanupService } from './services/trash-cleanup.service';
|
|||||||
import { PagePermissionService } from './services/page-permission.service';
|
import { PagePermissionService } from './services/page-permission.service';
|
||||||
import { PagePermissionController } from './page-permission.controller';
|
import { PagePermissionController } from './page-permission.controller';
|
||||||
import { StorageModule } from '../../integrations/storage/storage.module';
|
import { StorageModule } from '../../integrations/storage/storage.module';
|
||||||
|
import { CollaborationModule } from '../../collaboration/collaboration.module';
|
||||||
|
import { WatcherModule } from '../watcher/watcher.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [PageController, PagePermissionController],
|
controllers: [PageController, PagePermissionController],
|
||||||
@@ -16,6 +18,6 @@ import { StorageModule } from '../../integrations/storage/storage.module';
|
|||||||
PagePermissionService,
|
PagePermissionService,
|
||||||
],
|
],
|
||||||
exports: [PageService, PageHistoryService, PagePermissionService],
|
exports: [PageService, PageHistoryService, PagePermissionService],
|
||||||
imports: [StorageModule],
|
imports: [StorageModule, CollaborationModule, WatcherModule],
|
||||||
})
|
})
|
||||||
export class PageModule {}
|
export class PageModule {}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { CreatePageDto } from '../dto/create-page.dto';
|
import { CreatePageDto, ContentFormat } from '../dto/create-page.dto';
|
||||||
import { UpdatePageDto } from '../dto/update-page.dto';
|
import { ContentOperation, UpdatePageDto } from '../dto/update-page.dto';
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||||
import { InsertablePage, Page, User } from '@docmost/db/types/entity.types';
|
import { InsertablePage, Page, User } from '@docmost/db/types/entity.types';
|
||||||
@@ -19,6 +19,7 @@ import { KyselyDB } from '@docmost/db/types/kysely.types';
|
|||||||
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
||||||
import { MovePageDto } from '../dto/move-page.dto';
|
import { MovePageDto } from '../dto/move-page.dto';
|
||||||
import { generateSlugId } from '../../../common/helpers';
|
import { generateSlugId } from '../../../common/helpers';
|
||||||
|
import { getPageTitle } from '../../../common/helpers';
|
||||||
import { executeTx } from '@docmost/db/utils';
|
import { executeTx } from '@docmost/db/utils';
|
||||||
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
|
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
|
||||||
import { v7 as uuid7 } from 'uuid';
|
import { v7 as uuid7 } from 'uuid';
|
||||||
@@ -29,7 +30,11 @@ import {
|
|||||||
isAttachmentNode,
|
isAttachmentNode,
|
||||||
removeMarkTypeFromDoc,
|
removeMarkTypeFromDoc,
|
||||||
} from '../../../common/helpers/prosemirror/utils';
|
} from '../../../common/helpers/prosemirror/utils';
|
||||||
import { jsonToNode, jsonToText } from 'src/collaboration/collaboration.util';
|
import {
|
||||||
|
htmlToJson,
|
||||||
|
jsonToNode,
|
||||||
|
jsonToText,
|
||||||
|
} from 'src/collaboration/collaboration.util';
|
||||||
import {
|
import {
|
||||||
CopyPageMapEntry,
|
CopyPageMapEntry,
|
||||||
ICopyPageAttachment,
|
ICopyPageAttachment,
|
||||||
@@ -41,6 +46,9 @@ import { Queue } from 'bullmq';
|
|||||||
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
||||||
import { EventName } from '../../../common/events/event.contants';
|
import { EventName } from '../../../common/events/event.contants';
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
import { CollaborationGateway } from '../../../collaboration/collaboration.gateway';
|
||||||
|
import { markdownToHtml } from '@docmost/editor-ext';
|
||||||
|
import { WatcherService } from '../../watcher/watcher.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PageService {
|
export class PageService {
|
||||||
@@ -54,7 +62,10 @@ export class PageService {
|
|||||||
private readonly storageService: StorageService,
|
private readonly storageService: StorageService,
|
||||||
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
||||||
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
||||||
|
@InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
|
||||||
private eventEmitter: EventEmitter2,
|
private eventEmitter: EventEmitter2,
|
||||||
|
private collaborationGateway: CollaborationGateway,
|
||||||
|
private readonly watcherService: WatcherService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findById(
|
async findById(
|
||||||
@@ -94,7 +105,22 @@ export class PageService {
|
|||||||
parentPageId = parentPage.id;
|
parentPageId = parentPage.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const createdPage = await this.pageRepo.insertPage({
|
let content = undefined;
|
||||||
|
let textContent = undefined;
|
||||||
|
let ydoc = undefined;
|
||||||
|
|
||||||
|
if (createPageDto?.content && createPageDto?.format) {
|
||||||
|
const prosemirrorJson = await this.parseProsemirrorContent(
|
||||||
|
createPageDto.content,
|
||||||
|
createPageDto.format,
|
||||||
|
);
|
||||||
|
|
||||||
|
content = prosemirrorJson;
|
||||||
|
textContent = jsonToText(prosemirrorJson);
|
||||||
|
ydoc = createYdocFromJson(prosemirrorJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = await this.pageRepo.insertPage({
|
||||||
slugId: generateSlugId(),
|
slugId: generateSlugId(),
|
||||||
title: createPageDto.title,
|
title: createPageDto.title,
|
||||||
position: await this.nextPagePosition(
|
position: await this.nextPagePosition(
|
||||||
@@ -107,9 +133,23 @@ export class PageService {
|
|||||||
creatorId: userId,
|
creatorId: userId,
|
||||||
workspaceId: workspaceId,
|
workspaceId: workspaceId,
|
||||||
lastUpdatedById: userId,
|
lastUpdatedById: userId,
|
||||||
|
content,
|
||||||
|
textContent,
|
||||||
|
ydoc,
|
||||||
});
|
});
|
||||||
|
|
||||||
return createdPage;
|
this.generalQueue
|
||||||
|
.add(QueueJob.ADD_PAGE_WATCHERS, {
|
||||||
|
userIds: [userId],
|
||||||
|
pageId: page.id,
|
||||||
|
spaceId: createPageDto.spaceId,
|
||||||
|
workspaceId,
|
||||||
|
})
|
||||||
|
.catch((err) =>
|
||||||
|
this.logger.warn(`Failed to queue add-page-watchers: ${err.message}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
async nextPagePosition(spaceId: string, parentPageId?: string) {
|
async nextPagePosition(spaceId: string, parentPageId?: string) {
|
||||||
@@ -156,24 +196,49 @@ export class PageService {
|
|||||||
async update(
|
async update(
|
||||||
page: Page,
|
page: Page,
|
||||||
updatePageDto: UpdatePageDto,
|
updatePageDto: UpdatePageDto,
|
||||||
userId: string,
|
user: User,
|
||||||
): Promise<Page> {
|
): Promise<Page> {
|
||||||
const contributors = new Set<string>(page.contributorIds);
|
const contributors = new Set<string>(page.contributorIds);
|
||||||
contributors.add(userId);
|
contributors.add(user.id);
|
||||||
const contributorIds = Array.from(contributors);
|
const contributorIds = Array.from(contributors);
|
||||||
|
|
||||||
await this.pageRepo.updatePage(
|
await this.pageRepo.updatePage(
|
||||||
{
|
{
|
||||||
title: updatePageDto.title,
|
title: updatePageDto.title,
|
||||||
icon: updatePageDto.icon,
|
icon: updatePageDto.icon,
|
||||||
lastUpdatedById: userId,
|
lastUpdatedById: user.id,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
contributorIds: contributorIds,
|
contributorIds: contributorIds,
|
||||||
},
|
},
|
||||||
page.id,
|
page.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
return this.pageRepo.findById(page.id, {
|
this.generalQueue
|
||||||
|
.add(QueueJob.ADD_PAGE_WATCHERS, {
|
||||||
|
userIds: [user.id],
|
||||||
|
pageId: page.id,
|
||||||
|
spaceId: page.spaceId,
|
||||||
|
workspaceId: page.workspaceId,
|
||||||
|
})
|
||||||
|
.catch((err) =>
|
||||||
|
this.logger.warn(`Failed to queue add-page-watchers: ${err.message}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
updatePageDto.content &&
|
||||||
|
updatePageDto.operation &&
|
||||||
|
updatePageDto.format
|
||||||
|
) {
|
||||||
|
await this.updatePageContent(
|
||||||
|
page.id,
|
||||||
|
updatePageDto.content,
|
||||||
|
updatePageDto.operation,
|
||||||
|
updatePageDto.format,
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.pageRepo.findById(page.id, {
|
||||||
includeSpace: true,
|
includeSpace: true,
|
||||||
includeContent: true,
|
includeContent: true,
|
||||||
includeCreator: true,
|
includeCreator: true,
|
||||||
@@ -182,6 +247,23 @@ export class PageService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updatePageContent(
|
||||||
|
pageId: string,
|
||||||
|
content: string | object,
|
||||||
|
operation: ContentOperation,
|
||||||
|
format: ContentFormat,
|
||||||
|
user: User,
|
||||||
|
): Promise<void> {
|
||||||
|
const prosemirrorJson = await this.parseProsemirrorContent(content, format);
|
||||||
|
|
||||||
|
const documentName = `page.${pageId}`;
|
||||||
|
await this.collaborationGateway.handleYjsEvent(
|
||||||
|
'updatePageContent',
|
||||||
|
documentName,
|
||||||
|
{ operation, prosemirrorJson, user },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async getSidebarPages(
|
async getSidebarPages(
|
||||||
spaceId: string,
|
spaceId: string,
|
||||||
pagination: PaginationOptions,
|
pagination: PaginationOptions,
|
||||||
@@ -368,6 +450,11 @@ export class PageService {
|
|||||||
trx,
|
trx,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Update watchers and remove those without access to new space
|
||||||
|
await this.watcherService.movePageWatchersToSpace(pageIdsToMove, spaceId, {
|
||||||
|
trx,
|
||||||
|
});
|
||||||
|
|
||||||
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
|
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
|
||||||
pageId: pageIdsToMove,
|
pageId: pageIdsToMove,
|
||||||
workspaceId: rootPage.workspaceId,
|
workspaceId: rootPage.workspaceId,
|
||||||
@@ -489,7 +576,7 @@ export class PageService {
|
|||||||
// Add "Copy of " prefix to the root page title only for duplicates in same space
|
// Add "Copy of " prefix to the root page title only for duplicates in same space
|
||||||
let title = page.title;
|
let title = page.title;
|
||||||
if (isDuplicateInSameSpace && page.id === rootPage.id) {
|
if (isDuplicateInSameSpace && page.id === rootPage.id) {
|
||||||
const originalTitle = page.title || 'Untitled';
|
const originalTitle = getPageTitle(page.title);
|
||||||
title = `Copy of ${originalTitle}`;
|
title = `Copy of ${originalTitle}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -820,6 +907,38 @@ export class PageService {
|
|||||||
await this.pageRepo.removePage(pageId, userId, workspaceId);
|
await this.pageRepo.removePage(pageId, userId, workspaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async parseProsemirrorContent(
|
||||||
|
content: string | object,
|
||||||
|
format: ContentFormat,
|
||||||
|
): Promise<any> {
|
||||||
|
let prosemirrorJson: any;
|
||||||
|
|
||||||
|
switch (format) {
|
||||||
|
case 'markdown': {
|
||||||
|
const html = await markdownToHtml(content as string);
|
||||||
|
prosemirrorJson = htmlToJson(html as string);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'html': {
|
||||||
|
prosemirrorJson = htmlToJson(content as string);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'json':
|
||||||
|
default: {
|
||||||
|
prosemirrorJson = content;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
jsonToNode(prosemirrorJson);
|
||||||
|
} catch (err) {
|
||||||
|
throw new BadRequestException('Invalid content format');
|
||||||
|
}
|
||||||
|
|
||||||
|
return prosemirrorJson;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filters a list of pages to only those accessible to the user while maintaining tree integrity.
|
* Filters a list of pages to only those accessible to the user while maintaining tree integrity.
|
||||||
* A page is included only if:
|
* A page is included only if:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||||
|
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
|
||||||
import { AddSpaceMembersDto } from '../dto/add-space-members.dto';
|
import { AddSpaceMembersDto } from '../dto/add-space-members.dto';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { Space, SpaceMember, User } from '@docmost/db/types/entity.types';
|
import { Space, SpaceMember, User } from '@docmost/db/types/entity.types';
|
||||||
@@ -14,12 +15,16 @@ import { RemoveSpaceMemberDto } from '../dto/remove-space-member.dto';
|
|||||||
import { UpdateSpaceMemberRoleDto } from '../dto/update-space-member-role.dto';
|
import { UpdateSpaceMemberRoleDto } from '../dto/update-space-member-role.dto';
|
||||||
import { SpaceRole } from '../../../common/helpers/types/permission';
|
import { SpaceRole } from '../../../common/helpers/types/permission';
|
||||||
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
||||||
|
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||||
|
import { executeTx } from '@docmost/db/utils';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SpaceMemberService {
|
export class SpaceMemberService {
|
||||||
constructor(
|
constructor(
|
||||||
private spaceMemberRepo: SpaceMemberRepo,
|
private spaceMemberRepo: SpaceMemberRepo,
|
||||||
|
private groupUserRepo: GroupUserRepo,
|
||||||
private spaceRepo: SpaceRepo,
|
private spaceRepo: SpaceRepo,
|
||||||
|
private watcherRepo: WatcherRepo,
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -203,10 +208,28 @@ export class SpaceMemberService {
|
|||||||
await this.validateLastAdmin(dto.spaceId);
|
await this.validateLastAdmin(dto.spaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.spaceMemberRepo.removeSpaceMemberById(
|
let affectedUserIds: string[] = [];
|
||||||
spaceMember.id,
|
if (dto.userId) {
|
||||||
dto.spaceId,
|
affectedUserIds = [dto.userId];
|
||||||
);
|
} else if (dto.groupId) {
|
||||||
|
affectedUserIds = await this.groupUserRepo.getUserIdsByGroupId(
|
||||||
|
dto.groupId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await executeTx(this.db, async (trx) => {
|
||||||
|
await this.spaceMemberRepo.removeSpaceMemberById(
|
||||||
|
spaceMember.id,
|
||||||
|
dto.spaceId,
|
||||||
|
{ trx },
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.watcherRepo.deleteByUsersWithoutSpaceAccess(
|
||||||
|
affectedUserIds,
|
||||||
|
dto.spaceId,
|
||||||
|
{ trx },
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateSpaceMemberRole(
|
async updateSpaceMemberRole(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { SpaceController } from './space.controller';
|
|||||||
import { SpaceMemberService } from './services/space-member.service';
|
import { SpaceMemberService } from './services/space-member.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [],
|
||||||
controllers: [SpaceController],
|
controllers: [SpaceController],
|
||||||
providers: [SpaceService, SpaceMemberService],
|
providers: [SpaceService, SpaceMemberService],
|
||||||
exports: [SpaceService, SpaceMemberService],
|
exports: [SpaceService, SpaceMemberService],
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsString, IsNotEmpty } from 'class-validator';
|
||||||
|
|
||||||
|
export class WatcherPageDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
pageId: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/***
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
ForbiddenException,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
NotFoundException,
|
||||||
|
Post,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { WatcherService } from './watcher.service';
|
||||||
|
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||||
|
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||||
|
import { WatcherPageDto } from './dto/watcher.dto';
|
||||||
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
|
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
||||||
|
import {
|
||||||
|
SpaceCaslAction,
|
||||||
|
SpaceCaslSubject,
|
||||||
|
} from '../casl/interfaces/space-ability.type';
|
||||||
|
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Controller('pages')
|
||||||
|
export class WatcherController {
|
||||||
|
constructor(
|
||||||
|
private readonly watcherService: WatcherService,
|
||||||
|
private readonly pageRepo: PageRepo,
|
||||||
|
private readonly spaceAbility: SpaceAbilityFactory,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('watch')
|
||||||
|
async watchPage(
|
||||||
|
@Body() dto: WatcherPageDto,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
const page = await this.pageRepo.findById(dto.pageId);
|
||||||
|
if (!page) {
|
||||||
|
throw new NotFoundException('Page not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||||
|
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.watcherService.watchPage(
|
||||||
|
user.id,
|
||||||
|
page.id,
|
||||||
|
page.spaceId,
|
||||||
|
workspace.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { watching: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('unwatch')
|
||||||
|
async unwatchPage(@Body() dto: WatcherPageDto, @AuthUser() user: User) {
|
||||||
|
const page = await this.pageRepo.findById(dto.pageId);
|
||||||
|
if (!page) {
|
||||||
|
throw new NotFoundException('Page not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||||
|
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.watcherService.unwatchPage(user.id, page.id);
|
||||||
|
|
||||||
|
return { watching: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('watch-status')
|
||||||
|
async getWatchStatus(@Body() dto: WatcherPageDto, @AuthUser() user: User) {
|
||||||
|
const page = await this.pageRepo.findById(dto.pageId);
|
||||||
|
if (!page) {
|
||||||
|
throw new NotFoundException('Page not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||||
|
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
const watching = await this.watcherService.isWatchingPage(user.id, page.id);
|
||||||
|
|
||||||
|
return { watching };
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
***/
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { WatcherService } from './watcher.service';
|
||||||
|
import { CaslModule } from '../casl/casl.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [CaslModule],
|
||||||
|
controllers: [],
|
||||||
|
providers: [WatcherService],
|
||||||
|
exports: [WatcherService],
|
||||||
|
})
|
||||||
|
export class WatcherModule {}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
WatcherRepo,
|
||||||
|
WatcherType,
|
||||||
|
} from '@docmost/db/repos/watcher/watcher.repo';
|
||||||
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
|
import { KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||||
|
import { InsertableWatcher } from '@docmost/db/types/entity.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WatcherService {
|
||||||
|
constructor(private readonly watcherRepo: WatcherRepo) {}
|
||||||
|
|
||||||
|
async watchPage(
|
||||||
|
userId: string,
|
||||||
|
pageId: string,
|
||||||
|
spaceId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
) {
|
||||||
|
const watcher: InsertableWatcher = {
|
||||||
|
userId,
|
||||||
|
pageId,
|
||||||
|
spaceId,
|
||||||
|
workspaceId,
|
||||||
|
type: WatcherType.PAGE,
|
||||||
|
addedById: userId,
|
||||||
|
};
|
||||||
|
return this.watcherRepo.upsert(watcher, trx);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addPageWatchers(
|
||||||
|
userIds: string[],
|
||||||
|
pageId: string,
|
||||||
|
spaceId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
) {
|
||||||
|
if (userIds.length === 0) return;
|
||||||
|
|
||||||
|
const watchers: InsertableWatcher[] = userIds.map((userId) => ({
|
||||||
|
userId,
|
||||||
|
pageId,
|
||||||
|
spaceId,
|
||||||
|
workspaceId,
|
||||||
|
type: WatcherType.PAGE,
|
||||||
|
addedById: userId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return this.watcherRepo.insertMany(watchers, trx);
|
||||||
|
}
|
||||||
|
|
||||||
|
async unwatchPage(userId: string, pageId: string) {
|
||||||
|
return this.watcherRepo.mute(userId, pageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async isWatchingPage(userId: string, pageId: string): Promise<boolean> {
|
||||||
|
return this.watcherRepo.isWatching(userId, pageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPageWatchers(pageId: string, pagination: PaginationOptions) {
|
||||||
|
return this.watcherRepo.findPageWatchers(pageId, pagination);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPageWatcherIds(
|
||||||
|
pageId: string,
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
): Promise<string[]> {
|
||||||
|
return this.watcherRepo.getPageWatcherIds(pageId, trx);
|
||||||
|
}
|
||||||
|
|
||||||
|
async countPageWatchers(pageId: string): Promise<number> {
|
||||||
|
return this.watcherRepo.countPageWatchers(pageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async cleanupOnSpaceAccessChange(
|
||||||
|
userIds: string[],
|
||||||
|
spaceId: string,
|
||||||
|
opts?: { trx?: KyselyTransaction },
|
||||||
|
): Promise<void> {
|
||||||
|
const { trx } = opts;
|
||||||
|
await this.watcherRepo.deleteByUsersWithoutSpaceAccess(userIds, spaceId, {
|
||||||
|
trx,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async movePageWatchersToSpace(
|
||||||
|
pageIds: string[],
|
||||||
|
spaceId: string,
|
||||||
|
opts?: { trx?: KyselyTransaction },
|
||||||
|
): Promise<void> {
|
||||||
|
await this.watcherRepo.updateSpaceIdByPageIds(spaceId, pageIds, opts);
|
||||||
|
await this.watcherRepo.deleteByPageIdsWithoutSpaceAccess(
|
||||||
|
pageIds,
|
||||||
|
spaceId,
|
||||||
|
opts,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ import { generateRandomSuffixNumbers } from '../../../common/helpers';
|
|||||||
import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers';
|
import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers';
|
||||||
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
||||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||||
|
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WorkspaceService {
|
export class WorkspaceService {
|
||||||
@@ -51,6 +52,7 @@ export class WorkspaceService {
|
|||||||
private domainService: DomainService,
|
private domainService: DomainService,
|
||||||
private licenseCheckService: LicenseCheckService,
|
private licenseCheckService: LicenseCheckService,
|
||||||
private shareRepo: ShareRepo,
|
private shareRepo: ShareRepo,
|
||||||
|
private watcherRepo: WatcherRepo,
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
||||||
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
|
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
|
||||||
@@ -116,6 +118,7 @@ export class WorkspaceService {
|
|||||||
let status = undefined;
|
let status = undefined;
|
||||||
let plan = undefined;
|
let plan = undefined;
|
||||||
let billingEmail = undefined;
|
let billingEmail = undefined;
|
||||||
|
let settings = undefined;
|
||||||
|
|
||||||
if (this.environmentService.isCloud()) {
|
if (this.environmentService.isCloud()) {
|
||||||
// generate unique hostname
|
// generate unique hostname
|
||||||
@@ -129,6 +132,7 @@ export class WorkspaceService {
|
|||||||
status = WorkspaceStatus.Active;
|
status = WorkspaceStatus.Active;
|
||||||
plan = 'standard';
|
plan = 'standard';
|
||||||
billingEmail = user.email;
|
billingEmail = user.email;
|
||||||
|
settings = { ai: { generative: true } };
|
||||||
}
|
}
|
||||||
|
|
||||||
// create workspace
|
// create workspace
|
||||||
@@ -141,6 +145,7 @@ export class WorkspaceService {
|
|||||||
trialEndAt,
|
trialEndAt,
|
||||||
plan,
|
plan,
|
||||||
billingEmail,
|
billingEmail,
|
||||||
|
settings,
|
||||||
},
|
},
|
||||||
trx,
|
trx,
|
||||||
);
|
);
|
||||||
@@ -553,6 +558,10 @@ export class WorkspaceService {
|
|||||||
.deleteFrom('authAccounts')
|
.deleteFrom('authAccounts')
|
||||||
.where('userId', '=', userId)
|
.where('userId', '=', userId)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
|
await this.watcherRepo.deleteByUserAndWorkspace(userId, workspaceId, {
|
||||||
|
trx,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import { MigrationService } from '@docmost/db/services/migration.service';
|
|||||||
import { UserTokenRepo } from './repos/user-token/user-token.repo';
|
import { UserTokenRepo } from './repos/user-token/user-token.repo';
|
||||||
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
|
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
|
||||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||||
|
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
|
||||||
|
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||||
import { PageListener } from '@docmost/db/listeners/page.listener';
|
import { PageListener } from '@docmost/db/listeners/page.listener';
|
||||||
import { PostgresJSDialect } from 'kysely-postgres-js';
|
import { PostgresJSDialect } from 'kysely-postgres-js';
|
||||||
import * as postgres from 'postgres';
|
import * as postgres from 'postgres';
|
||||||
@@ -82,6 +84,8 @@ import { normalizePostgresUrl } from '../common/helpers';
|
|||||||
UserTokenRepo,
|
UserTokenRepo,
|
||||||
BacklinkRepo,
|
BacklinkRepo,
|
||||||
ShareRepo,
|
ShareRepo,
|
||||||
|
NotificationRepo,
|
||||||
|
WatcherRepo,
|
||||||
PageListener,
|
PageListener,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
@@ -99,6 +103,8 @@ import { normalizePostgresUrl } from '../common/helpers';
|
|||||||
UserTokenRepo,
|
UserTokenRepo,
|
||||||
BacklinkRepo,
|
BacklinkRepo,
|
||||||
ShareRepo,
|
ShareRepo,
|
||||||
|
NotificationRepo,
|
||||||
|
WatcherRepo,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class DatabaseModule
|
export class DatabaseModule
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { type Kysely, sql } from 'kysely';
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema
|
||||||
|
.createTable('notifications')
|
||||||
|
.addColumn('id', 'uuid', (col) =>
|
||||||
|
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||||
|
)
|
||||||
|
.addColumn('user_id', 'uuid', (col) =>
|
||||||
|
col.references('users.id').onDelete('cascade').notNull(),
|
||||||
|
)
|
||||||
|
.addColumn('workspace_id', 'uuid', (col) =>
|
||||||
|
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||||
|
)
|
||||||
|
.addColumn('type', 'text', (col) => col.notNull())
|
||||||
|
.addColumn('actor_id', 'uuid', (col) =>
|
||||||
|
col.references('users.id').onDelete('set null'),
|
||||||
|
)
|
||||||
|
.addColumn('page_id', 'uuid', (col) =>
|
||||||
|
col.references('pages.id').onDelete('cascade'),
|
||||||
|
)
|
||||||
|
.addColumn('space_id', 'uuid', (col) =>
|
||||||
|
col.references('spaces.id').onDelete('cascade'),
|
||||||
|
)
|
||||||
|
.addColumn('comment_id', 'uuid', (col) =>
|
||||||
|
col.references('comments.id').onDelete('cascade'),
|
||||||
|
)
|
||||||
|
.addColumn('data', 'jsonb')
|
||||||
|
.addColumn('read_at', 'timestamptz')
|
||||||
|
.addColumn('emailed_at', 'timestamptz')
|
||||||
|
.addColumn('archived_at', 'timestamptz')
|
||||||
|
.addColumn('created_at', 'timestamptz', (col) =>
|
||||||
|
col.notNull().defaultTo(sql`now()`),
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createIndex('idx_notifications_user_id')
|
||||||
|
.on('notifications')
|
||||||
|
.columns(['user_id', 'id desc'])
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createIndex('idx_notifications_user_unread')
|
||||||
|
.on('notifications')
|
||||||
|
.column('user_id')
|
||||||
|
.where(sql.ref('read_at'), 'is', null)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema.dropTable('notifications').execute();
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user