Compare commits

..

45 Commits

Author SHA1 Message Date
Philipinho b3d50558db react19 2026-01-24 01:08:42 +00:00
Philipinho 605bf760be Tiptap v3.17.0 2026-01-24 00:34:31 +00:00
Philipinho c5c9237b00 Merge branch 'main' into tiptap3-migration 2026-01-24 00:18:48 +00:00
Philipinho 511c822a90 fix collab connection status display 2026-01-20 16:17:14 +00:00
Philipinho 7af31fe228 update lock file 2026-01-20 15:26:45 +00:00
Philipinho dd460713ce Merge branch 'feat/asset-placeholders' into tiptap3-migration 2026-01-20 15:21:24 +00:00
Philipinho b0f3bec4d1 * add skeleton pulse animation
* add translation strings
* fix attachment view responsiveness
2026-01-20 15:03:49 +00:00
Arek Nawo 8ea92668b2 fix: Allow media as attachment 2026-01-20 13:52:02 +01:00
Arek Nawo dcf9228344 fix: Drag & drop, paste upload 2026-01-20 13:51:53 +01:00
Arek Nawo dbd1308c72 feat: Multiple file upload, improved placeholders, local previews 2026-01-20 11:10:11 +01:00
Arek Nawo 03ae58253a fix: Use imageDimensionsFromStream 2026-01-19 23:42:13 +01:00
Arek Nawo b99d803b81 fix: Transaction handling on asset upload 2026-01-19 20:53:59 +01:00
Arek Nawo b9b5ddb848 fix: Video view styles 2026-01-19 20:45:45 +01:00
Arek Nawo dd8c42e1f3 feat: Improved placeholder and upload handling for attachments 2026-01-19 20:45:24 +01:00
Arek Nawo 13c29545a2 refactor: Image node and view clean-up 2026-01-19 20:15:14 +01:00
Arek Nawo 0c5c83a17a feat: Improved placeholder and upload handling for videos 2026-01-19 20:14:48 +01:00
Arek Nawo 5bda5623f2 feat: Improved placeholder and upload handling for images 2026-01-19 16:54:24 +01:00
Philipinho 051bc80ab7 cleanup 2026-01-16 17:10:58 +00:00
Philipinho 78d363febb cleanup 2026-01-16 16:59:42 +00:00
Philipinho 8681d9a8c4 Merge branch 'tiptap3-migration' of https://github.com/areknawo/docmost into tiptap3-migration 2026-01-16 15:42:47 +00:00
Philipinho b9543b01bd Merge branch 'main' into tiptap3-migration 2026-01-16 15:42:16 +00:00
Arek Nawo 5510434221 fix: Connect/disconnect websocketProvider 2026-01-09 12:04:45 +01:00
Arek Nawo f671e7a3b9 feat: Update collaboration connection for HocusPocus v3 2026-01-09 01:01:48 +01:00
Arek Nawo 974bcea690 feat: Migrate tippy.js menus to Floating UI 2026-01-09 00:33:28 +01:00
Arek Nawo 601ed88931 fix: Set isInitialized to force immediate react node view rendering 2026-01-08 22:51:28 +01:00
Philipinho cfbaedcd63 Merge branch 'main' into tiptap3-migration 2025-12-16 15:56:00 +00:00
Philipinho 5fc04aa7df fix collaboration caret css 2025-12-13 01:05:34 +00:00
Philipinho c357f169e1 Merge branch 'main' into tiptap3-migration 2025-12-13 00:57:13 +00:00
Philipinho 1cbd2854bb fix menus 2025-09-28 20:51:57 +01:00
Philipinho 3af1482a31 fix converter 2025-09-27 21:10:40 +01:00
Philipinho d31d1f7bbd fix bubble menu 2025-09-27 14:50:27 +01:00
Philipinho cc0146d0cd Merge branch 'main' into tiptap3-migration 2025-09-26 18:53:18 +01:00
Philipinho 83ce9cf240 tiptap 3.6.1 2025-09-26 18:52:49 +01:00
Philipinho e7e85e9fdd merge main 2025-09-10 04:19:04 +01:00
Philipinho 2d710612b1 Merge branch 'main' into tiptap3-migration 2025-09-10 04:00:01 +01:00
Philipinho a0814ef49a add tippyoptions for reference 2025-08-18 13:45:41 -07:00
Philipinho bf17289ab2 fix editable state 2025-08-15 02:06:59 -07:00
Philipinho c2cd412ac7 Switch to useEditorState
- Set shouldRerenderOnTransaction to false
2025-08-15 01:19:05 -07:00
Philipinho 71dfcf6bce update tiptap version 2025-08-14 23:29:48 -07:00
Philipinho 23e8ab032e disable duplicate extensions 2025-08-03 19:01:50 -07:00
Philipinho 0e1d4e5eee fix flicker 2025-08-03 19:01:28 -07:00
Philipinho 6f83f32d5c remove unused code 2025-08-03 18:34:32 -07:00
Philipinho 63ea2f7663 fix collaboration 2025-08-03 18:24:55 -07:00
Philipinho 66a3dad632 Merge branch 'main' into tiptap3-migration 2025-08-03 17:45:02 -07:00
Philipinho 2adc6a60d2 Tiptap3 migration - WIP 2025-08-02 19:09:06 -07:00
148 changed files with 3263 additions and 5749 deletions
+11 -11
View File
@@ -1,7 +1,7 @@
{ {
"name": "client", "name": "client",
"private": true, "private": true,
"version": "0.25.1", "version": "0.24.1",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
@@ -42,15 +42,15 @@
"mermaid": "^11.12.2", "mermaid": "^11.12.2",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"posthog-js": "^1.255.1", "posthog-js": "^1.255.1",
"react": "^18.3.1", "react": "^19.2.3",
"react-arborist": "3.4.0", "react-arborist": "3.4.0",
"react-clear-modal": "^2.0.17", "react-clear-modal": "^2.0.17",
"react-dom": "^18.3.1", "react-dom": "^19.2.3",
"react-drawio": "^1.0.7", "react-drawio": "^1.0.7",
"react-error-boundary": "^4.1.2", "react-error-boundary": "^6.1.0",
"react-helmet-async": "^2.0.5", "react-helmet-async": "^2.0.5",
"react-i18next": "^15.0.1", "react-i18next": "^15.0.1",
"react-router-dom": "^7.12.0", "react-router-dom": "^7.13.0",
"semver": "^7.7.3", "semver": "^7.7.3",
"socket.io-client": "^4.8.3", "socket.io-client": "^4.8.3",
"tiptap-extension-global-drag-handle": "^0.1.18", "tiptap-extension-global-drag-handle": "^0.1.18",
@@ -63,13 +63,13 @@
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/katex": "^0.16.7", "@types/katex": "^0.16.7",
"@types/node": "22.19.1", "@types/node": "22.19.1",
"@types/react": "^18.3.12", "@types/react": "^19.2.9",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.2",
"eslint": "^9.15.0", "eslint": "^9.15.0",
"eslint-plugin-react": "^7.37.2", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.16", "eslint-plugin-react-refresh": "^0.4.26",
"globals": "^15.13.0", "globals": "^15.13.0",
"optics-ts": "^2.4.1", "optics-ts": "^2.4.1",
"postcss": "^8.4.49", "postcss": "^8.4.49",
@@ -29,7 +29,6 @@
"Choose your preferred interface language.": "Wählen Sie Ihre bevorzugte Benutzersprache.", "Choose your preferred interface language.": "Wählen Sie Ihre bevorzugte Benutzersprache.",
"Choose your preferred page width.": "Wählen Sie Ihre bevorzugte Seitenbreite.", "Choose your preferred page width.": "Wählen Sie Ihre bevorzugte Seitenbreite.",
"Confirm": "Bestätigen", "Confirm": "Bestätigen",
"Copy as Markdown": "Als Markdown kopieren",
"Copy link": "Link kopieren", "Copy link": "Link kopieren",
"Create": "Erstellen", "Create": "Erstellen",
"Create group": "Gruppe erstellen", "Create group": "Gruppe erstellen",
@@ -123,8 +122,6 @@
"page": "Seite", "page": "Seite",
"Page deleted successfully": "Seite erfolgreich gelöscht", "Page deleted successfully": "Seite erfolgreich gelöscht",
"Page history": "Seitengeschichte", "Page history": "Seitengeschichte",
"Select version": "Version auswählen",
"Highlight changes": "Änderungen hervorheben",
"Page import is in progress. Please do not close this tab.": "Der Seitenimport läuft. Bitte schließen Sie diesen Tab nicht.", "Page import is in progress. Please do not close this tab.": "Der Seitenimport läuft. Bitte schließen Sie diesen Tab nicht.",
"Pages": "Seiten", "Pages": "Seiten",
"pages": "Seiten", "pages": "Seiten",
@@ -237,9 +234,7 @@
"Anyone with this link can join this workspace.": "Jeder mit diesem Link kann dem Arbeitsbereich beitreten.", "Anyone with this link can join this workspace.": "Jeder mit diesem Link kann dem Arbeitsbereich beitreten.",
"Invite link": "Einladungslink", "Invite link": "Einladungslink",
"Copy": "Kopieren", "Copy": "Kopieren",
"Copy to space": "In Raum kopieren",
"Copied": "Kopiert", "Copied": "Kopiert",
"Duplicate": "Duplizieren",
"Select a user": "Benutzer auswählen", "Select a user": "Benutzer auswählen",
"Select a group": "Gruppe auswählen", "Select a group": "Gruppe auswählen",
"Export all pages and attachments in this space.": "Alle Seiten und Anhänge in diesem Bereich exportieren.", "Export all pages and attachments in this space.": "Alle Seiten und Anhänge in diesem Bereich exportieren.",
@@ -256,7 +251,6 @@
"Export failed:": "Export fehlgeschlagen:", "Export failed:": "Export fehlgeschlagen:",
"export error": "Exportfehler", "export error": "Exportfehler",
"Export page": "Seite exportieren", "Export page": "Seite exportieren",
"Export successful": "Export erfolgreich",
"Export space": "Bereich exportieren", "Export space": "Bereich exportieren",
"Export {{type}}": "Exportiere {{type}}", "Export {{type}}": "Exportiere {{type}}",
"File exceeds the {{limit}} attachment limit": "Datei überschreitet das Anhängelimit von {{limit}}", "File exceeds the {{limit}} attachment limit": "Datei überschreitet das Anhängelimit von {{limit}}",
@@ -332,8 +326,6 @@
"Upload any image from your device.": "Laden Sie ein beliebiges Bild von Ihrem Gerät hoch.", "Upload any image from your device.": "Laden Sie ein beliebiges Bild von Ihrem Gerät hoch.",
"Upload any video from your device.": "Laden Sie ein beliebiges Video von Ihrem Gerät hoch.", "Upload any video from your device.": "Laden Sie ein beliebiges Video von Ihrem Gerät hoch.",
"Upload any file from your device.": "Laden Sie eine beliebige Datei von Ihrem Gerät hoch.", "Upload any file from your device.": "Laden Sie eine beliebige Datei von Ihrem Gerät hoch.",
"Uploading {{name}}": "Lade {{name}} hoch",
"Uploading file": "Datei wird hochgeladen",
"Table": "Tabelle", "Table": "Tabelle",
"Insert a table.": "Tabelle einfügen.", "Insert a table.": "Tabelle einfügen.",
"Insert collapsible block.": "Einklappbaren Block einfügen.", "Insert collapsible block.": "Einklappbaren Block einfügen.",
@@ -29,7 +29,6 @@
"Choose your preferred interface language.": "Choose your preferred interface language.", "Choose your preferred interface language.": "Choose your preferred interface language.",
"Choose your preferred page width.": "Choose your preferred page width.", "Choose your preferred page width.": "Choose your preferred page width.",
"Confirm": "Confirm", "Confirm": "Confirm",
"Copy as Markdown": "Copy as Markdown",
"Copy link": "Copy link", "Copy link": "Copy link",
"Create": "Create", "Create": "Create",
"Create group": "Create group", "Create group": "Create group",
@@ -123,8 +122,6 @@
"page": "page", "page": "page",
"Page deleted successfully": "Page deleted successfully", "Page deleted successfully": "Page deleted successfully",
"Page history": "Page history", "Page history": "Page history",
"Select version": "Select version",
"Highlight changes": "Highlight changes",
"Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.", "Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.",
"Pages": "Pages", "Pages": "Pages",
"pages": "pages", "pages": "pages",
@@ -256,7 +253,6 @@
"Export failed:": "Export failed:", "Export failed:": "Export failed:",
"export error": "export error", "export error": "export error",
"Export page": "Export page", "Export page": "Export page",
"Export successful": "Export successful",
"Export space": "Export space", "Export space": "Export space",
"Export {{type}}": "Export {{type}}", "Export {{type}}": "Export {{type}}",
"File exceeds the {{limit}} attachment limit": "File exceeds the {{limit}} attachment limit", "File exceeds the {{limit}} attachment limit": "File exceeds the {{limit}} attachment limit",
@@ -29,7 +29,6 @@
"Choose your preferred interface language.": "Elige tu idioma de interfaz preferido.", "Choose your preferred interface language.": "Elige tu idioma de interfaz preferido.",
"Choose your preferred page width.": "Elige el ancho de página que prefieras.", "Choose your preferred page width.": "Elige el ancho de página que prefieras.",
"Confirm": "Confirmar", "Confirm": "Confirmar",
"Copy as Markdown": "Copiar como Markdown",
"Copy link": "Copiar enlace", "Copy link": "Copiar enlace",
"Create": "Crear", "Create": "Crear",
"Create group": "Crear grupo", "Create group": "Crear grupo",
@@ -123,8 +122,6 @@
"page": "página", "page": "página",
"Page deleted successfully": "Página eliminada con éxito", "Page deleted successfully": "Página eliminada con éxito",
"Page history": "Historial de la página", "Page history": "Historial de la página",
"Select version": "Seleccionar versión",
"Highlight changes": "Resaltar cambios",
"Page import is in progress. Please do not close this tab.": "La importación de la página está en curso. Por favor, no cierre esta pestaña.", "Page import is in progress. Please do not close this tab.": "La importación de la página está en curso. Por favor, no cierre esta pestaña.",
"Pages": "Páginas", "Pages": "Páginas",
"pages": "páginas", "pages": "páginas",
@@ -256,7 +253,6 @@
"Export failed:": "Exportación fallida:", "Export failed:": "Exportación fallida:",
"export error": "error de exportación", "export error": "error de exportación",
"Export page": "Exportar página", "Export page": "Exportar página",
"Export successful": "Exportación exitosa",
"Export space": "Exportar espacio", "Export space": "Exportar espacio",
"Export {{type}}": "Exportar {{type}}", "Export {{type}}": "Exportar {{type}}",
"File exceeds the {{limit}} attachment limit": "El archivo supera el límite de {{limit}} adjuntos", "File exceeds the {{limit}} attachment limit": "El archivo supera el límite de {{limit}} adjuntos",
@@ -332,8 +328,6 @@
"Upload any image from your device.": "Sube cualquier imagen desde tu dispositivo.", "Upload any image from your device.": "Sube cualquier imagen desde tu dispositivo.",
"Upload any video from your device.": "Sube cualquier video desde tu dispositivo.", "Upload any video from your device.": "Sube cualquier video desde tu dispositivo.",
"Upload any file from your device.": "Sube cualquier archivo desde tu dispositivo.", "Upload any file from your device.": "Sube cualquier archivo desde tu dispositivo.",
"Uploading {{name}}": "Subiendo {{name}}",
"Uploading file": "Subiendo archivo",
"Table": "Tabla", "Table": "Tabla",
"Insert a table.": "Insertar una tabla.", "Insert a table.": "Insertar una tabla.",
"Insert collapsible block.": "Insertar bloque desplegable.", "Insert collapsible block.": "Insertar bloque desplegable.",
@@ -29,7 +29,6 @@
"Choose your preferred interface language.": "Choisissez votre langue d'interface préférée.", "Choose your preferred interface language.": "Choisissez votre langue d'interface préférée.",
"Choose your preferred page width.": "Choisissez votre largeur de page préférée.", "Choose your preferred page width.": "Choisissez votre largeur de page préférée.",
"Confirm": "Confirmer", "Confirm": "Confirmer",
"Copy as Markdown": "Copier comme Markdown",
"Copy link": "Copier le lien", "Copy link": "Copier le lien",
"Create": "Créer", "Create": "Créer",
"Create group": "Créer groupe", "Create group": "Créer groupe",
@@ -123,8 +122,6 @@
"page": "page", "page": "page",
"Page deleted successfully": "Page supprimée avec succès", "Page deleted successfully": "Page supprimée avec succès",
"Page history": "Historique de la page", "Page history": "Historique de la page",
"Select version": "Sélectionner la version",
"Highlight changes": "Mettre en évidence les changements",
"Page import is in progress. Please do not close this tab.": "L'importation de la page est en cours. Veuillez ne pas fermer cet onglet.", "Page import is in progress. Please do not close this tab.": "L'importation de la page est en cours. Veuillez ne pas fermer cet onglet.",
"Pages": "Pages", "Pages": "Pages",
"pages": "pages", "pages": "pages",
@@ -256,7 +253,6 @@
"Export failed:": "Échec de l'exportation :", "Export failed:": "Échec de l'exportation :",
"export error": "exporter l'erreur", "export error": "exporter l'erreur",
"Export page": "Exporter la page", "Export page": "Exporter la page",
"Export successful": "Exportation réussie",
"Export space": "Exporter l'espace", "Export space": "Exporter l'espace",
"Export {{type}}": "Exporter {{type}}", "Export {{type}}": "Exporter {{type}}",
"File exceeds the {{limit}} attachment limit": "Le fichier dépasse la limite de {{limit}} pièces jointes", "File exceeds the {{limit}} attachment limit": "Le fichier dépasse la limite de {{limit}} pièces jointes",
@@ -332,8 +328,6 @@
"Upload any image from your device.": "Téléchargez n'importe quelle image depuis votre appareil.", "Upload any image from your device.": "Téléchargez n'importe quelle image depuis votre appareil.",
"Upload any video from your device.": "Téléchargez n'importe quelle vidéo depuis votre appareil.", "Upload any video from your device.": "Téléchargez n'importe quelle vidéo depuis votre appareil.",
"Upload any file from your device.": "Téléchargez n'importe quel fichier depuis votre appareil.", "Upload any file from your device.": "Téléchargez n'importe quel fichier depuis votre appareil.",
"Uploading {{name}}": "Téléchargement de {{name}}",
"Uploading file": "Téléchargement du fichier",
"Table": "Tableau", "Table": "Tableau",
"Insert a table.": "Insérez un tableau.", "Insert a table.": "Insérez un tableau.",
"Insert collapsible block.": "Insérer un bloc repliable.", "Insert collapsible block.": "Insérer un bloc repliable.",
@@ -29,7 +29,6 @@
"Choose your preferred interface language.": "Scegli la lingua da utilizzare per l'interfaccia.", "Choose your preferred interface language.": "Scegli la lingua da utilizzare per l'interfaccia.",
"Choose your preferred page width.": "Scegli la larghezza della pagina che preferisci.", "Choose your preferred page width.": "Scegli la larghezza della pagina che preferisci.",
"Confirm": "Conferma", "Confirm": "Conferma",
"Copy as Markdown": "Copia come Markdown",
"Copy link": "Copia link", "Copy link": "Copia link",
"Create": "Crea", "Create": "Crea",
"Create group": "Crea gruppo", "Create group": "Crea gruppo",
@@ -123,8 +122,6 @@
"page": "pagina", "page": "pagina",
"Page deleted successfully": "Pagina eliminata con successo", "Page deleted successfully": "Pagina eliminata con successo",
"Page history": "Cronologia della pagina", "Page history": "Cronologia della pagina",
"Select version": "Seleziona versione",
"Highlight changes": "Evidenzia modifiche",
"Page import is in progress. Please do not close this tab.": "L'importazione della pagina è in corso. Si prega di non chiudere questa scheda.", "Page import is in progress. Please do not close this tab.": "L'importazione della pagina è in corso. Si prega di non chiudere questa scheda.",
"Pages": "Pagine", "Pages": "Pagine",
"pages": "pagine", "pages": "pagine",
@@ -256,7 +253,6 @@
"Export failed:": "Esportazione fallita:", "Export failed:": "Esportazione fallita:",
"export error": "errore di esportazione", "export error": "errore di esportazione",
"Export page": "Esporta pagina", "Export page": "Esporta pagina",
"Export successful": "Esportazione riuscita",
"Export space": "Esporta spazio", "Export space": "Esporta spazio",
"Export {{type}}": "Esporta {{type}}", "Export {{type}}": "Esporta {{type}}",
"File exceeds the {{limit}} attachment limit": "Il file supera il limite per gli allegati di {{limit}}", "File exceeds the {{limit}} attachment limit": "Il file supera il limite per gli allegati di {{limit}}",
@@ -332,8 +328,6 @@
"Upload any image from your device.": "Carica un'immagine dal tuo dispositivo.", "Upload any image from your device.": "Carica un'immagine dal tuo dispositivo.",
"Upload any video from your device.": "Carica qualsiasi video dal tuo dispositivo.", "Upload any video from your device.": "Carica qualsiasi video dal tuo dispositivo.",
"Upload any file from your device.": "Carica qualsiasi file dal tuo dispositivo.", "Upload any file from your device.": "Carica qualsiasi file dal tuo dispositivo.",
"Uploading {{name}}": "Caricamento di {{name}}",
"Uploading file": "Caricamento file",
"Table": "Tabella", "Table": "Tabella",
"Insert a table.": "Inserisci una tabella.", "Insert a table.": "Inserisci una tabella.",
"Insert collapsible block.": "Inserisci blocco comprimibile.", "Insert collapsible block.": "Inserisci blocco comprimibile.",
@@ -29,7 +29,6 @@
"Choose your preferred interface language.": "お好みの言語を選択してください", "Choose your preferred interface language.": "お好みの言語を選択してください",
"Choose your preferred page width.": "お好みのページ幅を選択してください", "Choose your preferred page width.": "お好みのページ幅を選択してください",
"Confirm": "確認", "Confirm": "確認",
"Copy as Markdown": "Markdownとしてコピー",
"Copy link": "リンクをコピー", "Copy link": "リンクをコピー",
"Create": "新規作成", "Create": "新規作成",
"Create group": "グループを作成", "Create group": "グループを作成",
@@ -123,8 +122,6 @@
"page": "ページ", "page": "ページ",
"Page deleted successfully": "ページを削除しました", "Page deleted successfully": "ページを削除しました",
"Page history": "ページ履歴", "Page history": "ページ履歴",
"Select version": "バージョンを選択",
"Highlight changes": "変更を強調表示",
"Page import is in progress. Please do not close this tab.": "ページをインポート中です。このタブを閉じないでください", "Page import is in progress. Please do not close this tab.": "ページをインポート中です。このタブを閉じないでください",
"Pages": "ページ", "Pages": "ページ",
"pages": "ページ", "pages": "ページ",
@@ -256,7 +253,6 @@
"Export failed:": "エクスポートに失敗しました:", "Export failed:": "エクスポートに失敗しました:",
"export error": "エクスポートエラー", "export error": "エクスポートエラー",
"Export page": "エクスポートページ", "Export page": "エクスポートページ",
"Export successful": "エクスポート成功",
"Export space": "エクスポートスペース", "Export space": "エクスポートスペース",
"Export {{type}}": "{{type}}をエクスポート", "Export {{type}}": "{{type}}をエクスポート",
"File exceeds the {{limit}} attachment limit": "ファイルが{{limit}}の添付制限を超えています", "File exceeds the {{limit}} attachment limit": "ファイルが{{limit}}の添付制限を超えています",
@@ -332,8 +328,6 @@
"Upload any image from your device.": "デバイスから画像をアップロードします", "Upload any image from your device.": "デバイスから画像をアップロードします",
"Upload any video from your device.": "デバイスから動画をアップロードします", "Upload any video from your device.": "デバイスから動画をアップロードします",
"Upload any file from your device.": "デバイスからファイルをアップロードします", "Upload any file from your device.": "デバイスからファイルをアップロードします",
"Uploading {{name}}": "{{name}} をアップロード中",
"Uploading file": "ファイルをアップロード中",
"Table": "テーブル", "Table": "テーブル",
"Insert a table.": "テーブルを挿入します", "Insert a table.": "テーブルを挿入します",
"Insert collapsible block.": "折りたたみブロックを挿入します", "Insert collapsible block.": "折りたたみブロックを挿入します",
@@ -29,7 +29,6 @@
"Choose your preferred interface language.": "선호하는 인터페이스 언어를 선택하세요.", "Choose your preferred interface language.": "선호하는 인터페이스 언어를 선택하세요.",
"Choose your preferred page width.": "선호하는 페이지 너비를 선택하세요.", "Choose your preferred page width.": "선호하는 페이지 너비를 선택하세요.",
"Confirm": "확인", "Confirm": "확인",
"Copy as Markdown": "Markdown으로 복사",
"Copy link": "링크 복사", "Copy link": "링크 복사",
"Create": "생성", "Create": "생성",
"Create group": "팀 생성", "Create group": "팀 생성",
@@ -123,8 +122,6 @@
"page": "페이지", "page": "페이지",
"Page deleted successfully": "페이지 삭제 완료", "Page deleted successfully": "페이지 삭제 완료",
"Page history": "페이지 기록", "Page history": "페이지 기록",
"Select version": "버전 선택",
"Highlight changes": "변경 사항 강조",
"Page import is in progress. Please do not close this tab.": "페이지 가져오기가 진행 중입니다. 이 탭을 닫지 마세요.", "Page import is in progress. Please do not close this tab.": "페이지 가져오기가 진행 중입니다. 이 탭을 닫지 마세요.",
"Pages": "페이지", "Pages": "페이지",
"pages": "페이지", "pages": "페이지",
@@ -256,7 +253,6 @@
"Export failed:": "내보내기 실패:", "Export failed:": "내보내기 실패:",
"export error": "내보내기 오류", "export error": "내보내기 오류",
"Export page": "페이지 내보내기", "Export page": "페이지 내보내기",
"Export successful": "내보내기 성공",
"Export space": "Space 내보내기", "Export space": "Space 내보내기",
"Export {{type}}": "{{type}} 내보내기", "Export {{type}}": "{{type}} 내보내기",
"File exceeds the {{limit}} attachment limit": "첨부 파일 크기 제한 {{limit}}을 초과했습니다", "File exceeds the {{limit}} attachment limit": "첨부 파일 크기 제한 {{limit}}을 초과했습니다",
@@ -332,8 +328,6 @@
"Upload any image from your device.": "기기에서 이미지를 업로드하세요.", "Upload any image from your device.": "기기에서 이미지를 업로드하세요.",
"Upload any video from your device.": "기기에서 비디오를 업로드하세요.", "Upload any video from your device.": "기기에서 비디오를 업로드하세요.",
"Upload any file from your device.": "기기에서 파일을 업로드하세요.", "Upload any file from your device.": "기기에서 파일을 업로드하세요.",
"Uploading {{name}}": "{{name}} 업로드 중",
"Uploading file": "파일 업로드 중",
"Table": "테이블", "Table": "테이블",
"Insert a table.": "테이블 삽입.", "Insert a table.": "테이블 삽입.",
"Insert collapsible block.": "접을 수 있는 블록 삽입.", "Insert collapsible block.": "접을 수 있는 블록 삽입.",
@@ -29,7 +29,6 @@
"Choose your preferred interface language.": "Kies uw gewenste interfacetaal.", "Choose your preferred interface language.": "Kies uw gewenste interfacetaal.",
"Choose your preferred page width.": "Kies uw gewenste paginabreedte.", "Choose your preferred page width.": "Kies uw gewenste paginabreedte.",
"Confirm": "Bevestig", "Confirm": "Bevestig",
"Copy as Markdown": "Kopiëren als Markdown",
"Copy link": "Link kopiëren", "Copy link": "Link kopiëren",
"Create": "Aanmaken", "Create": "Aanmaken",
"Create group": "Groep aanmaken", "Create group": "Groep aanmaken",
@@ -123,8 +122,6 @@
"page": "pagina", "page": "pagina",
"Page deleted successfully": "Pagina succesvol verwijderd", "Page deleted successfully": "Pagina succesvol verwijderd",
"Page history": "Pagina geschiedenis", "Page history": "Pagina geschiedenis",
"Select version": "Selecteer versie",
"Highlight changes": "Wijzigingen markeren",
"Page import is in progress. Please do not close this tab.": "Importeren van pagina's is bezig. Sluit dit tabblad niet.", "Page import is in progress. Please do not close this tab.": "Importeren van pagina's is bezig. Sluit dit tabblad niet.",
"Pages": "Pagina's", "Pages": "Pagina's",
"pages": "pagina's", "pages": "pagina's",
@@ -256,7 +253,6 @@
"Export failed:": "Exporteren mislukt:", "Export failed:": "Exporteren mislukt:",
"export error": "Exporteer fout", "export error": "Exporteer fout",
"Export page": "Exporteer pagina", "Export page": "Exporteer pagina",
"Export successful": "Export succesvol",
"Export space": "Exporteer ruimte", "Export space": "Exporteer ruimte",
"Export {{type}}": "Exporteer {{type}}", "Export {{type}}": "Exporteer {{type}}",
"File exceeds the {{limit}} attachment limit": "Bestand overschrijdt de bijlagelimiet van {{limit}}", "File exceeds the {{limit}} attachment limit": "Bestand overschrijdt de bijlagelimiet van {{limit}}",
@@ -332,8 +328,6 @@
"Upload any image from your device.": "Upload een afbeelding vanaf uw apparaat.", "Upload any image from your device.": "Upload een afbeelding vanaf uw apparaat.",
"Upload any video from your device.": "Upload een video vanaf uw apparaat.", "Upload any video from your device.": "Upload een video vanaf uw apparaat.",
"Upload any file from your device.": "Upload een bestand vanaf uw apparaat.", "Upload any file from your device.": "Upload een bestand vanaf uw apparaat.",
"Uploading {{name}}": "Uploaden {{name}}",
"Uploading file": "Bestand uploaden",
"Table": "Tabel", "Table": "Tabel",
"Insert a table.": "Voeg een tabel in.", "Insert a table.": "Voeg een tabel in.",
"Insert collapsible block.": "Inklapbaar blok invoegen.", "Insert collapsible block.": "Inklapbaar blok invoegen.",
@@ -29,7 +29,6 @@
"Choose your preferred interface language.": "Escolha o idioma da interface.", "Choose your preferred interface language.": "Escolha o idioma da interface.",
"Choose your preferred page width.": "Escolha a largura preferida da página.", "Choose your preferred page width.": "Escolha a largura preferida da página.",
"Confirm": "Confirmar", "Confirm": "Confirmar",
"Copy as Markdown": "Copiar como Markdown",
"Copy link": "Copiar link", "Copy link": "Copiar link",
"Create": "Criar", "Create": "Criar",
"Create group": "Criar grupo", "Create group": "Criar grupo",
@@ -123,8 +122,6 @@
"page": "página", "page": "página",
"Page deleted successfully": "Página excluída com sucesso", "Page deleted successfully": "Página excluída com sucesso",
"Page history": "Histórico da página", "Page history": "Histórico da página",
"Select version": "Selecionar versão",
"Highlight changes": "Destacar alterações",
"Page import is in progress. Please do not close this tab.": "A importação da página está em andamento. Por favor, não feche esta aba.", "Page import is in progress. Please do not close this tab.": "A importação da página está em andamento. Por favor, não feche esta aba.",
"Pages": "Páginas", "Pages": "Páginas",
"pages": "páginas", "pages": "páginas",
@@ -256,7 +253,6 @@
"Export failed:": "Falha ao exportar:", "Export failed:": "Falha ao exportar:",
"export error": "erro de exportação", "export error": "erro de exportação",
"Export page": "Exportar página", "Export page": "Exportar página",
"Export successful": "Exportação bem-sucedida",
"Export space": "Exportar espaço", "Export space": "Exportar espaço",
"Export {{type}}": "Exportar para {{type}}", "Export {{type}}": "Exportar para {{type}}",
"File exceeds the {{limit}} attachment limit": "O arquivo excede o limite de anexos {{limit}}", "File exceeds the {{limit}} attachment limit": "O arquivo excede o limite de anexos {{limit}}",
@@ -332,8 +328,6 @@
"Upload any image from your device.": "Envie qualquer imagem do seu dispositivo.", "Upload any image from your device.": "Envie qualquer imagem do seu dispositivo.",
"Upload any video from your device.": "Envie qualquer vídeo do seu dispositivo.", "Upload any video from your device.": "Envie qualquer vídeo do seu dispositivo.",
"Upload any file from your device.": "Envie qualquer arquivo do seu dispositivo.", "Upload any file from your device.": "Envie qualquer arquivo do seu dispositivo.",
"Uploading {{name}}": "Enviando {{name}}",
"Uploading file": "Enviando arquivo",
"Table": "Tabela", "Table": "Tabela",
"Insert a table.": "Insira uma tabela.", "Insert a table.": "Insira uma tabela.",
"Insert collapsible block.": "Insira um bloco colapsável.", "Insert collapsible block.": "Insira um bloco colapsável.",
@@ -29,7 +29,6 @@
"Choose your preferred interface language.": "Выберите предпочитаемый язык интерфейса.", "Choose your preferred interface language.": "Выберите предпочитаемый язык интерфейса.",
"Choose your preferred page width.": "Выберите предпочитаемую ширину страницы.", "Choose your preferred page width.": "Выберите предпочитаемую ширину страницы.",
"Confirm": "Подтвердить", "Confirm": "Подтвердить",
"Copy as Markdown": "Копировать как Markdown",
"Copy link": "Копировать ссылку", "Copy link": "Копировать ссылку",
"Create": "Создать", "Create": "Создать",
"Create group": "Создать группу", "Create group": "Создать группу",
@@ -123,8 +122,6 @@
"page": "страница", "page": "страница",
"Page deleted successfully": "Страница успешно удалена", "Page deleted successfully": "Страница успешно удалена",
"Page history": "История страницы", "Page history": "История страницы",
"Select version": "Выбрать версию",
"Highlight changes": "Выделить изменения",
"Page import is in progress. Please do not close this tab.": "Импорт страницы в процессе. Пожалуйста, не закрывайте эту вкладку.", "Page import is in progress. Please do not close this tab.": "Импорт страницы в процессе. Пожалуйста, не закрывайте эту вкладку.",
"Pages": "Страницы", "Pages": "Страницы",
"pages": "страницы", "pages": "страницы",
@@ -256,7 +253,6 @@
"Export failed:": "Экспортирование не удалось:", "Export failed:": "Экспортирование не удалось:",
"export error": "ошибка экспорта", "export error": "ошибка экспорта",
"Export page": "Экспорт страницы", "Export page": "Экспорт страницы",
"Export successful": "Экспорт выполнен успешно",
"Export space": "Экспорт пространства", "Export space": "Экспорт пространства",
"Export {{type}}": "Экспорт {{type}}", "Export {{type}}": "Экспорт {{type}}",
"File exceeds the {{limit}} attachment limit": "Файл превышает лимит вложений {{limit}}", "File exceeds the {{limit}} attachment limit": "Файл превышает лимит вложений {{limit}}",
@@ -332,8 +328,6 @@
"Upload any image from your device.": "Загрузить любое изображение с вашего устройства.", "Upload any image from your device.": "Загрузить любое изображение с вашего устройства.",
"Upload any video from your device.": "Загрузить любое видео с вашего устройства.", "Upload any video from your device.": "Загрузить любое видео с вашего устройства.",
"Upload any file from your device.": "Загрузить любой файл с вашего устройства.", "Upload any file from your device.": "Загрузить любой файл с вашего устройства.",
"Uploading {{name}}": "Загрузка {{name}}",
"Uploading file": "Загрузка файла",
"Table": "Таблица", "Table": "Таблица",
"Insert a table.": "Вставить таблицу.", "Insert a table.": "Вставить таблицу.",
"Insert collapsible block.": "Вставить сворачиваемый блок.", "Insert collapsible block.": "Вставить сворачиваемый блок.",
@@ -29,7 +29,6 @@
"Choose your preferred interface language.": "Оберіть бажану мову інтерфейсу.", "Choose your preferred interface language.": "Оберіть бажану мову інтерфейсу.",
"Choose your preferred page width.": "Оберіть бажану ширину сторінки.", "Choose your preferred page width.": "Оберіть бажану ширину сторінки.",
"Confirm": "Підтвердити", "Confirm": "Підтвердити",
"Copy as Markdown": "Скопіювати як Markdown",
"Copy link": "Копіювати посилання", "Copy link": "Копіювати посилання",
"Create": "Створити", "Create": "Створити",
"Create group": "Створити групу", "Create group": "Створити групу",
@@ -123,8 +122,6 @@
"page": "сторінка", "page": "сторінка",
"Page deleted successfully": "Сторінку успішно видалено", "Page deleted successfully": "Сторінку успішно видалено",
"Page history": "Історія сторінки", "Page history": "Історія сторінки",
"Select version": "Вибрати версію",
"Highlight changes": "Підсвітити зміни",
"Page import is in progress. Please do not close this tab.": "Імпорт сторінки в процесі. Будь ласка, не закривайте цю вкладку.", "Page import is in progress. Please do not close this tab.": "Імпорт сторінки в процесі. Будь ласка, не закривайте цю вкладку.",
"Pages": "Сторінки", "Pages": "Сторінки",
"pages": "сторінки", "pages": "сторінки",
@@ -256,7 +253,6 @@
"Export failed:": "Експортування не вдалося:", "Export failed:": "Експортування не вдалося:",
"export error": "помилка експорту", "export error": "помилка експорту",
"Export page": "Експорт сторінки", "Export page": "Експорт сторінки",
"Export successful": "Експорт виконано успішно",
"Export space": "Експорт простору", "Export space": "Експорт простору",
"Export {{type}}": "Експорт {{type}}", "Export {{type}}": "Експорт {{type}}",
"File exceeds the {{limit}} attachment limit": "Файл перевищує ліміт вкладень {{limit}}", "File exceeds the {{limit}} attachment limit": "Файл перевищує ліміт вкладень {{limit}}",
@@ -332,8 +328,6 @@
"Upload any image from your device.": "Завантажити будь-яке зображення з вашого пристрою.", "Upload any image from your device.": "Завантажити будь-яке зображення з вашого пристрою.",
"Upload any video from your device.": "Завантажити будь-яке відео з вашого пристрою.", "Upload any video from your device.": "Завантажити будь-яке відео з вашого пристрою.",
"Upload any file from your device.": "Завантажити будь-який файл з вашого пристрою.", "Upload any file from your device.": "Завантажити будь-який файл з вашого пристрою.",
"Uploading {{name}}": "Завантаження {{name}}",
"Uploading file": "Завантаження файлу",
"Table": "Таблиця", "Table": "Таблиця",
"Insert a table.": "Вставити таблицю.", "Insert a table.": "Вставити таблицю.",
"Insert collapsible block.": "Вставити блок, що згортається.", "Insert collapsible block.": "Вставити блок, що згортається.",
@@ -29,7 +29,6 @@
"Choose your preferred interface language.": "选择您喜欢的界面语言。", "Choose your preferred interface language.": "选择您喜欢的界面语言。",
"Choose your preferred page width.": "选择您喜欢的页面宽度。", "Choose your preferred page width.": "选择您喜欢的页面宽度。",
"Confirm": "确认", "Confirm": "确认",
"Copy as Markdown": "复制为Markdown",
"Copy link": "复制链接", "Copy link": "复制链接",
"Create": "创建", "Create": "创建",
"Create group": "创建群组", "Create group": "创建群组",
@@ -123,8 +122,6 @@
"page": "个页面", "page": "个页面",
"Page deleted successfully": "页面已成功删除", "Page deleted successfully": "页面已成功删除",
"Page history": "页面历史", "Page history": "页面历史",
"Select version": "选择版本",
"Highlight changes": "突出显示更改",
"Page import is in progress. Please do not close this tab.": "页面导入正在进行中。请不要关闭此标签页。", "Page import is in progress. Please do not close this tab.": "页面导入正在进行中。请不要关闭此标签页。",
"Pages": "页面", "Pages": "页面",
"pages": "个页面", "pages": "个页面",
@@ -256,7 +253,6 @@
"Export failed:": "导出失败:", "Export failed:": "导出失败:",
"export error": "导出出错", "export error": "导出出错",
"Export page": "导出页面", "Export page": "导出页面",
"Export successful": "导出成功",
"Export space": "导出空间", "Export space": "导出空间",
"Export {{type}}": "导出为 {{type}}", "Export {{type}}": "导出为 {{type}}",
"File exceeds the {{limit}} attachment limit": "文件超出了 {{limit}} 类型附件限制", "File exceeds the {{limit}} attachment limit": "文件超出了 {{limit}} 类型附件限制",
@@ -332,8 +328,6 @@
"Upload any image from your device.": "从设备上传任何图像", "Upload any image from your device.": "从设备上传任何图像",
"Upload any video from your device.": "从设备上传任何视频", "Upload any video from your device.": "从设备上传任何视频",
"Upload any file from your device.": "从设备上传任何文件", "Upload any file from your device.": "从设备上传任何文件",
"Uploading {{name}}": "正在上传{{name}}",
"Uploading file": "正在上传文件",
"Table": "表格", "Table": "表格",
"Insert a table.": "插入一个表格", "Insert a table.": "插入一个表格",
"Insert collapsible block.": "插入一个折叠块", "Insert collapsible block.": "插入一个折叠块",
@@ -30,11 +30,9 @@ export default function ExportModal({
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown); const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
const [includeChildren, setIncludeChildren] = useState<boolean>(false); const [includeChildren, setIncludeChildren] = useState<boolean>(false);
const [includeAttachments, setIncludeAttachments] = useState<boolean>(false); const [includeAttachments, setIncludeAttachments] = useState<boolean>(false);
const [isExporting, setIsExporting] = useState<boolean>(false);
const { t } = useTranslation(); const { t } = useTranslation();
const handleExport = async () => { const handleExport = async () => {
setIsExporting(true);
try { try {
if (type === "page") { if (type === "page") {
await exportPage({ await exportPage({
@@ -47,9 +45,6 @@ export default function ExportModal({
if (type === "space") { if (type === "space") {
await exportSpace({ spaceId: id, format, includeAttachments }); await exportSpace({ spaceId: id, format, includeAttachments });
} }
notifications.show({
message: t("Export successful"),
});
onClose(); onClose();
} catch (err) { } catch (err) {
notifications.show({ notifications.show({
@@ -57,8 +52,6 @@ export default function ExportModal({
color: "red", color: "red",
}); });
console.error("export error", err); console.error("export error", err);
} finally {
setIsExporting(false);
} }
}; };
@@ -143,7 +136,7 @@ export default function ExportModal({
<Button onClick={onClose} variant="default"> <Button onClick={onClose} variant="default">
{t("Cancel")} {t("Cancel")}
</Button> </Button>
<Button onClick={handleExport} loading={isExporting}>{t("Export")}</Button> <Button onClick={handleExport}>{t("Export")}</Button>
</Group> </Group>
</Modal.Body> </Modal.Body>
</Modal.Content> </Modal.Content>
@@ -2,17 +2,17 @@ import { Button, Group } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export interface PagePaginationProps { export interface PagePaginationProps {
currentPage: number;
hasPrevPage: boolean; hasPrevPage: boolean;
hasNextPage: boolean; hasNextPage: boolean;
onPrev: () => void; onPageChange: (newPage: number) => void;
onNext: () => void;
} }
export default function Paginate({ export default function Paginate({
currentPage,
hasPrevPage, hasPrevPage,
hasNextPage, hasNextPage,
onPrev, onPageChange,
onNext,
}: PagePaginationProps) { }: PagePaginationProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -25,7 +25,7 @@ export default function Paginate({
<Button <Button
variant="default" variant="default"
size="compact-sm" size="compact-sm"
onClick={onPrev} onClick={() => onPageChange(currentPage - 1)}
disabled={!hasPrevPage} disabled={!hasPrevPage}
> >
{t("Prev")} {t("Prev")}
@@ -34,7 +34,7 @@ export default function Paginate({
<Button <Button
variant="default" variant="default"
size="compact-sm" size="compact-sm"
onClick={onNext} onClick={() => onPageChange(currentPage + 1)}
disabled={!hasNextPage} disabled={!hasNextPage}
> >
{t("Next")} {t("Next")}
@@ -13,7 +13,7 @@ import { getShares } from "@/features/share/services/share-service.ts";
import { getApiKeys } from "@/ee/api-key"; import { getApiKeys } from "@/ee/api-key";
export const prefetchWorkspaceMembers = () => { export const prefetchWorkspaceMembers = () => {
const params: QueryParams = { limit: 100, query: "" }; const params = { limit: 100, page: 1, query: "" } as QueryParams;
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ["workspaceMembers", params], queryKey: ["workspaceMembers", params],
queryFn: () => getWorkspaceMembers(params), queryFn: () => getWorkspaceMembers(params),
@@ -22,15 +22,15 @@ export const prefetchWorkspaceMembers = () => {
export const prefetchSpaces = () => { export const prefetchSpaces = () => {
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ["spaces", {}], queryKey: ["spaces", { page: 1 }],
queryFn: () => getSpaces({}), queryFn: () => getSpaces({ page: 1 }),
}); });
}; };
export const prefetchGroups = () => { export const prefetchGroups = () => {
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ["groups", {}], queryKey: ["groups", { page: 1 }],
queryFn: () => getGroups({}), queryFn: () => getGroups({ page: 1 }),
}); });
}; };
@@ -62,21 +62,21 @@ export const prefetchSsoProviders = () => {
export const prefetchShares = () => { export const prefetchShares = () => {
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ["share-list", {}], queryKey: ["share-list", { page: 1 }],
queryFn: () => getShares({}), queryFn: () => getShares({ page: 1, limit: 100 }),
}); });
}; };
export const prefetchApiKeys = () => { export const prefetchApiKeys = () => {
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ["api-key-list", {}], queryKey: ["api-key-list", { page: 1 }],
queryFn: () => getApiKeys({}), queryFn: () => getApiKeys({ page: 1 }),
}); });
}; };
export const prefetchApiKeyManagement = () => { export const prefetchApiKeyManagement = () => {
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ["api-key-list", { adminView: true }], queryKey: ["api-key-list", { page: 1 }],
queryFn: () => getApiKeys({ adminView: true }), queryFn: () => getApiKeys({ page: 1, adminView: true }),
}); });
}; };
@@ -10,19 +10,19 @@ import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-moda
import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal"; import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal"; import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
import Paginate from "@/components/common/paginate"; import Paginate from "@/components/common/paginate";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate"; import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts"; import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
import { IApiKey } from "@/ee/api-key"; import { IApiKey } from "@/ee/api-key";
export default function UserApiKeys() { export default function UserApiKeys() {
const { t } = useTranslation(); const { t } = useTranslation();
const { cursor, goNext, goPrev } = useCursorPaginate(); const { page, setPage } = usePaginateAndSearch();
const [createModalOpened, setCreateModalOpened] = useState(false); const [createModalOpened, setCreateModalOpened] = useState(false);
const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null); const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null);
const [updateModalOpened, setUpdateModalOpened] = useState(false); const [updateModalOpened, setUpdateModalOpened] = useState(false);
const [revokeModalOpened, setRevokeModalOpened] = useState(false); const [revokeModalOpened, setRevokeModalOpened] = useState(false);
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null); const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
const { data, isLoading } = useGetApiKeysQuery({ cursor }); const { data, isLoading } = useGetApiKeysQuery({ page });
const handleCreateSuccess = (response: IApiKey) => { const handleCreateSuccess = (response: IApiKey) => {
setCreatedApiKey(response); setCreatedApiKey(response);
@@ -65,10 +65,10 @@ export default function UserApiKeys() {
{data?.items.length > 0 && ( {data?.items.length > 0 && (
<Paginate <Paginate
hasPrevPage={data?.meta?.hasPrevPage} currentPage={page}
hasNextPage={data?.meta?.hasNextPage} hasPrevPage={data?.meta.hasPrevPage}
onNext={() => goNext(data?.meta?.nextCursor)} hasNextPage={data?.meta.hasNextPage}
onPrev={goPrev} onPageChange={setPage}
/> />
)} )}
@@ -10,20 +10,20 @@ import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-moda
import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal"; import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal"; import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
import Paginate from "@/components/common/paginate"; import Paginate from "@/components/common/paginate";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate"; import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts"; import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
import { IApiKey } from "@/ee/api-key"; import { IApiKey } from "@/ee/api-key";
import useUserRole from '@/hooks/use-user-role.tsx'; import useUserRole from '@/hooks/use-user-role.tsx';
export default function WorkspaceApiKeys() { export default function WorkspaceApiKeys() {
const { t } = useTranslation(); const { t } = useTranslation();
const { cursor, goNext, goPrev } = useCursorPaginate(); const { page, setPage } = usePaginateAndSearch();
const [createModalOpened, setCreateModalOpened] = useState(false); const [createModalOpened, setCreateModalOpened] = useState(false);
const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null); const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null);
const [updateModalOpened, setUpdateModalOpened] = useState(false); const [updateModalOpened, setUpdateModalOpened] = useState(false);
const [revokeModalOpened, setRevokeModalOpened] = useState(false); const [revokeModalOpened, setRevokeModalOpened] = useState(false);
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null); const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
const { data, isLoading } = useGetApiKeysQuery({ cursor, adminView: true }); const { data, isLoading } = useGetApiKeysQuery({ page, adminView: true });
const { isAdmin } = useUserRole(); const { isAdmin } = useUserRole();
if (!isAdmin) { if (!isAdmin) {
@@ -76,10 +76,10 @@ export default function WorkspaceApiKeys() {
{data?.items.length > 0 && ( {data?.items.length > 0 && (
<Paginate <Paginate
hasPrevPage={data?.meta?.hasPrevPage} currentPage={page}
hasNextPage={data?.meta?.hasNextPage} hasPrevPage={data?.meta.hasPrevPage}
onNext={() => goNext(data?.meta?.nextCursor)} hasNextPage={data?.meta.hasNextPage}
onPrev={goPrev} onPageChange={setPage}
/> />
)} )}
@@ -43,7 +43,7 @@ export default function SsoProviderList() {
return null; return null;
} }
if (data?.items.length === 0) { if (data?.length === 0) {
return <Text c="dimmed">{t("No SSO providers found.")}</Text>; return <Text c="dimmed">{t("No SSO providers found.")}</Text>;
} }
@@ -81,7 +81,7 @@ export default function SsoProviderList() {
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
{data?.items {data
.sort((a, b) => { .sort((a, b) => {
const enabledDiff = Number(b.isEnabled) - Number(a.isEnabled); const enabledDiff = Number(b.isEnabled) - Number(a.isEnabled);
if (enabledDiff !== 0) return enabledDiff; if (enabledDiff !== 0) return enabledDiff;
@@ -104,11 +104,7 @@ export default function SsoProviderList() {
</Group> </Group>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Badge <Badge color={"gray"} variant="light" style={{ whiteSpace: "nowrap" }}>
color={"gray"}
variant="light"
style={{ whiteSpace: "nowrap" }}
>
{provider.type.toUpperCase()} {provider.type.toUpperCase()}
</Badge> </Badge>
</Table.Td> </Table.Td>
@@ -138,41 +134,41 @@ export default function SsoProviderList() {
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Group gap="xs" wrap="nowrap"> <Group gap="xs" wrap="nowrap">
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color="gray" color="gray"
onClick={() => handleEdit(provider)} onClick={() => handleEdit(provider)}
> >
<IconPencil size={16} /> <IconPencil size={16} />
</ActionIcon> </ActionIcon>
<Menu <Menu
transitionProps={{ transition: "pop" }} transitionProps={{ transition: "pop" }}
withArrow withArrow
position="bottom-end" position="bottom-end"
withinPortal withinPortal
> >
<Menu.Target> <Menu.Target>
<ActionIcon variant="subtle" color="gray"> <ActionIcon variant="subtle" color="gray">
<IconDots size={16} /> <IconDots size={16} />
</ActionIcon> </ActionIcon>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Item <Menu.Item
onClick={() => handleEdit(provider)} onClick={() => handleEdit(provider)}
leftSection={<IconPencil size={16} />} leftSection={<IconPencil size={16} />}
> >
{t("Edit")} {t("Edit")}
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item
onClick={() => openDeleteModal(provider.id)} onClick={() => openDeleteModal(provider.id)}
leftSection={<IconTrash size={16} />} leftSection={<IconTrash size={16} />}
color="red" color="red"
disabled={provider.type === SSO_PROVIDER.GOOGLE} disabled={provider.type === SSO_PROVIDER.GOOGLE}
> >
{t("Delete")} {t("Delete")}
</Menu.Item> </Menu.Item>
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
</Group> </Group>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
@@ -13,9 +13,8 @@ import {
} from "@/ee/security/services/security-service.ts"; } from "@/ee/security/services/security-service.ts";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { IAuthProvider } from "@/ee/security/types/security.types.ts"; import { IAuthProvider } from "@/ee/security/types/security.types.ts";
import { IPagination } from "@/lib/types.ts";
export function useGetSsoProviders(): UseQueryResult<IPagination<IAuthProvider>, Error> { export function useGetSsoProviders(): UseQueryResult<IAuthProvider[], Error> {
return useQuery({ return useQuery({
queryKey: ["sso-providers"], queryKey: ["sso-providers"],
queryFn: () => getSsoProviders(), queryFn: () => getSsoProviders(),
@@ -1,6 +1,5 @@
import api from "@/lib/api-client.ts"; import api from "@/lib/api-client.ts";
import { IAuthProvider } from "@/ee/security/types/security.types.ts"; import { IAuthProvider } from "@/ee/security/types/security.types.ts";
import { IPagination } from "@/lib/types.ts";
export async function getSsoProviderById(data: { export async function getSsoProviderById(data: {
providerId: string; providerId: string;
@@ -9,8 +8,8 @@ export async function getSsoProviderById(data: {
return req.data; return req.data;
} }
export async function getSsoProviders(): Promise<IPagination<IAuthProvider>> { export async function getSsoProviders(): Promise<IAuthProvider[]> {
const req = await api.post<IPagination<IAuthProvider>>("/sso/providers"); const req = await api.post<IAuthProvider[]>("/sso/providers");
return req.data; return req.data;
} }
@@ -164,7 +164,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
const enterHandler = () => { const enterHandler = () => {
if (!renderItems.length) return; if (!renderItems.length) return;
if (renderItems[selectedIndex]?.entityType !== "header") { if (renderItems[selectedIndex].entityType !== "header") {
selectItem(selectedIndex); selectItem(selectedIndex);
} }
}; };
@@ -77,7 +77,7 @@ const mentionRenderItems = () => {
{ {
placement: "bottom-start", placement: "bottom-start",
middleware: [offset(0), flip(), shift()], middleware: [offset(0), flip(), shift()],
}, }
).then(({ x, y }) => { ).then(({ x, y }) => {
Object.assign(element.style, { Object.assign(element.style, {
left: `${x}px`, left: `${x}px`,
@@ -86,7 +86,7 @@ const mentionRenderItems = () => {
zIndex: "9999", zIndex: "9999",
}); });
}); });
}, }
); );
}, },
onUpdate: (props: { onUpdate: (props: {
@@ -115,30 +115,23 @@ const mentionRenderItems = () => {
// destroy component if space is greater 3 without a match // destroy component if space is greater 3 without a match
if ( if (
whitespaceCount > 4 && whitespaceCount > 3 &&
//@ts-ignore //@ts-ignore
props.editor.storage.mentionItems.length === 1 props.editor.storage.mentionItems.length === 0
) { ) {
destroy(); destroy();
return; return;
} }
// fallback exit
if (whitespaceCount > 7) {
destroy();
return;
}
}, },
onKeyDown: (props: { event: KeyboardEvent }) => { onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") { if (props.event.key)
destroy(); if (
return true; props.event.key === "Escape" ||
} (props.event.key === "Enter" && !component)
) {
if (props.event.key === "Enter" && !component) { destroy();
destroy(); return false;
return false; }
}
return (component?.ref as any)?.onKeyDown(props); return (component?.ref as any)?.onKeyDown(props);
}, },
onExit: () => { onExit: () => {
@@ -1,7 +1,7 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { posToDOMRect, findParentNode } from "@tiptap/react"; import { posToDOMRect, findParentNode } from "@tiptap/react";
import { Node as PMNode } from "@tiptap/pm/model"; import { Node as PMNode } from "@tiptap/pm/model";
import React, { useCallback } from "react"; import React, { JSX, useCallback } from "react";
import { ActionIcon, Tooltip } from "@mantine/core"; import { ActionIcon, Tooltip } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react"; import { IconTrash } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -1,4 +1,4 @@
import React, { useCallback } from "react"; import React, { JSX, useCallback } from "react";
import { import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
@@ -1,6 +1,6 @@
import { posToDOMRect, findParentNode } from "@tiptap/react"; import { posToDOMRect, findParentNode } from "@tiptap/react";
import { Node as PMNode } from "@tiptap/pm/model"; import { Node as PMNode } from "@tiptap/pm/model";
import React, { useCallback } from "react"; import React, { JSX, useCallback } from "react";
import { import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
@@ -248,7 +248,7 @@ export const mainExtensions = [
Escape: () => { Escape: () => {
const event = new CustomEvent("closeFindDialogFromEditor", {}); const event = new CustomEvent("closeFindDialogFromEditor", {});
document.dispatchEvent(event); document.dispatchEvent(event);
return false; return true;
}, },
}; };
}, },
@@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { EditorProvider } from "@tiptap/react"; import { EditorProvider } from "@tiptap/react";
import { mainExtensions } from "@/features/editor/extensions/extensions"; import { mainExtensions } from "@/features/editor/extensions/extensions";
import { Document } from "@tiptap/extension-document"; import { Document } from "@tiptap/extension-document";
import { Heading, UniqueID } from "@docmost/editor-ext"; import { Heading, generateNodeId, UniqueID } from "@docmost/editor-ext";
import { Text } from "@tiptap/extension-text"; import { Text } from "@tiptap/extension-text";
import { Placeholder } from "@tiptap/extension-placeholder"; import { Placeholder } from "@tiptap/extension-placeholder";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
@@ -8,7 +8,7 @@
} }
.mantine-AppShell-main { .mantine-AppShell-main {
padding: 0 !important; padding-top: 0 !important;
min-height: auto !important; min-height: auto !important;
} }
@@ -1,25 +1,26 @@
import { Table, Group, Text, Anchor } from "@mantine/core"; import { Table, Group, Text, Anchor } from "@mantine/core";
import { useGetGroupsQuery } from "@/features/group/queries/group-query"; import { useGetGroupsQuery } from "@/features/group/queries/group-query";
import { useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx"; import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatMemberCount } from "@/lib"; import { formatMemberCount } from "@/lib";
import { IGroup } from "@/features/group/types/group.types.ts"; import { IGroup } from "@/features/group/types/group.types.ts";
import Paginate from "@/components/common/paginate.tsx"; import Paginate from "@/components/common/paginate.tsx";
import { queryClient } from "@/main.tsx"; import { queryClient } from "@/main.tsx";
import { getSpaces } from "@/features/space/services/space-service.ts";
import { getGroupMembers } from "@/features/group/services/group-service.ts"; import { getGroupMembers } from "@/features/group/services/group-service.ts";
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx"; import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
export default function GroupList() { export default function GroupList() {
const { t } = useTranslation(); const { t } = useTranslation();
const { cursor, goNext, goPrev } = useCursorPaginate(); const [page, setPage] = useState(1);
const { data, isLoading } = useGetGroupsQuery({ cursor }); const { data, isLoading } = useGetGroupsQuery({ page });
const prefetchGroupMembers = (groupId: string) => { const prefetchGroupMembers = (groupId: string) => {
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ["groupMembers", groupId, {}], queryKey: ["groupMembers", groupId, { page: 1 }],
queryFn: () => getGroupMembers(groupId, {}), queryFn: () => getGroupMembers(groupId, { page: 1 }),
}); });
}; };
@@ -84,10 +85,10 @@ export default function GroupList() {
{data?.items.length > 0 && ( {data?.items.length > 0 && (
<Paginate <Paginate
hasPrevPage={data?.meta?.hasPrevPage} currentPage={page}
hasNextPage={data?.meta?.hasNextPage} hasPrevPage={data?.meta.hasPrevPage}
onNext={() => goNext(data?.meta?.nextCursor)} hasNextPage={data?.meta.hasNextPage}
onPrev={goPrev} onPageChange={setPage}
/> />
)} )}
</> </>
@@ -4,7 +4,7 @@ import {
useRemoveGroupMemberMutation, useRemoveGroupMemberMutation,
} from "@/features/group/queries/group-query"; } from "@/features/group/queries/group-query";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import React from "react"; import React, { useState } from "react";
import { IconDots } from "@tabler/icons-react"; import { IconDots } from "@tabler/icons-react";
import { modals } from "@mantine/modals"; import { modals } from "@mantine/modals";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
@@ -12,13 +12,12 @@ import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { IUser } from "@/features/user/types/user.types.ts"; import { IUser } from "@/features/user/types/user.types.ts";
import Paginate from "@/components/common/paginate.tsx"; import Paginate from "@/components/common/paginate.tsx";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
export default function GroupMembersList() { export default function GroupMembersList() {
const { t } = useTranslation(); const { t } = useTranslation();
const { groupId } = useParams(); const { groupId } = useParams();
const { cursor, goNext, goPrev } = useCursorPaginate(); const [page, setPage] = useState(1);
const { data, isLoading } = useGroupMembersQuery(groupId, { cursor }); const { data, isLoading } = useGroupMembersQuery(groupId, { page });
const removeGroupMember = useRemoveGroupMemberMutation(); const removeGroupMember = useRemoveGroupMemberMutation();
const { isAdmin } = useUserRole(); const { isAdmin } = useUserRole();
@@ -108,10 +107,10 @@ export default function GroupMembersList() {
{data?.items.length > 0 && ( {data?.items.length > 0 && (
<Paginate <Paginate
hasPrevPage={data?.meta?.hasPrevPage} currentPage={page}
hasNextPage={data?.meta?.hasNextPage} hasPrevPage={data?.meta.hasPrevPage}
onNext={() => goNext(data?.meta?.nextCursor)} hasNextPage={data?.meta.hasNextPage}
onPrev={goPrev} onPageChange={setPage}
/> />
)} )}
</> </>
@@ -1,9 +1,4 @@
import { atom } from "jotai"; import { atom } from "jotai";
export const historyAtoms = atom<boolean>(false); export const historyAtoms = atom<boolean>(false);
export const activeHistoryIdAtom = atom<string>(""); export const activeHistoryIdAtom = atom<string>('');
export const activeHistoryPrevIdAtom = atom<string>("");
export const highlightChangesAtom = atom<boolean>(true);
export type DiffCounts = { added: number; deleted: number; total: number };
export const diffCountsAtom = atom<DiffCounts | null>(null);
@@ -1,69 +0,0 @@
.container {
display: flex;
flex-direction: column;
height: calc(100vh - 60px);
position: relative;
overflow: hidden;
}
.selectorWrapper {
padding: var(--mantine-spacing-sm);
border-bottom: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
flex-shrink: 0;
}
.selector {
width: 100%;
text-align: left;
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
cursor: pointer;
&:hover {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5));
}
}
.dropdown {
max-height: rem(300px);
}
.option {
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
&[data-combobox-selected] {
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
}
&:hover {
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
}
}
.editorArea {
flex: 1;
min-height: 0;
}
.editorContent {
padding: var(--mantine-spacing-md);
padding-bottom: rem(60px);
}
.actionButtons {
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
padding-bottom: rem(70px);
border-top: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
flex-shrink: 0;
}
.floatingBar {
position: fixed;
bottom: var(--mantine-spacing-md);
left: 50%;
transform: translateX(-50%);
z-index: 100;
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
white-space: nowrap;
}
@@ -1,79 +0,0 @@
.history {
display: block;
width: 100%;
padding: var(--mantine-spacing-md);
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
@mixin hover {
background-color: light-dark(
var(--mantine-color-gray-2),
var(--mantine-color-dark-8)
);
}
}
.historyEditor {
:global(.ProseMirror) {
padding: 0 !important;
}
& :global(.history-diff-added) {
background: light-dark(#e1f3f2, #01654a) !important;
color: light-dark(#007b69, #cafff7) !important;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
}
& :global(.history-diff-deleted) {
text-decoration: line-through;
color: light-dark(var(--mantine-color-red-7), var(--mantine-color-red-4));
background: light-dark(var(--mantine-color-red-0), rgba(255, 0, 0, 0.1));
border-radius: rem(2px);
padding: 0 rem(2px);
}
& :global(.history-diff-node-added) {
outline: rem(2px) solid
light-dark(var(--mantine-color-teal-5), var(--mantine-color-teal-7));
outline-offset: rem(2px);
border-radius: rem(4px);
}
& :global(.history-diff-node-deleted) {
opacity: 0.5;
outline: rem(2px) dashed
light-dark(var(--mantine-color-red-4), var(--mantine-color-red-6));
outline-offset: rem(4px);
border-radius: rem(4px);
}
}
.active {
background-color: light-dark(
var(--mantine-color-gray-2),
var(--mantine-color-dark-8)
);
}
.sidebar {
max-height: rem(700px);
width: rem(250px);
padding: var(--mantine-spacing-sm);
display: flex;
flex-direction: column;
border-right: rem(1px) solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
.sidebarFlex {
display: flex;
}
.sidebarMain {
flex: 1;
}
.sidebarRightSection {
flex: 1;
padding: rem(16px) rem(40px);
}
@@ -1,203 +1,33 @@
import "@/features/editor/styles/index.css"; import '@/features/editor/styles/index.css';
import { useEffect } from "react"; import React, { useEffect } from 'react';
import { EditorContent, useEditor } from "@tiptap/react"; import { EditorContent, useEditor } from '@tiptap/react';
import { mainExtensions } from "@/features/editor/extensions/extensions"; import { mainExtensions } from '@/features/editor/extensions/extensions';
import { Title } from "@mantine/core"; import { Title } from '@mantine/core';
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import historyClasses from "./css/history.module.css";
import { recreateTransform } from "@docmost/editor-ext";
import { DOMSerializer, Node } from "@tiptap/pm/model";
import { ChangeSet, simplifyChanges } from "@tiptap/pm/changeset";
import { useAtom } from "jotai";
import {
diffCountsAtom,
highlightChangesAtom,
} from "@/features/page-history/atoms/history-atoms";
export interface HistoryEditorProps { export interface HistoryEditorProps {
title: string; title: string;
content: any; content: any;
previousContent?: any;
} }
export function HistoryEditor({ export function HistoryEditor({ title, content }: HistoryEditorProps) {
title,
content,
previousContent,
}: HistoryEditorProps) {
const [highlightChanges] = useAtom(highlightChangesAtom);
const [, setDiffCounts] = useAtom(diffCountsAtom);
const editor = useEditor({ const editor = useEditor({
extensions: mainExtensions, extensions: mainExtensions,
editable: false, editable: false,
}); });
useEffect(() => { useEffect(() => {
if (!editor || !content) return; if (editor && content) {
let decorationSet = DecorationSet.empty;
let addedCount = 0;
let deletedCount = 0;
if (previousContent) {
try {
const schema = editor.schema;
const oldContent = Node.fromJSON(schema, previousContent);
const newContent = Node.fromJSON(schema, content);
const tr = recreateTransform(oldContent, newContent, {
complexSteps: false,
wordDiffs: true,
simplifyDiff: true,
});
const changeSet = ChangeSet.create(oldContent).addSteps(
tr.doc,
tr.mapping.maps,
[],
);
const changes = simplifyChanges(changeSet.changes, newContent);
editor.commands.setContent(content);
const specialNodeTypes = new Set([
"image",
"attachment",
"video",
"excalidraw",
"drawio",
"mermaid",
"mathBlock",
"mathInline",
"table",
"details",
"callout",
]);
const decorations: Decoration[] = [];
let changeIndex = 0;
for (const change of changes) {
if (change.toB > change.fromB) {
changeIndex++;
const currentIndex = changeIndex;
let foundSpecialNode: { node: Node; pos: number } | null = null;
newContent.nodesBetween(change.fromB, change.toB, (node, pos) => {
if (specialNodeTypes.has(node.type.name)) {
const nodeEnd = pos + node.nodeSize;
if (change.fromB <= pos && change.toB >= nodeEnd) {
foundSpecialNode = { node, pos };
return false;
}
}
});
if (foundSpecialNode) {
const nodeEnd =
foundSpecialNode.pos + foundSpecialNode.node.nodeSize;
decorations.push(
Decoration.node(foundSpecialNode.pos, nodeEnd, {
class: "history-diff-node-added",
"data-diff-index": String(currentIndex),
}),
);
} else {
decorations.push(
Decoration.inline(change.fromB, change.toB, {
class: "history-diff-added",
"data-diff-index": String(currentIndex),
}),
);
}
addedCount += 1;
}
if (change.toA > change.fromA) {
changeIndex++;
const currentIndex = changeIndex;
let foundDeletedNode: { node: Node; pos: number } | null = null;
oldContent.nodesBetween(change.fromA, change.toA, (node, pos) => {
if (specialNodeTypes.has(node.type.name)) {
const nodeEnd = pos + node.nodeSize;
if (change.fromA <= pos && change.toA >= nodeEnd) {
foundDeletedNode = { node, pos };
return false;
}
}
});
if (foundDeletedNode) {
decorations.push(
Decoration.widget(change.fromB, () => {
const wrapper = document.createElement("div");
wrapper.className = "history-diff-node-deleted";
wrapper.setAttribute("data-diff-index", String(currentIndex));
const serializer = DOMSerializer.fromSchema(schema);
const dom = serializer.serializeNode(foundDeletedNode!.node);
wrapper.appendChild(dom);
return wrapper;
}),
);
} else {
const deletedText = oldContent.textBetween(
change.fromA,
change.toA,
"",
);
if (deletedText) {
decorations.push(
Decoration.widget(change.fromB, () => {
const span = document.createElement("span");
span.className = "history-diff-deleted";
span.setAttribute("data-diff-index", String(currentIndex));
span.textContent = deletedText;
return span;
}),
);
}
}
deletedCount += 1;
}
}
decorationSet = DecorationSet.create(newContent, decorations);
} catch (e) {
console.error("History diff failed:", e);
editor.commands.setContent(content);
}
} else {
editor.commands.setContent(content); editor.commands.setContent(content);
} }
}, [title, content, editor]);
const total = addedCount + deletedCount;
// @ts-ignore
setDiffCounts({ added: addedCount, deleted: deletedCount, total });
editor.setOptions({
editorProps: {
...editor.options.editorProps,
decorations: () =>
highlightChanges ? decorationSet : DecorationSet.empty,
},
});
}, [
title,
content,
editor,
previousContent,
highlightChanges,
setDiffCounts,
]);
return ( return (
<div> <>
<Title order={1}>{title}</Title> <div>
{editor && ( <Title order={1}>{title}</Title>
<EditorContent
editor={editor} {editor && <EditorContent editor={editor} />}
className={historyClasses.historyEditor} </div>
/> </>
)}
</div>
); );
} }
@@ -1,42 +1,20 @@
import { Text, Group, UnstyledButton } from "@mantine/core"; import { Text, Group, UnstyledButton } from "@mantine/core";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { formattedDate } from "@/lib/time"; import { formattedDate } from "@/lib/time";
import classes from "./css/history.module.css"; import classes from "./history.module.css";
import clsx from "clsx"; import clsx from "clsx";
import { IPageHistory } from "@/features/page-history/types/page.types";
import { memo, useCallback } from "react";
interface HistoryItemProps { interface HistoryItemProps {
historyItem: IPageHistory; historyItem: any;
index: number; onSelect: (id: string) => void;
onSelect: (id: string, index: number) => void;
onHover?: (id: string, index: number) => void;
onHoverEnd?: () => void;
isActive: boolean; isActive: boolean;
} }
const HistoryItem = memo(function HistoryItem({ function HistoryItem({ historyItem, onSelect, isActive }: HistoryItemProps) {
historyItem,
index,
onSelect,
onHover,
onHoverEnd,
isActive,
}: HistoryItemProps) {
const handleClick = useCallback(() => {
onSelect(historyItem.id, index);
}, [onSelect, historyItem.id, index]);
const handleMouseEnter = useCallback(() => {
onHover?.(historyItem.id, index);
}, [onHover, historyItem.id, index]);
return ( return (
<UnstyledButton <UnstyledButton
p="xs" p="xs"
onClick={handleClick} onClick={() => onSelect(historyItem.id)}
onMouseEnter={handleMouseEnter}
onMouseLeave={onHoverEnd}
className={clsx(classes.history, { [classes.active]: isActive })} className={clsx(classes.history, { [classes.active]: isActive })}
> >
<Group wrap="nowrap"> <Group wrap="nowrap">
@@ -49,11 +27,11 @@ const HistoryItem = memo(function HistoryItem({
<Group gap={4} wrap="nowrap"> <Group gap={4} wrap="nowrap">
<CustomAvatar <CustomAvatar
size="sm" size="sm"
avatarUrl={historyItem.lastUpdatedBy?.avatarUrl} avatarUrl={historyItem.lastUpdatedBy.avatarUrl}
name={historyItem.lastUpdatedBy?.name} name={historyItem.lastUpdatedBy.name}
/> />
<Text size="sm" c="dimmed" lineClamp={1}> <Text size="sm" c="dimmed" lineClamp={1}>
{historyItem.lastUpdatedBy?.name} {historyItem.lastUpdatedBy.name}
</Text> </Text>
</Group> </Group>
</div> </div>
@@ -61,6 +39,6 @@ const HistoryItem = memo(function HistoryItem({
</Group> </Group>
</UnstyledButton> </UnstyledButton>
); );
}); }
export default HistoryItem; export default HistoryItem;
@@ -1,27 +1,29 @@
import { import {
usePageHistoryListQuery, usePageHistoryListQuery,
prefetchPageHistory, usePageHistoryQuery,
} from "@/features/page-history/queries/page-history-query"; } from "@/features/page-history/queries/page-history-query";
import HistoryItem from "@/features/page-history/components/history-item"; import HistoryItem from "@/features/page-history/components/history-item";
import { import {
activeHistoryIdAtom, activeHistoryIdAtom,
activeHistoryPrevIdAtom,
historyAtoms, historyAtoms,
} from "@/features/page-history/atoms/history-atoms"; } from "@/features/page-history/atoms/history-atoms";
import { useAtom, useSetAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useRef } from "react"; import { useCallback, useEffect } from "react";
import { Button, ScrollArea, Group, Divider, Text } from "@mantine/core";
import { import {
Button, pageEditorAtom,
ScrollArea, titleEditorAtom,
Group, } from "@/features/editor/atoms/editor-atoms";
Divider, import { modals } from "@mantine/modals";
Loader, import { notifications } from "@mantine/notifications";
Center,
} from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useHistoryRestore } from "@/features/page-history/hooks"; import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
const PREFETCH_DELAY_MS = 150; import { useParams } from "react-router-dom";
import {
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type.ts";
interface Props { interface Props {
pageId: string; pageId: string;
@@ -30,89 +32,62 @@ interface Props {
function HistoryList({ pageId }: Props) { function HistoryList({ pageId }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom); const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
const setActiveHistoryPrevId = useSetAtom(activeHistoryPrevIdAtom);
const setHistoryModalOpen = useSetAtom(historyAtoms);
const { const {
data: pageHistoryData, data: pageHistoryList,
isLoading, isLoading,
isError, isError,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = usePageHistoryListQuery(pageId); } = usePageHistoryListQuery(pageId);
const { data: activeHistoryData } = usePageHistoryQuery(activeHistoryId);
const historyItems = useMemo( const [mainEditor] = useAtom(pageEditorAtom);
() => pageHistoryData?.pages.flatMap((page) => page.items) ?? [], const [mainEditorTitle] = useAtom(titleEditorAtom);
[pageHistoryData], const [, setHistoryModalOpen] = useAtom(historyAtoms);
);
const loadMoreRef = useRef<HTMLDivElement>(null); const { spaceSlug } = useParams();
const prefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const { data: space } = useSpaceQuery(spaceSlug);
const spaceRules = space?.membership?.permissions;
const spaceAbility = useSpaceAbility(spaceRules);
const { canRestore, confirmRestore } = useHistoryRestore(); const confirmModal = () =>
modals.openConfirmModal({
title: t("Please confirm your action"),
children: (
<Text size="sm">
{t(
"Are you sure you want to restore this version? Any changes not versioned will be lost.",
)}
</Text>
),
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
onConfirm: handleRestore,
});
const clearPrefetchTimeout = useCallback(() => { const handleRestore = useCallback(() => {
if (prefetchTimeoutRef.current) { if (activeHistoryData) {
clearTimeout(prefetchTimeoutRef.current); mainEditorTitle
prefetchTimeoutRef.current = null; .chain()
.clearContent()
.setContent(activeHistoryData.title, { emitUpdate: true })
.run();
mainEditor
.chain()
.clearContent()
.setContent(activeHistoryData.content)
.run();
setHistoryModalOpen(false);
notifications.show({ message: t("Successfully restored") });
} }
}, []); }, [activeHistoryData]);
const handleHover = useCallback(
(historyId: string, index: number) => {
clearPrefetchTimeout();
prefetchTimeoutRef.current = setTimeout(() => {
prefetchPageHistory(historyId);
const prevId = historyItems[index + 1]?.id;
if (prevId) {
prefetchPageHistory(prevId);
}
}, PREFETCH_DELAY_MS);
},
[clearPrefetchTimeout, historyItems],
);
useEffect(() => { useEffect(() => {
return clearPrefetchTimeout; if (
}, [clearPrefetchTimeout]); pageHistoryList &&
pageHistoryList.items.length > 0 &&
const handleSelect = useCallback( !activeHistoryId
(id: string, index: number) => { ) {
setActiveHistoryId(id); setActiveHistoryId(pageHistoryList.items[0].id);
setActiveHistoryPrevId(historyItems[index + 1]?.id ?? "");
},
[historyItems, setActiveHistoryId, setActiveHistoryPrevId],
);
useEffect(() => {
if (historyItems.length > 0 && !activeHistoryId) {
setActiveHistoryId(historyItems[0].id);
setActiveHistoryPrevId(historyItems[1]?.id ?? "");
} }
}, [ }, [pageHistoryList]);
historyItems,
activeHistoryId,
setActiveHistoryId,
setActiveHistoryPrevId,
]);
useEffect(() => {
const sentinel = loadMoreRef.current;
if (!sentinel || !hasNextPage) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !isFetchingNextPage) {
fetchNextPage();
}
},
{ threshold: 0.1 },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
if (isLoading) { if (isLoading) {
return <></>; return <></>;
@@ -122,36 +97,34 @@ function HistoryList({ pageId }: Props) {
return <div>{t("Error loading page history.")}</div>; return <div>{t("Error loading page history.")}</div>;
} }
if (historyItems.length === 0) { if (!pageHistoryList || pageHistoryList.items.length === 0) {
return <>{t("No page history saved yet.")}</>; return <>{t("No page history saved yet.")}</>;
} }
return ( return (
<div> <div>
<ScrollArea h={620} w="100%" type="scroll" scrollbarSize={5}> <ScrollArea h={620} w="100%" type="scroll" scrollbarSize={5}>
{historyItems.map((historyItem, index) => ( {pageHistoryList &&
<HistoryItem pageHistoryList.items.map((historyItem, index) => (
key={historyItem.id} <HistoryItem
historyItem={historyItem} key={index}
index={index} historyItem={historyItem}
onSelect={handleSelect} onSelect={setActiveHistoryId}
onHover={handleHover} isActive={historyItem.id === activeHistoryId}
onHoverEnd={clearPrefetchTimeout} />
isActive={historyItem.id === activeHistoryId} ))}
/>
))}
{hasNextPage && <div ref={loadMoreRef} style={{ height: 1 }} />}
{isFetchingNextPage && (
<Center py="sm">
<Loader size="sm" />
</Center>
)}
</ScrollArea> </ScrollArea>
{canRestore && ( {spaceAbility.cannot(
SpaceCaslAction.Manage,
SpaceCaslSubject.Page,
) ? null : (
<> <>
<Divider /> <Divider />
<Group p="xs" wrap="nowrap"> <Group p="xs" wrap="nowrap">
<Button size="compact-md" onClick={confirmModal}>
{t("Restore")}
</Button>
<Button <Button
variant="default" variant="default"
size="compact-md" size="compact-md"
@@ -159,9 +132,6 @@ function HistoryList({ pageId }: Props) {
> >
{t("Cancel")} {t("Cancel")}
</Button> </Button>
<Button size="compact-md" onClick={confirmRestore}>
{t("Restore")}
</Button>
</Group> </Group>
</> </>
)} )}
@@ -1,45 +1,21 @@
import { import { ScrollArea } from "@mantine/core";
ActionIcon,
Group,
Paper,
ScrollArea,
Switch,
Text,
} from "@mantine/core";
import HistoryList from "@/features/page-history/components/history-list"; import HistoryList from "@/features/page-history/components/history-list";
import classes from "./css/history.module.css"; import classes from "./history.module.css";
import { useAtom, useAtomValue } from "jotai"; import { useAtom } from "jotai";
import { import { activeHistoryIdAtom } from "@/features/page-history/atoms/history-atoms";
activeHistoryIdAtom,
activeHistoryPrevIdAtom,
diffCountsAtom,
highlightChangesAtom,
} from "@/features/page-history/atoms/history-atoms";
import HistoryView from "@/features/page-history/components/history-view"; import HistoryView from "@/features/page-history/components/history-view";
import { useRef } from "react"; import { useEffect } from "react";
import { IconChevronUp, IconChevronDown } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
useDiffNavigation,
useHistoryReset,
} from "@/features/page-history/hooks";
interface Props { interface Props {
pageId: string; pageId: string;
} }
export default function HistoryModalBody({ pageId }: Props) { export default function HistoryModalBody({ pageId }: Props) {
const { t } = useTranslation(); const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
const scrollViewportRef = useRef<HTMLDivElement>(null);
const activeHistoryId = useAtomValue(activeHistoryIdAtom); useEffect(() => {
const activeHistoryPrevId = useAtomValue(activeHistoryPrevIdAtom); setActiveHistoryId("");
const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom); }, [pageId]);
const diffCounts = useAtomValue(diffCountsAtom);
useHistoryReset(pageId);
const { currentChangeIndex, handlePrevChange, handleNextChange } =
useDiffNavigation(scrollViewportRef);
return ( return (
<div className={classes.sidebarFlex}> <div className={classes.sidebarFlex}>
@@ -49,63 +25,11 @@ export default function HistoryModalBody({ pageId }: Props) {
</div> </div>
</nav> </nav>
<div style={{ position: "relative", flex: 1 }}> <ScrollArea h="650" w="100%" scrollbarSize={5}>
<ScrollArea <div className={classes.sidebarRightSection}>
h={650} {activeHistoryId && <HistoryView historyId={activeHistoryId} />}
w="100%" </div>
scrollbarSize={5} </ScrollArea>
viewportRef={scrollViewportRef}
>
<div className={classes.sidebarRightSection}>
{activeHistoryId && <HistoryView />}
</div>
</ScrollArea>
{activeHistoryId && activeHistoryPrevId && (
<Paper
shadow="md"
radius="xl"
px="md"
py="xs"
style={{
position: "absolute",
bottom: 16,
left: "50%",
transform: "translateX(-50%)",
}}
>
<Group gap="md" wrap="nowrap">
<Switch
label={t("Highlight changes")}
checked={highlightChanges}
onChange={(e) => setHighlightChanges(e.currentTarget.checked)}
styles={{ label: { userSelect: "none", whiteSpace: "nowrap" } }}
/>
{highlightChanges && diffCounts && diffCounts.total > 0 && (
<Group gap="xs" wrap="nowrap">
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{currentChangeIndex} of {diffCounts.total}
</Text>
<ActionIcon
variant="subtle"
size="sm"
onClick={handlePrevChange}
>
<IconChevronUp size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
size="sm"
onClick={handleNextChange}
>
<IconChevronDown size={16} />
</ActionIcon>
</Group>
)}
</Group>
</Paper>
)}
</div>
</div> </div>
); );
} }
@@ -1,208 +0,0 @@
import {
ActionIcon,
Box,
Button,
Group,
Paper,
ScrollArea,
Select,
Switch,
Text,
} from "@mantine/core";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import {
activeHistoryIdAtom,
activeHistoryPrevIdAtom,
diffCountsAtom,
highlightChangesAtom,
historyAtoms,
} from "@/features/page-history/atoms/history-atoms";
import HistoryView from "@/features/page-history/components/history-view";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { IconCheck, IconChevronDown, IconChevronUp } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { usePageHistoryListQuery } from "@/features/page-history/queries/page-history-query";
import { formattedDate } from "@/lib/time";
import {
useDiffNavigation,
useHistoryReset,
useHistoryRestore,
} from "@/features/page-history/hooks";
import classes from "./css/history-mobile.module.css";
interface Props {
pageId: string;
pageTitle?: string;
}
export default function HistoryModalMobile({ pageId, pageTitle }: Props) {
const { t } = useTranslation();
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
const setActiveHistoryPrevId = useSetAtom(activeHistoryPrevIdAtom);
const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom);
const diffCounts = useAtomValue(diffCountsAtom);
const setHistoryModalOpen = useSetAtom(historyAtoms);
const scrollViewportRef = useRef<HTMLDivElement>(null);
const dropdownViewportRef = useRef<HTMLDivElement>(null);
const {
data: pageHistoryData,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = usePageHistoryListQuery(pageId);
const historyItems = useMemo(
() => pageHistoryData?.pages.flatMap((page) => page.items) ?? [],
[pageHistoryData],
);
const selectData = useMemo(
() =>
historyItems.map((item) => ({
value: item.id,
label: formattedDate(new Date(item.createdAt)),
userName: item.lastUpdatedBy?.name,
})),
[historyItems],
);
useHistoryReset(pageId);
const { canRestore, confirmRestore } = useHistoryRestore();
const { currentChangeIndex, handlePrevChange, handleNextChange } =
useDiffNavigation(scrollViewportRef);
useEffect(() => {
if (historyItems.length > 0 && !activeHistoryId) {
setActiveHistoryId(historyItems[0].id);
setActiveHistoryPrevId(historyItems[1]?.id ?? "");
}
}, [
historyItems,
activeHistoryId,
setActiveHistoryId,
setActiveHistoryPrevId,
]);
const handleDropdownScroll = useCallback(() => {
const viewport = dropdownViewportRef.current;
if (!viewport || !hasNextPage || isFetchingNextPage) return;
const { scrollTop, scrollHeight, clientHeight } = viewport;
const isNearBottom = scrollTop + clientHeight >= scrollHeight - 50;
if (isNearBottom) {
fetchNextPage();
}
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
const handleSelectVersion = useCallback(
(value: string | null) => {
if (!value) return;
const index = historyItems.findIndex((item) => item.id === value);
if (index >= 0) {
setActiveHistoryId(value);
setActiveHistoryPrevId(historyItems[index + 1]?.id ?? "");
}
},
[historyItems, setActiveHistoryId, setActiveHistoryPrevId],
);
if (isLoading) {
return null;
}
return (
<Box className={classes.container}>
<Box className={classes.selectorWrapper}>
<Select
data={selectData}
value={activeHistoryId}
onChange={handleSelectVersion}
placeholder={t("Select version")}
checkIconPosition="right"
maxDropdownHeight={300}
renderOption={({ option, checked }) => (
<Group justify="space-between" wrap="nowrap" w="100%">
<div>
<Text size="sm">{option.label}</Text>
<Text size="xs" c="dimmed">
{(option as { userName?: string }).userName}
</Text>
</div>
{checked && <IconCheck size={16} />}
</Group>
)}
comboboxProps={{ withinPortal: false }}
scrollAreaProps={{
viewportRef: dropdownViewportRef,
onScrollPositionChange: handleDropdownScroll,
}}
/>
</Box>
<ScrollArea
className={classes.editorArea}
viewportRef={scrollViewportRef}
scrollbarSize={5}
>
<Box className={classes.editorContent}>
{activeHistoryId && <HistoryView />}
</Box>
</ScrollArea>
{canRestore && (
<Group className={classes.actionButtons} justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setHistoryModalOpen(false)}>
{t("Cancel")}
</Button>
<Button onClick={confirmRestore}>{t("Restore")}</Button>
</Group>
)}
{activeHistoryId && (
<Paper
shadow="sm"
radius="xl"
px="md"
py="xs"
className={classes.floatingBar}
>
<Group gap="sm" wrap="nowrap">
<Switch
label={t("Highlight changes")}
checked={highlightChanges}
onChange={(e) => setHighlightChanges(e.currentTarget.checked)}
size="sm"
styles={{ label: { userSelect: "none", whiteSpace: "nowrap" } }}
/>
{highlightChanges && diffCounts && diffCounts.total > 0 && (
<Group gap={4} wrap="nowrap">
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{currentChangeIndex} of {diffCounts.total}
</Text>
<ActionIcon
variant="subtle"
size="sm"
onClick={handlePrevChange}
>
<IconChevronUp size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
size="sm"
onClick={handleNextChange}
>
<IconChevronDown size={16} />
</ActionIcon>
</Group>
)}
</Group>
</Paper>
)}
</Box>
);
}
@@ -2,26 +2,21 @@ import { Modal, Text } from "@mantine/core";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { historyAtoms } from "@/features/page-history/atoms/history-atoms"; import { historyAtoms } from "@/features/page-history/atoms/history-atoms";
import HistoryModalBody from "@/features/page-history/components/history-modal-body"; import HistoryModalBody from "@/features/page-history/components/history-modal-body";
import HistoryModalMobile from "@/features/page-history/components/history-modal-mobile";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMediaQuery } from "@mantine/hooks";
interface Props { interface Props {
pageId: string; pageId: string;
pageTitle?: string;
} }
export default function HistoryModal({ pageId }: Props) {
export default function HistoryModal({ pageId, pageTitle }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const [isModalOpen, setModalOpen] = useAtom(historyAtoms); const [isModalOpen, setModalOpen] = useAtom(historyAtoms);
const isMobile = useMediaQuery("(max-width: 800px)");
if (isMobile) { return (
return ( <>
<Modal.Root <Modal.Root
size={1200}
opened={isModalOpen} opened={isModalOpen}
onClose={() => setModalOpen(false)} onClose={() => setModalOpen(false)}
fullScreen
> >
<Modal.Overlay /> <Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}> <Modal.Content style={{ overflow: "hidden" }}>
@@ -33,37 +28,11 @@ export default function HistoryModal({ pageId, pageTitle }: Props) {
</Modal.Title> </Modal.Title>
<Modal.CloseButton /> <Modal.CloseButton />
</Modal.Header> </Modal.Header>
<Modal.Body <Modal.Body>
p={0} <HistoryModalBody pageId={pageId} />
style={{ height: "calc(100vh - 60px)", overflow: "hidden" }}
>
<HistoryModalMobile pageId={pageId} pageTitle={pageTitle} />
</Modal.Body> </Modal.Body>
</Modal.Content> </Modal.Content>
</Modal.Root> </Modal.Root>
); </>
}
return (
<Modal.Root
size={1400}
opened={isModalOpen}
onClose={() => setModalOpen(false)}
>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header>
<Modal.Title>
<Text size="md" fw={500}>
{t("Page history")}
</Text>
</Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>
<HistoryModalBody pageId={pageId} />
</Modal.Body>
</Modal.Content>
</Modal.Root>
); );
} }
@@ -1,44 +1,29 @@
import { usePageHistoryQuery } from "@/features/page-history/queries/page-history-query"; import { usePageHistoryQuery } from "@/features/page-history/queries/page-history-query";
import { HistoryEditor } from "@/features/page-history/components/history-editor"; import { HistoryEditor } from "@/features/page-history/components/history-editor";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useAtomValue } from "jotai";
import {
activeHistoryIdAtom,
activeHistoryPrevIdAtom,
} from "@/features/page-history/atoms/history-atoms";
function HistoryView() { interface HistoryProps {
historyId: string;
}
function HistoryView({ historyId }: HistoryProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const historyId = useAtomValue(activeHistoryIdAtom); const { data, isLoading, isError } = usePageHistoryQuery(historyId);
const prevHistoryId = useAtomValue(activeHistoryPrevIdAtom);
const { if (isLoading) {
data,
isLoading: isLoadingCurrent,
isError: isErrorCurrent,
} = usePageHistoryQuery(historyId);
const {
data: prevData,
isLoading: isLoadingPrev,
isError: isErrorPrev,
} = usePageHistoryQuery(prevHistoryId);
if (isLoadingCurrent || isLoadingPrev) {
return <></>; return <></>;
} }
if (isErrorCurrent || !data) { if (isError || !data) {
return <div>{t("Error fetching page data.")}</div>; return <div>{t("Error fetching page data.")}</div>;
} }
return ( return (
<div> data && (
<HistoryEditor <div>
content={data.content} <HistoryEditor content={data.content} title={data.title} />
title={data.title} </div>
previousContent={!isErrorPrev ? prevData?.content : undefined} )
/>
</div>
); );
} }
@@ -0,0 +1,37 @@
.history {
display: block;
width: 100%;
padding: var(--mantine-spacing-md);
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
@mixin hover {
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-8));
}
}
.active {
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-8));
}
.sidebar {
max-height: rem(700px);
width: rem(250px);
padding: var(--mantine-spacing-sm);
display: flex;
flex-direction: column;
border-right: rem(1px) solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
.sidebarFlex {
display: flex;
}
.sidebarMain {
flex: 1;
}
.sidebarRightSection {
flex: 1;
padding: rem(16px) rem(40px);
}
@@ -1,3 +0,0 @@
export { useDiffNavigation } from "./use-diff-navigation";
export { useHistoryRestore } from "./use-history-restore";
export { useHistoryReset } from "./use-history-reset";
@@ -1,58 +0,0 @@
import { useAtomValue } from "jotai";
import { RefObject, useCallback, useEffect, useState } from "react";
import { diffCountsAtom } from "@/features/page-history/atoms/history-atoms";
/**
* Manages navigation between diff changes in the history view.
* Provides prev/next handlers and auto-scrolls to the current change.
*/
export function useDiffNavigation(
scrollViewportRef: RefObject<HTMLDivElement>,
) {
const diffCounts = useAtomValue(diffCountsAtom);
const [currentChangeIndex, setCurrentChangeIndex] = useState(0);
const scrollToChangeIndex = useCallback(
(index: number) => {
const viewport = scrollViewportRef.current;
if (!viewport || index < 1) return;
const element = viewport.querySelector(`[data-diff-index="${index}"]`);
if (element instanceof HTMLElement) {
const elementTop = element.offsetTop;
const viewportHeight = viewport.clientHeight;
const scrollTarget =
elementTop - viewportHeight / 2 + element.offsetHeight / 2;
viewport.scrollTo({ top: scrollTarget, behavior: "smooth" });
}
},
[scrollViewportRef],
);
useEffect(() => {
if (diffCounts && diffCounts.total > 0) {
setCurrentChangeIndex(1);
requestAnimationFrame(() => scrollToChangeIndex(1));
} else {
setCurrentChangeIndex(0);
}
}, [diffCounts, scrollToChangeIndex]);
const handlePrevChange = useCallback(() => {
if (!diffCounts || diffCounts.total === 0) return;
const newIndex =
currentChangeIndex <= 1 ? diffCounts.total : currentChangeIndex - 1;
setCurrentChangeIndex(newIndex);
scrollToChangeIndex(newIndex);
}, [diffCounts, currentChangeIndex, scrollToChangeIndex]);
const handleNextChange = useCallback(() => {
if (!diffCounts || diffCounts.total === 0) return;
const newIndex =
currentChangeIndex >= diffCounts.total ? 1 : currentChangeIndex + 1;
setCurrentChangeIndex(newIndex);
scrollToChangeIndex(newIndex);
}, [diffCounts, currentChangeIndex, scrollToChangeIndex]);
return { currentChangeIndex, handlePrevChange, handleNextChange };
}
@@ -1,24 +0,0 @@
import { useAtom } from "jotai";
import { useEffect } from "react";
import {
activeHistoryIdAtom,
activeHistoryPrevIdAtom,
diffCountsAtom,
} from "@/features/page-history/atoms/history-atoms";
/**
* Resets history state when pageId changes.
* Clears active selection and diff counts.
*/
export function useHistoryReset(pageId: string) {
const [, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
const [, setActiveHistoryPrevId] = useAtom(activeHistoryPrevIdAtom);
const [, setDiffCounts] = useAtom(diffCountsAtom);
useEffect(() => {
setActiveHistoryId("");
setActiveHistoryPrevId("");
// @ts-ignore
setDiffCounts(null);
}, [pageId, setActiveHistoryId, setActiveHistoryPrevId, setDiffCounts]);
}
@@ -1,78 +0,0 @@
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Text } from "@mantine/core";
import { modals } from "@mantine/modals";
import { notifications } from "@mantine/notifications";
import { useParams } from "react-router-dom";
import {
activeHistoryIdAtom,
historyAtoms,
} from "@/features/page-history/atoms/history-atoms";
import { usePageHistoryQuery } from "@/features/page-history/queries/page-history-query";
import {
pageEditorAtom,
titleEditorAtom,
} from "@/features/editor/atoms/editor-atoms";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability";
import { useSpaceQuery } from "@/features/space/queries/space-query";
import {
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type";
export function useHistoryRestore() {
const { t } = useTranslation();
const activeHistoryId = useAtomValue(activeHistoryIdAtom);
const { data: activeHistoryData } = usePageHistoryQuery(activeHistoryId);
const mainEditor = useAtomValue(pageEditorAtom);
const mainEditorTitle = useAtomValue(titleEditorAtom);
const setHistoryModalOpen = useSetAtom(historyAtoms);
const { spaceSlug } = useParams();
const { data: space } = useSpaceQuery(spaceSlug);
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
const canRestore = spaceAbility.can(
SpaceCaslAction.Manage,
SpaceCaslSubject.Page,
);
const handleRestore = useCallback(() => {
if (!activeHistoryData) return;
mainEditorTitle
.chain()
.clearContent()
.setContent(activeHistoryData.title, { emitUpdate: true })
.run();
mainEditor
.chain()
.clearContent()
.setContent(activeHistoryData.content)
.run();
setHistoryModalOpen(false);
notifications.show({ message: t("Successfully restored") });
}, [activeHistoryData, mainEditor, mainEditorTitle, setHistoryModalOpen, t]);
const confirmRestore = useCallback(() => {
modals.openConfirmModal({
title: t("Please confirm your action"),
children: (
<Text size="sm">
{t(
"Are you sure you want to restore this version? Any changes not versioned will be lost.",
)}
</Text>
),
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
onConfirm: handleRestore,
});
}, [t, handleRestore]);
return { canRestore, confirmRestore };
}
@@ -1,38 +1,19 @@
import { import { useQuery, UseQueryResult } from "@tanstack/react-query";
InfiniteData,
useInfiniteQuery,
UseInfiniteQueryResult,
useQuery,
UseQueryResult,
} from "@tanstack/react-query";
import { import {
getPageHistoryById, getPageHistoryById,
getPageHistoryList, getPageHistoryList,
} from "@/features/page-history/services/page-history-service"; } from "@/features/page-history/services/page-history-service";
import { IPageHistory } from "@/features/page-history/types/page.types"; import { IPageHistory } from "@/features/page-history/types/page.types";
import { IPagination } from "@/lib/types.ts"; import { IPagination } from "@/lib/types.ts";
import { queryClient } from "@/main";
const HISTORY_STALE_TIME = 60 * 60 * 1000;
export function prefetchPageHistory(historyId: string) {
return queryClient.prefetchQuery({
queryKey: ["page-history", historyId],
queryFn: () => getPageHistoryById(historyId),
staleTime: HISTORY_STALE_TIME,
});
}
export function usePageHistoryListQuery( export function usePageHistoryListQuery(
pageId: string, pageId: string,
): UseInfiniteQueryResult<InfiniteData<IPagination<IPageHistory>, unknown>> { ): UseQueryResult<IPagination<IPageHistory>, Error> {
return useInfiniteQuery({ return useQuery({
queryKey: ["page-history-list", pageId], queryKey: ["page-history-list", pageId],
queryFn: ({ pageParam }) => getPageHistoryList(pageId, pageParam), queryFn: () => getPageHistoryList(pageId),
enabled: !!pageId, enabled: !!pageId,
gcTime: 0, gcTime: 0,
initialPageParam: undefined,
getNextPageParam: (lastPage) => lastPage.meta?.nextCursor ?? undefined,
}); });
} }
@@ -43,6 +24,6 @@ export function usePageHistoryQuery(
queryKey: ["page-history", historyId], queryKey: ["page-history", historyId],
queryFn: () => getPageHistoryById(historyId), queryFn: () => getPageHistoryById(historyId),
enabled: !!historyId, enabled: !!historyId,
staleTime: HISTORY_STALE_TIME, staleTime: 10 * 60 * 1000,
}); });
} }
@@ -4,11 +4,9 @@ import { IPagination } from "@/lib/types.ts";
export async function getPageHistoryList( export async function getPageHistoryList(
pageId: string, pageId: string,
cursor?: string,
): Promise<IPagination<IPageHistory>> { ): Promise<IPagination<IPageHistory>> {
const req = await api.post("/pages/history", { const req = await api.post("/pages/history", {
pageId, pageId,
cursor,
}); });
return req.data; return req.data;
} }
@@ -7,7 +7,6 @@ import {
IconHistory, IconHistory,
IconLink, IconLink,
IconList, IconList,
IconMarkdown,
IconMessage, IconMessage,
IconPrinter, IconPrinter,
IconTrash, IconTrash,
@@ -29,7 +28,6 @@ import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.
import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx"; import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import ExportModal from "@/components/common/export-modal"; import ExportModal from "@/components/common/export-modal";
import { htmlToMarkdown } from "@docmost/editor-ext";
import { import {
pageEditorAtom, pageEditorAtom,
yjsConnectionStatusAtom, yjsConnectionStatusAtom,
@@ -62,7 +60,6 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
const event = new CustomEvent("closeFindDialogFromEditor", {}); const event = new CustomEvent("closeFindDialogFromEditor", {});
document.dispatchEvent(event); document.dispatchEvent(event);
}, },
{ preventDefault: false },
], ],
], ],
[], [],
@@ -131,15 +128,6 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
notifications.show({ message: t("Link copied") }); notifications.show({ message: t("Link copied") });
}; };
const handleCopyAsMarkdown = () => {
if (!pageEditor) return;
const html = pageEditor.getHTML();
const markdown = htmlToMarkdown(html);
const title = page?.title ? `# ${page.title}\n\n` : "";
clipboard.copy(`${title}${markdown}`);
notifications.show({ message: t("Copied") });
};
const handlePrint = () => { const handlePrint = () => {
setTimeout(() => { setTimeout(() => {
window.print(); window.print();
@@ -177,13 +165,6 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
> >
{t("Copy link")} {t("Copy link")}
</Menu.Item> </Menu.Item>
<Menu.Item
leftSection={<IconMarkdown size={16} />}
onClick={handleCopyAsMarkdown}
>
{t("Copy as Markdown")}
</Menu.Item>
<Menu.Divider /> <Menu.Divider />
<Menu.Item leftSection={<IconArrowsHorizontal size={16} />}> <Menu.Item leftSection={<IconArrowsHorizontal size={16} />}>
@@ -299,9 +280,7 @@ function ConnectionWarning() {
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { useEffect(() => {
const isDisconnected = ["disconnected", "connecting"].includes( const isDisconnected = ["disconnected", "connecting"].includes(yjsConnectionStatus);
yjsConnectionStatus,
);
if (isDisconnected) { if (isDisconnected) {
if (!timeoutRef.current) { if (!timeoutRef.current) {
@@ -172,10 +172,6 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
queryKey: ["root-sidebar-pages", fileTask.spaceId], queryKey: ["root-sidebar-pages", fileTask.spaceId],
}); });
await queryClient.invalidateQueries({
queryKey: ["recent-changes", fileTask.spaceId],
});
setTimeout(() => { setTimeout(() => {
emit({ emit({
operation: "refetchRootTreeNodeEvent", operation: "refetchRootTreeNodeEvent",
@@ -163,6 +163,9 @@ export function useDeletePageMutation() {
export function useMovePageMutation() { export function useMovePageMutation() {
return useMutation<void, Error, IMovePage>({ return useMutation<void, Error, IMovePage>({
mutationFn: (data) => movePage(data), mutationFn: (data) => movePage(data),
onSuccess: () => {
invalidateOnMovePage();
},
}); });
} }
@@ -250,10 +253,12 @@ export function useGetSidebarPagesQuery(
return useInfiniteQuery({ return useInfiniteQuery({
queryKey: ["sidebar-pages", data], queryKey: ["sidebar-pages", data],
enabled: !!data?.pageId || !!data?.spaceId, enabled: !!data?.pageId || !!data?.spaceId,
queryFn: ({ pageParam }) => getSidebarPages({ ...data, cursor: pageParam }), queryFn: ({ pageParam }) => getSidebarPages({ ...data, page: pageParam }),
initialPageParam: undefined, initialPageParam: 1,
getPreviousPageParam: (firstPage) =>
firstPage.meta.hasPrevPage ? firstPage.meta.page - 1 : undefined,
getNextPageParam: (lastPage) => getNextPageParam: (lastPage) =>
lastPage.meta?.nextCursor ?? undefined, lastPage.meta.hasNextPage ? lastPage.meta.page + 1 : undefined,
}); });
} }
@@ -261,11 +266,13 @@ export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) {
return useInfiniteQuery({ return useInfiniteQuery({
queryKey: ["root-sidebar-pages", data.spaceId], queryKey: ["root-sidebar-pages", data.spaceId],
queryFn: async ({ pageParam }) => { queryFn: async ({ pageParam }) => {
return getSidebarPages({ spaceId: data.spaceId, cursor: pageParam }); return getSidebarPages({ spaceId: data.spaceId, page: pageParam });
}, },
initialPageParam: undefined, initialPageParam: 1,
getPreviousPageParam: (firstPage) =>
firstPage.meta.hasPrevPage ? firstPage.meta.page - 1 : undefined,
getNextPageParam: (lastPage) => getNextPageParam: (lastPage) =>
lastPage.meta?.nextCursor ?? undefined, lastPage.meta.hasNextPage ? lastPage.meta.page + 1 : undefined,
}); });
} }
@@ -451,127 +458,17 @@ export function invalidateOnUpdatePage(
}); });
} }
export function updateCacheOnMovePage( export function invalidateOnMovePage() {
spaceId: string, //for move invalidate all sidebars for now (how to do???)
pageId: string, //invalidate all root sidebar pages
oldParentId: string | null, queryClient.invalidateQueries({
newParentId: string | null, queryKey: ["root-sidebar-pages"],
pageData: Partial<IPage>, });
) { //invalidate all sub sidebar pages
// Remove page from old parent's cache queryClient.invalidateQueries({
const oldQueryKey = queryKey: ["sidebar-pages"],
oldParentId === null });
? ["root-sidebar-pages", spaceId] // ---
: ["sidebar-pages", { pageId: oldParentId, spaceId }];
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
oldQueryKey,
(old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.filter((item) => item.id !== pageId),
})),
};
},
);
// Update old parent's hasChildren flag if it has no more children
if (oldParentId !== null) {
const oldParentCache = queryClient.getQueryData<
InfiniteData<IPagination<IPage>>
>(["sidebar-pages", { pageId: oldParentId, spaceId }]);
const remainingChildren =
oldParentCache?.pages.flatMap((p) => p.items).length ?? 0;
if (remainingChildren === 0) {
// Update hasChildren in all caches where old parent appears
const allSideBarMatches = queryClient.getQueriesData({
predicate: (query) =>
query.queryKey[0] === "root-sidebar-pages" ||
query.queryKey[0] === "sidebar-pages",
});
allSideBarMatches.forEach(([key]) => {
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
key,
(old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.map((item) =>
item.id === oldParentId
? { ...item, hasChildren: false }
: item,
),
})),
};
},
);
});
}
}
// Add page to new parent's cache
const newQueryKey =
newParentId === null
? ["root-sidebar-pages", spaceId]
: ["sidebar-pages", { pageId: newParentId, spaceId }];
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(
newQueryKey,
(old) => {
if (!old) return old;
// Check if page already exists in new location
const exists = old.pages.some((page) =>
page.items.some((item) => item.id === pageId),
);
if (exists) return old;
return {
...old,
pages: old.pages.map((page, index) => {
if (index === old.pages.length - 1) {
return {
...page,
items: [...page.items, pageData],
};
}
return page;
}),
};
},
);
// Update new parent's hasChildren flag
if (newParentId !== null) {
const allSideBarMatches = queryClient.getQueriesData({
predicate: (query) =>
query.queryKey[0] === "root-sidebar-pages" ||
query.queryKey[0] === "sidebar-pages",
});
allSideBarMatches.forEach(([key]) => {
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(key, (old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.map((item) =>
item.id === newParentId ? { ...item, hasChildren: true } : item,
),
})),
};
});
});
}
} }
export function invalidateOnDeletePage(pageId: string) { export function invalidateOnDeletePage(pageId: string) {
@@ -72,19 +72,22 @@ export async function getSidebarPages(
export async function getAllSidebarPages( export async function getAllSidebarPages(
params: SidebarPagesParams, params: SidebarPagesParams,
): Promise<InfiniteData<IPagination<IPage>, unknown>> { ): Promise<InfiniteData<IPagination<IPage>, unknown>> {
let cursor: string | undefined = undefined; let page = 1;
let hasNextPage = false;
const pages: IPagination<IPage>[] = []; const pages: IPagination<IPage>[] = [];
const pageParams: (string | undefined)[] = []; const pageParams: number[] = [];
do { do {
const req = await api.post("/pages/sidebar-pages", { ...params, cursor }); const req = await api.post("/pages/sidebar-pages", { ...params, page: page });
const data: IPagination<IPage> = req.data; const data: IPagination<IPage> = req.data;
pages.push(data); pages.push(data);
pageParams.push(cursor); pageParams.push(page);
cursor = data.meta.nextCursor ?? undefined; hasNextPage = data.meta.hasNextPage;
} while (cursor);
page += 1;
} while (hasNextPage);
return { return {
pageParams, pageParams,
@@ -115,14 +118,7 @@ export async function exportPage(data: IExportPageParams): Promise<void> {
.split("filename=")[1] .split("filename=")[1]
.replace(/"/g, ""); .replace(/"/g, "");
let decodedFileName = fileName; saveAs(req.data, decodeURIComponent(fileName));
try {
decodedFileName = decodeURIComponent(fileName);
} catch (err) {
// fallback to raw filename
}
saveAs(req.data, decodedFileName);
} }
export async function importPage(file: File, spaceId: string) { export async function importPage(file: File, spaceId: string) {
@@ -30,15 +30,15 @@ import { useState } from "react";
import TrashPageContentModal from "@/features/page/trash/components/trash-page-content-modal"; import TrashPageContentModal from "@/features/page/trash/components/trash-page-content-modal";
import { UserInfo } from "@/components/common/user-info.tsx"; import { UserInfo } from "@/components/common/user-info.tsx";
import Paginate from "@/components/common/paginate.tsx"; import Paginate from "@/components/common/paginate.tsx";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate"; import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
export default function Trash() { export default function Trash() {
const { t } = useTranslation(); const { t } = useTranslation();
const { spaceSlug } = useParams(); const { spaceSlug } = useParams();
const { cursor, goNext, goPrev } = useCursorPaginate(); const { page, setPage } = usePaginateAndSearch();
const { data: space } = useGetSpaceBySlugQuery(spaceSlug); const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
const { data: deletedPages, isLoading } = useDeletedPagesQuery(space?.id, { const { data: deletedPages, isLoading } = useDeletedPagesQuery(space?.id, {
cursor, limit: 50 page, limit: 50
}); });
const restorePageMutation = useRestorePageMutation(); const restorePageMutation = useRestorePageMutation();
const deletePageMutation = useDeletePageMutation(); const deletePageMutation = useDeletePageMutation();
@@ -206,10 +206,10 @@ export default function Trash() {
{deletedPages && deletedPages.items.length > 0 && ( {deletedPages && deletedPages.items.length > 0 && (
<Paginate <Paginate
hasPrevPage={deletedPages.meta?.hasPrevPage} currentPage={page}
hasNextPage={deletedPages.meta?.hasNextPage} hasPrevPage={deletedPages.meta.hasPrevPage}
onNext={() => goNext(deletedPages.meta?.nextCursor)} hasNextPage={deletedPages.meta.hasNextPage}
onPrev={goPrev} onPageChange={setPage}
/> />
)} )}
</Stack> </Stack>
@@ -94,9 +94,9 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
spaceId, spaceId,
}); });
const [, setTreeApi] = useAtom<TreeApi<SpaceTreeNode>>(treeApiAtom); const [, setTreeApi] = useAtom<TreeApi<SpaceTreeNode>>(treeApiAtom);
const treeApiRef = useRef<TreeApi<SpaceTreeNode>>(); const treeApiRef = useRef<TreeApi<SpaceTreeNode>>(null);
const [openTreeNodes, setOpenTreeNodes] = useAtom<OpenMap>(openTreeNodesAtom); const [openTreeNodes, setOpenTreeNodes] = useAtom<OpenMap>(openTreeNodesAtom);
const rootElement = useRef<HTMLDivElement>(); const rootElement = useRef<HTMLDivElement>(null);
const [isRootReady, setIsRootReady] = useState(false); const [isRootReady, setIsRootReady] = useState(false);
const { ref: sizeRef, width, height } = useElementSize(); const { ref: sizeRef, width, height } = useElementSize();
const mergedRef = useMergedRef((element) => { const mergedRef = useMergedRef((element) => {
@@ -16,7 +16,6 @@ import {
useRemovePageMutation, useRemovePageMutation,
useMovePageMutation, useMovePageMutation,
useUpdatePageMutation, useUpdatePageMutation,
updateCacheOnMovePage,
} from "@/features/page/queries/page-query.ts"; } from "@/features/page/queries/page-query.ts";
import { generateJitteredKeyBetween } from "fractional-indexing-jittered"; import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
import { SpaceTreeNode } from "@/features/page/tree/types.ts"; import { SpaceTreeNode } from "@/features/page/tree/types.ts";
@@ -176,25 +175,9 @@ export function useTreeMutation<T>(spaceId: string) {
parentPageId: args.parentId, parentPageId: args.parentId,
}; };
const draggedNode = args.dragNodes[0];
const nodeData = draggedNode.data as SpaceTreeNode;
const oldParentId = nodeData.parentPageId ?? null;
const pageData = {
id: nodeData.id,
slugId: nodeData.slugId,
title: nodeData.name,
icon: nodeData.icon,
position: newPosition,
spaceId: nodeData.spaceId,
parentPageId: args.parentId,
hasChildren: nodeData.hasChildren,
};
try { try {
await movePageMutation.mutateAsync(payload); await movePageMutation.mutateAsync(payload);
updateCacheOnMovePage(spaceId, draggedNodeId, oldParentId, args.parentId, pageData);
setTimeout(() => { setTimeout(() => {
emit({ emit({
operation: "moveTreeNode", operation: "moveTreeNode",
@@ -202,10 +185,8 @@ export function useTreeMutation<T>(spaceId: string) {
payload: { payload: {
id: draggedNodeId, id: draggedNodeId,
parentId: args.parentId, parentId: args.parentId,
oldParentId,
index: args.index, index: args.index,
position: newPosition, position: newPosition,
pageData,
}, },
}); });
}, 50); }, 50);
@@ -62,7 +62,7 @@ export interface ICopyPageToSpace {
export interface SidebarPagesParams { export interface SidebarPagesParams {
spaceId?: string; spaceId?: string;
pageId?: string; pageId?: string;
cursor?: string; page?: number; // pagination
} }
export interface IPageInput { export interface IPageInput {
@@ -18,6 +18,7 @@ import {
IconFileDescription, IconFileDescription,
IconSearch, IconSearch,
IconCheck, IconCheck,
IconSparkles,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
@@ -25,7 +26,7 @@ import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import { useLicense } from "@/ee/hooks/use-license"; import { useLicense } from "@/ee/hooks/use-license";
import classes from "./search-spotlight-filters.module.css"; import classes from "./search-spotlight-filters.module.css";
import { isCloud } from "@/lib/config.ts"; import { isCloud } from "@/lib/config.ts";
import { useAtom } from "jotai"; import { useAtom } from "jotai/index";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
interface SearchSpotlightFiltersProps { interface SearchSpotlightFiltersProps {
@@ -52,6 +53,7 @@ export function SearchSpotlightFilters({
const [workspace] = useAtom(workspaceAtom); const [workspace] = useAtom(workspaceAtom);
const { data: spacesData } = useGetSpacesQuery({ const { data: spacesData } = useGetSpacesQuery({
page: 1,
limit: 100, limit: 100,
query: debouncedSpaceQuery, query: debouncedSpaceQuery,
}); });
@@ -263,9 +265,7 @@ export function SearchSpotlightFilters({
contentType !== option.value && contentType !== option.value &&
handleFilterChange("contentType", option.value) handleFilterChange("contentType", option.value)
} }
disabled={ disabled={option.disabled || (isAiMode && option.value === "attachment")}
option.disabled || (isAiMode && option.value === "attachment")
}
> >
<Group flex="1" gap="xs"> <Group flex="1" gap="xs">
<div> <div>
@@ -275,13 +275,11 @@ export function SearchSpotlightFilters({
{t("Enterprise")} {t("Enterprise")}
</Badge> </Badge>
)} )}
{!option.disabled && {!option.disabled && isAiMode && option.value === "attachment" && (
isAiMode && <Text size="xs" mt={4}>
option.value === "attachment" && ( {t("Ask AI not available for attachments")}
<Text size="xs" mt={4}> </Text>
{t("Ask AI not available for attachments")} )}
</Text>
)}
</div> </div>
{contentType === option.value && <IconCheck size={20} />} {contentType === option.value && <IconCheck size={20} />}
</Group> </Group>
@@ -10,8 +10,8 @@ import {
export async function searchPage( export async function searchPage(
params: IPageSearchParams, params: IPageSearchParams,
): Promise<IPageSearch[]> { ): Promise<IPageSearch[]> {
const req = await api.post<{ items: IPageSearch[] }>("/search", params); const req = await api.post<IPageSearch[]>("/search", params);
return req.data.items; return req.data;
} }
export async function searchSuggestions( export async function searchSuggestions(
@@ -24,13 +24,13 @@ export async function searchSuggestions(
export async function searchShare( export async function searchShare(
params: IPageSearchParams, params: IPageSearchParams,
): Promise<IPageSearch[]> { ): Promise<IPageSearch[]> {
const req = await api.post<{ items: IPageSearch[] }>("/search/share-search", params); const req = await api.post<IPageSearch[]>("/search/share-search", params);
return req.data.items; return req.data;
} }
export async function searchAttachments( export async function searchAttachments(
params: IPageSearchParams, params: IPageSearchParams,
): Promise<IAttachmentSearch[]> { ): Promise<IAttachmentSearch[]> {
const req = await api.post<{ items: IAttachmentSearch[] }>("/search-attachments", params); const req = await api.post<IAttachmentSearch[]>("/search-attachments", params);
return req.data.items; return req.data;
} }
@@ -1,9 +1,8 @@
import { Table, Group, Text, Anchor } from "@mantine/core"; import { Table, Group, Text, Anchor } from "@mantine/core";
import React from "react"; import React, { useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import Paginate from "@/components/common/paginate.tsx"; import Paginate from "@/components/common/paginate.tsx";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import { useGetSharesQuery } from "@/features/share/queries/share-query.ts"; import { useGetSharesQuery } from "@/features/share/queries/share-query.ts";
import { ISharedItem } from "@/features/share/types/share.types.ts"; import { ISharedItem } from "@/features/share/types/share.types.ts";
import { format } from "date-fns"; import { format } from "date-fns";
@@ -15,8 +14,8 @@ import classes from "./share.module.css";
export default function ShareList() { export default function ShareList() {
const { t } = useTranslation(); const { t } = useTranslation();
const { cursor, goNext, goPrev } = useCursorPaginate(); const [page, setPage] = useState(1);
const { data, isLoading } = useGetSharesQuery({ cursor }); const { data, isLoading } = useGetSharesQuery({ page });
return ( return (
<> <>
@@ -87,10 +86,10 @@ export default function ShareList() {
{data?.items.length > 0 && ( {data?.items.length > 0 && (
<Paginate <Paginate
hasPrevPage={data?.meta?.hasPrevPage} currentPage={page}
hasNextPage={data?.meta?.hasNextPage} hasPrevPage={data?.meta.hasPrevPage}
onNext={() => goNext(data?.meta?.nextCursor)} hasNextPage={data?.meta.hasNextPage}
onPrev={goPrev} onPageChange={setPage}
/> />
)} )}
</> </>
@@ -36,7 +36,7 @@ export default function SharedTree({ sharedPageTree }: SharedTree) {
const [tree, setTree] = useState< const [tree, setTree] = useState<
TreeApi<SharedPageTreeNode> | null | undefined TreeApi<SharedPageTreeNode> | null | undefined
>(null); >(null);
const rootElement = useRef<HTMLDivElement>(); const rootElement = useRef<HTMLDivElement>(null);
const { ref: sizeRef, width, height } = useElementSize(); const { ref: sizeRef, width, height } = useElementSize();
const mergedRef = useMergedRef(rootElement, sizeRef); const mergedRef = useMergedRef(rootElement, sizeRef);
const { pageSlug } = useParams(); const { pageSlug } = useParams();
@@ -33,7 +33,7 @@ export function useGetSharesQuery(
params?: QueryParams, params?: QueryParams,
): UseQueryResult<IPagination<ISharedItem>, Error> { ): UseQueryResult<IPagination<ISharedItem>, Error> {
return useQuery({ return useQuery({
queryKey: ["share-list", params], queryKey: ["share-list"],
queryFn: () => getShares(params), queryFn: () => getShares(params),
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
}); });
@@ -6,7 +6,6 @@ import { ISpace } from "../types/space.types";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route"; import APP_ROUTE from "@/lib/app-route";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { useState } from "react";
interface DeleteSpaceModalProps { interface DeleteSpaceModalProps {
space: ISpace; space: ISpace;
@@ -15,7 +14,6 @@ interface DeleteSpaceModalProps {
export default function DeleteSpaceModal({ space }: DeleteSpaceModalProps) { export default function DeleteSpaceModal({ space }: DeleteSpaceModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const [isDeleting, setIsDeleting] = useState(false);
const deleteSpaceMutation = useDeleteSpaceMutation(); const deleteSpaceMutation = useDeleteSpaceMutation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -37,15 +35,12 @@ export default function DeleteSpaceModal({ space }: DeleteSpaceModalProps) {
return; return;
} }
setIsDeleting(true);
try { try {
// pass slug too so we can clear the local cache // pass slug too so we can clear the local cache
await deleteSpaceMutation.mutateAsync({ id: space.id, slug: space.slug }); await deleteSpaceMutation.mutateAsync({ id: space.id, slug: space.slug });
navigate(APP_ROUTE.HOME); navigate(APP_ROUTE.HOME);
} catch (error) { } catch (error) {
console.error("Failed to delete space", error); console.error("Failed to delete space", error);
} finally {
setIsDeleting(false);
} }
}; };
@@ -84,7 +79,7 @@ export default function DeleteSpaceModal({ space }: DeleteSpaceModalProps) {
<Button onClick={close} variant="default"> <Button onClick={close} variant="default">
{t("Cancel")} {t("Cancel")}
</Button> </Button>
<Button onClick={handleDelete} color="red" loading={isDeleting}> <Button onClick={handleDelete} color="red">
{t("Confirm")} {t("Confirm")}
</Button> </Button>
</Group> </Group>
@@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom";
import { SpaceSelect } from "./space-select"; import { SpaceSelect } from "./space-select";
import { getSpaceUrl } from "@/lib/config"; import { getSpaceUrl } from "@/lib/config";
import { Button, Popover, Text } from "@mantine/core"; import { Button, Popover, Text } from "@mantine/core";
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react"; import { IconChevronDown } from "@tabler/icons-react";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts"; import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
@@ -21,7 +21,7 @@ export function SwitchSpace({
spaceIcon, spaceIcon,
}: SwitchSpaceProps) { }: SwitchSpaceProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const [opened, { close, toggle }] = useDisclosure(false); const [opened, { close, open, toggle }] = useDisclosure(false);
const handleSelect = (value: string) => { const handleSelect = (value: string) => {
if (value) { if (value) {
@@ -44,9 +44,9 @@ export function SwitchSpace({
variant="subtle" variant="subtle"
fullWidth fullWidth
justify="space-between" justify="space-between"
rightSection={opened ? <IconChevronUp size={18} /> : <IconChevronDown size={18} />} rightSection={<IconChevronDown size={18} />}
color="gray" color="gray"
onClick={toggle} onClick={open}
> >
<CustomAvatar <CustomAvatar
name={spaceName} name={spaceName}
@@ -15,7 +15,7 @@ import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts
export default function SpaceGrid() { export default function SpaceGrid() {
const { t } = useTranslation(); const { t } = useTranslation();
const { data, isLoading } = useGetSpacesQuery({ limit: 10 }); const { data, isLoading } = useGetSpacesQuery({ page: 1, limit: 10 });
const cards = data?.items.slice(0, 9).map((space, index) => ( const cards = data?.items.slice(0, 9).map((space, index) => (
<Card <Card
@@ -1,6 +1,5 @@
import { Group, Table, Text } from "@mantine/core"; import { Group, Table, Text } from "@mantine/core";
import React, { useState } from "react"; import React, { useState } from "react";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts"; import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx"; import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
@@ -13,8 +12,8 @@ import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
export default function SpaceList() { export default function SpaceList() {
const { t } = useTranslation(); const { t } = useTranslation();
const { cursor, goNext, goPrev } = useCursorPaginate(); const [page, setPage] = useState(1);
const { data, isLoading } = useGetSpacesQuery({ cursor }); const { data, isLoading } = useGetSpacesQuery({ page });
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const [selectedSpaceId, setSelectedSpaceId] = useState<string>(null); const [selectedSpaceId, setSelectedSpaceId] = useState<string>(null);
@@ -73,10 +72,10 @@ export default function SpaceList() {
{data?.items.length > 0 && ( {data?.items.length > 0 && (
<Paginate <Paginate
hasPrevPage={data?.meta?.hasPrevPage} currentPage={page}
hasNextPage={data?.meta?.hasNextPage} hasPrevPage={data?.meta.hasPrevPage}
onNext={() => goNext(data?.meta?.nextCursor)} hasNextPage={data?.meta.hasNextPage}
onPrev={goPrev} onPageChange={setPage}
/> />
)} )}
@@ -41,9 +41,9 @@ export default function SpaceMembersList({
readOnly, readOnly,
}: SpaceMembersProps) { }: SpaceMembersProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { search, cursor, goNext, goPrev, handleSearch } = usePaginateAndSearch(); const { search, page, setPage, handleSearch } = usePaginateAndSearch();
const { data, isLoading } = useSpaceMembersQuery(spaceId, { const { data, isLoading } = useSpaceMembersQuery(spaceId, {
cursor, page,
limit: 100, limit: 100,
query: search, query: search,
}); });
@@ -206,10 +206,10 @@ export default function SpaceMembersList({
{data?.items.length > 0 && ( {data?.items.length > 0 && (
<Paginate <Paginate
hasPrevPage={data?.meta?.hasPrevPage} currentPage={page}
hasNextPage={data?.meta?.hasNextPage} hasPrevPage={data?.meta.hasPrevPage}
onNext={() => goNext(data?.meta?.nextCursor)} hasNextPage={data?.meta.hasNextPage}
onPrev={goPrev} onPageChange={setPage}
/> />
)} )}
</> </>
@@ -28,19 +28,19 @@ import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
interface AllSpacesListProps { interface AllSpacesListProps {
spaces: any[]; spaces: any[];
onSearch: (query: string) => void; onSearch: (query: string) => void;
page: number;
hasPrevPage?: boolean; hasPrevPage?: boolean;
hasNextPage?: boolean; hasNextPage?: boolean;
onNext: () => void; onPageChange: (page: number) => void;
onPrev: () => void;
} }
export default function AllSpacesList({ export default function AllSpacesList({
spaces, spaces,
onSearch, onSearch,
page,
hasPrevPage, hasPrevPage,
hasNextPage, hasNextPage,
onNext, onPageChange,
onPrev,
}: AllSpacesListProps) { }: AllSpacesListProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [settingsOpened, { open: openSettings, close: closeSettings }] = const [settingsOpened, { open: openSettings, close: closeSettings }] =
@@ -145,10 +145,10 @@ export default function AllSpacesList({
{spaces.length > 0 && ( {spaces.length > 0 && (
<Paginate <Paginate
currentPage={page}
hasPrevPage={hasPrevPage} hasPrevPage={hasPrevPage}
hasNextPage={hasNextPage} hasNextPage={hasNextPage}
onNext={onNext} onPageChange={onPageChange}
onPrev={onPrev}
/> />
)} )}
@@ -69,12 +69,5 @@ export async function exportSpace(data: IExportSpaceParams): Promise<void> {
.split("filename=")[1] .split("filename=")[1]
.replace(/"/g, ""); .replace(/"/g, "");
let decodedFileName = fileName; saveAs(req.data, decodeURIComponent(fileName));
try {
decodedFileName = decodeURIComponent(fileName);
} catch (err) {
// fallback to raw filename
}
saveAs(req.data, decodedFileName);
} }
@@ -45,10 +45,8 @@ export type MoveTreeNodeEvent = {
payload: { payload: {
id: string; id: string;
parentId: string; parentId: string;
oldParentId: string | null;
index: number; index: number;
position: string; position: string;
pageData: Partial<IPage>;
}; };
}; };
@@ -8,7 +8,7 @@ import { IPagination } from "@/lib/types";
import { import {
invalidateOnCreatePage, invalidateOnCreatePage,
invalidateOnDeletePage, invalidateOnDeletePage,
updateCacheOnMovePage, invalidateOnMovePage,
invalidateOnUpdatePage, invalidateOnUpdatePage,
} from "../page/queries/page-query"; } from "../page/queries/page-query";
import { RQ_KEY } from "../comment/queries/comment-query"; import { RQ_KEY } from "../comment/queries/comment-query";
@@ -41,13 +41,7 @@ export const useQuerySubscription = () => {
invalidateOnCreatePage(data.payload.data); invalidateOnCreatePage(data.payload.data);
break; break;
case "moveTreeNode": case "moveTreeNode":
updateCacheOnMovePage( invalidateOnMovePage();
data.spaceId,
data.payload.id,
data.payload.oldParentId,
data.payload.parentId,
data.payload.pageData,
);
break; break;
case "deleteTreeNode": case "deleteTreeNode":
invalidateOnDeletePage(data.payload.node.id); invalidateOnDeletePage(data.payload.node.id);
@@ -1,6 +1,6 @@
import { Group, Table, Avatar, Text, Alert } from "@mantine/core"; import { Group, Table, Avatar, Text, Alert } from "@mantine/core";
import { useWorkspaceInvitationsQuery } from "@/features/workspace/queries/workspace-query.ts"; import { useWorkspaceInvitationsQuery } from "@/features/workspace/queries/workspace-query.ts";
import React from "react"; import React, { useState } from "react";
import { getUserRoleLabel } from "@/features/workspace/types/user-role-data.ts"; import { getUserRoleLabel } from "@/features/workspace/types/user-role-data.ts";
import InviteActionMenu from "@/features/workspace/components/members/components/invite-action-menu.tsx"; import InviteActionMenu from "@/features/workspace/components/members/components/invite-action-menu.tsx";
import { IconInfoCircle } from "@tabler/icons-react"; import { IconInfoCircle } from "@tabler/icons-react";
@@ -8,13 +8,12 @@ import { timeAgo } from "@/lib/time.ts";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import Paginate from "@/components/common/paginate.tsx"; import Paginate from "@/components/common/paginate.tsx";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
export default function WorkspaceInvitesTable() { export default function WorkspaceInvitesTable() {
const { t } = useTranslation(); const { t } = useTranslation();
const { cursor, goNext, goPrev } = useCursorPaginate(); const [page, setPage] = useState(1);
const { data, isLoading } = useWorkspaceInvitationsQuery({ const { data, isLoading } = useWorkspaceInvitationsQuery({
cursor, page,
limit: 100, limit: 100,
}); });
const { isAdmin } = useUserRole(); const { isAdmin } = useUserRole();
@@ -66,10 +65,10 @@ export default function WorkspaceInvitesTable() {
{data?.items.length > 0 && ( {data?.items.length > 0 && (
<Paginate <Paginate
hasPrevPage={data?.meta?.hasPrevPage} currentPage={page}
hasNextPage={data?.meta?.hasNextPage} hasPrevPage={data?.meta.hasPrevPage}
onNext={() => goNext(data?.meta?.nextCursor)} hasNextPage={data?.meta.hasNextPage}
onPrev={goPrev} onPageChange={setPage}
/> />
)} )}
</> </>
@@ -21,9 +21,9 @@ import MemberActionMenu from "@/features/workspace/components/members/components
export default function WorkspaceMembersTable() { export default function WorkspaceMembersTable() {
const { t } = useTranslation(); const { t } = useTranslation();
const { search, cursor, goNext, goPrev, handleSearch } = usePaginateAndSearch(); const { search, page, setPage, handleSearch } = usePaginateAndSearch();
const { data, isLoading } = useWorkspaceMembersQuery({ const { data, isLoading } = useWorkspaceMembersQuery({
cursor, page,
limit: 100, limit: 100,
query: search, query: search,
}); });
@@ -111,10 +111,10 @@ export default function WorkspaceMembersTable() {
{data?.items.length > 0 && ( {data?.items.length > 0 && (
<Paginate <Paginate
hasPrevPage={data?.meta?.hasPrevPage} currentPage={page}
hasNextPage={data?.meta?.hasNextPage} hasPrevPage={data?.meta.hasPrevPage}
onNext={() => goNext(data?.meta?.nextCursor)} hasNextPage={data?.meta.hasNextPage}
onPrev={goPrev} onPageChange={setPage}
/> />
)} )}
</> </>
@@ -1,28 +0,0 @@
import { useState, useCallback } from "react";
export function useCursorPaginate() {
const [cursor, setCursor] = useState<string | undefined>(undefined);
const [cursorStack, setCursorStack] = useState<(string | undefined)[]>([]);
const goNext = useCallback((nextCursor: string | null | undefined) => {
if (nextCursor) {
setCursorStack((prev) => [...prev, cursor]);
setCursor(nextCursor);
}
}, [cursor]);
const goPrev = useCallback(() => {
setCursorStack((prev) => {
const next = prev.slice(0, -1);
setCursor(prev[prev.length - 1]);
return next;
});
}, []);
const resetCursor = useCallback(() => {
setCursor(undefined);
setCursorStack([]);
}, []);
return { cursor, goNext, goPrev, resetCursor };
}
@@ -2,33 +2,16 @@ import { useState, useRef, useCallback } from "react";
export function usePaginateAndSearch(initialQuery: string = "") { export function usePaginateAndSearch(initialQuery: string = "") {
const [search, setSearch] = useState(initialQuery); const [search, setSearch] = useState(initialQuery);
const [cursor, setCursor] = useState<string | undefined>(undefined); const [page, setPage] = useState(1);
const [cursorStack, setCursorStack] = useState<(string | undefined)[]>([]);
const prevSearchRef = useRef(search); const prevSearchRef = useRef(search);
const handleSearch = useCallback((newQuery: string) => { const handleSearch = useCallback((newQuery: string) => {
if (prevSearchRef.current !== newQuery) { if (prevSearchRef.current !== newQuery) {
prevSearchRef.current = newQuery; prevSearchRef.current = newQuery;
setSearch(newQuery); setSearch(newQuery);
setCursor(undefined); setPage(1);
setCursorStack([]);
} }
}, []); }, []);
const goNext = useCallback((nextCursor: string | null | undefined) => { return { search, page, setPage, handleSearch };
if (nextCursor) {
setCursorStack((prev) => [...prev, cursor]);
setCursor(nextCursor);
}
}, [cursor]);
const goPrev = useCallback(() => {
setCursorStack((prev) => {
const next = prev.slice(0, -1);
setCursor(prev[prev.length - 1]);
return next;
});
}, []);
return { search, cursor, goNext, goPrev, handleSearch };
} }
+2 -4
View File
@@ -1,7 +1,6 @@
export interface QueryParams { export interface QueryParams {
query?: string; query?: string;
cursor?: string; page?: number;
beforeCursor?: string;
limit?: number; limit?: number;
adminView?: boolean; adminView?: boolean;
} }
@@ -30,10 +29,9 @@ export interface ApiResponse<T> {
export type IPaginationMeta = { export type IPaginationMeta = {
limit: number; limit: number;
page: number;
hasNextPage: boolean; hasNextPage: boolean;
hasPrevPage: boolean; hasPrevPage: boolean;
nextCursor: string | null;
prevCursor: string | null;
}; };
export type IPagination<T> = { export type IPagination<T> = {
items: T[]; items: T[];
+4 -4
View File
@@ -11,10 +11,10 @@ import useUserRole from "@/hooks/use-user-role";
export default function Spaces() { export default function Spaces() {
const { t } = useTranslation(); const { t } = useTranslation();
const { isAdmin } = useUserRole(); const { isAdmin } = useUserRole();
const { search, cursor, goNext, goPrev, handleSearch } = usePaginateAndSearch(); const { search, page, setPage, handleSearch } = usePaginateAndSearch();
const { data, isLoading } = useGetSpacesQuery({ const { data, isLoading } = useGetSpacesQuery({
cursor, page,
limit: 30, limit: 30,
query: search, query: search,
}); });
@@ -41,10 +41,10 @@ export default function Spaces() {
<AllSpacesList <AllSpacesList
spaces={data?.items || []} spaces={data?.items || []}
onSearch={handleSearch} onSearch={handleSearch}
page={page}
hasPrevPage={data?.meta?.hasPrevPage} hasPrevPage={data?.meta?.hasPrevPage}
hasNextPage={data?.meta?.hasNextPage} hasNextPage={data?.meta?.hasNextPage}
onNext={() => goNext(data?.meta?.nextCursor)} onPageChange={setPage}
onPrev={goPrev}
/> />
</Box> </Box>
</Container> </Container>
+11 -14
View File
@@ -1,6 +1,6 @@
{ {
"name": "server", "name": "server",
"version": "0.25.1", "version": "0.24.1",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -33,28 +33,28 @@
"@ai-sdk/google": "^3.0.9", "@ai-sdk/google": "^3.0.9",
"@ai-sdk/openai": "^3.0.11", "@ai-sdk/openai": "^3.0.11",
"@ai-sdk/openai-compatible": "^2.0.12", "@ai-sdk/openai-compatible": "^2.0.12",
"@aws-sdk/client-s3": "3.982.0", "@aws-sdk/client-s3": "3.701.0",
"@aws-sdk/lib-storage": "3.982.0", "@aws-sdk/lib-storage": "3.701.0",
"@aws-sdk/s3-request-presigner": "3.982.0", "@aws-sdk/s3-request-presigner": "3.701.0",
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^9.4.0", "@fastify/multipart": "^9.3.0",
"@fastify/static": "^9.0.0", "@fastify/static": "^8.3.0",
"@langchain/core": "1.1.18", "@langchain/core": "1.1.13",
"@langchain/textsplitters": "1.0.1", "@langchain/textsplitters": "1.0.1",
"@nestjs-labs/nestjs-ioredis": "^11.0.4", "@nestjs-labs/nestjs-ioredis": "^11.0.4",
"@nestjs/bullmq": "^11.0.4", "@nestjs/bullmq": "^11.0.4",
"@nestjs/common": "^11.1.11", "@nestjs/common": "^11.1.11",
"@nestjs/config": "^4.0.2", "@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.13", "@nestjs/core": "^11.1.11",
"@nestjs/event-emitter": "^3.0.1", "@nestjs/event-emitter": "^3.0.1",
"@nestjs/jwt": "11.0.0", "@nestjs/jwt": "11.0.0",
"@nestjs/mapped-types": "^2.1.0", "@nestjs/mapped-types": "^2.1.0",
"@nestjs/passport": "^11.0.5", "@nestjs/passport": "^11.0.5",
"@nestjs/platform-fastify": "^11.1.13", "@nestjs/platform-fastify": "^11.1.11",
"@nestjs/platform-socket.io": "^11.1.13", "@nestjs/platform-socket.io": "^11.1.11",
"@nestjs/schedule": "^6.1.0", "@nestjs/schedule": "^6.1.0",
"@nestjs/terminus": "^11.0.0", "@nestjs/terminus": "^11.0.0",
"@nestjs/websockets": "^11.1.13", "@nestjs/websockets": "^11.1.11",
"@node-saml/passport-saml": "^5.1.0", "@node-saml/passport-saml": "^5.1.0",
"@react-email/components": "0.0.28", "@react-email/components": "0.0.28",
"@react-email/render": "1.0.2", "@react-email/render": "1.0.2",
@@ -76,10 +76,8 @@
"kysely-migration-cli": "^0.4.2", "kysely-migration-cli": "^0.4.2",
"kysely-postgres-js": "^3.0.0", "kysely-postgres-js": "^3.0.0",
"ldapts": "^7.4.0", "ldapts": "^7.4.0",
"lib0": "^0.2.117",
"mammoth": "^1.11.0", "mammoth": "^1.11.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"msgpackr": "^1.11.8",
"nanoid": "3.3.11", "nanoid": "3.3.11",
"nestjs-kysely": "^1.2.0", "nestjs-kysely": "^1.2.0",
"nestjs-pino": "^4.5.0", "nestjs-pino": "^4.5.0",
@@ -104,7 +102,6 @@
"socket.io": "^4.8.3", "socket.io": "^4.8.3",
"stripe": "^17.5.0", "stripe": "^17.5.0",
"tmp-promise": "^3.0.3", "tmp-promise": "^3.0.3",
"tseep": "^1.3.1",
"typesense": "^2.1.0", "typesense": "^2.1.0",
"ws": "^8.19.0", "ws": "^8.19.0",
"yauzl": "^3.2.0" "yauzl": "^3.2.0"
@@ -30,20 +30,12 @@ export class CollabWsAdapter {
return this.wss; return this.wss;
} }
public close() {
try {
this.wss.close();
} catch (err) {
console.error(err);
}
}
public destroy() { public destroy() {
try { try {
this.wss.close();
this.wss.clients.forEach((client) => { this.wss.clients.forEach((client) => {
client.terminate(); client.terminate();
}); });
this.wss.close();
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} }
@@ -1,9 +1,10 @@
import { Hocuspocus } from '@hocuspocus/server'; import { Hocuspocus, Server as HocuspocusServer } from '@hocuspocus/server';
import { IncomingMessage } from 'http'; import { IncomingMessage } from 'http';
import WebSocket from 'ws'; import WebSocket from 'ws';
import { AuthenticationExtension } from './extensions/authentication.extension'; import { AuthenticationExtension } from './extensions/authentication.extension';
import { PersistenceExtension } from './extensions/persistence.extension'; import { PersistenceExtension } from './extensions/persistence.extension';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Redis } from '@hocuspocus/extension-redis';
import { EnvironmentService } from '../integrations/environment/environment.service'; import { EnvironmentService } from '../integrations/environment/environment.service';
import { import {
createRetryStrategy, createRetryStrategy,
@@ -11,39 +12,19 @@ import {
RedisConfig, RedisConfig,
} from '../common/helpers'; } from '../common/helpers';
import { LoggerExtension } from './extensions/logger.extension'; import { LoggerExtension } from './extensions/logger.extension';
import {
RedisSyncExtension,
SerializedHTTPRequest,
} from './extensions/redis-sync';
import { WsSocketWrapper } from './extensions/redis-sync/ws-socket-wrapper';
import RedisClient from 'ioredis';
import { pack, unpack } from 'msgpackr';
import { nanoid } from 'nanoid';
import * as os from 'node:os';
import { CollabWsAdapter } from './adapter/collab-ws.adapter';
import {
CollaborationHandler,
CollabEventHandlers,
} from './collaboration.handler';
@Injectable() @Injectable()
export class CollaborationGateway { export class CollaborationGateway {
private readonly hocuspocus: Hocuspocus; private hocuspocus: Hocuspocus;
private redisConfig: RedisConfig; private redisConfig: RedisConfig;
// @ts-ignore
private readonly redisSync: RedisSyncExtension<CollabEventHandlers> | null =
null;
private readonly withRedis: boolean;
constructor( constructor(
private authenticationExtension: AuthenticationExtension, private authenticationExtension: AuthenticationExtension,
private persistenceExtension: PersistenceExtension, private persistenceExtension: PersistenceExtension,
private loggerExtension: LoggerExtension, private loggerExtension: LoggerExtension,
private environmentService: EnvironmentService, private environmentService: EnvironmentService,
private collabEventsService: CollaborationHandler,
) { ) {
this.redisConfig = parseRedisUrl(this.environmentService.getRedisUrl()); this.redisConfig = parseRedisUrl(this.environmentService.getRedisUrl());
this.withRedis = !this.environmentService.isCollabDisableRedis();
this.hocuspocus = new Hocuspocus({ this.hocuspocus = new Hocuspocus({
debounce: 10000, debounce: 10000,
@@ -53,80 +34,26 @@ export class CollaborationGateway {
this.authenticationExtension, this.authenticationExtension,
this.persistenceExtension, this.persistenceExtension,
this.loggerExtension, this.loggerExtension,
...(this.environmentService.isCollabDisableRedis()
? []
: [
new Redis({
host: this.redisConfig.host,
port: this.redisConfig.port,
options: {
password: this.redisConfig.password,
db: this.redisConfig.db,
family: this.redisConfig.family,
retryStrategy: createRetryStrategy(),
},
}),
]),
], ],
}); });
if (this.withRedis) {
// @ts-ignore
this.redisSync = new RedisSyncExtension({
redis: new RedisClient({
host: this.redisConfig.host,
port: this.redisConfig.port,
password: this.redisConfig.password,
db: this.redisConfig.db,
family: this.redisConfig.family,
retryStrategy: createRetryStrategy(),
}),
serverId: `collab-${os?.hostname()}-${nanoid(10)}`,
prefix: 'collab',
pack,
unpack,
// @ts-ignore
customEvents: this.collabEventsService.getHandlers(this.hocuspocus),
});
this.hocuspocus.configuration.extensions.push(this.redisSync);
// @ts-ignore
this.redisSync.onConfigure({ instance: this.hocuspocus });
}
}
private serializeRequest(request: IncomingMessage): SerializedHTTPRequest {
return {
method: request.method ?? 'GET',
url: request.url ?? '/',
headers: {
'sec-websocket-key': request.headers['sec-websocket-key'] ?? '',
'sec-websocket-protocol':
request.headers['sec-websocket-protocol'] ?? '',
},
socket: { remoteAddress: request.socket?.remoteAddress ?? '' },
};
} }
handleConnection(client: WebSocket, request: IncomingMessage): any { handleConnection(client: WebSocket, request: IncomingMessage): any {
if (this.redisSync) { this.hocuspocus.handleConnection(client, request);
const serializedHTTPRequest = this.serializeRequest(request);
const socketId = serializedHTTPRequest.headers['sec-websocket-key'];
// Create wrapper socket that only receives events via emit()
// This prevents double-handling since Hocuspocus won't listen to raw WebSocket events
const wrappedSocket = new WsSocketWrapper(client);
// Route through RedisSync extension (this calls handleConnection internally)
this.redisSync.onSocketOpen(wrappedSocket as any, serializedHTTPRequest);
// Forward raw WebSocket messages to the extension
client.on('message', (data: ArrayBuffer) => {
this.redisSync!.onSocketMessage(
wrappedSocket as any,
serializedHTTPRequest,
data,
);
});
// Forward close events
client.on('close', (code: number, reason: Buffer) => {
this.redisSync!.onSocketClose(socketId, code, reason);
});
// Forward pong events for keepalive
client.on('pong', (data: Buffer) => {
wrappedSocket.emit('pong', data);
});
} else {
// Fallback to direct Hocuspocus connection
this.hocuspocus.handleConnection(client, request);
}
} }
getConnectionCount() { getConnectionCount() {
@@ -137,52 +64,7 @@ export class CollaborationGateway {
return this.hocuspocus.getDocumentsCount(); return this.hocuspocus.getDocumentsCount();
} }
handleYjsEvent<TName extends keyof CollabEventHandlers>( async destroy(): Promise<void> {
eventName: TName, //await this.hocuspocus.destroy();
documentName: string,
payload: Parameters<CollabEventHandlers[TName]>[1],
) {
return this.redisSync?.handleEvent(eventName, documentName, payload);
}
openDirectConnection(documentName: string, context?: any) {
return this.hocuspocus.openDirectConnection(documentName, context);
}
/*
*Can be used before calling openDirectConnection directly
*/
async lockDocument(documentName: string) {
return this.redisSync.lockDocument(documentName);
}
/*
*Releases a document lock and stops the interval that maintains it.
*/
async releaseLock(documentName: string) {
return this.redisSync.releaseLock(documentName);
}
async destroy(collabWsAdapter: CollabWsAdapter): Promise<void> {
// eslint-disable-next-line no-async-promise-executor
await new Promise(async (resolve) => {
try {
// Wait for all documents to unload
this.hocuspocus.configuration.extensions.push({
async afterUnloadDocument({ instance }) {
if (instance.getDocumentsCount() === 0) resolve('');
},
});
collabWsAdapter?.close();
if (this.hocuspocus.getDocumentsCount() === 0) resolve('');
this.hocuspocus.closeConnections();
} catch (error) {
console.error(error);
}
});
await this.hocuspocus.hooks('onDestroy', { instance: this.hocuspocus });
} }
} }
@@ -1,42 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { Hocuspocus, Document } from '@hocuspocus/server';
export type CollabEventHandlers = ReturnType<
CollaborationHandler['getHandlers']
>;
@Injectable()
export class CollaborationHandler {
private readonly logger = new Logger(CollaborationHandler.name);
constructor() {}
getHandlers(hocuspocus: Hocuspocus) {
return {
alterState: async (documentName: string, payload: { pageId: string }) => {
// dummy
// this.logger.log('Processing', documentName, payload);
// await this.withYdocConnection(hocuspocus, documentName, {}, (doc) => {
// const fragment = doc.getXmlFragment('default');
//});
},
};
}
async withYdocConnection(
hocuspocus: Hocuspocus,
documentName: string,
context: any = {},
fn: (doc: Document) => void,
): Promise<void> {
const connection = await hocuspocus.openDirectConnection(
documentName,
context,
);
try {
await connection.transact(fn);
} finally {
await connection.disconnect();
}
}
}
@@ -9,7 +9,6 @@ import { WebSocket } from 'ws';
import { TokenModule } from '../core/auth/token.module'; import { TokenModule } from '../core/auth/token.module';
import { HistoryListener } from './listeners/history.listener'; import { HistoryListener } from './listeners/history.listener';
import { LoggerExtension } from './extensions/logger.extension'; import { LoggerExtension } from './extensions/logger.extension';
import { CollaborationHandler } from './collaboration.handler';
@Module({ @Module({
providers: [ providers: [
@@ -18,7 +17,6 @@ import { CollaborationHandler } from './collaboration.handler';
PersistenceExtension, PersistenceExtension,
LoggerExtension, LoggerExtension,
HistoryListener, HistoryListener,
CollaborationHandler,
], ],
exports: [CollaborationGateway], exports: [CollaborationGateway],
imports: [TokenModule], imports: [TokenModule],
@@ -48,12 +46,16 @@ export class CollaborationModule implements OnModuleInit, OnModuleDestroy {
}); });
wss.on('error', (error) => wss.on('error', (error) =>
this.logger.error('WebSocket server error:', error), this.logger.log('WebSocket server error:', error),
); );
} }
async onModuleDestroy(): Promise<void> { async onModuleDestroy(): Promise<void> {
await this.collaborationGateway?.destroy(this.collabWsAdapter); if (this.collaborationGateway) {
this.collabWsAdapter?.destroy(); await this.collaborationGateway.destroy();
}
if (this.collabWsAdapter) {
this.collabWsAdapter.destroy();
}
} }
} }
@@ -41,8 +41,7 @@ import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
// see: https://github.com/ueberdosis/tiptap/issues/5352 // see: https://github.com/ueberdosis/tiptap/issues/5352
// see:https://github.com/ueberdosis/tiptap/issues/4089 // see:https://github.com/ueberdosis/tiptap/issues/4089
//import { generateJSON } from '@tiptap/html'; //import { generateJSON } from '@tiptap/html';
import { Node, Schema } from '@tiptap/pm/model'; import { Node } from '@tiptap/pm/model';
import { Logger } from '@nestjs/common';
export const tiptapExtensions = [ export const tiptapExtensions = [
StarterKit.configure({ StarterKit.configure({
@@ -111,53 +110,9 @@ export function jsonToText(tiptapJson: JSONContent) {
} }
export function jsonToNode(tiptapJson: JSONContent) { export function jsonToNode(tiptapJson: JSONContent) {
const schema = getSchema(tiptapExtensions); return Node.fromJSON(getSchema(tiptapExtensions), tiptapJson);
try {
return Node.fromJSON(schema, tiptapJson);
} catch (error) {
if (
error instanceof RangeError &&
error.message.includes('Unknown node type')
) {
Logger.warn('Stripping unknown node types from document:', error.message);
const cleanedJson = stripUnknownNodes(tiptapJson, schema);
return Node.fromJSON(schema, cleanedJson);
}
throw error;
}
} }
export function getPageId(documentName: string) { export function getPageId(documentName: string) {
return documentName.split('.')[1]; return documentName.split('.')[1];
} }
function stripUnknownNodes(
json: JSONContent,
schema: Schema,
): JSONContent | null {
if (!json || typeof json !== 'object') return json;
// Recursively clean children first, flattening any unwrapped content
if (json.content && Array.isArray(json.content)) {
const newContent: JSONContent[] = [];
for (const child of json.content) {
const cleaned = stripUnknownNodes(child, schema);
if (Array.isArray(cleaned)) {
newContent.push(...cleaned);
} else if (cleaned) {
newContent.push(cleaned);
}
}
json.content = newContent;
}
// Check if this node is unknown AFTER processing children
if (json.type && !schema.nodes[json.type]) {
// Unwrap: return cleaned children directly instead of wrapping
return (
json.content && json.content.length > 0 ? json.content : null
) as any;
}
return json;
}
@@ -9,11 +9,11 @@ import { Injectable, Logger } from '@nestjs/common';
export class LoggerExtension implements Extension { export class LoggerExtension implements Extension {
private readonly logger = new Logger('Collab' + LoggerExtension.name); private readonly logger = new Logger('Collab' + LoggerExtension.name);
async onDisconnect(data: onDisconnectPayload) {
this.logger.debug(`User disconnected from "${data.documentName}".`);
}
async afterUnloadDocument(data: onLoadDocumentPayload) { async afterUnloadDocument(data: onLoadDocumentPayload) {
this.logger.debug('Unloaded ' + data.documentName + ' from memory'); this.logger.debug('Unloaded ' + data.documentName + ' from memory');
} }
async onDisconnect(data: onDisconnectPayload) {
this.logger.debug('User disconnected from ' + data.documentName);
}
} }
@@ -1,70 +0,0 @@
import type RedisClient from 'ioredis';
import { EventEmitter } from 'tseep';
import type {
Pack,
RSAMessageClose,
RSAMessagePing,
RSAMessageSend,
} from './redis-sync.types';
export class CollabProxySocket extends EventEmitter {
private readonly replyTo: string;
private readonly serverChannel: string;
private readonly socketId: string;
private pub: RedisClient;
private readonly pack: Pack;
readyState = 1;
constructor(
pub: RedisClient,
pack: Pack,
replyTo: string,
serverChannel: string,
socketId: string,
) {
super();
this.replyTo = replyTo;
this.socketId = socketId;
this.serverChannel = serverChannel;
this.pub = pub;
this.pack = pack;
this.once('close', () => {
this.readyState = 3;
});
}
private publish(msg: RSAMessageClose | RSAMessagePing | RSAMessageSend) {
this.pub.publish(this.replyTo, this.pack(msg));
}
close(code?: number, reason?: string) {
if (this.readyState !== 1) return;
const msg: RSAMessageClose = {
type: 'close',
code,
reason,
socketId: this.socketId,
};
this.publish(msg);
}
ping() {
if (this.readyState !== 1) return;
const msg: RSAMessagePing = {
type: 'ping',
socketId: this.socketId,
replyTo: this.serverChannel,
};
this.publish(msg);
}
send(message: Uint8Array) {
if (this.readyState !== 1) return;
const msg: RSAMessageSend = {
type: 'send',
socketId: this.socketId,
message,
};
this.publish(msg);
}
}
@@ -1,2 +0,0 @@
export * from './redis-sync.extension';
export type { SerializedHTTPRequest } from './redis-sync.extension';
@@ -1,378 +0,0 @@
// Source https://github.com/ueberdosis/hocuspocus/pull/1008 - MIT
import {
Extension,
Hocuspocus,
IncomingMessage,
afterUnloadDocumentPayload,
onConfigurePayload,
onLoadDocumentPayload,
} from '@hocuspocus/server';
import RedisClient from 'ioredis';
import { readVarString } from 'lib0/decoding.js';
import { CollabProxySocket } from './collab-proxy-socket';
import {
BaseWebSocket,
Configuration,
CustomEvents,
Pack,
RSAMessage,
RSAMessageCloseProxy,
RSAMessageCustomEventComplete,
RSAMessageCustomEventStart,
RSAMessagePong,
RSAMessageProxy,
RSAMessageUnload,
SerializedHTTPRequest,
Unpack,
} from './redis-sync.types';
export type { Pack, SerializedHTTPRequest } from './redis-sync.types';
type ServerId = string;
type DocumentName = string;
type SocketId = string;
export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
priority = 1000;
private readonly pub: RedisClient;
private sub: RedisClient;
private readonly pack: Pack;
private readonly unpack: Unpack;
private originSockets: Record<SocketId, BaseWebSocket> = {};
private locks: Record<DocumentName, NodeJS.Timeout> = {};
private lockPromises: Record<DocumentName, Promise<ServerId | null>> = {};
private proxySockets: Record<SocketId, CollabProxySocket> = {};
private readonly prefix: string;
private readonly lockPrefix: string;
private readonly msgChannel: string;
private readonly serverId: ServerId;
private readonly customEventTTL: number;
private readonly lockTTL: number;
private instance!: Hocuspocus;
private readonly customEvents: TCE;
private replyIdCounter: number = 0;
// @ts-ignore
private pendingReplies: Record<number, PromiseWithResolvers<any>['resolve']> =
{};
constructor(configuration: Configuration<TCE>) {
const {
redis,
pack,
unpack,
serverId,
lockTTL,
prefix,
customEvents,
customEventTTL,
} = configuration;
this.pub = redis.duplicate();
this.sub = redis.duplicate();
this.pack = pack;
this.unpack = unpack;
this.serverId = serverId;
this.lockTTL = lockTTL ?? 10_000;
this.customEventTTL = customEventTTL ?? 30_000;
this.prefix = prefix ?? 'collab';
this.lockPrefix = `${this.prefix}Lock`;
this.msgChannel = `${this.prefix}Msg`;
this.customEvents = (customEvents as any) ?? ({} as any as CustomEvents);
this.sub.subscribe(this.msgChannel, `${this.msgChannel}:${this.serverId}`);
this.sub.on('messageBuffer', this.handleRedisMessage);
this.pub.on('error', () => {});
this.sub.on('error', () => {});
}
private getKey(documentName: string) {
return `${this.lockPrefix}:${documentName}`;
}
private closeProxy(socketId: string) {
const proxySocket = this.proxySockets[socketId];
if (proxySocket) {
proxySocket.emit(
'close',
1000,
Buffer.from('provider_initiated', 'utf-8'),
);
delete this.proxySockets[socketId];
}
}
private pongProxy(socketId: string) {
this.proxySockets[socketId]?.emit('pong');
}
private handleProxyMessage(
msg: Pick<RSAMessageProxy, 'replyTo' | 'message' | 'serializedHTTPRequest'>,
) {
const { replyTo, message, serializedHTTPRequest } = msg;
const { headers } = serializedHTTPRequest;
const socketId = headers['sec-websocket-key']!;
let socket = this.proxySockets[socketId];
if (!socket) {
socket = new CollabProxySocket(
this.pub,
this.pack,
replyTo,
`${this.msgChannel}:${this.serverId}`,
socketId,
);
this.proxySockets[socketId] = socket;
this.instance.handleConnection(
socket as any,
serializedHTTPRequest as any,
{},
);
}
socket.emit('message', message);
}
private getOrClaimLock(documentName: string) {
const lockPromise = this.pub.set(
this.getKey(documentName),
this.serverId,
'PX',
this.lockTTL,
'NX',
'GET',
);
this.lockPromises[documentName] = lockPromise;
// Briefly cache the serverId that claimed the doc to reduce load on redis
// When the claimant unloads the doc, it will send an unload message to immediately clear this
// a lockTTL / 2 guarantees stale reads < lockTTL upon server crash
setTimeout(() => {
delete this.lockPromises[documentName];
}, this.lockTTL / 2);
return lockPromise;
}
private getOrClaimLockThrottled(documentName: string) {
const existingWorkerIdPromise = this.lockPromises[documentName];
if (existingWorkerIdPromise) return existingWorkerIdPromise;
return this.getOrClaimLock(documentName);
}
private handleRedisMessage = async (
_channel: Buffer,
packedMessage: Buffer,
) => {
const msg = this.unpack(packedMessage) as RSAMessage;
const { type } = msg;
if (type === 'proxy') {
this.handleProxyMessage(msg);
return;
}
if (type === 'closeProxy') {
this.closeProxy(msg.socketId);
return;
}
if (type === 'pong') {
this.pongProxy(msg.socketId);
return;
}
if (type === 'unload') {
delete this.lockPromises[msg.documentName];
return;
}
if (type === 'customEventStart') {
const { documentName, eventName, payload, replyTo, replyId } = msg;
const res = await this.handleEventLocally(
eventName as Extract<keyof TCE, string>,
documentName,
payload,
);
const reply: RSAMessageCustomEventComplete = {
type: 'customEventComplete',
replyId,
payload: res,
};
this.pub.publish(`${replyTo}`, this.pack(reply));
return;
}
if (type === 'customEventComplete') {
const { replyId, payload } = msg;
const resolveFn = this.pendingReplies[replyId];
if (!resolveFn) return;
delete this.pendingReplies[replyId];
resolveFn(payload);
return;
}
const { socketId } = msg;
const socket = this.originSockets[socketId];
if (!socket) {
// origin socket already cleaned up
return;
}
if (type === 'close') {
socket.close(msg.code, msg.reason);
} else if (type === 'ping') {
// Reply instantly to the proxy socket, without forwarding to client
// The origin socket handles heartbeat for itself
const { replyTo, socketId } = msg;
const reply: RSAMessagePong = {
type: 'pong',
socketId,
};
this.pub.publish(`${replyTo}`, this.pack(reply));
} else if (type === 'send') {
socket.send(msg.message);
}
};
async maintainLock(documentName: string) {
this.locks[documentName] = setInterval(() => {
this.pub.set(
this.getKey(documentName),
this.serverId,
'PX',
this.lockTTL,
);
}, this.lockTTL / 2);
}
async releaseLock(documentName: string) {
clearInterval(this.locks[documentName]);
delete this.locks[documentName];
return this.pub.del(this.getKey(documentName));
}
private async handleEventLocally<TName extends Extract<keyof TCE, string>>(
eventName: TName,
documentName: string,
payload: any,
) {
const handler = this.customEvents[eventName];
if (!handler) throw new Error(`Invalid eventName: ${eventName}`);
const result = await handler(documentName, payload);
return result as Promise<ReturnType<TCE[TName]>>;
}
async handleEvent<TName extends Extract<keyof TCE, string>>(
eventName: TName,
documentName: string,
payload: any,
) {
const isDocLoadedOnInstance = this.instance.documents.has(documentName);
if (isDocLoadedOnInstance) {
return this.handleEventLocally(eventName, documentName, payload);
}
const proxyTo = await this.getOrClaimLockThrottled(documentName);
if (proxyTo && proxyTo !== this.serverId) {
++this.replyIdCounter; // bug in biome thinks this.replyIdCounter is not used if written on the line below
const replyId = this.replyIdCounter;
// another server owns the doc
const proxyMessage: RSAMessageCustomEventStart = {
eventName,
documentName,
payload,
replyTo: `${this.msgChannel}:${this.serverId}`,
replyId,
type: 'customEventStart',
};
const msg = this.pack(proxyMessage);
this.pub.publish(`${this.msgChannel}:${proxyTo}`, msg);
// @ts-ignore
const { promise, resolve, reject } = Promise.withResolvers();
this.pendingReplies[replyId] = resolve;
setTimeout(() => {
reject('TIMEOUT');
}, this.customEventTTL);
return promise as Promise<ReturnType<TCE[TName]>>;
}
// This server owns the document, but hocuspocus hasn't loaded it yet
return this.handleEventLocally(eventName, documentName, payload);
}
async lockDocument(documentName: string) {
const proxyTo = await this.getOrClaimLockThrottled(documentName);
if (proxyTo && proxyTo !== this.serverId) {
throw new Error(`Could not lock document: ${documentName}`);
}
this.maintainLock(documentName);
return () => this.releaseLock(documentName);
}
/* WebSocket Server Hooks */
onSocketOpen(
ws: BaseWebSocket,
serializedHTTPRequest: SerializedHTTPRequest,
context = {},
) {
const socketId = serializedHTTPRequest.headers['sec-websocket-key']!;
this.originSockets[socketId] = ws;
this.instance.handleConnection(
ws as any,
serializedHTTPRequest as any,
context,
);
}
async onSocketMessage(
ws: BaseWebSocket,
serializedHTTPRequest: SerializedHTTPRequest,
detachableMsg: ArrayBuffer,
) {
const message = new Uint8Array(detachableMsg.slice());
const tmpMsg = new IncomingMessage(detachableMsg);
const documentName = readVarString(tmpMsg.decoder);
const isDocLoadedOnInstance = this.instance.documents.has(documentName);
if (isDocLoadedOnInstance) {
ws.emit('message', message);
return;
}
const proxyTo = await this.getOrClaimLockThrottled(documentName);
if (proxyTo && proxyTo !== this.serverId) {
// another server owns the doc
const proxyMessage: RSAMessageProxy = {
serializedHTTPRequest: serializedHTTPRequest,
replyTo: `${this.msgChannel}:${this.serverId}`,
message,
type: 'proxy',
};
const msg = this.pack(proxyMessage);
this.pub.publish(`${this.msgChannel}:${proxyTo}`, msg);
return;
}
// This server owns the document, but hocuspocus hasn't loaded it yet
ws.emit('message', message);
}
onSocketClose(socketId: string, code?: number, reason?: ArrayBuffer) {
const socket = this.originSockets[socketId];
if (!socket) return;
// at this point the socket is considered GC'd and we cannot call close
// The origin socket did not set up any connections for the proxy, so none of the hooks will work if we just emit
socket?.emit('close', code, reason);
delete this.originSockets[socketId];
const msg: RSAMessageCloseProxy = { type: 'closeProxy', socketId };
this.pub.publish(this.msgChannel, this.pack(msg)).catch(() => {});
}
/* Hocuspocus hooks */
async onConfigure({ instance }: onConfigurePayload) {
this.instance = instance;
}
async onLoadDocument(data: onLoadDocumentPayload) {
const { documentName } = data;
// Refresh the lock TTL
this.maintainLock(documentName);
}
async afterUnloadDocument(data: afterUnloadDocumentPayload) {
const { documentName } = data;
this.releaseLock(documentName);
// Broadcast to cluster to immediately remove the cached redis value
const msg: RSAMessageUnload = { type: 'unload', documentName };
this.pub.publish(this.msgChannel, this.pack(msg));
}
async onDestroy() {
this.pub.disconnect(false);
this.sub.disconnect(false);
}
}
@@ -1,121 +0,0 @@
import EventEmitter from 'node:events';
import { IncomingHttpHeaders } from 'node:http2';
import RedisClient from 'ioredis';
export type SecondParam<T> = T extends (
arg1: unknown,
arg2: infer A,
...args: unknown[]
) => unknown
? A
: never;
export type SerializedHTTPRequest = {
method: string;
url: string;
headers: IncomingHttpHeaders;
socket: { remoteAddress: string };
};
export type RSAMessageProxy = {
type: 'proxy';
replyTo: string;
message: Uint8Array<ArrayBufferLike>;
serializedHTTPRequest: SerializedHTTPRequest;
};
export type RSAMessageCloseProxy = {
type: 'closeProxy';
socketId: string;
};
export type RSAMessageUnload = {
type: 'unload';
documentName: string;
};
export type RSAMessageClose = {
type: 'close';
code?: number;
reason?: string;
socketId: string;
};
export type RSAMessagePing = {
type: 'ping';
socketId: string;
replyTo: string;
};
export type RSAMessagePong = {
type: 'pong';
socketId: string;
};
export type RSAMessageSend = {
type: 'send';
// @ts-ignore
message: Uint8Array<ArrayBufferLike>;
socketId: string;
};
export type RSAMessageCustomEventStart<TName = string, TPayload = unknown> = {
type: 'customEventStart';
documentName: string;
eventName: TName;
payload: TPayload;
replyTo: string;
replyId: number;
};
export type RSAMessageCustomEventComplete = {
type: 'customEventComplete';
replyId: number;
payload: unknown;
};
export type RSAMessage =
| RSAMessageProxy
| RSAMessageCloseProxy
| RSAMessageUnload
| RSAMessageClose
| RSAMessagePing
| RSAMessagePong
| RSAMessageSend
| RSAMessageCustomEventStart
| RSAMessageCustomEventComplete;
// @ts-ignore
export type Pack = (msg: RSAMessage) => string | Buffer<ArrayBufferLike>;
export type Unpack = (
// @ts-ignore
packedMessage: Uint8Array | Buffer<ArrayBufferLike>,
) => RSAMessage;
type ServerId = string;
type DocumentName = string;
type CustomEventName = string;
export type CustomEvents = Record<
CustomEventName,
(documentName: string, payload: unknown) => Promise<unknown>
>;
export interface Configuration<TCE> {
redis: RedisClient;
pack: Pack;
unpack: Unpack;
serverId: ServerId;
lockTTL?: number;
customEventTTL?: number;
prefix?: string;
customEvents?: TCE;
}
export type BaseWebSocket = EventEmitter & {
readyState: number;
close(code?: number, reason?: string): void;
ping(): void;
send(message: Uint8Array): void;
};
@@ -1,47 +0,0 @@
import { EventEmitter } from 'events';
import type WebSocket from 'ws';
/**
* Wrapper around ws WebSocket that only receives events via emit().
* This prevents double-handling when used with RedisSyncExtension.
*/
export class WsSocketWrapper extends EventEmitter {
private ws: WebSocket;
readyState = 1;
constructor(ws: WebSocket) {
super();
this.ws = ws;
this.once('close', () => {
this.readyState = 3;
});
}
close(code?: number, reason?: string) {
if (this.readyState !== 1) return;
this.readyState = 3;
try {
this.ws.close(code, reason);
} catch (e) {
// Socket already closed
}
}
ping() {
if (this.readyState !== 1) return;
try {
this.ws.ping();
} catch (e) {
// Socket already closed
}
}
send(message: Uint8Array) {
if (this.readyState !== 1) return;
try {
this.ws.send(message);
} catch (e) {
// Socket already closed
}
}
}
@@ -3,7 +3,6 @@ import { OnEvent } from '@nestjs/event-emitter';
import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo'; import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
import { Page } from '@docmost/db/types/entity.types'; import { Page } from '@docmost/db/types/entity.types';
import { isDeepStrictEqual } from 'node:util'; import { isDeepStrictEqual } from 'node:util';
import { EnvironmentService } from '../../integrations/environment/environment.service';
export class UpdatedPageEvent { export class UpdatedPageEvent {
page: Page; page: Page;
@@ -13,10 +12,7 @@ export class UpdatedPageEvent {
export class HistoryListener { export class HistoryListener {
private readonly logger = new Logger(HistoryListener.name); private readonly logger = new Logger(HistoryListener.name);
constructor( constructor(private readonly pageHistoryRepo: PageHistoryRepo) {}
private readonly pageHistoryRepo: PageHistoryRepo,
private readonly environmentService: EnvironmentService,
) {}
@OnEvent('collab.page.updated') @OnEvent('collab.page.updated')
async handleCreatePageHistory(event: UpdatedPageEvent) { async handleCreatePageHistory(event: UpdatedPageEvent) {
@@ -24,17 +20,13 @@ export class HistoryListener {
const pageCreationTime = new Date(page.createdAt).getTime(); const pageCreationTime = new Date(page.createdAt).getTime();
const currentTime = Date.now(); const currentTime = Date.now();
const FIVE_MINUTES = this.environmentService.isDevelopment() const FIVE_MINUTES = 5 * 60 * 1000;
? 60 * 1000
: 5 * 60 * 1000;
if (currentTime - pageCreationTime < FIVE_MINUTES) { if (currentTime - pageCreationTime < FIVE_MINUTES) {
return; return;
} }
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(page.id, { const lastHistory = await this.pageHistoryRepo.findPageLastHistory(page.id);
includeContent: true,
});
if ( if (
!lastHistory || !lastHistory ||
@@ -12,15 +12,12 @@ async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>( const app = await NestFactory.create<NestFastifyApplication>(
CollabAppModule, CollabAppModule,
new FastifyAdapter({ new FastifyAdapter({
routerOptions: { ignoreTrailingSlash: true,
maxParamLength: 1000, ignoreDuplicateSlashes: true,
ignoreTrailingSlash: true, maxParamLength: 500,
ignoreDuplicateSlashes: true,
},
}), }),
{ {
logger: false, bufferLogs: true,
bufferLogs: false,
}, },
); );
@@ -37,8 +34,7 @@ async function bootstrap() {
const logger = new Logger('CollabServer'); const logger = new Logger('CollabServer');
const port = process.env.COLLAB_PORT || 3001; const port = process.env.COLLAB_PORT || 3001;
const host = process.env.HOST || '0.0.0.0'; await app.listen(port, '0.0.0.0', () => {
await app.listen(port, host, () => {
logger.log(`Listening on http://127.0.0.1:${port}`); logger.log(`Listening on http://127.0.0.1:${port}`);
}); });
} }
@@ -1,16 +0,0 @@
export type ExportPageMetadata = {
pageId: string;
slugId: string;
icon: string | null;
position: string;
parentPath: string | null;
createdAt: string;
updatedAt: string;
};
export type ExportMetadata = {
exportedAt: string;
source: 'docmost';
version: string;
pages: Record<string, ExportPageMetadata>;
};
+8 -15
View File
@@ -5,14 +5,13 @@ const CONTEXTS_TO_IGNORE = [
'InstanceLoader', 'InstanceLoader',
'RoutesResolver', 'RoutesResolver',
'RouterExplorer', 'RouterExplorer',
'LegacyRouteConverter',
'WebSocketsController', 'WebSocketsController',
]; ];
export function createPinoConfig(): Params { export function createPinoConfig(): Params {
const isProduction = process.env.NODE_ENV?.toLowerCase() === 'production'; const isProduction = process.env.NODE_ENV === 'production';
const isDebugMode = process.env.DEBUG_MODE?.toLowerCase() === 'true'; const isDebugMode = process.env.DEBUG_MODE === 'true';
const logHttp = process.env.LOG_HTTP?.toLowerCase() === 'true'; const logHttp = process.env.LOG_HTTP === 'true';
const level = isProduction && !isDebugMode ? 'info' : 'debug'; const level = isProduction && !isDebugMode ? 'info' : 'debug';
@@ -33,20 +32,14 @@ export function createPinoConfig(): Params {
: undefined, : undefined,
formatters: { formatters: {
level: (label) => ({ level: label }), level: (label) => ({ level: label }),
}, log: (object: Record<string, unknown>) => {
hooks: {
logMethod(inputArgs, method) {
if (isProduction && !isDebugMode) { if (isProduction && !isDebugMode) {
for (const arg of inputArgs) { const context = object['context'] as string | undefined;
if (typeof arg === 'object' && arg !== null && 'context' in arg) { if (context && CONTEXTS_TO_IGNORE.includes(context)) {
const context = (arg as Record<string, unknown>)['context']; return { filtered: true };
if (typeof context === 'string' && CONTEXTS_TO_IGNORE.includes(context)) {
return;
}
}
} }
} }
return method.apply(this, inputArgs); return object;
}, },
}, },
serializers: { serializers: {
@@ -181,9 +181,7 @@ export class AttachmentController {
} }
try { try {
const fileStream = await this.storageService.readStream( const fileStream = await this.storageService.read(attachment.filePath);
attachment.filePath,
);
res.headers({ res.headers({
'Content-Type': attachment.mimeType, 'Content-Type': attachment.mimeType,
'Cache-Control': 'private, max-age=3600', 'Cache-Control': 'private, max-age=3600',
@@ -243,9 +241,7 @@ export class AttachmentController {
} }
try { try {
const fileStream = await this.storageService.readStream( const fileStream = await this.storageService.read(attachment.filePath);
attachment.filePath,
);
res.headers({ res.headers({
'Content-Type': attachment.mimeType, 'Content-Type': attachment.mimeType,
'Cache-Control': 'public, max-age=3600', 'Cache-Control': 'public, max-age=3600',
@@ -371,14 +367,14 @@ export class AttachmentController {
const filePath = `${getAttachmentFolderPath(attachmentType, workspace.id)}/${fileName}`; const filePath = `${getAttachmentFolderPath(attachmentType, workspace.id)}/${fileName}`;
try { try {
const fileStream = await this.storageService.readStream(filePath); const fileStream = await this.storageService.read(filePath);
res.headers({ res.headers({
'Content-Type': getMimeType(filePath), 'Content-Type': getMimeType(filePath),
'Cache-Control': 'private, max-age=86400', 'Cache-Control': 'private, max-age=86400',
}); });
return res.send(fileStream); return res.send(fileStream);
} catch (err) { } catch (err) {
// this.logger.error(err); // this.logger.error(err);
throw new NotFoundException('File not found'); throw new NotFoundException('File not found');
} }
} }
@@ -9,14 +9,16 @@ import { UpdateCommentDto } from './dto/update-comment.dto';
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo'; import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
import { Comment, Page, User } from '@docmost/db/types/entity.types'; import { Comment, Page, User } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { PaginationResult } from '@docmost/db/pagination/pagination';
import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination'; import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
@Injectable() @Injectable()
export class CommentService { export class CommentService {
constructor( constructor(
private commentRepo: CommentRepo, private commentRepo: CommentRepo,
private pageRepo: PageRepo, private pageRepo: PageRepo,
private spaceMemberRepo: SpaceMemberRepo,
) {} ) {}
async findById(commentId: string) { async findById(commentId: string) {
@@ -66,14 +68,14 @@ export class CommentService {
async findByPageId( async findByPageId(
pageId: string, pageId: string,
pagination: PaginationOptions, pagination: PaginationOptions,
): Promise<CursorPaginationResult<Comment>> { ): Promise<PaginationResult<Comment>> {
const page = await this.pageRepo.findById(pageId); const page = await this.pageRepo.findById(pageId);
if (!page) { if (!page) {
throw new BadRequestException('Page not found'); throw new BadRequestException('Page not found');
} }
return this.commentRepo.findPageComments(pageId, pagination); return await this.commentRepo.findPageComments(pageId, pagination);
} }
async update( async update(
@@ -11,7 +11,7 @@ import { UpdateGroupDto } from '../dto/update-group.dto';
import { KyselyTransaction } from '@docmost/db/types/kysely.types'; import { KyselyTransaction } from '@docmost/db/types/kysely.types';
import { GroupRepo } from '@docmost/db/repos/group/group.repo'; import { GroupRepo } from '@docmost/db/repos/group/group.repo';
import { Group, InsertableGroup, User } from '@docmost/db/types/entity.types'; import { Group, InsertableGroup, User } from '@docmost/db/types/entity.types';
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination'; import { PaginationResult } from '@docmost/db/pagination/pagination';
import { GroupUserService } from './group-user.service'; import { GroupUserService } from './group-user.service';
@Injectable() @Injectable()
@@ -132,8 +132,12 @@ export class GroupService {
async getWorkspaceGroups( async getWorkspaceGroups(
workspaceId: string, workspaceId: string,
paginationOptions: PaginationOptions, paginationOptions: PaginationOptions,
): Promise<CursorPaginationResult<Group>> { ): Promise<PaginationResult<Group>> {
return this.groupRepo.getGroupsPaginated(workspaceId, paginationOptions); const groups = await this.groupRepo.getGroupsPaginated(
workspaceId,
paginationOptions,
);
return groups;
} }
async deleteGroup(groupId: string, workspaceId: string): Promise<void> { async deleteGroup(groupId: string, workspaceId: string): Promise<void> {
@@ -215,6 +215,7 @@ export class PageController {
} }
} }
// TODO: scope to workspaces
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('/history') @Post('/history')
async getPageHistory( async getPageHistory(
@@ -2,25 +2,25 @@ import { Injectable } from '@nestjs/common';
import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo'; import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
import { PageHistory } from '@docmost/db/types/entity.types'; import { PageHistory } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination'; import { PaginationResult } from '@docmost/db/pagination/pagination';
@Injectable() @Injectable()
export class PageHistoryService { export class PageHistoryService {
constructor(private pageHistoryRepo: PageHistoryRepo) {} constructor(private pageHistoryRepo: PageHistoryRepo) {}
async findById(historyId: string): Promise<PageHistory> { async findById(historyId: string): Promise<PageHistory> {
return await this.pageHistoryRepo.findById(historyId, { return await this.pageHistoryRepo.findById(historyId);
includeContent: true,
});
} }
async findHistoryByPageId( async findHistoryByPageId(
pageId: string, pageId: string,
paginationOptions: PaginationOptions, paginationOptions: PaginationOptions,
): Promise<CursorPaginationResult<PageHistory>> { ): Promise<PaginationResult<any>> {
return this.pageHistoryRepo.findPageHistoryByPageId( const pageHistory = await this.pageHistoryRepo.findPageHistoryByPageId(
pageId, pageId,
paginationOptions, paginationOptions,
); );
return pageHistory;
} }
} }
@@ -10,9 +10,9 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { InsertablePage, Page, User } from '@docmost/db/types/entity.types'; import { InsertablePage, Page, User } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { import {
CursorPaginationResult, executeWithPagination,
executeWithCursorPagination, PaginationResult,
} from '@docmost/db/pagination/cursor-pagination'; } from '@docmost/db/pagination/pagination';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types'; import { KyselyDB } from '@docmost/db/types/kysely.types';
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered'; import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
@@ -180,7 +180,7 @@ export class PageService {
spaceId: string, spaceId: string,
pagination: PaginationOptions, pagination: PaginationOptions,
pageId?: string, pageId?: string,
): Promise<CursorPaginationResult<Partial<Page> & { hasChildren: boolean }>> { ): Promise<any> {
let query = this.db let query = this.db
.selectFrom('pages') .selectFrom('pages')
.select([ .select([
@@ -195,6 +195,7 @@ export class PageService {
'deletedAt', 'deletedAt',
]) ])
.select((eb) => this.pageRepo.withHasChildren(eb)) .select((eb) => this.pageRepo.withHasChildren(eb))
.orderBy('position', (ob) => ob.collate('C').asc())
.where('deletedAt', 'is', null) .where('deletedAt', 'is', null)
.where('spaceId', '=', spaceId); .where('spaceId', '=', spaceId);
@@ -204,19 +205,12 @@ export class PageService {
query = query.where('parentPageId', 'is', null); query = query.where('parentPageId', 'is', null);
} }
return executeWithCursorPagination(query, { const result = executeWithPagination(query, {
page: pagination.page,
perPage: 250, perPage: 250,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [
{ expression: 'position', direction: 'asc', orderModifier: (ob) => ob.collate('C').asc() },
{ expression: 'id', direction: 'asc' },
],
parseCursor: (cursor) => ({
position: cursor.position,
id: cursor.id,
}),
}); });
return result;
} }
async movePageToSpace(rootPage: Page, spaceId: string) { async movePageToSpace(rootPage: Page, spaceId: string) {
@@ -265,7 +259,7 @@ export class PageService {
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, { await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
pageId: pageIds, pageId: pageIds,
workspaceId: rootPage.workspaceId, workspaceId: rootPage.workspaceId
}); });
} }
}); });
@@ -393,14 +387,9 @@ export class PageService {
workspaceId: page.workspaceId, workspaceId: page.workspaceId,
creatorId: authUser.id, creatorId: authUser.id,
lastUpdatedById: authUser.id, lastUpdatedById: authUser.id,
parentPageId: parentPageId: page.id === rootPage.id
page.id === rootPage.id ? (isDuplicateInSameSpace ? rootPage.parentPageId : null)
? isDuplicateInSameSpace : (page.parentPageId ? pageMap.get(page.parentPageId)?.newPageId : null),
? rootPage.parentPageId
: null
: page.parentPageId
? pageMap.get(page.parentPageId)?.newPageId
: null,
}; };
}), }),
); );
@@ -580,22 +569,22 @@ export class PageService {
async getRecentSpacePages( async getRecentSpacePages(
spaceId: string, spaceId: string,
pagination: PaginationOptions, pagination: PaginationOptions,
): Promise<CursorPaginationResult<Page>> { ): Promise<PaginationResult<Page>> {
return this.pageRepo.getRecentPagesInSpace(spaceId, pagination); return await this.pageRepo.getRecentPagesInSpace(spaceId, pagination);
} }
async getRecentPages( async getRecentPages(
userId: string, userId: string,
pagination: PaginationOptions, pagination: PaginationOptions,
): Promise<CursorPaginationResult<Page>> { ): Promise<PaginationResult<Page>> {
return this.pageRepo.getRecentPages(userId, pagination); return await this.pageRepo.getRecentPages(userId, pagination);
} }
async getDeletedSpacePages( async getDeletedSpacePages(
spaceId: string, spaceId: string,
pagination: PaginationOptions, pagination: PaginationOptions,
): Promise<CursorPaginationResult<Page>> { ): Promise<PaginationResult<Page>> {
return this.pageRepo.getDeletedPagesInSpace(spaceId, pagination); return await this.pageRepo.getDeletedPagesInSpace(spaceId, pagination);
} }
async forceDelete(pageId: string, workspaceId: string): Promise<void> { async forceDelete(pageId: string, workspaceId: string): Promise<void> {
@@ -26,11 +26,11 @@ export class SearchService {
userId?: string; userId?: string;
workspaceId: string; workspaceId: string;
}, },
): Promise<{ items: SearchResponseDto[] }> { ): Promise<SearchResponseDto[]> {
const { query } = searchParams; const { query } = searchParams;
if (query.length < 1) { if (query.length < 1) {
return { items: [] }; return;
} }
const searchQuery = tsquery(query.trim() + '*'); const searchQuery = tsquery(query.trim() + '*');
@@ -62,7 +62,7 @@ export class SearchService {
) )
.where('deletedAt', 'is', null) .where('deletedAt', 'is', null)
.orderBy('rank', 'desc') .orderBy('rank', 'desc')
.limit(searchParams.limit || 25) .limit(searchParams.limit | 25)
.offset(searchParams.offset || 0); .offset(searchParams.offset || 0);
if (!searchParams.shareId) { if (!searchParams.shareId) {
@@ -86,7 +86,7 @@ export class SearchService {
const shareId = searchParams.shareId; const shareId = searchParams.shareId;
const share = await this.shareRepo.findById(shareId); const share = await this.shareRepo.findById(shareId);
if (!share || share.workspaceId !== opts.workspaceId) { if (!share || share.workspaceId !== opts.workspaceId) {
return { items: [] }; return [];
} }
const pageIdsToSearch = []; const pageIdsToSearch = [];
@@ -108,10 +108,10 @@ export class SearchService {
.where('id', 'in', pageIdsToSearch) .where('id', 'in', pageIdsToSearch)
.where('workspaceId', '=', opts.workspaceId); .where('workspaceId', '=', opts.workspaceId);
} else { } else {
return { items: [] }; return [];
} }
} else { } else {
return { items: [] }; return [];
} }
//@ts-ignore //@ts-ignore
@@ -127,7 +127,7 @@ export class SearchService {
return result; return result;
}); });
return { items: searchResults }; return searchResults;
} }
async searchSuggestions( async searchSuggestions(
@@ -13,7 +13,7 @@ import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { RemoveSpaceMemberDto } from '../dto/remove-space-member.dto'; import { RemoveSpaceMemberDto } from '../dto/remove-space-member.dto';
import { UpdateSpaceMemberRoleDto } from '../dto/update-space-member-role.dto'; import { UpdateSpaceMemberRoleDto } from '../dto/update-space-member-role.dto';
import { SpaceRole } from '../../../common/helpers/types/permission'; import { SpaceRole } from '../../../common/helpers/types/permission';
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination'; import { PaginationResult } from '@docmost/db/pagination/pagination';
@Injectable() @Injectable()
export class SpaceMemberService { export class SpaceMemberService {
@@ -68,16 +68,18 @@ export class SpaceMemberService {
spaceId: string, spaceId: string,
workspaceId: string, workspaceId: string,
pagination: PaginationOptions, pagination: PaginationOptions,
): Promise<CursorPaginationResult<any>> { ) {
const space = await this.spaceRepo.findById(spaceId, workspaceId); const space = await this.spaceRepo.findById(spaceId, workspaceId);
if (!space) { if (!space) {
throw new NotFoundException('Space not found'); throw new NotFoundException('Space not found');
} }
return await this.spaceMemberRepo.getSpaceMembersPaginated( const members = await this.spaceMemberRepo.getSpaceMembersPaginated(
spaceId, spaceId,
pagination, pagination,
); );
return members;
} }
async addMembersToSpaceBatch( async addMembersToSpaceBatch(
@@ -274,7 +276,7 @@ export class SpaceMemberService {
async getUserSpaces( async getUserSpaces(
userId: string, userId: string,
pagination: PaginationOptions, pagination: PaginationOptions,
): Promise<CursorPaginationResult<Space>> { ): Promise<PaginationResult<Space>> {
return this.spaceMemberRepo.getUserSpaces(userId, pagination); return await this.spaceMemberRepo.getUserSpaces(userId, pagination);
} }
} }

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