Compare commits

..

4 Commits

Author SHA1 Message Date
Philipinho 3cd0fa1985 fix interval 2026-02-09 13:26:15 -08:00
Philipinho b0e9217a40 display contributor avatars in history list 2026-02-09 13:23:37 -08:00
Philipinho 7e0aa2adac feat: save multiple version contributors 2026-02-09 13:05:28 -08:00
Philipinho 5f0afd6f9f Refactor: use queue for page history 2026-02-09 09:04:18 -08:00
197 changed files with 5084 additions and 12672 deletions
+12 -14
View File
@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.25.3",
"version": "0.25.2",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@@ -14,19 +14,18 @@
"@docmost/editor-ext": "workspace:*",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@excalidraw/excalidraw": "0.18.0-3a5ef40",
"@mantine/core": "^8.3.14",
"@mantine/dates": "^8.3.14",
"@mantine/form": "^8.3.14",
"@mantine/hooks": "^8.3.14",
"@mantine/modals": "^8.3.14",
"@mantine/notifications": "^8.3.14",
"@mantine/spotlight": "^8.3.14",
"@excalidraw/excalidraw": "0.18.0-c158187",
"@mantine/core": "^8.3.12",
"@mantine/dates": "^8.3.12",
"@mantine/form": "^8.3.12",
"@mantine/hooks": "^8.3.12",
"@mantine/modals": "^8.3.12",
"@mantine/notifications": "^8.3.12",
"@mantine/spotlight": "^8.3.12",
"@tabler/icons-react": "^3.36.1",
"@tanstack/react-query": "^5.90.17",
"alfaaz": "^1.1.0",
"axios": "^1.13.5",
"blueimp-load-image": "^5.16.0",
"axios": "^1.13.2",
"clsx": "^2.1.1",
"emoji-mart": "^5.6.0",
"file-saver": "^2.0.5",
@@ -42,7 +41,7 @@
"mantine-form-zod-resolver": "^1.3.0",
"mermaid": "^11.12.2",
"mitt": "^3.0.1",
"posthog-js": "1.345.5",
"posthog-js": "^1.255.1",
"react": "^18.3.1",
"react-arborist": "3.4.0",
"react-clear-modal": "^2.0.17",
@@ -60,7 +59,6 @@
"devDependencies": {
"@eslint/js": "^9.16.0",
"@tanstack/eslint-plugin-query": "^5.62.1",
"@types/blueimp-load-image": "^5.16.0",
"@types/file-saver": "^2.0.7",
"@types/js-cookie": "^3.0.6",
"@types/katex": "^0.16.7",
@@ -68,7 +66,7 @@
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.2",
"eslint": "^9.15.0",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.16",
@@ -355,11 +355,6 @@
"Insert current date": "Aktuelles Datum einfügen",
"Draw and sketch excalidraw diagrams": "Excalidraw-Diagramme zeichnen und skizzieren",
"Multiple": "Mehrere",
"Turn into": "In verwandeln",
"Text align": "Text ausrichten",
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
"Go to homepage": "Go to homepage",
"Pages you create will show up here.": "Pages you create will show up here.",
"Heading {{level}}": "Überschrift {{level}}",
"Toggle title": "Titel umschalten",
"Write anything. Enter \"/\" for commands": "Schreiben Sie irgendetwas. Geben Sie \"/\" für Befehle ein",
@@ -587,33 +582,13 @@
"Ask AI": "KI fragen",
"AI is thinking...": "Die KI überlegt...",
"Ask a question...": "Fragen stellen...",
"AI Answers": "KI-Antworten",
"AI-powered search (AI Answers)": "KI-unterstützte Suche (KI-Antworten)",
"AI-powered search (Ask AI)": "KI-gestützte Suche (KI fragen)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Die KI-Suche verwendet Vektor-Einbettungen, um semantische Suchfunktionen in Ihrem Arbeitsbereich bereitzustellen.",
"Toggle AI search": "KI-Suche umschalten",
"Generative AI (Ask AI)": "Generative KI (KI fragen)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Aktivieren Sie die KI-unterstützte Inhaltserstellung im Editor. Ermöglicht Benutzern das Erzeugen, Verbessern, Übersetzen und Transformieren von Text.",
"Toggle generative AI": "Generative KI umschalten",
"Sources": "Quellen",
"AI Answers not available for attachments": "KI-Antworten sind für Anhänge nicht verfügbar",
"Ask AI not available for attachments": "KI fragen nicht für Anhänge verfügbar",
"No answer available": "Keine Antwort verfügbar",
"Background color": "Hintergrundfarbe",
"Highlight color": "Hervorhebungsfarbe",
"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"
"Remove color": "Farbe entfernen"
}
@@ -355,11 +355,6 @@
"Insert current date": "Insert current date",
"Draw and sketch excalidraw diagrams": "Draw and sketch excalidraw diagrams",
"Multiple": "Multiple",
"Turn into": "Turn into",
"Text align": "Text align",
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
"Go to homepage": "Go to homepage",
"Pages you create will show up here.": "Pages you create will show up here.",
"Heading {{level}}": "Heading {{level}}",
"Toggle title": "Toggle title",
"Write anything. Enter \"/\" for commands": "Write anything. Enter \"/\" for commands",
@@ -587,33 +582,13 @@
"Ask AI": "Ask AI",
"AI is thinking...": "AI is thinking...",
"Ask a question...": "Ask a question...",
"AI Answers": "AI Answers",
"AI-powered search (AI Answers)": "AI-powered search (AI Answers)",
"AI-powered search (Ask AI)": "AI-powered search (Ask AI)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.",
"Toggle AI search": "Toggle AI search",
"Generative AI (Ask AI)": "Generative AI (Ask AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.",
"Toggle generative AI": "Toggle generative AI",
"Sources": "Sources",
"AI Answers not available for attachments": "AI Answers not available for attachments",
"Ask AI not available for attachments": "Ask AI not available for attachments",
"No answer available": "No answer available",
"Background color": "Background color",
"Highlight color": "Highlight 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"
"Remove color": "Remove color"
}
@@ -355,11 +355,6 @@
"Insert current date": "Insertar fecha actual",
"Draw and sketch excalidraw diagrams": "Dibujar y esbozar diagramas de Excalidraw",
"Multiple": "Múltiple",
"Turn into": "Convertir en",
"Text align": "Alineación del texto",
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
"Go to homepage": "Go to homepage",
"Pages you create will show up here.": "Pages you create will show up here.",
"Heading {{level}}": "Encabezado {{level}}",
"Toggle title": "Alternar título",
"Write anything. Enter \"/\" for commands": "Escribe cualquier cosa. Ingresa \"/\" para comandos",
@@ -587,33 +582,13 @@
"Ask AI": "Preguntar a IA",
"AI is thinking...": "IA está pensando...",
"Ask a question...": "Haz una pregunta...",
"AI Answers": "Respuestas de IA",
"AI-powered search (AI Answers)": "Búsqueda impulsada por IA (Respuestas de IA)",
"AI-powered search (Ask AI)": "Búsqueda impulsada por IA (Preguntar a IA)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La búsqueda de IA utiliza incrustaciones vectoriales para proporcionar capacidades de búsqueda semántica en todo el contenido de su espacio de trabajo.",
"Toggle AI search": "Alternar búsqueda de IA",
"Generative AI (Ask AI)": "IA generativa (Preguntar a la IA)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Habilitar la generación de contenido impulsada por IA en el editor. Permite a los usuarios generar, mejorar, traducir y transformar texto.",
"Toggle generative AI": "Activar IA generativa",
"Sources": "Fuentes",
"AI Answers not available for attachments": "Respuestas de IA no disponibles para archivos adjuntos",
"Ask AI not available for attachments": "Preguntar a IA no está disponible para adjuntos",
"No answer available": "No hay respuesta disponible",
"Background color": "Color de fondo",
"Highlight color": "Color de resaltado",
"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"
"Remove color": "Eliminar color"
}
@@ -355,11 +355,6 @@
"Insert current date": "Insérer la date actuelle",
"Draw and sketch excalidraw diagrams": "Dessiner et esquisser des diagrammes Excalidraw",
"Multiple": "Multiple",
"Turn into": "Transformer en",
"Text align": "Alignement du texte",
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
"Go to homepage": "Go to homepage",
"Pages you create will show up here.": "Pages you create will show up here.",
"Heading {{level}}": "Titre {{level}}",
"Toggle title": "Basculer le titre",
"Write anything. Enter \"/\" for commands": "Écrivez n'importe quoi. Entrez \"/\" pour les commandes",
@@ -587,33 +582,13 @@
"Ask AI": "Demander à l'IA",
"AI is thinking...": "L'IA réfléchit...",
"Ask a question...": "Posez une question...",
"AI Answers": "Réponses IA",
"AI-powered search (AI Answers)": "Recherche propulsée par IA (Réponses IA)",
"AI-powered search (Ask AI)": "Recherche assistée par l'IA (Demander à l'IA)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La recherche IA utilise des incorporations vectorielles pour fournir des capacités de recherche sémantique à travers le contenu de votre espace de travail.",
"Toggle AI search": "Basculer la recherche IA",
"Generative AI (Ask AI)": "IA générative (Demandez à l'IA)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Activer la génération de contenu assistée par IA dans l'éditeur. Permet aux utilisateurs de générer, améliorer, traduire et transformer du texte.",
"Toggle generative AI": "Activer/désactiver l'IA générative",
"Sources": "Sources",
"AI Answers not available for attachments": "Réponses IA non disponibles pour les pièces jointes",
"Ask AI not available for attachments": "Demande à l'IA non disponible pour les pièces jointes",
"No answer available": "Pas de réponse disponible",
"Background color": "Couleur de fond",
"Highlight color": "Couleur de surbrillance",
"Remove color": "Supprimer la couleur",
"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"
"Remove color": "Supprimer la couleur"
}
@@ -355,11 +355,6 @@
"Insert current date": "Inserisci la data corrente",
"Draw and sketch excalidraw diagrams": "Disegna e schizza diagrammi excalidraw",
"Multiple": "Multiplo",
"Turn into": "Trasforma in",
"Text align": "Allinea testo",
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
"Go to homepage": "Go to homepage",
"Pages you create will show up here.": "Pages you create will show up here.",
"Heading {{level}}": "Intestazione {{level}}",
"Toggle title": "Attiva/disattiva titolo",
"Write anything. Enter \"/\" for commands": "Scrivi qualcosa. Digita \"/\" per i comandi",
@@ -587,33 +582,13 @@
"Ask AI": "Chiedi all'AI",
"AI is thinking...": "L'AI sta pensando...",
"Ask a question...": "Fai una domanda...",
"AI Answers": "Risposte AI",
"AI-powered search (AI Answers)": "Ricerca con AI (Risposte AI)",
"AI-powered search (Ask AI)": "Ricerca potenziata dall'AI (Chiedi all'AI)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La ricerca AI utilizza embeddings vettoriali per fornire capacità di ricerca semantica nel contenuto della tua area di lavoro.",
"Toggle AI search": "Attiva/disattiva ricerca AI",
"Generative AI (Ask AI)": "AI generativa (Chiedi AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Abilita la generazione di contenuti con AI nell'editor. Consente agli utenti di generare, migliorare, tradurre e trasformare il testo.",
"Toggle generative AI": "Attiva/Disattiva AI generativa",
"Sources": "Fonti",
"AI Answers not available for attachments": "Risposte AI non disponibili per gli allegati",
"Ask AI not available for attachments": "Chiedere all'AI non è disponibile per gli allegati",
"No answer available": "Nessuna risposta disponibile",
"Background color": "Colore di sfondo",
"Highlight color": "Colore evidenziato",
"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"
"Remove color": "Rimuovi colore"
}
@@ -355,11 +355,6 @@
"Insert current date": "現在の日付を挿入します",
"Draw and sketch excalidraw diagrams": "Excalidraw 図を挿入します",
"Multiple": "複数",
"Turn into": "変換する",
"Text align": "テキストの配置",
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
"Go to homepage": "Go to homepage",
"Pages you create will show up here.": "Pages you create will show up here.",
"Heading {{level}}": "見出し {{level}}",
"Toggle title": "タイトルの表示/非表示を切り替える",
"Write anything. Enter \"/\" for commands": "文字を入力するか、「/」でコマンドを呼び出します",
@@ -587,33 +582,13 @@
"Ask AI": "AIに質問する",
"AI is thinking...": "AIが考え中...",
"Ask a question...": "質問を入力...",
"AI Answers": "AI回答",
"AI-powered search (AI Answers)": "AI搭載検索 (AI回答)",
"AI-powered search (Ask AI)": "AIによる検索(AIに質問)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI検索はベクター埋め込みを使用してワークスペース全体の意味検索を実現します",
"Toggle AI search": "AI検索を切り替え",
"Generative AI (Ask AI)": "生成AI (Ask AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "エディターでAIを活用したコンテンツ生成を有効にします。ユーザーがテキストの生成、改善、翻訳、および変換を行うことができます。",
"Toggle generative AI": "生成AIを切り替える",
"Sources": "ソース",
"AI Answers not available for attachments": "添付ファイルにはAI回答を利用できません",
"Ask AI not available for attachments": "添付ファイルにはAI質問は利用できません",
"No answer available": "回答がありません",
"Background color": "背景色",
"Highlight 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": "以前のもの"
"Remove color": "色を削除"
}
@@ -355,11 +355,6 @@
"Insert current date": "현재 날짜 삽입",
"Draw and sketch excalidraw diagrams": "Excalidraw diagram 그리기 및 스케치",
"Multiple": "복제",
"Turn into": "변경하기",
"Text align": "텍스트 정렬",
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
"Go to homepage": "Go to homepage",
"Pages you create will show up here.": "Pages you create will show up here.",
"Heading {{level}}": "제목 {{level}}",
"Toggle title": "제목 토글",
"Write anything. Enter \"/\" for commands": "아무거나 입력하세요. 명령어를 사용하려면 \"/\"를 입력하세요",
@@ -587,33 +582,13 @@
"Ask AI": "AI에게 묻기",
"AI is thinking...": "AI가 생각 중입니다...",
"Ask a question...": "질문하세요...",
"AI Answers": "AI 답변",
"AI-powered search (AI Answers)": "AI 구동 검색 (AI 답변)",
"AI-powered search (Ask AI)": "AI 지원 검색 (AI에게 묻기)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI 검색은 벡터 임베딩을 사용하여 작업공간 콘텐츠에 대한 의미 검색 기능을 제공합니다.",
"Toggle AI search": "AI 검색 전환",
"Generative AI (Ask AI)": "생성 AI (Ask AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "편집기에서 AI 구동 콘텐츠 생성을 활성화합니다. 사용자가 텍스트를 생성, 개선, 번역 및 변환할 수 있습니다.",
"Toggle generative AI": "생성 AI 토글",
"Sources": "출처",
"AI Answers not available for attachments": "첨부 파일에 대해 AI 답변을 사용할 수 없습니다",
"Ask AI not available for attachments": "AI에게 묻기 기능은 첨부 파일에 대해 사용할 수 없습니다",
"No answer available": "답변을 제공할 수 없습니다",
"Background color": "배경 색",
"Highlight 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": "이전"
"Remove color": "색 제거"
}
@@ -355,11 +355,6 @@
"Insert current date": "Huidige datum invoeren",
"Draw and sketch excalidraw diagrams": "Teken en schets excalidraw diagrammen",
"Multiple": "Meerdere",
"Turn into": "Omzetten naar",
"Text align": "Tekstuitlijning",
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
"Go to homepage": "Go to homepage",
"Pages you create will show up here.": "Pages you create will show up here.",
"Heading {{level}}": "Kop {{level}}",
"Toggle title": "Schakel titel in/uit",
"Write anything. Enter \"/\" for commands": "Schrijf iets. Voer \"/\" in voor commando's",
@@ -587,33 +582,13 @@
"Ask AI": "Vraag AI",
"AI is thinking...": "AI is aan het nadenken...",
"Ask a question...": "Stel een vraag...",
"AI Answers": "AI Antwoorden",
"AI-powered search (AI Answers)": "AI-gestuurde zoekopdracht (AI Antwoorden)",
"AI-powered search (Ask AI)": "AI-ondersteunde zoekopdracht (Vraag AI)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI-zoekopdracht maakt gebruik van vectorembeddings om semantische zoekmogelijkheden te bieden in uw werkruimte-inhoud.",
"Toggle AI search": "Schakel AI-zoekopdracht in/uit",
"Generative AI (Ask AI)": "Generatieve AI (Vraag het AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Schakel AI-gestuurde inhoudsgeneratie in de editor in. Hiermee kunnen gebruikers tekst genereren, verbeteren, vertalen en transformeren.",
"Toggle generative AI": "Generatieve AI schakelen",
"Sources": "Bronnen",
"AI Answers not available for attachments": "AI Antwoorden niet beschikbaar voor bijlagen",
"Ask AI not available for attachments": "Vraag AI is niet beschikbaar voor bijlages",
"No answer available": "Geen antwoord beschikbaar",
"Background color": "Achtergrondkleur",
"Highlight color": "Markeerkleur",
"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"
"Remove color": "Kleur verwijderen"
}
@@ -355,11 +355,6 @@
"Insert current date": "Insira a data atual",
"Draw and sketch excalidraw diagrams": "Desenhe e esboce diagramas Excalidraw",
"Multiple": "Múltiplo",
"Turn into": "Transformar em",
"Text align": "Alinhar texto",
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
"Go to homepage": "Go to homepage",
"Pages you create will show up here.": "Pages you create will show up here.",
"Heading {{level}}": "Título {{level}}",
"Toggle title": "Alternar título",
"Write anything. Enter \"/\" for commands": "Escreva qualquer coisa. Digite \"/\" para comandos",
@@ -587,33 +582,13 @@
"Ask AI": "Pergunte à IA",
"AI is thinking...": "IA está pensando...",
"Ask a question...": "Faça uma pergunta...",
"AI Answers": "Respostas de IA",
"AI-powered search (AI Answers)": "Pesquisa com IA (Respostas de IA)",
"AI-powered search (Ask AI)": "Pesquisa com IA (Pergunte à IA)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "A pesquisa IA usa vetores de incorporação para fornecer capacidades de pesquisa semântica em todo o conteúdo do seu espaço de trabalho.",
"Toggle AI search": "Alternar pesquisa de IA",
"Generative AI (Ask AI)": "IA generativa (Perguntar à IA)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Habilitar geração de conteúdo com IA no editor. Permite aos usuários gerar, melhorar, traduzir e transformar texto.",
"Toggle generative AI": "Alternar IA generativa",
"Sources": "Fontes",
"AI Answers not available for attachments": "Respostas de IA não disponíveis para anexos",
"Ask AI not available for attachments": "Perguntar à IA não está disponível para anexos",
"No answer available": "Nenhuma resposta disponível",
"Background color": "Cor de fundo",
"Highlight color": "Cor de destaque",
"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"
"Remove color": "Remover cor"
}
@@ -355,11 +355,6 @@
"Insert current date": "Вставить текущую дату",
"Draw and sketch excalidraw diagrams": "Вставить и рисовать диаграммы Excalidraw",
"Multiple": "Несколько",
"Turn into": "Преобразовать в",
"Text align": "Выравнивание текста",
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
"Go to homepage": "Go to homepage",
"Pages you create will show up here.": "Pages you create will show up here.",
"Heading {{level}}": "Заголовок {{level}}",
"Toggle title": "Переключить заголовок",
"Write anything. Enter \"/\" for commands": "Начните писать. Введите \"/\" для списка команд",
@@ -587,33 +582,13 @@
"Ask AI": "Спросить ИИ",
"AI is thinking...": "ИИ обрабатывает запрос...",
"Ask a question...": "Задайте вопрос...",
"AI Answers": "Ответы ИИ",
"AI-powered search (AI Answers)": "Поиск на базе ИИ (Ответы ИИ)",
"AI-powered search (Ask AI)": "Поиск на базе ИИ (Спросить ИИ)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Поиск ИИ использует векторные встраивания для обеспечения семантического поиска по содержимому вашего рабочего пространства.",
"Toggle AI search": "Переключить поиск ИИ",
"Generative AI (Ask AI)": "Генеративный ИИ (Спросить ИИ)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Включите создание контента на базе ИИ в редакторе. Позволяет пользователям генерировать, улучшать, переводить и преобразовывать текст.",
"Toggle generative AI": "Переключить генеративный ИИ",
"Sources": "Источники",
"AI Answers not available for attachments": "Ответы ИИ недоступны для вложений",
"Ask AI not available for attachments": "Функция \"Спросить ИИ\" недоступна для вложений",
"No answer available": "Ответ недоступен",
"Background color": "Цвет фона",
"Highlight 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": "Старше"
"Remove color": "Удалить цвет"
}
@@ -355,11 +355,6 @@
"Insert current date": "Вставити поточну дату",
"Draw and sketch excalidraw diagrams": "Вставити та малювати діаграми Excalidraw",
"Multiple": "Декілька",
"Turn into": "Перетворити",
"Text align": "Вирівнювання тексту",
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
"Go to homepage": "Go to homepage",
"Pages you create will show up here.": "Pages you create will show up here.",
"Heading {{level}}": "Заголовок {{level}}",
"Toggle title": "Перемкнути заголовок",
"Write anything. Enter \"/\" for commands": "Почніть писати. Введіть \"/\" для списку команд",
@@ -587,33 +582,13 @@
"Ask AI": "Запитати ШІ",
"AI is thinking...": "ШІ думає...",
"Ask a question...": "Задайте питання...",
"AI Answers": "Відповіді ШІ",
"AI-powered search (AI Answers)": "Пошук на базі ШІ (Відповіді ШІ)",
"AI-powered search (Ask AI)": "Пошук на базі ШІ (Запитати ШІ)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Пошук з ШІ використовує векторні вбудовування для надання можливостей семантичного пошуку у вашому робочому вмісті.",
"Toggle AI search": "Переключити пошук з ШІ",
"Generative AI (Ask AI)": "Генеративний ШІ (Запитати ШІ)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Увімкнути генерацію контенту за допомогою ШІ в редакторі. Дозволяє користувачам генерувати, покращувати, перекладати та трансформувати текст.",
"Toggle generative AI": "Переключити генеративний ШІ",
"Sources": "Джерела",
"AI Answers not available for attachments": "Відповіді ШІ недоступні для вкладень",
"Ask AI not available for attachments": "Запитати ШІ недоступно для вкладень",
"No answer available": "Відповідь недоступна",
"Background color": "Колір фону",
"Highlight 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": "Старіші"
"Remove color": "Видалити колір"
}
@@ -355,11 +355,6 @@
"Insert current date": "插入当前日期",
"Draw and sketch excalidraw diagrams": "绘制 Excalidraw 图表",
"Multiple": "多个",
"Turn into": "变成",
"Text align": "文本对齐",
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
"Go to homepage": "Go to homepage",
"Pages you create will show up here.": "Pages you create will show up here.",
"Heading {{level}}": "{{level}} 级标题",
"Toggle title": "切换标题",
"Write anything. Enter \"/\" for commands": "开始编写内容,输入 \"/\" 以使用指令",
@@ -587,33 +582,13 @@
"Ask AI": "询问AI",
"AI is thinking...": "AI正在思考...",
"Ask a question...": "提问...",
"AI Answers": "AI答案",
"AI-powered search (AI Answers)": "AI驱动的搜索 (AI答案)",
"AI-powered search (Ask AI)": "AI驱动的搜索(询问AI",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI搜索使用向量嵌入提供跨工作空间内容的语义搜索功能。",
"Toggle AI search": "切换AI搜索",
"Generative AI (Ask AI)": "生成型AI (询问AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "在编辑器中启用AI驱动的内容生成。允许用户生成、改进、翻译和转换文本。",
"Toggle generative AI": "切换生成型AI",
"Sources": "来源",
"AI Answers not available for attachments": "AI答案不适用于附件",
"Ask AI not available for attachments": "附件不支持询问AI",
"No answer available": "无可用答案",
"Background color": "背景颜色",
"Highlight 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": "较早"
"Remove color": "移除颜色"
}
+8 -3
View File
@@ -14,6 +14,7 @@ import AccountPreferences from "@/pages/settings/account/account-preferences.tsx
import SpaceHome from "@/pages/space/space-home.tsx";
import PageRedirect from "@/pages/page/page-redirect.tsx";
import Layout from "@/components/layouts/global/layout.tsx";
import { ErrorBoundary } from "react-error-boundary";
import InviteSignup from "@/pages/auth/invite-signup.tsx";
import ForgotPassword from "@/pages/auth/forgot-password.tsx";
import PasswordReset from "./pages/auth/password-reset";
@@ -37,7 +38,6 @@ import SpaceTrash from "@/pages/space/space-trash.tsx";
import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
import Integrations from "@/features/integration/pages/integrations.tsx";
export default function App() {
const { t } = useTranslation();
@@ -84,7 +84,13 @@ export default function App() {
<Route path={"/s/:spaceSlug/trash"} element={<SpaceTrash />} />
<Route
path={"/s/:spaceSlug/p/:pageSlug"}
element={<Page />}
element={
<ErrorBoundary
fallback={<>{t("Failed to load page. An error occurred.")}</>}
>
<Page />
</ErrorBoundary>
}
/>
<Route path={"/settings"}>
@@ -103,7 +109,6 @@ export default function App() {
<Route path={"sharing"} element={<Shares />} />
<Route path={"security"} element={<Security />} />
<Route path={"ai"} element={<AiSettings />} />
<Route path={"integrations"} element={<Integrations />} />
{!isCloud() && <Route path={"license"} element={<License />} />}
{isCloud() && <Route path={"billing"} element={<Billing />} />}
</Route>
@@ -1,33 +0,0 @@
// Source: https://github.com/mantinedev/mantine/blob/master/packages/@mantine/core/src/components/CopyButton/CopyButton.tsx - MIT
// modified to use the polyfilled clipboard api
import React from "react";
import { useClipboard } from "@/hooks/use-clipboard";
import { useProps } from "@mantine/core";
interface CopyButtonProps {
/** Children callback, provides current status and copy function as an argument */
children: (payload: { copied: boolean; copy: () => void }) => React.ReactNode;
/** Value that is copied to the clipboard when the button is clicked */
value: string;
/** Copied status timeout in ms @default `1000` */
timeout?: number;
}
const defaultProps = {
timeout: 1000,
} satisfies Partial<CopyButtonProps>;
export function CopyButton(props: CopyButtonProps) {
const { children, timeout, value, ...others } = useProps(
"CopyButton",
defaultProps,
props,
);
const clipboard = useClipboard({ timeout });
const copy = () => clipboard.copy(value);
return <>{children({ copy, copied: clipboard.copied, ...others })}</>;
}
CopyButton.displayName = "@mantine/core/CopyButton";
+1 -2
View File
@@ -1,5 +1,4 @@
import { ActionIcon, Tooltip } from "@mantine/core";
import { CopyButton } from "@/components/common/copy-button";
import { ActionIcon, CopyButton, Tooltip } from "@mantine/core";
import { IconCheck, IconCopy } from "@tabler/icons-react";
import React from "react";
import { useTranslation } from "react-i18next";
@@ -11,8 +11,7 @@ import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { formattedDate } from "@/lib/time.ts";
import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts";
import { IconFileDescription, IconFiles } from "@tabler/icons-react";
import { EmptyState } from "@/components/ui/empty-state.tsx";
import { IconFileDescription } from "@tabler/icons-react";
import { getSpaceUrl } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
import { getInitialsColor } from "@/lib/get-initials-color.ts";
@@ -86,10 +85,8 @@ export default function RecentChanges({ spaceId }: Props) {
</Table>
</Table.ScrollContainer>
) : (
<EmptyState
icon={IconFiles}
title={t("No pages yet")}
description={t("Pages you create will show up here.")}
/>
<Text size="md" ta="center">
{t("No pages yet")}
</Text>
);
}
@@ -22,7 +22,6 @@ import {
searchSpotlight,
shareSearchSpotlight,
} from "@/features/search/constants.ts";
import { NotificationPopover } from "@/features/notification/components/notification-popover.tsx";
const links = [{ link: APP_ROUTE.HOME, label: "Home" }];
@@ -98,7 +97,6 @@ export function AppHeader() {
</div>
<Group px={"xl"} wrap="nowrap">
<NotificationPopover />
{isCloud() && isTrial && trialDaysLeft !== 0 && (
<Badge
variant="light"
@@ -13,7 +13,6 @@ import {
IconKey,
IconWorld,
IconSparkles,
IconPlug,
} from "@tabler/icons-react";
import { Link, useLocation } from "react-router-dom";
import classes from "./settings.module.css";
@@ -116,12 +115,7 @@ const groupedData: DataGroup[] = [
icon: IconSparkles,
path: "/settings/ai",
isAdmin: true,
},
{
label: "Integrations",
icon: IconPlug,
path: "/settings/integrations",
isAdmin: true,
isSelfhosted: true,
},
],
},
@@ -1,8 +0,0 @@
.root {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
}
@@ -1,30 +0,0 @@
import { Stack, Text } from "@mantine/core";
import { type TablerIcon } from "@tabler/icons-react";
import { ReactNode } from "react";
import classes from "./empty-state.module.css";
type EmptyStateProps = {
icon: TablerIcon;
title: string;
description?: string;
action?: ReactNode;
};
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
return (
<div className={classes.root}>
<Stack align="center" gap="xs">
<Icon size={40} stroke={1.5} color="var(--mantine-color-dimmed)" />
<Text size="lg" fw={500}>
{title}
</Text>
{description && (
<Text size="sm" c="dimmed" maw={350}>
{description}
</Text>
)}
{action}
</Stack>
</div>
);
}
@@ -1,61 +0,0 @@
.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;
}
}
@@ -1,325 +0,0 @@
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 };
@@ -1,219 +0,0 @@
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 };
@@ -1,72 +0,0 @@
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 };
@@ -1,32 +0,0 @@
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">
<div>
<Text size="md">{t("AI-powered search (AI Answers)")}</Text>
<Text size="md">{t("AI-powered search (Ask AI)")}</Text>
<Text size="sm" c="dimmed">
{t(
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.",
@@ -1,48 +0,0 @@
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>
);
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { useMutation, UseMutationResult } from "@tanstack/react-query";
import { useState, useCallback } from "react";
import { aiAnswers, IAiSearchResponse } from "@/ee/ai/services/ai-search-service.ts";
import { askAi, IAiSearchResponse } from "@/ee/ai/services/ai-search-service.ts";
import { IPageSearchParams } from "@/features/search/types/search.types.ts";
// @ts-ignore
@@ -26,7 +26,7 @@ export function useAiSearch(): UseAiSearchResult {
const { contentType, ...apiParams } = params;
return await aiAnswers(apiParams, (chunk) => {
return await askAi(apiParams, (chunk) => {
if (chunk.content) {
setStreamingAnswer((prev) => prev + chunk.content);
}
+7 -10
View File
@@ -1,25 +1,25 @@
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import { getAppName, isCloud } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import React from "react";
import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next";
import useLicense from "@/ee/hooks/use-license.tsx";
import EnableAiSearch from "@/ee/ai/components/enable-ai-search.tsx";
import EnableGenerativeAi from "@/ee/ai/components/enable-generative-ai.tsx";
import { Alert, Stack } from "@mantine/core";
import { Alert } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx";
import { isCloud } from "@/lib/config.ts";
export default function AiSettings() {
const { t } = useTranslation();
const { isAdmin } = useUserRole();
const hasAccess = useIsCloudEE();
const { hasLicenseKey } = useLicense();
if (!isAdmin) {
return null;
}
const hasAccess = isCloud() || (!isCloud() && hasLicenseKey);
return (
<>
<Helmet>
@@ -40,10 +40,7 @@ export default function AiSettings() {
</Alert>
)}
<Stack gap="md">
{!isCloud() && <EnableAiSearch />}
<EnableGenerativeAi />
</Stack>
<EnableAiSearch />
</>
);
}
@@ -15,11 +15,11 @@ export interface IAiSearchResponse {
}>;
}
export async function aiAnswers(
export async function askAi(
params: IPageSearchParams,
onChunk?: (chunk: { content?: string; sources?: any[] }) => void,
): Promise<IAiSearchResponse> {
const response = await fetch("/api/ai/answers", {
const response = await fetch("/api/ai/ask", {
method: "POST",
headers: {
"Content-Type": "application/json",
+3 -6
View File
@@ -43,16 +43,13 @@ export async function generateAiContentStream(
}
const processStream = async () => {
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split("\n");
for (const line of lines) {
if (line.startsWith("data: ")) {
@@ -69,7 +66,7 @@ export async function generateAiContentStream(
onChunk(parsed);
}
} catch (e) {
// Skip invalid JSON
// Ignore parse errors for incomplete chunks
}
}
}
-1
View File
@@ -6,7 +6,6 @@ export enum AiAction {
SIMPLIFY = "simplify",
CHANGE_TONE = "change_tone",
SUMMARIZE = "summarize",
EXPLAIN = "explain",
CONTINUE_WRITING = "continue_writing",
TRANSLATE = "translate",
CUSTOM = "custom",
@@ -7,7 +7,7 @@ import { notifications } from "@mantine/notifications";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { IAuthProvider } from "@/ee/security/types/security.types";
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route";
import APP_ROUTE from "@/lib/app-route";
import { ldapLogin } from "@/ee/security/services/ldap-auth-service";
const formSchema = z.object({
@@ -59,13 +59,13 @@ export function LdapLoginModal({
// Handle MFA like the regular login
if (response?.userHasMfa) {
onClose();
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE + window.location.search);
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE);
} else if (response?.requiresMfaSetup) {
onClose();
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED + window.location.search);
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED);
} else {
onClose();
navigate(getPostLoginRedirect());
navigate(APP_ROUTE.HOME);
}
} catch (err: any) {
setIsLoading(false);
@@ -8,10 +8,10 @@ import {
Group,
List,
Code,
CopyButton,
Alert,
PasswordInput,
} from "@mantine/core";
import { CopyButton } from "@/components/common/copy-button";
import {
IconRefresh,
IconCopy,
@@ -18,7 +18,7 @@ import { useNavigate } from "react-router-dom";
import { notifications } from "@mantine/notifications";
import classes from "./mfa-challenge.module.css";
import { verifyMfa } from "@/ee/mfa";
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route";
import APP_ROUTE from "@/lib/app-route";
import { useTranslation } from "react-i18next";
import * as z from "zod";
import { MfaBackupCodeInput } from "./mfa-backup-code-input";
@@ -53,7 +53,7 @@ export function MfaChallenge() {
setIsLoading(true);
try {
await verifyMfa(values.code);
navigate(getPostLoginRedirect());
navigate(APP_ROUTE.HOME);
} catch (error: any) {
setIsLoading(false);
notifications.show({
@@ -11,6 +11,7 @@ import {
PinInput,
Alert,
List,
CopyButton,
ActionIcon,
Tooltip,
Paper,
@@ -19,7 +20,6 @@ import {
Collapse,
UnstyledButton,
} from "@mantine/core";
import { CopyButton } from "@/components/common/copy-button";
import {
IconQrcode,
IconShieldCheck,
@@ -3,7 +3,7 @@ import { Container, Paper, Title, Text, Alert, Stack } from "@mantine/core";
import { IconAlertCircle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { MfaSetupModal } from "@/ee/mfa";
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts";
import APP_ROUTE from "@/lib/app-route.ts";
import { useNavigate } from "react-router-dom";
export default function MfaSetupRequired() {
@@ -11,7 +11,7 @@ export default function MfaSetupRequired() {
const navigate = useNavigate();
const handleSetupComplete = () => {
navigate(getPostLoginRedirect());
navigate(APP_ROUTE.HOME);
};
return (
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route";
import APP_ROUTE from "@/lib/app-route";
import { validateMfaAccess } from "@/ee/mfa";
export function useMfaPageProtection() {
@@ -13,10 +13,8 @@ export function useMfaPageProtection() {
const checkAccess = async () => {
const result = await validateMfaAccess();
const search = location.search;
if (!result.valid) {
navigate(APP_ROUTE.AUTH.LOGIN + search);
navigate(APP_ROUTE.AUTH.LOGIN);
return;
}
@@ -28,17 +26,17 @@ export function useMfaPageProtection() {
if (result.requiresMfaSetup && !isOnSetupPage) {
// User needs to set up MFA but is on challenge page
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED + search);
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED);
} else if (
!result.requiresMfaSetup &&
result.userHasMfa &&
!isOnChallengePage
) {
// User has MFA and should be on challenge page
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE + search);
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE);
} else if (!result.isTransferToken) {
// User has a regular auth token, shouldn't be on MFA pages
navigate(getPostLoginRedirect());
navigate(APP_ROUTE.HOME);
} else {
setIsValid(true);
}
@@ -1,62 +1,20 @@
import api from "@/lib/api-client";
import loadImage from "blueimp-load-image";
import {
AvatarIconType,
IAttachment,
} from "@/features/attachments/types/attachment.types.ts";
async function compressAndResizeIcon(
file: File,
type: AvatarIconType,
): Promise<File> {
const isPng = file.type === "image/png";
const { image: canvas } = await loadImage(file, {
maxWidth: 300,
maxHeight: 300,
canvas: true,
orientation: true,
imageSmoothingQuality: "high",
});
if (type === AvatarIconType.AVATAR || !isPng) {
const ctx = (canvas as HTMLCanvasElement).getContext("2d")!;
ctx.globalCompositeOperation = "destination-over";
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.globalCompositeOperation = "source-over";
}
const outputType = isPng ? "image/png" : "image/jpeg";
return new Promise<File>((resolve, reject) => {
(canvas as HTMLCanvasElement).toBlob(
(blob) => {
if (!blob) {
reject(new Error("Failed to compress image"));
return;
}
resolve(new File([blob], file.name, { type: outputType }));
},
outputType,
isPng ? undefined : 0.85,
);
});
}
export async function uploadIcon(
file: File,
type: AvatarIconType,
spaceId?: string,
): Promise<IAttachment> {
const processed = await compressAndResizeIcon(file, type);
const formData = new FormData();
formData.append("type", type);
if (spaceId) {
formData.append("spaceId", spaceId);
}
formData.append("image", processed);
formData.append("image", file);
return await api.post("/attachments/upload-image", formData, {
headers: {
@@ -23,7 +23,7 @@ import {
acceptInvitation,
createWorkspace,
} from "@/features/workspace/services/workspace-service.ts";
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts";
import APP_ROUTE from "@/lib/app-route.ts";
import { RESET } from "jotai/utils";
import { useTranslation } from "react-i18next";
import { isCloud } from "@/lib/config.ts";
@@ -44,11 +44,11 @@ export default function useAuth() {
// Check if MFA is required
if (response?.userHasMfa) {
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE + window.location.search);
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE);
} else if (response?.requiresMfaSetup) {
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED + window.location.search);
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED);
} else {
navigate(getPostLoginRedirect());
navigate(APP_ROUTE.HOME);
}
} catch (err) {
setIsLoading(false);
@@ -1,6 +1,6 @@
import { useEffect } from "react";
import useCurrentUser from "@/features/user/hooks/use-current-user.ts";
import { getPostLoginRedirect } from "@/lib/app-route.ts";
import APP_ROUTE from "@/lib/app-route.ts";
import { useNavigate } from "react-router-dom";
export function useRedirectIfAuthenticated() {
@@ -9,7 +9,7 @@ export function useRedirectIfAuthenticated() {
useEffect(() => {
if (data && data?.user) {
navigate(getPostLoginRedirect());
navigate(APP_ROUTE.HOME);
}
}, [isLoading, data]);
}
@@ -31,7 +31,6 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
const [currentUser] = useAtom(currentUserAtom);
const [, setAsideState] = useAtom(asideStateAtom);
const useClickOutsideRef = useClickOutside(() => {
if (document.querySelector("#mention")) return;
handleDialogClose();
});
const createCommentMutation = useCreateCommentMutation();
@@ -106,7 +105,6 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
position={{ bottom: 500, right: 50 }}
withCloseButton
withBorder
data-comment-dialog
>
<Stack gap={2}>
<Group>
@@ -1,15 +1,14 @@
import { EditorContent, ReactNodeViewRenderer, useEditor } from "@tiptap/react";
import { EditorContent, useEditor } from "@tiptap/react";
import { Placeholder } from "@tiptap/extension-placeholder";
import { Underline } from "@tiptap/extension-underline";
import { Link } from "@tiptap/extension-link";
import { StarterKit } from "@tiptap/starter-kit";
import { Mention, LinkExtension } from "@docmost/editor-ext";
import classes from "./comment.module.css";
import { useFocusWithin } from "@mantine/hooks";
import clsx from "clsx";
import { forwardRef, useEffect, useImperativeHandle } from "react";
import { useTranslation } from "react-i18next";
import EmojiCommand from "@/features/editor/extensions/emoji-command";
import mentionRenderItems from "@/features/editor/components/mention/mention-suggestion";
import MentionView from "@/features/editor/components/mention/mention-view";
interface CommentEditorProps {
defaultContent?: any;
@@ -40,29 +39,13 @@ const CommentEditor = forwardRef(
StarterKit.configure({
gapcursor: false,
dropcursor: false,
link: false,
}),
Placeholder.configure({
placeholder: placeholder || t("Reply..."),
}),
LinkExtension,
Underline,
Link,
EmojiCommand,
Mention.configure({
suggestion: {
allowSpaces: true,
items: () => [],
// @ts-ignore
render: mentionRenderItems,
},
HTMLAttributes: {
class: "mention",
},
}).extend({
addNodeView() {
this.editor.isInitialized = true;
return ReactNodeViewRenderer(MentionView);
},
}),
],
editorProps: {
handleDOMEvents: {
@@ -77,8 +60,7 @@ const CommentEditor = forwardRef(
].includes(event.key)
) {
const emojiCommand = document.querySelector("#emoji-command");
const mentionPopup = document.querySelector("#mention");
if (emojiCommand || mentionPopup) {
if (emojiCommand) {
return true;
}
}
@@ -102,14 +84,9 @@ const CommentEditor = forwardRef(
autofocus: (autofocus && "end") || false,
});
// Sync content from props for read-only editors (e.g. when updated via
// websocket on another browser). Skip for editable editors to avoid
// resetting the cursor position on every keystroke.
useEffect(() => {
if (!editable && commentEditor && defaultContent) {
commentEditor.commands.setContent(defaultContent);
}
}, [defaultContent, editable, commentEditor]);
commentEditor.commands.setContent(defaultContent);
}, [defaultContent]);
useEffect(() => {
setTimeout(() => {
@@ -126,11 +103,7 @@ const CommentEditor = forwardRef(
}));
return (
<div
ref={focusRef}
className={classes.commentEditor}
data-editable={editable || undefined}
>
<div ref={focusRef} className={classes.commentEditor}>
<EditorContent
editor={commentEditor}
className={clsx(classes.ProseMirror, { [classes.focused]: focused })}
@@ -1,5 +1,5 @@
import { Group, Text, Box, Badge } from "@mantine/core";
import React, { useEffect, useRef, useState } from "react";
import React, { useEffect, useState } from "react";
import classes from "./comment.module.css";
import { useAtom, useAtomValue } from "jotai";
import { timeAgo } from "@/lib/time";
@@ -40,7 +40,6 @@ function CommentListItem({
const [isLoading, setIsLoading] = useState(false);
const editor = useAtomValue(pageEditorAtom);
const [content, setContent] = useState<string>(comment.content);
const editContentRef = useRef<any>(null);
const updateCommentMutation = useUpdateCommentMutation();
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
const resolveCommentMutation = useResolveCommentMutation();
@@ -57,13 +56,9 @@ function CommentListItem({
setIsLoading(true);
const commentToUpdate = {
commentId: comment.id,
content: JSON.stringify(editContentRef.current ?? content),
content: JSON.stringify(content),
};
await updateCommentMutation.mutateAsync(commentToUpdate);
if (editContentRef.current) {
setContent(editContentRef.current);
editContentRef.current = null;
}
setIsEditing(false);
emit({
@@ -133,7 +128,6 @@ function CommentListItem({
setIsEditing(true);
}
function cancelEdit() {
editContentRef.current = null;
setIsEditing(false);
}
@@ -200,7 +194,7 @@ function CommentListItem({
<CommentEditor
defaultContent={content}
editable={true}
onUpdate={(newContent: any) => { editContentRef.current = newContent; }}
onUpdate={(newContent: any) => setContent(newContent)}
onSave={handleUpdateComment}
autofocus={true}
/>
@@ -32,14 +32,11 @@
max-width: 100%;
white-space: pre-wrap;
word-break: break-word;
max-height: 20vh;
padding-left: 6px;
padding-right: 6px;
margin-top: 10px;
margin-bottom: 2px;
}
&[data-editable] .ProseMirror :global(.ProseMirror){
max-height: 50vh;
overflow: hidden auto;
}
@@ -8,5 +8,3 @@ export const titleEditorAtom = atom<Editor | null>(null);
export const readOnlyEditorAtom = atom<Editor | null>(null);
export const yjsConnectionStatusAtom = atom<string>("");
export const showAiMenuAtom = atom(false);
@@ -1,53 +1,11 @@
.bubbleMenu {
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
max-width: 100vw;
width: fit-content;
box-shadow: 0 4px 12px light-dark(#cfcfcf, #0f0f0f);
border-radius: 6px;
border-radius: 2px;
border: 1px solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-gray-8));
background-color: light-dark(
var(--mantine-color-white),
var(--mantine-color-dark-6)
);
> * {
flex-shrink: 0;
}
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
.active {
color: light-dark(var(--mantine-color-blue-8), var(--mantine-color-gray-5));
}
}
.buttonRoot {
height: 34px;
padding-left: rem(8);
padding-right: rem(4);
border: none;
border-radius: 6px;
}
.buttonSeparator {
border-right: 1px solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)) !important;
}
.divider {
width: 1px;
height: 16px;
align-self: center;
margin: 0 4px;
background-color: light-dark(
var(--mantine-color-gray-3),
var(--mantine-color-dark-3)
);
}
@@ -9,11 +9,10 @@ import {
IconStrikethrough,
IconUnderline,
IconMessage,
IconSparkles,
} from "@tabler/icons-react";
import clsx from "clsx";
import classes from "./bubble-menu.module.css";
import { ActionIcon, Button, rem, Tooltip } from "@mantine/core";
import { ActionIcon, rem, Tooltip } from "@mantine/core";
import { ColorSelector } from "./color-selector";
import { NodeSelector } from "./node-selector";
import { TextAlignmentSelector } from "./text-alignment-selector";
@@ -21,13 +20,11 @@ import {
draftCommentIdAtom,
showCommentPopupAtom,
} from "@/features/comment/atoms/comment-atom";
import { useAtom, useAtomValue } from "jotai";
import { useAtom } from "jotai";
import { v7 as uuid7 } from "uuid";
import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
import { useTranslation } from "react-i18next";
import { showAiMenuAtom } from "@/features/editor/atoms/editor-atoms";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
export interface BubbleMenuItem {
name: string;
@@ -42,22 +39,14 @@ type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const { t } = useTranslation();
const [showAiMenu, setShowAiMenu] = useAtom(showAiMenuAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const workspace = useAtomValue(workspaceAtom);
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
const showCommentPopupRef = useRef(showCommentPopup);
const showAiMenuRef = useRef(showAiMenu);
useEffect(() => {
showCommentPopupRef.current = showCommentPopup;
}, [showCommentPopup]);
useEffect(() => {
showAiMenuRef.current = showAiMenu;
}, [showAiMenu]);
const editorState = useEditorState({
editor: props.editor,
selector: (ctx) => {
@@ -134,7 +123,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
empty ||
isNodeSelection(selection) ||
isCellSelection(selection) ||
showAiMenuRef.current ||
showCommentPopupRef?.current
) {
return false;
@@ -158,31 +146,9 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
// Hide the bubble menu immediately when AI menu is shown
if (showAiMenu) return;
return (
<BubbleMenu
{...bubbleMenuProps}
style={{ zIndex: 200, position: "relative" }}
>
<BubbleMenu {...bubbleMenuProps} style={{ zIndex: 200, position: "relative"}}>
<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
editor={props.editor}
isOpen={isNodeSelectorOpen}
@@ -246,18 +212,16 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
}}
/>
<Tooltip label={t(commentItem.name)} withArrow withinPortal={false}>
<ActionIcon
variant="default"
size="lg"
radius="6px"
aria-label={t(commentItem.name)}
style={{ border: "none" }}
onClick={commentItem.command}
>
<IconMessage size={16} stroke={2} />
</ActionIcon>
</Tooltip>
<ActionIcon
variant="default"
size="lg"
radius="0"
aria-label={t(commentItem.name)}
style={{ border: "none" }}
onClick={commentItem.command}
>
<IconMessage size={16} stroke={2} />
</ActionIcon>
</div>
</BubbleMenu>
);
@@ -1,6 +1,7 @@
import React, { Dispatch, FC, SetStateAction } from "react";
import { IconCheck, IconChevronDown } from "@tabler/icons-react";
import { IconCheck, IconChevronDown, IconPalette } from "@tabler/icons-react";
import {
ActionIcon,
Button,
Popover,
rem,
@@ -14,8 +15,6 @@ import {
import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import classes from "./bubble-menu.module.css";
export interface BubbleColorMenuItem {
name: string;
@@ -167,10 +166,14 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
onClick={() => setIsOpen(!isOpen)}
data-text-color={activeColorItem?.color || ""}
data-highlight-color={activeHighlightItem?.color || ""}
className={clsx(["color-selector-trigger", classes.buttonRoot])}
className="color-selector-trigger"
style={{
height: "34px",
border: "none",
fontWeight: 500,
fontSize: rem(16),
paddingLeft: rem(8),
paddingRight: rem(4),
}}
>
A
@@ -34,7 +34,7 @@ export const LinkSelector: FC<LinkSelectorProps> = ({
withArrow
>
<Popover.Target>
<Tooltip label={t("Add link")} withArrow withinPortal={false}>
<Tooltip label={t("Add link")} withArrow>
<ActionIcon
variant="default"
size="lg"
@@ -1,7 +1,6 @@
import React, { Dispatch, FC, SetStateAction } from "react";
import {
IconBlockquote,
IconCaretRightFilled,
IconCheck,
IconCheckbox,
IconChevronDown,
@@ -9,16 +8,14 @@ import {
IconH1,
IconH2,
IconH3,
IconInfoCircle,
IconList,
IconListNumbers,
IconTypography,
} from "@tabler/icons-react";
import { Popover, Button, ScrollArea, Tooltip } from "@mantine/core";
import { Popover, Button, ScrollArea } from "@mantine/core";
import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next";
import classes from "./bubble-menu.module.css";
interface NodeSelectorProps {
editor: Editor | null;
@@ -57,8 +54,6 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
isTaskItem: ctx.editor.isActive("taskItem"),
isBlockquote: ctx.editor.isActive("blockquote"),
isCodeBlock: ctx.editor.isActive("codeBlock"),
isCallout: ctx.editor.isActive("callout"),
isDetails: ctx.editor.isActive("details"),
};
},
});
@@ -128,18 +123,6 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
command: () => editor.chain().focus().toggleCodeBlock().run(),
isActive: () => editorState?.isCodeBlock,
},
{
name: "Callout",
icon: IconInfoCircle,
command: () => editor.chain().focus().toggleCallout().run(),
isActive: () => editorState?.isCallout,
},
{
name: "Toggle block",
icon: IconCaretRightFilled,
command: () => editor.chain().focus().setDetails().run(),
isActive: () => editorState?.isDetails,
},
];
const activeItem = items.filter((item) => item.isActive()).pop() ?? {
@@ -149,18 +132,15 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
return (
<Popover opened={isOpen} withArrow>
<Popover.Target>
<Tooltip label={t("Turn into")} withArrow withinPortal={false} disabled={isOpen}>
<Button
className={classes.buttonRoot}
variant="default"
style={{ border: "none", height: "34px" }}
radius="0"
rightSection={<IconChevronDown size={16} />}
onClick={() => setIsOpen(!isOpen)}
>
{t(activeItem?.name)}
</Button>
</Tooltip>
<Button
variant="default"
style={{ border: "none", height: "34px" }}
radius="0"
rightSection={<IconChevronDown size={16} />}
onClick={() => setIsOpen(!isOpen)}
>
{t(activeItem?.name)}
</Button>
</Popover.Target>
<Popover.Dropdown>
@@ -7,7 +7,7 @@ import {
IconCheck,
IconChevronDown,
} from "@tabler/icons-react";
import { Popover, Button, ScrollArea, Tooltip, rem } from "@mantine/core";
import { Popover, Button, ScrollArea, rem } from "@mantine/core";
import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next";
@@ -84,18 +84,16 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
return (
<Popover opened={isOpen} withArrow>
<Popover.Target>
<Tooltip label={t("Text align")} withArrow withinPortal={false} disabled={isOpen}>
<Button
variant="default"
style={{ border: "none", height: "34px" }}
px="5"
radius="0"
rightSection={<IconChevronDown size={16} />}
onClick={() => setIsOpen(!isOpen)}
>
<activeItem.icon style={{ width: rem(16) }} stroke={2} />
</Button>
</Tooltip>
<Button
variant="default"
style={{ border: "none", height: "34px" }}
px="5"
radius="0"
rightSection={<IconChevronDown size={16} />}
onClick={() => setIsOpen(!isOpen)}
>
<activeItem.icon style={{ width: rem(16) }} stroke={2} />
</Button>
</Popover.Target>
<Popover.Dropdown>
@@ -1,6 +1,5 @@
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { ActionIcon, Group, Select, Tooltip } from "@mantine/core";
import { CopyButton } from "@/components/common/copy-button";
import { ActionIcon, CopyButton, Group, Select, Tooltip } from "@mantine/core";
import { useEffect, useState } from "react";
import { IconCheck, IconCopy } from "@tabler/icons-react";
import classes from "./code-block.module.css";
@@ -4,7 +4,6 @@ import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
import { Editor } from "@tiptap/core";
import { matchIntegrationLink } from "@docmost/editor-ext";
export const handlePaste = (
editor: Editor,
@@ -14,21 +13,6 @@ export const handlePaste = (
) => {
const clipboardData = event.clipboardData.getData("text/plain");
const integrationMatch = matchIntegrationLink(clipboardData.trim());
if (integrationMatch && editor.state.selection.empty) {
event.preventDefault();
editor
.chain()
.focus()
.setIntegrationLink({
url: clipboardData.trim(),
provider: integrationMatch.provider,
status: "pending",
})
.run();
return true;
}
if (INTERNAL_LINK_REGEX.test(clipboardData)) {
// we have to do this validation here to allow the default link extension to takeover if needs be
event.preventDefault();
@@ -1,10 +0,0 @@
.card {
max-width: 100%;
cursor: pointer;
transition: border-color 150ms ease;
margin: 4px 0;
}
.card:hover {
border-color: var(--mantine-color-blue-4);
}
@@ -1,146 +0,0 @@
import { NodeViewWrapper } from "@tiptap/react";
import {
Card,
Group,
Text,
Badge,
Avatar,
Skeleton,
Anchor,
Stack,
} from "@mantine/core";
import { useEffect, useCallback, memo } from "react";
import { unfurlUrl } from "@/features/integration/services/integration-service";
import classes from "./integration-link-view.module.css";
const providerIcons: Record<string, string> = {
github: "https://github.githubassets.com/favicons/favicon-dark.svg",
gitlab: "https://gitlab.com/assets/favicon-72a2cad5025aa931d6ea56c3201d1f18e68a8571da3c2571592f63571e0c5571.png",
jira: "https://wac-cdn.atlassian.com/assets/img/favicons/atlassian/favicon.png",
linear: "https://linear.app/favicon.ico",
google_docs: "https://ssl.gstatic.com/docs/documents/images/kix-favicon7.ico",
figma: "https://static.figma.com/app/icon/1/favicon.png",
};
function IntegrationLinkView(props: any) {
const { node, updateAttributes, editor } = props;
const { url, provider, unfurlData, status } = node.attrs;
const doUnfurl = useCallback(async () => {
if (status !== "pending" || !url) return;
try {
const result = await unfurlUrl({ url });
if (result) {
updateAttributes({
unfurlData: result,
status: "loaded",
});
} else {
updateAttributes({ status: "error" });
}
} catch {
updateAttributes({ status: "error" });
}
}, [url, status, updateAttributes]);
useEffect(() => {
if (status === "pending") {
doUnfurl();
}
}, [status, doUnfurl]);
if (status === "pending") {
return (
<NodeViewWrapper data-drag-handle="">
<Card className={classes.card} withBorder padding="sm" radius="sm">
<Group gap="sm">
<Skeleton circle height={24} />
<Stack gap={4} style={{ flex: 1 }}>
<Skeleton height={14} width="60%" />
<Skeleton height={10} width="80%" />
</Stack>
</Group>
</Card>
</NodeViewWrapper>
);
}
if (status === "error" || !unfurlData) {
return (
<NodeViewWrapper data-drag-handle="">
<Card className={classes.card} withBorder padding="sm" radius="sm">
<Anchor href={url} target="_blank" rel="noopener" size="sm">
{url}
</Anchor>
</Card>
</NodeViewWrapper>
);
}
const iconUrl = providerIcons[provider] ?? undefined;
return (
<NodeViewWrapper data-drag-handle="">
<Card
className={classes.card}
withBorder
padding="sm"
radius="sm"
component="a"
href={url}
target="_blank"
rel="noopener"
style={{ textDecoration: "none", color: "inherit" }}
>
<Group gap="sm" wrap="nowrap">
{unfurlData.authorAvatarUrl ? (
<Avatar src={unfurlData.authorAvatarUrl} size={28} radius="xl" />
) : iconUrl ? (
<Avatar src={iconUrl} size={28} radius="sm" />
) : null}
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={600} truncate>
{unfurlData.title}
</Text>
{unfurlData.status && (
<Badge
size="xs"
variant="light"
color={unfurlData.statusColor ?? "gray"}
style={{ flexShrink: 0 }}
>
{unfurlData.status}
</Badge>
)}
</Group>
{unfurlData.description && (
<Text size="xs" c="dimmed" lineClamp={1}>
{unfurlData.description}
</Text>
)}
<Group gap="xs">
{iconUrl && (
<Avatar src={iconUrl} size={14} radius="sm" />
)}
<Text size="xs" c="dimmed">
{unfurlData.provider}
</Text>
{unfurlData.author && (
<Text size="xs" c="dimmed">
· {unfurlData.author}
</Text>
)}
</Group>
</Stack>
</Group>
</Card>
</NodeViewWrapper>
);
}
export default memo(IntegrationLinkView);
@@ -27,7 +27,7 @@ export const LinkPreviewPanel = ({
<>
<Card withBorder radius="md" padding="xs" bg="var(--mantine-color-body)">
<Flex align="center">
<Tooltip label={url} withArrow withinPortal={false}>
<Tooltip label={url}>
<Anchor
href={url}
target="_blank"
@@ -10,7 +10,6 @@ import React, {
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query.ts";
import {
ActionIcon,
Divider,
Group,
Paper,
ScrollArea,
@@ -52,7 +51,6 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
const tree = useMemo(() => new SimpleTree<SpaceTreeNode>(data), [data]);
const createPageMutation = useCreatePageMutation();
const emit = useQueryEmit();
const isInCommentContext = props.isInCommentContext ?? false;
const { data: suggestion, isLoading } = useSearchSuggestionsQuery({
query: props.query,
@@ -60,7 +58,6 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
includePages: true,
spaceId: space.id,
limit: 10,
preload: true,
});
const createPageItem = (label: string) : MentionSuggestionItem => {
@@ -105,9 +102,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
})),
);
}
if (!isInCommentContext && props.query) {
items.push(createPageItem(props.query));
}
items.push(createPageItem(props.query));
setRenderItems(items);
// update editor storage
@@ -255,51 +250,35 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
}
}
// if no results and enter what to do?
useEffect(() => {
viewportRef.current
?.querySelector(`[data-item-index="${selectedIndex}"]`)
?.scrollIntoView({ block: "nearest" });
}, [selectedIndex]);
const popupWidth = isInCommentContext ? 280 : 320;
if (renderItems.length === 0) {
return (
<Paper id="mention" shadow="md" py="xs" withBorder radius="md">
<Text c="dimmed" size="sm" px="sm">
{ t("No results") }
</Text>
<Paper shadow="md" p="xs" withBorder>
{ t("No results") }
</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 (
<Paper id="mention" shadow="md" withBorder radius="md" py={6}>
<Paper id="mention" shadow="md" p="xs" withBorder>
<ScrollArea.Autosize
viewportRef={viewportRef}
mah={350}
w={popupWidth}
scrollbarSize={6}
w={320}
scrollbarSize={8}
>
{renderItems?.map((item, index) => {
if (item.entityType === "header") {
const isFirst = index === 0;
return (
<div key={`${item.label}-${index}`}>
{!isFirst && <Divider my={6} />}
<Text
c="dimmed"
size="xs"
fw={500}
px="sm"
pt={isFirst ? 2 : 4}
pb={4}
tt="uppercase"
>
<Text c="dimmed" mb={4} tt="uppercase">
{item.label}
</Text>
</div>
@@ -313,9 +292,8 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
className={clsx(classes.menuBtn, {
[classes.selectedItem]: index === selectedIndex,
})}
px="sm"
>
<Group gap="sm">
<Group>
<CustomAvatar
size={"sm"}
avatarUrl={item.avatarUrl}
@@ -330,7 +308,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
</Group>
</UnstyledButton>
);
} else if (item.entityType === "page" && item.id !== null) {
} else if (item.entityType === "page") {
return (
<UnstyledButton
data-item-index={index}
@@ -339,24 +317,28 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
className={clsx(classes.menuBtn, {
[classes.selectedItem]: index === selectedIndex,
})}
px="sm"
>
<Group gap="sm" wrap="nowrap">
<Group>
<ActionIcon
variant="subtle"
variant="default"
component="div"
aria-label={item.label}
color="gray"
size="sm"
>
{item.icon || (
<IconFileDescription size={18} stroke={1.5} />
<ActionIcon
component="span"
variant="transparent"
color="gray"
size={18}
>
{ (item.id) ? <IconFileDescription size={18} /> : <IconPlus size={18} /> }
</ActionIcon>
)}
</ActionIcon>
<div style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{item.label}
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{ (item.id) ? item.label : t("Create page") + ': ' + item.label }
</Text>
</div>
</Group>
@@ -366,37 +348,6 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
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>
</Paper>
);
@@ -17,13 +17,8 @@ const mentionRenderItems = () => {
let component: ReactRenderer | null = null;
let activeClientRect: (() => DOMRect) | null = null;
let updatePositionCleanup: (() => void) | null = null;
let outsideClickHandler: ((e: MouseEvent) => void) | null = null;
const destroy = () => {
if (outsideClickHandler) {
document.removeEventListener("pointerdown", outsideClickHandler);
outsideClickHandler = null;
}
updatePositionCleanup?.();
updatePositionCleanup = null;
component?.destroy();
@@ -50,14 +45,8 @@ const mentionRenderItems = () => {
return;
}
const editorDom = props.editor?.view?.dom;
const asideEl = editorDom?.closest(".mantine-AppShell-aside");
const dialogEl = editorDom?.closest("[data-comment-dialog]");
const isInCommentContext = !!(asideEl || dialogEl);
// const isInCommentContext = !!asideEl;
component = new ReactRenderer(MentionList, {
props: { ...props, isInCommentContext },
props,
editor: props.editor,
});
@@ -70,18 +59,6 @@ const mentionRenderItems = () => {
const { element } = component;
document.body.appendChild(element);
outsideClickHandler = (e: MouseEvent) => {
const target = e.target as Node;
if (element && !element.contains(target)) {
destroy();
}
};
document.addEventListener("pointerdown", outsideClickHandler);
const shiftMiddleware = asideEl
? shift({ boundary: asideEl, crossAxis: true, padding: 8 })
: shift();
updatePositionCleanup = autoUpdate(
{
getBoundingClientRect: () =>
@@ -99,7 +76,7 @@ const mentionRenderItems = () => {
element,
{
placement: "bottom-start",
middleware: [offset(4), flip(), shiftMiddleware],
middleware: [offset(0), flip(), shift()],
},
).then(({ x, y }) => {
Object.assign(element.style, {
@@ -31,14 +31,14 @@
.menuBtn {
width: 100%;
padding: 6px 4px;
margin-bottom: 1px;
padding: 4px;
margin-bottom: 2px;
color: var(--mantine-color-text);
border-radius: var(--mantine-radius-sm);
&:hover {
@mixin light {
background: var(--mantine-color-gray-1);
background: var(--mantine-color-gray-2);
}
@mixin dark {
@@ -49,7 +49,7 @@
.selectedItem {
@mixin light {
background: var(--mantine-color-gray-1);
background: var(--mantine-color-gray-2);
}
@mixin dark {
@@ -7,7 +7,6 @@ export interface MentionListProps {
range: Range;
text: string;
editor: Editor;
isInCommentContext?: boolean;
}
export type MentionSuggestionItem =
@@ -43,7 +43,6 @@ import {
Highlight,
UniqueID,
SharedStorage,
IntegrationLink,
} from "@docmost/editor-ext";
import {
randomElement,
@@ -61,7 +60,6 @@ import DrawioView from "../components/drawio/drawio-view";
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
import IntegrationLinkView from "@/features/editor/components/integration-link/integration-link-view.tsx";
import { common, createLowlight } from "lowlight";
import plaintext from "highlight.js/lib/languages/plaintext";
import powershell from "highlight.js/lib/languages/powershell";
@@ -233,9 +231,6 @@ export const mainExtensions = [
Subpages.configure({
view: SubpagesView,
}),
IntegrationLink.configure({
view: IntegrationLinkView,
}),
MarkdownClipboard.configure({
transformPastedText: true,
}),
@@ -66,7 +66,6 @@ import { PageEditMode } from "@/features/user/types/user.types.ts";
import { jwtDecode } from "jwt-decode";
import { searchSpotlight } from "@/features/search/constants.ts";
import { useEditorScroll } from "./hooks/use-editor-scroll";
import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu";
interface PageEditorProps {
pageId: string;
@@ -406,7 +405,6 @@ export default function PageEditor({
{editor && editorIsEditable && (
<div>
<EditorAiMenu editor={editor} />
<EditorBubbleMenu editor={editor} />
<TableMenu editor={editor} />
<TableCellMenu editor={editor} appendTo={menuContainerRef} />
@@ -160,7 +160,7 @@ export function TitleEditor({
// guard against Cannot access view['hasFocus'] error
if (!titleEditor?.isInitialized) return;
titleEditor?.commands?.focus("end");
}, 300);
}, 500);
}, [titleEditor]);
useEffect(() => {
@@ -1,104 +0,0 @@
import { Card, Group, Text, Badge, Button, Stack, Switch } from "@mantine/core";
import {
IconBrandGithub,
IconBrandSlack,
IconBrandGitlab,
IconPuzzle,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
IntegrationDefinition,
Integration,
} from "../types/integration.types";
const iconMap: Record<string, React.ElementType> = {
github: IconBrandGithub,
slack: IconBrandSlack,
gitlab: IconBrandGitlab,
};
type IntegrationCardProps = {
definition: IntegrationDefinition;
installation?: Integration;
onInstall: (type: string) => void;
onUninstall: (integrationId: string) => void;
onConfigure: (integration: Integration) => void;
onToggle: (integration: Integration, enabled: boolean) => void;
};
export default function IntegrationCard({
definition,
installation,
onInstall,
onUninstall,
onConfigure,
onToggle,
}: IntegrationCardProps) {
const { t } = useTranslation();
const Icon = iconMap[definition.icon] ?? IconPuzzle;
const isInstalled = !!installation;
return (
<Card withBorder padding="lg" radius="md">
<Group justify="space-between" mb="sm">
<Group gap="sm">
<Icon size={28} stroke={1.5} />
<div>
<Text fw={600} size="sm">
{definition.name}
</Text>
<Text size="xs" c="dimmed">
{definition.description}
</Text>
</div>
</Group>
</Group>
<Group gap="xs" mb="md">
{definition.capabilities.map((cap) => (
<Badge key={cap} size="xs" variant="light">
{cap}
</Badge>
))}
</Group>
{isInstalled ? (
<Stack gap="xs">
<Group justify="space-between">
<Switch
label={t("Enabled")}
checked={installation.isEnabled}
onChange={(e) => onToggle(installation, e.currentTarget.checked)}
size="sm"
/>
</Group>
<Group gap="xs">
<Button
size="xs"
variant="light"
onClick={() => onConfigure(installation)}
>
{t("Configure")}
</Button>
<Button
size="xs"
variant="subtle"
color="red"
onClick={() => onUninstall(installation.id)}
>
{t("Uninstall")}
</Button>
</Group>
</Stack>
) : (
<Button
size="xs"
variant="light"
onClick={() => onInstall(definition.type)}
>
{t("Install")}
</Button>
)}
</Card>
);
}
@@ -1,91 +0,0 @@
import { Modal, Button, Group, Stack, TextInput, Text } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { Integration, ConnectionStatus } from "../types/integration.types";
import {
useConnectionStatus,
useDisconnectIntegration,
} from "../queries/integration-query";
import * as integrationService from "../services/integration-service";
type IntegrationSettingsModalProps = {
integration: Integration | null;
opened: boolean;
onClose: () => void;
};
export default function IntegrationSettingsModal({
integration,
opened,
onClose,
}: IntegrationSettingsModalProps) {
const { t } = useTranslation();
const { data: connectionStatus } = useConnectionStatus(integration?.id);
const disconnectMutation = useDisconnectIntegration();
if (!integration) return null;
const handleConnect = async () => {
try {
const result = await integrationService.getOAuthAuthorizeUrl({
integrationId: integration.id,
});
window.location.href = result.authorizationUrl;
} catch (error) {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to start OAuth connection"),
color: "red",
});
}
};
const handleDisconnect = async () => {
await disconnectMutation.mutateAsync({
integrationId: integration.id,
});
};
const hasOAuth = true;
return (
<Modal
opened={opened}
onClose={onClose}
title={`${integration.type.charAt(0).toUpperCase() + integration.type.slice(1)} ${t("Settings")}`}
size="md"
>
<Stack gap="md">
{hasOAuth && (
<div>
<Text size="sm" fw={600} mb="xs">
{t("Connection")}
</Text>
{connectionStatus?.connected ? (
<Group gap="sm">
<Text size="sm" c="green">
{t("Connected")}
{connectionStatus.providerUserId &&
` (${connectionStatus.providerUserId})`}
</Text>
<Button
size="xs"
variant="subtle"
color="red"
onClick={handleDisconnect}
loading={disconnectMutation.isPending}
>
{t("Disconnect")}
</Button>
</Group>
) : (
<Button size="xs" variant="light" onClick={handleConnect}>
{t("Connect")} {integration.type}
</Button>
)}
</div>
)}
</Stack>
</Modal>
);
}
@@ -1,111 +0,0 @@
import { SimpleGrid, Text, Loader, Center, Alert } from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { useState, useCallback } from "react";
import { getAppName } from "@/lib/config";
import SettingsTitle from "@/components/settings/settings-title";
import IntegrationCard from "../components/integration-card";
import IntegrationSettingsModal from "../components/integration-settings-modal";
import {
useAvailableIntegrations,
useInstalledIntegrations,
useInstallIntegration,
useUninstallIntegration,
useUpdateIntegrationSettings,
} from "../queries/integration-query";
import { Integration } from "../types/integration.types";
export default function Integrations() {
const { t } = useTranslation();
const { data: available, isLoading: loadingAvailable } =
useAvailableIntegrations();
const { data: installed, isLoading: loadingInstalled } =
useInstalledIntegrations();
const installMutation = useInstallIntegration();
const uninstallMutation = useUninstallIntegration();
const updateMutation = useUpdateIntegrationSettings();
const [configuring, setConfiguring] = useState<Integration | null>(null);
const handleInstall = useCallback(
(type: string) => {
installMutation.mutate({ type });
},
[installMutation],
);
const handleUninstall = useCallback(
(integrationId: string) => {
uninstallMutation.mutate({ integrationId });
},
[uninstallMutation],
);
const handleConfigure = useCallback((integration: Integration) => {
setConfiguring(integration);
}, []);
const handleToggle = useCallback(
(integration: Integration, enabled: boolean) => {
updateMutation.mutate({
integrationId: integration.id,
isEnabled: enabled,
});
},
[updateMutation],
);
const isLoading = loadingAvailable || loadingInstalled;
const error = new URLSearchParams(window.location.search).get("error");
return (
<>
<Helmet>
<title>
{t("Integrations")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Integrations")} />
{error === "oauth_failed" && (
<Alert color="red" mb="md">
{t("OAuth connection failed. Please try again.")}
</Alert>
)}
{isLoading ? (
<Center py="xl">
<Loader />
</Center>
) : !available?.length ? (
<Text c="dimmed" size="sm">
{t("No integrations available.")}
</Text>
) : (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{available.map((def) => {
const installation = installed?.find((i) => i.type === def.type);
return (
<IntegrationCard
key={def.type}
definition={def}
installation={installation}
onInstall={handleInstall}
onUninstall={handleUninstall}
onConfigure={handleConfigure}
onToggle={handleToggle}
/>
);
})}
</SimpleGrid>
)}
<IntegrationSettingsModal
integration={configuring}
opened={!!configuring}
onClose={() => setConfiguring(null)}
/>
</>
);
}
@@ -1,109 +0,0 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications";
import * as integrationService from "../services/integration-service";
export function useAvailableIntegrations() {
return useQuery({
queryKey: ["available-integrations"],
queryFn: integrationService.getAvailableIntegrations,
});
}
export function useInstalledIntegrations() {
return useQuery({
queryKey: ["installed-integrations"],
queryFn: integrationService.getInstalledIntegrations,
});
}
export function useInstallIntegration() {
const qc = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: integrationService.installIntegration,
onSuccess: () => {
notifications.show({ message: t("Integration installed successfully") });
qc.invalidateQueries({ queryKey: ["installed-integrations"] });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to install integration"),
color: "red",
});
},
});
}
export function useUninstallIntegration() {
const qc = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: integrationService.uninstallIntegration,
onSuccess: () => {
notifications.show({
message: t("Integration uninstalled successfully"),
});
qc.invalidateQueries({ queryKey: ["installed-integrations"] });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to uninstall integration"),
color: "red",
});
},
});
}
export function useUpdateIntegrationSettings() {
const qc = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: integrationService.updateIntegrationSettings,
onSuccess: () => {
notifications.show({ message: t("Integration updated successfully") });
qc.invalidateQueries({ queryKey: ["installed-integrations"] });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to update integration"),
color: "red",
});
},
});
}
export function useConnectionStatus(integrationId: string | undefined) {
return useQuery({
queryKey: ["integration-connection", integrationId],
queryFn: () =>
integrationService.getConnectionStatus({
integrationId: integrationId!,
}),
enabled: !!integrationId,
});
}
export function useDisconnectIntegration() {
const qc = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: integrationService.disconnectIntegration,
onSuccess: (_data, variables) => {
notifications.show({ message: t("Integration disconnected") });
qc.invalidateQueries({
queryKey: ["integration-connection", variables.integrationId],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to disconnect integration"),
color: "red",
});
},
});
}
@@ -1,79 +0,0 @@
import api from "@/lib/api-client";
import {
IntegrationDefinition,
Integration,
ConnectionStatus,
UnfurlResult,
} from "../types/integration.types";
export async function getAvailableIntegrations(): Promise<
IntegrationDefinition[]
> {
const req = await api.post<IntegrationDefinition[]>(
"/integrations/available",
);
return req.data;
}
export async function getInstalledIntegrations(): Promise<Integration[]> {
const req = await api.post<Integration[]>("/integrations/list");
return req.data;
}
export async function installIntegration(data: {
type: string;
}): Promise<Integration> {
const req = await api.post<Integration>("/integrations/install", data);
return req.data;
}
export async function uninstallIntegration(data: {
integrationId: string;
}): Promise<void> {
await api.post("/integrations/uninstall", data);
}
export async function updateIntegrationSettings(data: {
integrationId: string;
settings?: Record<string, any>;
isEnabled?: boolean;
}): Promise<Integration> {
const req = await api.post<Integration>("/integrations/update", data);
return req.data;
}
export async function getConnectionStatus(data: {
integrationId: string;
}): Promise<ConnectionStatus> {
const req = await api.post<ConnectionStatus>(
"/integrations/connection/status",
data,
);
return req.data;
}
export async function getOAuthAuthorizeUrl(data: {
integrationId: string;
}): Promise<{ authorizationUrl: string }> {
const req = await api.post<{ authorizationUrl: string }>(
"/integrations/oauth/authorize",
data,
);
return req.data;
}
export async function disconnectIntegration(data: {
integrationId: string;
}): Promise<void> {
await api.post("/integrations/oauth/disconnect", data);
}
export async function unfurlUrl(data: {
url: string;
}): Promise<UnfurlResult | null> {
const req = await api.post<{ data: UnfurlResult | null }>(
"/integrations/unfurl",
data,
);
return req.data.data;
}
@@ -1,38 +0,0 @@
export type IntegrationCapability = "oauth" | "unfurl" | "actions" | "webhooks";
export type IntegrationDefinition = {
type: string;
name: string;
description: string;
icon: string;
capabilities: IntegrationCapability[];
};
export type Integration = {
id: string;
workspaceId: string;
type: string;
isEnabled: boolean;
settings: Record<string, any> | null;
installedById: string | null;
createdAt: string;
updatedAt: string;
};
export type ConnectionStatus = {
connected: boolean;
providerUserId?: string;
};
export type UnfurlResult = {
title: string;
description?: string;
url: string;
provider: string;
providerIcon?: string;
status?: string;
statusColor?: string;
author?: string;
authorAvatarUrl?: string;
metadata?: Record<string, any>;
};
@@ -1,148 +0,0 @@
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>
);
}
@@ -1,115 +0,0 @@
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>
);
}
@@ -1,142 +0,0 @@
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>
);
}
@@ -1,23 +0,0 @@
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]);
}
@@ -1,13 +0,0 @@
.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));
}
@@ -1,75 +0,0 @@
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],
}));
}
@@ -1,59 +0,0 @@
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 });
},
});
}
@@ -1,31 +0,0 @@
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");
}
@@ -1,39 +0,0 @@
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";
@@ -17,8 +17,7 @@ import React, { useEffect, useRef, useState } from "react";
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
import { useAtom, useAtomValue } from "jotai";
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
import { useDisclosure, useHotkeys } from "@mantine/hooks";
import { useClipboard } from "@/hooks/use-clipboard";
import { useClipboard, useDisclosure, useHotkeys } from "@mantine/hooks";
import { useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
@@ -79,8 +78,8 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
<Tooltip label={t("Comments")} openDelay={250} withArrow>
<ActionIcon
variant="subtle"
color="dark"
variant="default"
style={{ border: "none" }}
onClick={() => toggleAside("comments")}
>
<IconMessage size={20} stroke={2} />
@@ -89,8 +88,8 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
<Tooltip label={t("Table of contents")} openDelay={250} withArrow>
<ActionIcon
variant="subtle"
color="dark"
variant="default"
style={{ border: "none" }}
onClick={() => toggleAside("toc")}
>
<IconList size={20} stroke={2} />
@@ -166,7 +165,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
arrowPosition="center"
>
<Menu.Target>
<ActionIcon variant="subtle" color="dark">
<ActionIcon variant="default" style={{ border: "none" }}>
<IconDots size={20} />
</ActionIcon>
</Menu.Target>
@@ -16,7 +16,7 @@ import {
import { useEffect, useRef, useState } from "react";
import { Link, useParams } from "react-router-dom";
import classes from "@/features/page/tree/styles/tree.module.css";
import { ActionIcon, Box, Menu, rem, Text } from "@mantine/core";
import { ActionIcon, Box, Menu, rem } from "@mantine/core";
import {
IconArrowRight,
IconChevronDown,
@@ -54,11 +54,11 @@ import { IPage, SidebarPagesParams } from "@/features/page/types/page.types.ts";
import { queryClient } from "@/main.tsx";
import { OpenMap } from "react-arborist/dist/main/state/open-slice";
import {
useClipboard,
useDisclosure,
useElementSize,
useMergedRef,
} from "@mantine/hooks";
import { useClipboard } from "@/hooks/use-clipboard";
import { dfs } from "react-arborist/dist/module/utils";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
@@ -82,7 +82,6 @@ interface SpaceTreeProps {
const openTreeNodesAtom = atom<OpenMap>({});
export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
const { t } = useTranslation();
const { pageSlug } = useParams();
const { data, setData, controllers } =
useTreeMutation<TreeApi<SpaceTreeNode>>(spaceId);
@@ -107,16 +106,10 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
}
}, sizeRef);
const [isDataLoaded, setIsDataLoaded] = useState(false);
const spaceIdRef = useRef(spaceId);
spaceIdRef.current = spaceId;
const { data: currentPage } = usePageQuery({
pageId: extractPageSlugId(pageSlug),
});
useEffect(() => {
setIsDataLoaded(false);
}, [spaceId]);
useEffect(() => {
if (hasNextPage && !isFetching) {
fetchNextPage();
@@ -137,15 +130,12 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
}
// same space; append only missing roots
setIsDataLoaded(true);
return mergeRootTrees(prev, treeData);
});
}
}, [pagesData, hasNextPage, spaceId]);
}, [pagesData, hasNextPage]);
useEffect(() => {
const effectSpaceId = spaceId;
const fetchData = async () => {
if (isDataLoaded && currentPage) {
// check if pageId node is present in the tree
@@ -159,8 +149,6 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
if (!currentPage.id) return;
const ancestors = await getPageBreadcrumbs(currentPage.id);
if (spaceIdRef.current !== effectSpaceId) return;
if (ancestors && ancestors?.length > 1) {
let flatTreeItems = [...buildTree(ancestors)];
@@ -188,22 +176,22 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
// Wait for all fetch operations to complete
Promise.all(fetchPromises).then(() => {
if (spaceIdRef.current !== effectSpaceId) return;
// build tree with children
const ancestorsTree = buildTreeWithChildren(flatTreeItems);
// child of root page we're attaching the built ancestors to
const rootChild = ancestorsTree[0];
// attach built ancestors to tree using functional updater
// to avoid stale closure overwriting the current tree data
setData((currentData) =>
appendNodeChildren(currentData, rootChild.id, rootChild.children),
// attach built ancestors to tree
const updatedTree = appendNodeChildren(
data,
rootChild.id,
rootChild.children,
);
setData(updatedTree);
setTimeout(() => {
// focus on node and open all parents
treeApiRef.current?.select(currentPage.id);
treeApiRef.current.select(currentPage.id);
}, 100);
});
}
@@ -232,18 +220,11 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
};
}, [setTreeApi]);
const filteredData = data.filter((node) => node?.spaceId === spaceId);
return (
<div ref={mergedRef} className={classes.treeContainer}>
{isDataLoaded && filteredData.length === 0 && (
<Text size="xs" c="dimmed" py="xs" px="sm">
{t("No pages yet")}
</Text>
)}
{isRootReady && rootElement.current && (
<Tree
data={filteredData}
data={data.filter((node) => node?.spaceId === spaceId)}
disableDrag={readOnly}
disableDrop={readOnly}
disableEdit={readOnly}
@@ -44,8 +44,8 @@ export function SearchMobileControl({ onSearch }: SearchMobileControlProps) {
return (
<Tooltip label={t("Search")} withArrow>
<ActionIcon
variant="subtle"
color="dark"
variant="default"
style={{ border: "none" }}
onClick={onSearch}
size="sm"
>
@@ -140,7 +140,7 @@ export function SearchSpotlightFilters({
<Switch
checked={isAiMode}
onChange={(event) => onAskClick()}
label={t("AI Answers")}
label={t("Ask AI")}
size="sm"
color="blue"
labelPosition="left"
@@ -279,7 +279,7 @@ export function SearchSpotlightFilters({
isAiMode &&
option.value === "attachment" && (
<Text size="xs" mt={4}>
{t("AI Answers not available for attachments")}
{t("Ask AI not available for attachments")}
</Text>
)}
</div>
@@ -24,14 +24,13 @@ export function usePageSearchQuery(
}
export function useSearchSuggestionsQuery(
params: SearchSuggestionParams & { preload?: boolean },
params: SearchSuggestionParams,
): UseQueryResult<ISuggestionResult, Error> {
const { preload, ...queryParams } = params;
return useQuery({
queryKey: ["search-suggestion", params.query],
staleTime: 60 * 1000, // 1min
queryFn: () => searchSuggestions(queryParams),
enabled: preload || !!params.query,
queryFn: () => searchSuggestions(params),
enabled: !!params.query,
});
}
@@ -13,7 +13,7 @@ import {
buildPageUrl,
buildSharedPageUrl,
} from "@/features/page/page.utils.ts";
import { useClipboard } from "@/hooks/use-clipboard";
import { useClipboard } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import { useNavigate } from "react-router-dom";
import { useDeleteShareMutation } from "@/features/share/queries/share-query.ts";
@@ -45,7 +45,8 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
const { isTrial } = useTrial();
const [workspace] = useAtom(workspaceAtom);
const { data: space } = useSpaceQuery(spaceSlug);
const workspaceDisabled = workspace?.settings?.sharing?.disabled === true;
const workspaceDisabled =
workspace?.settings?.sharing?.disabled === true;
const spaceDisabled = space?.settings?.sharing?.disabled === true;
const sharingDisabled = workspaceDisabled || spaceDisabled;
const createShareMutation = useCreateShareMutation();
@@ -133,6 +134,7 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
<Popover width={350} position="bottom" withArrow shadow="md">
<Popover.Target>
<Button
style={{ border: "none" }}
size="compact-sm"
leftSection={
<Indicator
@@ -144,8 +146,7 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
<IconWorld size={20} stroke={1.5} />
</Indicator>
}
color="dark"
variant="subtle"
variant="default"
>
{t("Share")}
</Button>
@@ -8,7 +8,6 @@ import { io } from "socket.io-client";
import { SOCKET_URL } from "@/features/websocket/types";
import { useQuerySubscription } from "@/features/websocket/use-query-subscription.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 { Error404 } from "@/components/ui/error-404.tsx";
@@ -45,7 +44,6 @@ export function UserProvider({ children }: React.PropsWithChildren) {
useQuerySubscription();
useTreeSocket();
useNotificationSocket();
useEffect(() => {
if (data && data.user && data.workspace) {
@@ -8,7 +8,7 @@ import {
} from "@/features/workspace/queries/workspace-query.ts";
import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications";
import { useClipboard } from "@/hooks/use-clipboard";
import { useClipboard } from "@mantine/hooks";
import { getInviteLink } from "@/features/workspace/services/workspace-service.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
import { isCloud } from "@/lib/config.ts";
@@ -1,8 +1,7 @@
import { useAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useEffect, useState } from "react";
import { Button, Group, Text, TextInput } from "@mantine/core";
import { CopyButton } from "@/components/common/copy-button";
import { Button, CopyButton, Group, Text, TextInput } from "@mantine/core";
import { useTranslation } from "react-i18next";
export default function WorkspaceInviteSection() {
@@ -23,7 +23,6 @@ export interface IWorkspace {
hasLicenseKey?: boolean;
enforceMfa?: boolean;
aiSearch?: boolean;
generativeAi?: boolean;
disablePublicSharing?: boolean;
}
@@ -34,7 +33,6 @@ export interface IWorkspaceSettings {
export interface IWorkspaceAiSettings {
search?: boolean;
generative?: boolean;
}
export interface IWorkspaceSharingSettings {
-60
View File
@@ -1,60 +0,0 @@
// Source: https://github.com/mantinedev/mantine/blob/master/packages/@mantine/hooks/src/use-clipboard/use-clipboard.ts
// polyfilled to support execCommand fallback
import { useState } from "react";
import { execCommandCopy } from "@docmost/editor-ext";
export type UseClipboardOptions = {
timeout?: number;
};
export type UseClipboardReturnValue = {
copy: (value: string) => void;
reset: () => void;
error: Error | null;
copied: boolean;
};
export function useClipboard(
options: UseClipboardOptions = { timeout: 2000 },
): UseClipboardReturnValue {
const [error, setError] = useState<Error | null>(null);
const [copied, setCopied] = useState(false);
const [copyTimeout, setCopyTimeout] = useState<number | null>(null);
const handleCopyResult = (value: boolean) => {
window.clearTimeout(copyTimeout!);
setCopyTimeout(window.setTimeout(() => setCopied(false), options.timeout));
setCopied(value);
};
const copy = (value: string) => {
if ("clipboard" in navigator) {
navigator.clipboard
.writeText(value)
.then(() => handleCopyResult(true))
.catch(() => {
try {
execCommandCopy(value);
handleCopyResult(true);
} catch (err) {
setError(err instanceof Error ? err : new Error("Failed to copy"));
}
});
} else {
try {
execCommandCopy(value);
handleCopyResult(true);
} catch (err) {
setError(err instanceof Error ? err : new Error("Failed to copy"));
}
}
};
const reset = () => {
setCopied(false);
setError(null);
window.clearTimeout(copyTimeout!);
};
return { copy, reset, error, copied };
}
+1 -5
View File
@@ -68,14 +68,10 @@ function redirectToLogin() {
APP_ROUTE.AUTH.SIGNUP,
APP_ROUTE.AUTH.FORGOT_PASSWORD,
APP_ROUTE.AUTH.PASSWORD_RESET,
APP_ROUTE.AUTH.MFA_CHALLENGE,
APP_ROUTE.AUTH.MFA_SETUP_REQUIRED,
"/invites",
];
if (!exemptPaths.some((path) => window.location.pathname.startsWith(path))) {
const redirectTo = window.location.pathname;
const params = new URLSearchParams({ redirect: redirectTo });
window.location.href = `${APP_ROUTE.AUTH.LOGIN}?${params.toString()}`;
window.location.href = APP_ROUTE.AUTH.LOGIN;
}
}
-17
View File
@@ -25,25 +25,8 @@ const APP_ROUTE = {
SPACES: "/settings/spaces",
BILLING: "/settings/billing",
SECURITY: "/settings/security",
INTEGRATIONS: "/settings/integrations",
},
},
};
export function getPostLoginRedirect(): string {
const params = new URLSearchParams(window.location.search);
const redirect = params.get("redirect");
if (redirect) {
try {
const resolved = new URL(redirect, window.location.origin);
if (resolved.origin === window.location.origin) {
return resolved.pathname + resolved.search + resolved.hash;
}
} catch {
// malformed URL, fall through to default
}
}
return APP_ROUTE.HOME;
}
export default APP_ROUTE;
+2 -48
View File
@@ -13,11 +13,6 @@ import {
} from "@/features/space/permissions/permissions.type.ts";
import { useTranslation } from "react-i18next";
import React from "react";
import { EmptyState } from "@/components/ui/empty-state.tsx";
import { IconAlertTriangle, IconFileOff } from "@tabler/icons-react";
import { Button } from "@mantine/core";
import { Link } from "react-router-dom";
import { ErrorBoundary } from "react-error-boundary";
const MemoizedFullEditor = React.memo(FullEditor);
const MemoizedPageHeader = React.memo(PageHeader);
@@ -27,29 +22,6 @@ export default function Page() {
const { t } = useTranslation();
const { pageSlug } = useParams();
return (
<ErrorBoundary
resetKeys={[pageSlug]}
fallbackRender={({ resetErrorBoundary }) => (
<EmptyState
icon={IconAlertTriangle}
title={t("Failed to load page. An error occurred.")}
action={
<Button variant="default" size="sm" mt="xs" onClick={resetErrorBoundary}>
{t("Try again")}
</Button>
}
/>
)}
>
<PageContent pageSlug={pageSlug} />
</ErrorBoundary>
);
}
function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
const { t } = useTranslation();
const {
data: page,
isLoading,
@@ -67,27 +39,9 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
if (isError || !page) {
if ([401, 403, 404].includes(error?.["status"])) {
return (
<EmptyState
icon={IconFileOff}
title={t("Page not found")}
description={t(
"This page may have been deleted, moved, or you may not have access.",
)}
action={
<Button component={Link} to="/home" variant="default" size="sm" mt="xs">
{t("Go to homepage")}
</Button>
}
/>
);
return <div>{t("Page not found")}</div>;
}
return (
<EmptyState
icon={IconFileOff}
title={t("Error fetching page data.")}
/>
);
return <div>{t("Error fetching page data.")}</div>;
}
if (!space) {
+2 -25
View File
@@ -35,35 +35,12 @@ export const theme = createTheme({
blue,
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) => ({
variables: {
"--input-error-size": theme.fontSizes.sm,
},
light: {
"--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)",
},
light: {},
dark: {},
});
+19 -18
View File
@@ -1,6 +1,6 @@
{
"name": "server",
"version": "0.25.3",
"version": "0.25.2",
"description": "",
"author": "",
"private": true,
@@ -30,9 +30,9 @@
"test:e2e": "jest --config test/jest-e2e.json"
},
"dependencies": {
"@ai-sdk/google": "^3.0.29",
"@ai-sdk/openai": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.30",
"@ai-sdk/google": "^3.0.9",
"@ai-sdk/openai": "^3.0.11",
"@ai-sdk/openai-compatible": "^2.0.12",
"@aws-sdk/client-s3": "3.982.0",
"@aws-sdk/lib-storage": "3.982.0",
"@aws-sdk/s3-request-presigner": "3.982.0",
@@ -56,14 +56,14 @@
"@nestjs/terminus": "^11.0.0",
"@nestjs/websockets": "^11.1.13",
"@node-saml/passport-saml": "^5.1.0",
"@react-email/components": "1.0.7",
"@react-email/render": "2.0.4",
"@react-email/components": "0.0.28",
"@react-email/render": "1.0.2",
"@socket.io/redis-adapter": "^8.3.0",
"ai": "^6.0.86",
"ai-sdk-ollama": "^3.7.0",
"ai": "^6.0.37",
"ai-sdk-ollama": "^3.1.1",
"bcrypt": "^6.0.0",
"bullmq": "^5.65.0",
"cache-manager": "^7.2.8",
"cache-manager": "^6.4.3",
"cheerio": "^1.1.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
@@ -100,6 +100,7 @@
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"sanitize-filename-ts": "1.0.2",
"sharp": "0.34.3",
"socket.io": "^4.8.3",
"stripe": "^17.5.0",
"tmp-promise": "^3.0.3",
@@ -110,32 +111,32 @@
},
"devDependencies": {
"@eslint/js": "^9.20.0",
"@nestjs/cli": "^11.0.16",
"@nestjs/cli": "^11.0.4",
"@nestjs/schematics": "^11.0.1",
"@nestjs/testing": "^11.0.10",
"@types/bcrypt": "^5.0.2",
"@types/debounce": "^1.2.4",
"@types/fs-extra": "^11.0.4",
"@types/jest": "^30.0.0",
"@types/jest": "^29.5.14",
"@types/mime-types": "^2.1.4",
"@types/node": "^22.13.4",
"@types/nodemailer": "^6.4.17",
"@types/passport-google-oauth20": "^2.0.16",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.3",
"@types/supertest": "^6.0.2",
"@types/ws": "^8.5.14",
"@types/yauzl": "^2.10.3",
"eslint": "^9.39.2",
"eslint": "^9.20.1",
"eslint-config-prettier": "^10.0.1",
"globals": "^15.15.0",
"jest": "^30.2.0",
"jest": "^29.7.0",
"kysely-codegen": "^0.19.0",
"prettier": "^3.5.1",
"react-email": "5.2.8",
"react-email": "3.0.2",
"source-map-support": "^0.5.21",
"supertest": "^7.2.2",
"ts-jest": "^29.4.6",
"ts-loader": "^9.5.4",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
@@ -1,12 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
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<
CollaborationHandler['getHandlers']
@@ -27,44 +20,6 @@ export class CollaborationHandler {
// 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,10 +1,4 @@
import {
Global,
Logger,
Module,
OnModuleDestroy,
OnModuleInit,
} from '@nestjs/common';
import { Logger, Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { AuthenticationExtension } from './extensions/authentication.extension';
import { PersistenceExtension } from './extensions/persistence.extension';
import { CollaborationGateway } from './collaboration.gateway';
@@ -17,7 +11,6 @@ import { HistoryProcessor } from './processors/history.processor';
import { LoggerExtension } from './extensions/logger.extension';
import { CollaborationHandler } from './collaboration.handler';
import { CollabHistoryService } from './services/collab-history.service';
import { WatcherModule } from '../core/watcher/watcher.module';
@Module({
providers: [
@@ -30,7 +23,7 @@ import { WatcherModule } from '../core/watcher/watcher.module';
CollaborationHandler,
],
exports: [CollaborationGateway],
imports: [TokenModule, WatcherModule],
imports: [TokenModule],
})
export class CollaborationModule implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(CollaborationModule.name);
@@ -34,7 +34,6 @@ import {
Highlight,
UniqueID,
addUniqueIdsToDoc,
htmlToMarkdown,
} from '@docmost/editor-ext';
import { generateText, getSchema, JSONContent } from '@tiptap/core';
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
@@ -43,7 +42,6 @@ import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
// see:https://github.com/ueberdosis/tiptap/issues/4089
//import { generateJSON } from '@tiptap/html';
import { Node, Schema } from '@tiptap/pm/model';
import * as Y from 'yjs';
import { Logger } from '@nestjs/common';
export const tiptapExtensions = [
@@ -163,37 +161,3 @@ function stripUnknownNodes(
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);
}

Some files were not shown because too many files have changed in this diff Show More