Compare commits

..

6 Commits

Author SHA1 Message Date
Philipinho 2f92e1fecf add yjs utils 2026-02-12 11:13:28 -08:00
Philipinho e709e34f1f dry 2026-02-12 11:09:08 -08:00
Philipinho ae9484e274 rename contentOperation -> operation 2026-02-12 11:01:00 -08:00
Philipinho 3c81441ddb refactor naming
* support prepend
2026-02-12 10:57:30 -08:00
Philipinho 152702ebe0 import module 2026-02-11 23:50:24 -08:00
Philipinho 10b0ac06dd feat: page content update and retrieval output 2026-02-11 23:44:46 -08:00
179 changed files with 1246 additions and 8601 deletions
-2
View File
@@ -26,7 +26,6 @@
"@tanstack/react-query": "^5.90.17",
"alfaaz": "^1.1.0",
"axios": "^1.13.5",
"blueimp-load-image": "^5.16.0",
"clsx": "^2.1.1",
"emoji-mart": "^5.6.0",
"file-saver": "^2.0.5",
@@ -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",
@@ -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 -1
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";
@@ -83,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"}>
@@ -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"
@@ -115,6 +115,7 @@ const groupedData: DataGroup[] = [
icon: IconSparkles,
path: "/settings/ai",
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);
@@ -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({
@@ -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,112 +0,0 @@
import { Group, Menu, Text, UnstyledButton } from "@mantine/core";
import {
IconChevronDown,
IconLock,
IconShieldLock,
IconCheck,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import classes from "./page-permission.module.css";
type AccessLevel = "open" | "restricted";
type GeneralAccessSelectProps = {
value: AccessLevel;
onChange: (value: AccessLevel) => void;
disabled?: boolean;
hasInheritedRestriction?: boolean;
};
export function GeneralAccessSelect({
value,
onChange,
disabled,
hasInheritedRestriction,
}: GeneralAccessSelectProps) {
const { t } = useTranslation();
const isDirectlyRestricted = value === "restricted";
const showInheritedState = hasInheritedRestriction && !isDirectlyRestricted;
const currentLabel = showInheritedState
? t("Restricted by parent")
: isDirectlyRestricted
? t("Restricted")
: t("Open");
const currentDescription = showInheritedState
? t("Inherits restrictions from ancestor page")
: isDirectlyRestricted
? t("Only specific people can access")
: t("Everyone in this space can access");
const CurrentIcon = showInheritedState
? IconShieldLock
: isDirectlyRestricted
? IconLock
: IconShieldLock;
const accessOptions = [
{
value: "open" as const,
label: hasInheritedRestriction ? t("Restricted by parent") : t("Open"),
description: hasInheritedRestriction
? t("Use only inherited restrictions")
: t("Everyone in this space can access"),
icon: IconShieldLock,
},
{
value: "restricted" as const,
label: t("Restricted"),
description: hasInheritedRestriction
? t("Add restrictions on top of inherited")
: t("Only specific people can access"),
icon: IconLock,
},
];
return (
<Menu withArrow disabled={disabled}>
<Menu.Target>
<UnstyledButton className={classes.generalAccessBox} disabled={disabled}>
<div
className={`${classes.generalAccessIcon} ${isDirectlyRestricted || showInheritedState ? classes.generalAccessIconRestricted : ""}`}
>
<CurrentIcon size={18} stroke={1.5} />
</div>
<div style={{ flex: 1 }}>
<Group gap={4}>
<Text size="sm" fw={500}>
{currentLabel}
</Text>
{!disabled && <IconChevronDown size={14} />}
</Group>
<Text size="xs" c="dimmed">
{currentDescription}
</Text>
</div>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
{accessOptions.map((option) => (
<Menu.Item
key={option.value}
onClick={() => onChange(option.value)}
leftSection={<option.icon size={16} stroke={1.5} />}
rightSection={
option.value === value ? <IconCheck size={16} /> : null
}
>
<div>
<Text size="sm">{option.label}</Text>
<Text size="xs" c="dimmed">
{option.description}
</Text>
</div>
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
);
}
@@ -1,107 +0,0 @@
import { Menu, Text, UnstyledButton, Group } from "@mantine/core";
import { IconChevronDown, IconCheck } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useAtomValue } from "jotai";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text";
import { IconGroupCircle } from "@/components/icons/icon-people-circle";
import { userAtom } from "@/features/user/atoms/current-user-atom";
import { formatMemberCount } from "@/lib";
import {
IPagePermissionMember,
PagePermissionRole,
} from "@/ee/page-permission/types/page-permission.types";
import {
pagePermissionRoleData,
getPagePermissionRoleLabel,
} from "@/ee/page-permission/types/page-permission-role-data";
import classes from "./page-permission.module.css";
type PagePermissionItemProps = {
member: IPagePermissionMember;
onRoleChange: (memberId: string, type: "user" | "group", role: string) => void;
onRemove: (memberId: string, type: "user" | "group") => void;
disabled?: boolean;
};
export function PagePermissionItem({
member,
onRoleChange,
onRemove,
disabled,
}: PagePermissionItemProps) {
const { t } = useTranslation();
const currentUser = useAtomValue(userAtom);
const isCurrentUser = member.type === "user" && member.id === currentUser?.id;
const roleLabel = getPagePermissionRoleLabel(member.role);
return (
<div className={classes.permissionItem}>
<div className={classes.permissionItemInfo}>
{member.type === "user" && (
<CustomAvatar avatarUrl={member.avatarUrl} name={member.name} />
)}
{member.type === "group" && <IconGroupCircle />}
<div className={classes.permissionItemDetails}>
<AutoTooltipText
fz="sm"
fw={500}
tooltipLabel={isCurrentUser ? `${member.name} (${t("You")})` : member.name}
>
{member.name}
{isCurrentUser && <Text span c="dimmed"> ({t("You")})</Text>}
</AutoTooltipText>
<AutoTooltipText fz="xs" c="dimmed">
{member.type === "user" ? member.email : formatMemberCount(member.memberCount, t)}
</AutoTooltipText>
</div>
</div>
<div className={classes.permissionItemRole}>
{isCurrentUser || disabled ? (
<Text size="sm" c="dimmed">
{t(roleLabel)}
</Text>
) : (
<Menu withArrow position="bottom-end">
<Menu.Target>
<UnstyledButton>
<Group gap={4}>
<Text size="sm">{t(roleLabel)}</Text>
<IconChevronDown size={14} />
</Group>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
{pagePermissionRoleData.map((role) => (
<Menu.Item
key={role.value}
onClick={() => onRoleChange(member.id, member.type, role.value)}
rightSection={
role.value === member.role ? <IconCheck size={16} /> : null
}
>
<div>
<Text size="sm">{t(role.label)}</Text>
<Text size="xs" c="dimmed">
{t(role.description)}
</Text>
</div>
</Menu.Item>
))}
<Menu.Divider />
<Menu.Item
color="red"
onClick={() => onRemove(member.id, member.type)}
>
{t("Remove access")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</div>
</div>
);
}
@@ -1,179 +0,0 @@
import { Avatar, Group, ScrollArea, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useAtomValue } from "jotai";
import { modals } from "@mantine/modals";
import { userAtom } from "@/features/user/atoms/current-user-atom";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { IconGroupCircle } from "@/components/icons/icon-people-circle";
import {
IPagePermissionMember,
PagePermissionRole,
} from "@/ee/page-permission/types/page-permission.types";
import {
useRemovePagePermissionMutation,
useUpdatePagePermissionRoleMutation,
} from "@/ee/page-permission/queries/page-permission-query";
import { PagePermissionItem } from "./page-permission-item";
import classes from "./page-permission.module.css";
type PagePermissionListProps = {
pageId: string;
members: IPagePermissionMember[];
canManage: boolean;
onRemoveAll?: () => void;
};
export function PagePermissionList({
pageId,
members,
canManage,
onRemoveAll,
}: PagePermissionListProps) {
const { t } = useTranslation();
const currentUser = useAtomValue(userAtom);
const updateRoleMutation = useUpdatePagePermissionRoleMutation();
const removeMutation = useRemovePagePermissionMutation();
const handleRoleChange = async (
memberId: string,
type: "user" | "group",
newRole: string,
) => {
await updateRoleMutation.mutateAsync({
pageId,
role: newRole as PagePermissionRole,
...(type === "user" ? { userId: memberId } : { groupId: memberId }),
});
};
const handleRemove = (memberId: string, type: "user" | "group") => {
modals.openConfirmModal({
title: t("Remove access"),
children: (
<Text size="sm">
{t("Are you sure you want to remove this member's access to the page?")}
</Text>
),
centered: true,
labels: { confirm: t("Remove"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: async () => {
await removeMutation.mutateAsync({
pageId,
...(type === "user" ? { userIds: [memberId] } : { groupIds: [memberId] }),
});
},
});
};
const handleRemoveAll = () => {
modals.openConfirmModal({
title: t("Remove all access"),
children: (
<Text size="sm">
{t("Are you sure you want to remove all specific access? This will make the page open to everyone in the space.")}
</Text>
),
centered: true,
labels: { confirm: t("Remove all"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => onRemoveAll?.(),
});
};
const sortedMembers = [...members].sort((a, b) => {
if (a.type === "user" && a.id === currentUser?.id) return -1;
if (b.type === "user" && b.id === currentUser?.id) return 1;
if (a.type === "group" && b.type === "user") return -1;
if (a.type === "user" && b.type === "group") return 1;
return 0;
});
const getSummaryText = () => {
const names: string[] = [];
let remaining = 0;
for (const member of sortedMembers) {
if (names.length < 2) {
if (member.type === "user" && member.id === currentUser?.id) {
names.push(t("You"));
} else {
names.push(member.name);
}
} else {
remaining++;
}
}
if (remaining > 0) {
return `${names.join(", ")}, ${t("and {{count}} other", { count: remaining })}`;
}
return names.join(", ");
};
if (members.length === 0) {
return null;
}
return (
<>
<div className={classes.specificAccessHeader}>
<Text size="sm" fw={500}>
{t("Specific access")}
</Text>
{canManage && members.length > 0 && (
<>
<Text size="sm" c="dimmed">
</Text>
<Text
className={classes.removeAllLink}
onClick={handleRemoveAll}
>
{t("Remove all")}
</Text>
</>
)}
</div>
<Group gap={0} mb="xs">
<div className={classes.avatarStack}>
{sortedMembers.slice(0, 3).map((member, index) => (
<div
key={member.id}
className={classes.avatarStackItem}
style={{ zIndex: sortedMembers.length - index }}
>
{member.type === "user" ? (
<CustomAvatar
avatarUrl={member.avatarUrl}
name={member.name}
size={28}
/>
) : (
<Avatar size={28} radius="xl">
<IconGroupCircle />
</Avatar>
)}
</div>
))}
</div>
<Text size="sm" ml="xs">
{getSummaryText()}
</Text>
</Group>
<ScrollArea mah={250}>
{sortedMembers.map((member) => (
<PagePermissionItem
key={`${member.type}-${member.id}`}
member={member}
onRoleChange={handleRoleChange}
onRemove={handleRemove}
disabled={!canManage}
/>
))}
</ScrollArea>
</>
);
}
@@ -1,200 +0,0 @@
import { useState } from "react";
import {
Box,
Button,
Divider,
Group,
Loader,
Paper,
Select,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Link, useParams } from "react-router-dom";
import { IconArrowRight, IconLock, IconShieldLock } from "@tabler/icons-react";
import { MultiMemberSelect } from "@/features/space/components/multi-member-select";
import {
IPageRestrictionInfo,
PagePermissionRole,
} from "@/ee/page-permission/types/page-permission.types";
import {
useAddPagePermissionMutation,
usePagePermissionsQuery,
useRestrictPageMutation,
useUnrestrictPageMutation,
} from "@/ee/page-permission/queries/page-permission-query";
import { pagePermissionRoleData } from "@/ee/page-permission/types/page-permission-role-data";
import { GeneralAccessSelect } from "@/ee/page-permission";
import { PagePermissionList } from "@/ee/page-permission";
import classes from "./page-permission.module.css";
import { buildPageUrl } from "@/features/page/page.utils";
type PagePermissionTabProps = {
pageId: string;
restrictionInfo: IPageRestrictionInfo;
};
export function PagePermissionTab({
pageId,
restrictionInfo,
}: PagePermissionTabProps) {
const { t } = useTranslation();
const { spaceSlug } = useParams();
const [memberIds, setMemberIds] = useState<string[]>([]);
const [role, setRole] = useState<string>(PagePermissionRole.WRITER);
const { data: permissionsData, isLoading } = usePagePermissionsQuery(pageId);
const restrictMutation = useRestrictPageMutation();
const unrestrictMutation = useUnrestrictPageMutation();
const addPermissionMutation = useAddPagePermissionMutation();
const hasInheritedRestriction = restrictionInfo.hasInheritedRestriction;
const hasDirectRestriction = restrictionInfo.hasDirectRestriction;
const canManage = restrictionInfo.userAccess.canManage;
const handleDirectAccessChange = async (value: "open" | "restricted") => {
if (value === "restricted" && !hasDirectRestriction) {
await restrictMutation.mutateAsync(pageId);
} else if (value === "open" && hasDirectRestriction) {
await unrestrictMutation.mutateAsync(pageId);
}
};
const handleAddMembers = async () => {
if (memberIds.length === 0) return;
const userIds = memberIds
.filter((id) => id.startsWith("user-"))
.map((id) => id.replace("user-", ""));
const groupIds = memberIds
.filter((id) => id.startsWith("group-"))
.map((id) => id.replace("group-", ""));
await addPermissionMutation.mutateAsync({
pageId,
role: role as PagePermissionRole,
...(userIds.length > 0 && { userIds }),
...(groupIds.length > 0 && { groupIds }),
});
setMemberIds([]);
};
const handleRemoveAll = async () => {
await unrestrictMutation.mutateAsync(pageId);
};
return (
<Stack gap="md">
{hasInheritedRestriction && (
<Paper className={classes.inheritedSection} p="sm" radius="sm">
<Group gap="sm" wrap="nowrap">
<ThemeIcon
size="lg"
radius="sm"
variant="light"
color="orange"
>
<IconShieldLock size={18} stroke={1.5} />
</ThemeIcon>
<Box style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{t("Inherited restriction")}
</Text>
<Group gap={4}>
<Text size="xs" c="dimmed">
{t("Access limited by")}
</Text>
<Link
to={buildPageUrl(
spaceSlug,
restrictionInfo.id,
restrictionInfo.title,
)}
style={{ textDecoration: "none" }}
>
<Group gap={2}>
<Text size="xs" fw={500} c="blue">
{restrictionInfo.title || t("Untitled")}
</Text>
<IconArrowRight size={12} color="var(--mantine-color-blue-6)" />
</Group>
</Link>
</Group>
</Box>
</Group>
</Paper>
)}
<Box>
<Text size="sm" fw={500} mb="xs">
{t("This page")}
</Text>
<GeneralAccessSelect
value={hasDirectRestriction ? "restricted" : "open"}
onChange={handleDirectAccessChange}
disabled={!canManage}
hasInheritedRestriction={hasInheritedRestriction}
/>
{!hasDirectRestriction && !hasInheritedRestriction && (
<Text size="xs" c="dimmed" mt={4}>
{t("Everyone in this space can access this page")}
</Text>
)}
{!hasDirectRestriction && hasInheritedRestriction && (
<Text size="xs" c="dimmed" mt={4}>
{t("Add additional restrictions specific to this page")}
</Text>
)}
</Box>
{hasDirectRestriction && (
<>
<Divider />
{canManage && (
<Group gap="xs" align="flex-end">
<Box style={{ flex: 1 }}>
<MultiMemberSelect value={memberIds} onChange={setMemberIds} />
</Box>
<Select
data={pagePermissionRoleData.map((r) => ({
label: t(r.label),
value: r.value,
}))}
value={role}
onChange={(value) => value && setRole(value)}
allowDeselect={false}
variant="filled"
w={120}
/>
<Button
onClick={handleAddMembers}
disabled={memberIds.length === 0}
loading={addPermissionMutation.isPending}
>
{t("Add")}
</Button>
</Group>
)}
{isLoading ? (
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
) : (
<PagePermissionList
pageId={pageId}
members={permissionsData?.items || []}
canManage={canManage}
onRemoveAll={handleRemoveAll}
/>
)}
</>
)}
</Stack>
);
}
@@ -1,128 +0,0 @@
.generalAccessBox {
display: flex;
align-items: center;
gap: var(--mantine-spacing-sm);
padding: var(--mantine-spacing-xs) 0;
}
.generalAccessIcon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: var(--mantine-radius-sm);
@mixin light {
background-color: var(--mantine-color-gray-1);
}
@mixin dark {
background-color: var(--mantine-color-dark-5);
}
}
.generalAccessIconRestricted {
@mixin light {
background-color: var(--mantine-color-red-0);
color: var(--mantine-color-red-6);
}
@mixin dark {
background-color: rgba(250, 82, 82, 0.1);
color: var(--mantine-color-red-5);
}
}
.permissionItem {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--mantine-spacing-xs) 0;
gap: var(--mantine-spacing-sm);
}
.permissionItemInfo {
display: flex;
align-items: center;
gap: var(--mantine-spacing-sm);
flex: 1;
min-width: 0;
overflow: hidden;
}
.permissionItemDetails {
min-width: 0;
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.permissionItemRole {
flex-shrink: 0;
}
.avatarStack {
display: flex;
align-items: center;
}
.avatarStackItem {
margin-left: -8px;
border: 2px solid var(--mantine-color-body);
border-radius: 50%;
}
.avatarStackItem:first-child {
margin-left: 0;
}
.specificAccessHeader {
display: flex;
align-items: center;
gap: var(--mantine-spacing-xs);
margin-top: var(--mantine-spacing-md);
margin-bottom: var(--mantine-spacing-xs);
}
.removeAllLink {
cursor: pointer;
font-size: var(--mantine-font-size-sm);
@mixin light {
color: var(--mantine-color-gray-6);
}
@mixin dark {
color: var(--mantine-color-dark-2);
}
&:hover {
text-decoration: underline;
}
}
.inheritedInfo {
display: flex;
align-items: center;
gap: var(--mantine-spacing-xs);
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
border-radius: var(--mantine-radius-sm);
margin-bottom: var(--mantine-spacing-sm);
@mixin light {
background-color: var(--mantine-color-gray-0);
}
@mixin dark {
background-color: var(--mantine-color-dark-6);
}
}
.inheritedSection {
@mixin light {
background-color: var(--mantine-color-orange-0);
border: 1px solid var(--mantine-color-orange-2);
}
@mixin dark {
background-color: rgba(255, 146, 43, 0.08);
border: 1px solid rgba(255, 146, 43, 0.2);
}
}
@@ -1,99 +0,0 @@
import { useState } from "react";
import {
Button,
Indicator,
Loader,
Modal,
Tabs,
Center,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconWorld, IconLock } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
import { extractPageSlugId } from "@/lib";
import { usePageQuery } from "@/features/page/queries/page-query";
import { usePageRestrictionInfoQuery } from "@/ee/page-permission/queries/page-permission-query";
import { PagePermissionTab } from "@/ee/page-permission";
import { PublishTab } from "./publish-tab";
import { useShareForPageQuery } from "@/features/share/queries/share-query";
type PageShareModalProps = {
readOnly?: boolean;
};
export function PageShareModal({ readOnly }: PageShareModalProps) {
const { t } = useTranslation();
const { pageSlug } = useParams();
const pageSlugId = extractPageSlugId(pageSlug);
const [opened, { open, close }] = useDisclosure(false);
const [activeTab, setActiveTab] = useState<string | null>("share");
const { data: page } = usePageQuery({ pageId: pageSlugId });
const pageId = page?.id;
const isRestricted = page?.permissions?.hasRestriction ?? false;
const { data: share } = useShareForPageQuery(pageId);
const isPubliclyShared = !!share;
const { data: restrictionInfo, isLoading: restrictionLoading } =
usePageRestrictionInfoQuery(opened ? pageId : undefined);
return (
<>
<Button
style={{ border: "none" }}
size="compact-sm"
leftSection={
<Indicator
color={isRestricted ? "red" : "green"}
offset={5}
disabled={!isRestricted && !isPubliclyShared}
withBorder
>
{isRestricted ? (
<IconLock size={20} stroke={1.5} />
) : (
<IconWorld size={20} stroke={1.5} />
)}
</Indicator>
}
variant="default"
onClick={open}
>
{t("Share")}
</Button>
<Modal
opened={opened}
onClose={close}
title={t("Share")}
size={600}
>
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.List mb="md">
<Tabs.Tab value="share">{t("Share")}</Tabs.Tab>
<Tabs.Tab value="publish">{t("Publish")}</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="share">
{restrictionLoading || !pageId ? (
<Center py="xl">
<Loader size="sm" />
</Center>
) : (
<PagePermissionTab
pageId={pageId}
restrictionInfo={restrictionInfo}
/>
)}
</Tabs.Panel>
<Tabs.Panel value="publish">
<PublishTab pageId={pageId} readOnly={readOnly} />
</Tabs.Panel>
</Tabs>
</Modal>
</>
);
}
@@ -1,221 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import {
ActionIcon,
Anchor,
Button,
Group,
Stack,
Switch,
Text,
TextInput,
} from "@mantine/core";
import { IconExternalLink, IconLock } from "@tabler/icons-react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { getPageIcon } from "@/lib";
import CopyTextButton from "@/components/common/copy";
import { getAppUrl, isCloud } from "@/lib/config";
import { buildPageUrl } from "@/features/page/page.utils";
import {
useCreateShareMutation,
useDeleteShareMutation,
useShareForPageQuery,
useUpdateShareMutation,
} from "@/features/share/queries/share-query";
import useTrial from "@/ee/hooks/use-trial";
type PublishTabProps = {
pageId: string;
readOnly?: boolean;
};
export function PublishTab({ pageId, readOnly }: PublishTabProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const { pageSlug, spaceSlug } = useParams();
const { isTrial } = useTrial();
const { data: share } = useShareForPageQuery(pageId);
const createShareMutation = useCreateShareMutation();
const updateShareMutation = useUpdateShareMutation();
const deleteShareMutation = useDeleteShareMutation();
const pageIsShared = share && share.level === 0;
const isDescendantShared = share && share.level > 0;
const publicLink = `${getAppUrl()}/share/${share?.key}/p/${pageSlug}`;
const [isPagePublic, setIsPagePublic] = useState<boolean>(false);
useEffect(() => {
setIsPagePublic(!!share);
}, [share, pageId]);
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
if (value) {
createShareMutation.mutateAsync({
pageId: pageId,
includeSubPages: true,
searchIndexing: false,
});
setIsPagePublic(value);
} else {
if (share && share.id) {
deleteShareMutation.mutateAsync(share.id);
setIsPagePublic(value);
}
}
};
const handleSubPagesChange = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const value = event.currentTarget.checked;
updateShareMutation.mutateAsync({
shareId: share.id,
includeSubPages: value,
});
};
const handleIndexSearchChange = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const value = event.currentTarget.checked;
updateShareMutation.mutateAsync({
shareId: share.id,
searchIndexing: value,
});
};
const shareLink = useMemo(
() => (
<Group my="sm" gap={4} wrap="nowrap">
<TextInput
variant="filled"
value={publicLink}
readOnly
rightSection={<CopyTextButton text={publicLink} />}
style={{ width: "100%" }}
/>
<ActionIcon
component="a"
variant="default"
target="_blank"
href={publicLink}
size="sm"
>
<IconExternalLink size={16} />
</ActionIcon>
</Group>
),
[publicLink],
);
if (isCloud() && isTrial) {
return (
<Stack align="center" py="md">
<IconLock size={20} stroke={1.5} />
<Text size="sm" ta="center" fw={500}>
{t("Upgrade to share pages")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t(
"Page sharing is available on paid plans. Upgrade to share your pages publicly.",
)}
</Text>
<Button size="xs" onClick={() => navigate("/settings/billing")}>
{t("Upgrade Plan")}
</Button>
</Stack>
);
}
if (isDescendantShared) {
return (
<Stack gap="sm">
<Text size="sm">{t("Inherits public sharing from")}</Text>
<Anchor
size="sm"
underline="never"
style={{
cursor: "pointer",
color: "var(--mantine-color-text)",
}}
component={Link}
to={buildPageUrl(
spaceSlug,
share.sharedPage.slugId,
share.sharedPage.title,
)}
>
<Group gap="4" wrap="nowrap">
{getPageIcon(share.sharedPage.icon)}
<Text fz="sm" fw={500} lineClamp={1}>
{share.sharedPage.title || t("untitled")}
</Text>
</Group>
</Anchor>
{shareLink}
</Stack>
);
}
return (
<Stack gap="sm">
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="sm">
{isPagePublic ? t("Shared to web") : t("Share to web")}
</Text>
<Text size="xs" c="dimmed">
{isPagePublic
? t("Anyone with the link can view this page")
: t("Make this page publicly accessible")}
</Text>
</div>
<Switch
onChange={handleChange}
checked={isPagePublic}
disabled={readOnly}
size="xs"
/>
</Group>
{pageIsShared && (
<>
{shareLink}
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="sm">{t("Include sub-pages")}</Text>
<Text size="xs" c="dimmed">
{t("Make sub-pages public too")}
</Text>
</div>
<Switch
onChange={handleSubPagesChange}
checked={share.includeSubPages}
size="xs"
disabled={readOnly}
/>
</Group>
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="sm">{t("Search engine indexing")}</Text>
<Text size="xs" c="dimmed">
{t("Allow search engines to index page")}
</Text>
</div>
<Switch
onChange={handleIndexSearchChange}
checked={share.searchIndexing}
size="xs"
disabled={readOnly}
/>
</Group>
</>
)}
</Stack>
);
}
@@ -1,26 +0,0 @@
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability";
import {
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type";
import { usePageRestrictionInfoQuery } from "@/ee/page-permission/queries/page-permission-query";
export function usePagePermission(pageId: string, spaceRules: any) {
const spaceAbility = useSpaceAbility(spaceRules);
const { data: restrictionInfo, isLoading } =
usePageRestrictionInfoQuery(pageId);
if (isLoading || !restrictionInfo) {
return { canEdit: false, restrictionInfo: undefined };
}
const hasRestriction =
restrictionInfo.hasDirectRestriction ||
restrictionInfo.hasInheritedRestriction;
const canEdit = hasRestriction
? (restrictionInfo.userAccess?.canEdit ?? false)
: spaceAbility.can(SpaceCaslAction.Manage, SpaceCaslSubject.Page);
return { canEdit, restrictionInfo };
}
@@ -1,11 +0,0 @@
export * from "./components/page-share-modal";
export * from "./components/page-permission-tab";
export * from "./components/publish-tab";
export * from "./components/page-permission-list";
export * from "./components/page-permission-item";
export * from "./components/general-access-select";
export * from "./hooks/use-page-permission";
export * from "./queries/page-permission-query";
export * from "./services/page-permission-service";
export * from "./types/page-permission.types";
export * from "./types/page-permission-role-data";
@@ -1,159 +0,0 @@
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import {
IAddPagePermission,
IPagePermissionMember,
IPageRestrictionInfo,
IRemovePagePermission,
IUpdatePagePermissionRole,
} from "@/ee/page-permission/types/page-permission.types";
import {
addPagePermission,
getPagePermissions,
getPageRestrictionInfo,
removePagePermission,
restrictPage,
unrestrictPage,
updatePagePermissionRole,
} from "@/ee/page-permission/services/page-permission-service";
import { notifications } from "@mantine/notifications";
import { IPagination, QueryParams } from "@/lib/types";
import { useTranslation } from "react-i18next";
export function usePageRestrictionInfoQuery(
pageId: string | undefined,
): UseQueryResult<IPageRestrictionInfo, Error> {
return useQuery({
queryKey: ["page-restriction-info", pageId],
queryFn: () => getPageRestrictionInfo(pageId),
enabled: !!pageId,
});
}
export function usePagePermissionsQuery(
pageId: string,
params?: QueryParams,
): UseQueryResult<IPagination<IPagePermissionMember>, Error> {
return useQuery({
queryKey: ["page-permissions", pageId, params],
queryFn: () => getPagePermissions(pageId, params),
enabled: !!pageId,
placeholderData: keepPreviousData,
});
}
export function useRestrictPageMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, string>({
mutationFn: (pageId) => restrictPage(pageId),
onSuccess: (_, pageId) => {
queryClient.invalidateQueries({
queryKey: ["page-restriction-info", pageId],
});
queryClient.invalidateQueries({
queryKey: ["page-permissions", pageId],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to restrict page"),
color: "red",
});
},
});
}
export function useUnrestrictPageMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, string>({
mutationFn: (pageId) => unrestrictPage(pageId),
onSuccess: (_, pageId) => {
queryClient.invalidateQueries({
queryKey: ["page-restriction-info", pageId],
});
queryClient.invalidateQueries({
queryKey: ["page-permissions", pageId],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to remove page restriction"),
color: "red",
});
},
});
}
export function useAddPagePermissionMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, IAddPagePermission>({
mutationFn: (data) => addPagePermission(data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ["page-permissions", variables.pageId],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to add permission"),
color: "red",
});
},
});
}
export function useRemovePagePermissionMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, IRemovePagePermission>({
mutationFn: (data) => removePagePermission(data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ["page-permissions", variables.pageId],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to remove permission"),
color: "red",
});
},
});
}
export function useUpdatePagePermissionRoleMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, IUpdatePagePermissionRole>({
mutationFn: (data) => updatePagePermissionRole(data),
onSuccess: (_, variables) => {
queryClient.refetchQueries({
queryKey: ["page-permissions", variables.pageId],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to update permission"),
color: "red",
});
},
});
}
@@ -1,55 +0,0 @@
import api from "@/lib/api-client";
import { IPagination, QueryParams } from "@/lib/types";
import {
IAddPagePermission,
IPagePermissionMember,
IPageRestrictionInfo,
IRemovePagePermission,
IUpdatePagePermissionRole,
} from "@/ee/page-permission/types/page-permission.types";
export async function restrictPage(pageId: string): Promise<void> {
await api.post("/pages/restrict", { pageId });
}
export async function addPagePermission(
data: IAddPagePermission,
): Promise<void> {
await api.post("/pages/add-permission", data);
}
export async function removePagePermission(
data: IRemovePagePermission,
): Promise<void> {
await api.post("/pages/remove-permission", data);
}
export async function updatePagePermissionRole(
data: IUpdatePagePermissionRole,
): Promise<void> {
await api.post("/pages/update-permission", data);
}
export async function unrestrictPage(pageId: string): Promise<void> {
await api.post("/pages/remove-restriction", { pageId });
}
export async function getPagePermissions(
pageId: string,
params?: QueryParams,
): Promise<IPagination<IPagePermissionMember>> {
const req = await api.post<IPagination<IPagePermissionMember>>(
"/pages/permissions",
{ pageId, ...params },
);
return req.data;
}
export async function getPageRestrictionInfo(
pageId: string,
): Promise<IPageRestrictionInfo> {
const req = await api.post<IPageRestrictionInfo>("/pages/permission-info", {
pageId,
});
return req.data;
}
@@ -1,20 +0,0 @@
import { IRoleData } from "@/lib/types";
import { PagePermissionRole } from "./page-permission.types";
export const pagePermissionRoleData: IRoleData[] = [
{
label: "Can edit",
value: PagePermissionRole.WRITER,
description: "Can edit page and manage access",
},
{
label: "Can view",
value: PagePermissionRole.READER,
description: "Can only view page",
},
];
export function getPagePermissionRoleLabel(value: string): string | undefined {
const role = pagePermissionRoleData.find((item) => item.value === value);
return role ? role.label : undefined;
}
@@ -1,57 +0,0 @@
export enum PagePermissionRole {
READER = "reader",
WRITER = "writer",
}
export type IAddPagePermission = {
pageId: string;
role: PagePermissionRole;
userIds?: string[];
groupIds?: string[];
};
export type IRemovePagePermission = {
pageId: string;
userIds?: string[];
groupIds?: string[];
};
export type IUpdatePagePermissionRole = {
pageId: string;
role: PagePermissionRole;
userId?: string;
groupId?: string;
};
export type IPageRestrictionInfo = {
id: string;
title: string;
hasDirectRestriction: boolean;
hasInheritedRestriction: boolean;
userAccess: {
canView: boolean;
canEdit: boolean;
canManage: boolean;
};
};
type IPagePermissionBase = {
id: string;
name: string;
role: string;
createdAt: string;
};
export type IPagePermissionUser = IPagePermissionBase & {
type: "user";
email: string;
avatarUrl: string | null;
};
export type IPagePermissionGroup = IPagePermissionBase & {
type: "group";
memberCount: number;
isDefault: boolean;
};
export type IPagePermissionMember = IPagePermissionUser | IPagePermissionGroup;
@@ -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;
}
}
@@ -126,11 +108,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 })}
@@ -17,6 +17,11 @@ import { useTranslation } from "react-i18next";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
import {
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type.ts";
function CommentListWithTabs() {
const { t } = useTranslation();
@@ -33,7 +38,14 @@ function CommentListWithTabs() {
const isCloudEE = useIsCloudEE();
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
const canComment = page?.permissions?.canEdit ?? false;
const spaceRules = space?.membership?.permissions;
const spaceAbility = useSpaceAbility(spaceRules);
const canComment: boolean = spaceAbility.can(
SpaceCaslAction.Manage,
SpaceCaslSubject.Page
);
// Separate active and resolved comments
const { activeComments, resolvedComments } = useMemo(() => {
@@ -42,14 +54,14 @@ function CommentListWithTabs() {
}
const parentComments = comments.items.filter(
(comment: IComment) => comment.parentCommentId === null,
(comment: IComment) => comment.parentCommentId === null
);
const active = parentComments.filter(
(comment: IComment) => !comment.resolvedAt,
(comment: IComment) => !comment.resolvedAt
);
const resolved = parentComments.filter(
(comment: IComment) => comment.resolvedAt,
(comment: IComment) => comment.resolvedAt
);
return { activeComments: active, resolvedComments: resolved };
@@ -77,7 +89,7 @@ function CommentListWithTabs() {
setIsLoading(false);
}
},
[createCommentMutation, page?.id],
[createCommentMutation, page?.id]
);
const renderComments = useCallback(
@@ -119,7 +131,7 @@ function CommentListWithTabs() {
)}
</Paper>
),
[comments, handleAddReply, isLoading, space?.membership?.role],
[comments, handleAddReply, isLoading, space?.membership?.role]
);
if (isCommentsLoading) {
@@ -187,14 +199,7 @@ function CommentListWithTabs() {
}
return (
<div
style={{
height: "85vh",
display: "flex",
flexDirection: "column",
marginTop: "-15px",
}}
>
<div style={{ height: "85vh", display: "flex", flexDirection: "column", marginTop: '-15px' }}>
<Tabs defaultValue="open" variant="default" style={{ flex: "0 0 auto" }}>
<Tabs.List justify="center">
<Tabs.Tab
@@ -268,9 +273,9 @@ const ChildComments = ({
const getChildComments = useCallback(
(parentId: string) =>
comments.items.filter(
(comment: IComment) => comment.parentCommentId === parentId,
(comment: IComment) => comment.parentCommentId === parentId
),
[comments.items],
[comments.items]
);
return (
@@ -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
@@ -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>
@@ -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 =
@@ -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} />
@@ -171,14 +171,11 @@ export function TitleEditor({
}, [pageId]);
useEffect(() => {
if (titleEditor) {
if (userPageEditMode && editable) {
if (userPageEditMode === PageEditMode.Edit) {
titleEditor.setEditable(true);
} else if (userPageEditMode === PageEditMode.Read) {
titleEditor.setEditable(false);
}
} else {
// honor user default page edit mode preference
if (userPageEditMode && titleEditor && editable) {
if (userPageEditMode === PageEditMode.Edit) {
titleEditor.setEditable(true);
} else if (userPageEditMode === PageEditMode.Read) {
titleEditor.setEditable(false);
}
}
@@ -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";
@@ -40,7 +40,6 @@ import { PageStateSegmentedControl } from "@/features/user/components/page-state
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
import ShareModal from "@/features/share/components/share-modal.tsx";
import { PageShareModal } from "@/ee/page-permission";
interface PageHeaderMenuProps {
readOnly?: boolean;
@@ -76,14 +75,12 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
{!readOnly && <PageStateSegmentedControl size="xs" />}
{/*<ShareModal readOnly={readOnly} />*/}
<PageShareModal readOnly={readOnly}/>
<ShareModal readOnly={readOnly} />
<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} />
@@ -92,8 +89,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} />
@@ -169,7 +166,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,
@@ -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}
@@ -22,10 +22,6 @@ export interface IPage {
lastUpdatedBy: ILastUpdatedBy;
deletedBy: IDeletedBy;
space: Partial<ISpace>;
permissions?: {
canEdit: boolean;
hasRestriction: boolean;
};
}
interface ICreator {
@@ -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,
});
}
@@ -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>
@@ -9,7 +9,6 @@ import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
import { useTranslation } from "react-i18next";
interface MultiMemberSelectProps {
value?: string[];
onChange: (value: string[]) => void;
}
@@ -34,7 +33,7 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
</Group>
);
export function MultiMemberSelect({ value, onChange }: MultiMemberSelectProps) {
export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) {
const { t } = useTranslation();
const [searchValue, setSearchValue] = useState("");
const [debouncedQuery] = useDebouncedValue(searchValue, 500);
@@ -86,7 +85,6 @@ export function MultiMemberSelect({ value, onChange }: MultiMemberSelectProps) {
return (
<MultiSelect
data={data}
value={value}
renderOption={renderMultiSelectOption}
hidePickedOptions
maxDropdownHeight={300}
@@ -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) {
@@ -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 {
+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;
}
}
-16
View File
@@ -29,20 +29,4 @@ const APP_ROUTE = {
},
};
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;
+3 -2
View File
@@ -42,8 +42,9 @@ if (isCloud() && isPostHogEnabled) {
});
}
const container = document.getElementById("root") as HTMLElement;
const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container);
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement,
);
root.render(
<BrowserRouter>
+20 -51
View File
@@ -6,13 +6,14 @@ import { Helmet } from "react-helmet-async";
import PageHeader from "@/features/page/components/header/page-header.tsx";
import { extractPageSlugId } from "@/lib";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
import {
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type.ts";
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);
const MemoizedHistoryModal = React.memo(HistoryModal);
@@ -21,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,
@@ -52,7 +30,8 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
} = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
const canEdit = page?.permissions?.canEdit ?? false;
const spaceRules = space?.membership?.permissions;
const spaceAbility = useSpaceAbility(spaceRules);
if (isLoading) {
return <></>;
@@ -60,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) {
@@ -94,7 +55,12 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
<title>{`${page?.icon || ""} ${page?.title || t("untitled")}`}</title>
</Helmet>
<MemoizedPageHeader readOnly={!canEdit} />
<MemoizedPageHeader
readOnly={spaceAbility.cannot(
SpaceCaslAction.Manage,
SpaceCaslSubject.Page,
)}
/>
<MemoizedFullEditor
key={page.id}
@@ -103,7 +69,10 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
content={page.content}
slugId={page.slugId}
spaceSlug={page?.space?.slug}
editable={canEdit}
editable={spaceAbility.can(
SpaceCaslAction.Manage,
SpaceCaslSubject.Page,
)}
/>
<MemoizedHistoryModal pageId={page.id} />
</div>
+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: {},
});
+8 -14
View File
@@ -30,21 +30,19 @@
"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",
"@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^9.4.0",
"@fastify/static": "^9.0.0",
"@keyv/redis": "^5.1.6",
"@langchain/core": "1.1.18",
"@langchain/textsplitters": "1.0.1",
"@nestjs-labs/nestjs-ioredis": "^11.0.4",
"@nestjs/bullmq": "^11.0.4",
"@nestjs/cache-manager": "^3.1.0",
"@nestjs/common": "^11.1.11",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.13",
@@ -61,11 +59,11 @@
"@react-email/components": "1.0.7",
"@react-email/render": "2.0.4",
"@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",
@@ -102,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",
@@ -158,11 +157,6 @@
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node",
"moduleNameMapper": {
"^@docmost/db/(.*)$": "<rootDir>/database/$1",
"^@docmost/transactional/(.*)$": "<rootDir>/integrations/transactional/$1",
"^@docmost/ee/(.*)$": "<rootDir>/ee/$1"
}
"testEnvironment": "node"
}
}
-15
View File
@@ -1,7 +1,6 @@
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { EnvironmentService } from './integrations/environment/environment.service';
import { CoreModule } from './core/core.module';
import { EnvironmentModule } from './integrations/environment/environment.module';
import { CollaborationModule } from './collaboration/collaboration.module';
@@ -19,8 +18,6 @@ import { SecurityModule } from './integrations/security/security.module';
import { TelemetryModule } from './integrations/telemetry/telemetry.module';
import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
import { RedisConfigService } from './integrations/redis/redis-config.service';
import { CacheModule } from '@nestjs/cache-manager';
import KeyvRedis from '@keyv/redis';
import { LoggerModule } from './common/logger/logger.module';
const enterpriseModules = [];
@@ -46,18 +43,6 @@ try {
RedisModule.forRootAsync({
useClass: RedisConfigService,
}),
CacheModule.registerAsync({
isGlobal: true,
useFactory: async (environmentService: EnvironmentService) => {
const redisUrl = environmentService.getRedisUrl();
return {
ttl: 5 * 1000,
stores: [new KeyvRedis(redisUrl)],
};
},
inject: [EnvironmentService],
}),
CollaborationModule,
WsModule,
QueueModule,
@@ -17,7 +17,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 +29,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);
@@ -9,7 +9,6 @@ import { TokenService } from '../../core/auth/services/token.service';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils';
import { SpaceRole } from '../../common/helpers/types/permission';
import { getPageId } from '../collaboration.util';
@@ -24,7 +23,6 @@ export class AuthenticationExtension implements Extension {
private userRepo: UserRepo,
private pageRepo: PageRepo,
private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
) {}
async onAuthenticate(data: onAuthenticatePayload) {
@@ -54,7 +52,7 @@ export class AuthenticationExtension implements Extension {
const page = await this.pageRepo.findById(pageId);
if (!page) {
this.logger.debug(`Page not found: ${pageId}`);
this.logger.warn(`Page not found: ${pageId}`);
throw new NotFoundException('Page not found');
}
@@ -70,34 +68,9 @@ export class AuthenticationExtension implements Extension {
throw new UnauthorizedException();
}
// Check page-level permissions
const { hasAnyRestriction, canAccess, canEdit } =
await this.pagePermissionRepo.canUserEditPage(user.id, page.id);
if (hasAnyRestriction) {
if (!canAccess) {
this.logger.warn(
`User ${user.id} denied page-level access to page: ${pageId}`,
);
throw new UnauthorizedException();
}
if (!canEdit) {
data.connectionConfig.readOnly = true;
this.logger.debug(
`User ${user.id} granted readonly access to restricted page: ${pageId}`,
);
}
} else {
// No restrictions - use space-level permissions
if (userSpaceRole === SpaceRole.READER) {
data.connectionConfig.readOnly = true;
this.logger.debug(`User granted readonly access to page: ${pageId}`);
}
}
if (page.deletedAt) {
if (userSpaceRole === SpaceRole.READER) {
data.connectionConfig.readOnly = true;
this.logger.debug(`User granted readonly access to page: ${pageId}`);
}
this.logger.debug(`Authenticated user ${user.id} on page ${pageId}`);
@@ -19,13 +19,11 @@ import { Queue } from 'bullmq';
import {
extractMentions,
extractPageMentions,
extractUserMentions,
} from '../../common/helpers/prosemirror/utils';
import { isDeepStrictEqual } from 'node:util';
import {
IPageBacklinkJob,
IPageHistoryJob,
IPageMentionNotificationJob,
} from '../../integrations/queue/constants/queue.interface';
import { Page } from '@docmost/db/types/entity.types';
import { CollabHistoryService } from '../services/collab-history.service';
@@ -46,7 +44,6 @@ export class PersistenceExtension implements Extension {
@InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
@InjectQueue(QueueName.HISTORY_QUEUE) private historyQueue: Queue,
@InjectQueue(QueueName.NOTIFICATION_QUEUE) private notificationQueue: Queue,
private readonly collabHistory: CollabHistoryService,
) {}
@@ -173,24 +170,6 @@ export class PersistenceExtension implements Extension {
mentions: pageMentions,
} as IPageBacklinkJob);
const userMentions = extractUserMentions(mentions);
const oldMentions = page.content ? extractMentions(page.content) : [];
const oldMentionedUserIds = extractUserMentions(oldMentions).map((m) => m.entityId);
if (userMentions.length > 0) {
await this.notificationQueue.add(QueueJob.PAGE_MENTION_NOTIFICATION, {
userMentions: userMentions.map((m) => ({
userId: m.entityId,
mentionId: m.id,
creatorId: m.creatorId,
})),
oldMentionedUserIds,
pageId,
spaceId: page.spaceId,
workspaceId: page.workspaceId,
} as IPageMentionNotificationJob);
}
await this.aiQueue.add(QueueJob.PAGE_CONTENT_UPDATED, {
pageIds: [pageId],
workspaceId: page.workspaceId,
@@ -7,7 +7,6 @@ import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { isDeepStrictEqual } from 'node:util';
import { CollabHistoryService } from '../services/collab-history.service';
import { WatcherService } from '../../core/watcher/watcher.service';
@Processor(QueueName.HISTORY_QUEUE)
export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
@@ -17,7 +16,6 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
private readonly pageHistoryRepo: PageHistoryRepo,
private readonly pageRepo: PageRepo,
private readonly collabHistory: CollabHistoryService,
private readonly watcherService: WatcherService,
) {
super();
}
@@ -51,13 +49,6 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
await this.collabHistory.popContributors(pageId);
try {
await this.watcherService.addPageWatchers(
contributorIds,
pageId,
page.spaceId,
page.workspaceId,
);
await this.pageHistoryRepo.saveHistory(page, { contributorIds });
this.logger.debug(`History created for page: ${pageId}`);
} catch (err) {
@@ -7,7 +7,6 @@ import {
import { TransformHttpResponseInterceptor } from '../../common/interceptors/http-response.interceptor';
import { Logger } from '@nestjs/common';
import { Logger as PinoLogger } from 'nestjs-pino';
import { InternalLogFilter } from '../../common/logger/internal-log-filter';
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
@@ -20,7 +19,7 @@ async function bootstrap() {
},
}),
{
logger: new InternalLogFilter(),
logger: false,
bufferLogs: false,
},
);
@@ -9,7 +9,3 @@ export const LOCAL_STORAGE_PATH = path.resolve(
'..',
LOCAL_STORAGE_DIR,
);
export function getPageTitle(title: string | null | undefined): string {
return title || 'untitled';
}
@@ -64,30 +64,6 @@ export function extractPageMentions(mentionList: MentionNode[]): MentionNode[] {
return pageMentionList as MentionNode[];
}
export function extractUserMentionIdsFromJson(json: any): string[] {
const userIds: string[] = [];
function walk(node: any) {
if (!node) return;
if (
node.type === 'mention' &&
node.attrs?.entityType === 'user' &&
node.attrs?.entityId &&
!userIds.includes(node.attrs.entityId)
) {
userIds.push(node.attrs.entityId);
}
if (Array.isArray(node.content)) {
for (const child of node.content) {
walk(child);
}
}
}
walk(json);
return userIds;
}
export function getProsemirrorContent(content: any) {
return (
content ?? {

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