Compare commits

..

2 Commits

Author SHA1 Message Date
Philipinho 207c119792 fix collab server logging too 2026-01-29 00:41:00 +00:00
Philipinho 4683a85a8b - fix: set default Nest logger and bufferLogs to false for pino compatibility
- handle redis error event
2026-01-29 00:36:48 +00:00
146 changed files with 1764 additions and 4484 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "client", "name": "client",
"private": true, "private": true,
"version": "0.25.2", "version": "0.24.1",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
@@ -41,7 +41,7 @@
"Date": "Datum", "Date": "Datum",
"Delete": "Löschen", "Delete": "Löschen",
"Delete group": "Gruppe löschen", "Delete group": "Gruppe löschen",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Sind Sie sicher, dass Sie diese Seite löschen möchten? Dabei werden auch alle Unterseiten und der Seitenverlauf gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.", "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Sind Sie sicher, dass Sie diese Seite löschen möchten? Dadurch werden ihre Unterseiten und die Seitengeschichte gelöscht. Diese Aktion ist unwiderruflich.",
"Description": "Beschreibung", "Description": "Beschreibung",
"Details": "Details", "Details": "Details",
"e.g ACME": "z.B. ACME", "e.g ACME": "z.B. ACME",
@@ -66,7 +66,7 @@
"Enter your new preferred email": "Geben Sie Ihre neue bevorzugte E-Mail ein", "Enter your new preferred email": "Geben Sie Ihre neue bevorzugte E-Mail ein",
"Enter your password": "Geben Sie Ihr Passwort ein", "Enter your password": "Geben Sie Ihr Passwort ein",
"Error fetching page data.": "Fehler beim Abrufen der Seitendaten.", "Error fetching page data.": "Fehler beim Abrufen der Seitendaten.",
"Error loading page history.": "Fehler beim Laden des Seitenverlaufs.", "Error loading page history.": "Fehler beim Laden der Seitengeschichte.",
"Export": "Exportieren", "Export": "Exportieren",
"Failed to create page": "Erstellung der Seite fehlgeschlagen", "Failed to create page": "Erstellung der Seite fehlgeschlagen",
"Failed to delete page": "Löschen der Seite fehlgeschlagen", "Failed to delete page": "Löschen der Seite fehlgeschlagen",
@@ -114,7 +114,7 @@
"New page": "Neue Seite", "New page": "Neue Seite",
"New password": "Neues Passwort", "New password": "Neues Passwort",
"No group found": "Keine Gruppe gefunden", "No group found": "Keine Gruppe gefunden",
"No page history saved yet.": "Es wurde noch kein Seitenverlauf gespeichert.", "No page history saved yet.": "Es wurde noch keine Seitengeschichte gespeichert.",
"No pages yet": "Noch keine Seiten", "No pages yet": "Noch keine Seiten",
"No results found...": "Keine Ergebnisse gefunden...", "No results found...": "Keine Ergebnisse gefunden...",
"No user found": "Kein Benutzer gefunden", "No user found": "Kein Benutzer gefunden",
@@ -122,9 +122,7 @@
"Owner": "Besitzer", "Owner": "Besitzer",
"page": "Seite", "page": "Seite",
"Page deleted successfully": "Seite erfolgreich gelöscht", "Page deleted successfully": "Seite erfolgreich gelöscht",
"Page history": "Seitenverlauf", "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",
@@ -407,21 +405,6 @@
"Share deleted successfully": "Freigabe erfolgreich gelöscht", "Share deleted successfully": "Freigabe erfolgreich gelöscht",
"Share not found": "Freigabe nicht gefunden", "Share not found": "Freigabe nicht gefunden",
"Failed to share page": "Fehler beim Teilen der Seite", "Failed to share page": "Fehler beim Teilen der Seite",
"Disable public sharing": "Öffentliches Teilen deaktivieren",
"Prevent members from sharing pages publicly.": "Verhindern Sie, dass Mitglieder Seiten öffentlich teilen.",
"Toggle public sharing": "Öffentliches Teilen umschalten",
"Toggle space public sharing": "Öffentliches Teilen im Bereich umschalten",
"Public sharing is disabled at the workspace level": "Öffentliches Teilen ist auf der Arbeitsbereichsebene deaktiviert",
"Prevent pages in this space from being shared publicly.": "Verhindern Sie, dass Seiten in diesem Bereich öffentlich geteilt werden.",
"Requires an enterprise license": "Erfordert eine Unternehmenslizenz",
"Enable public sharing": "Öffentliches Teilen aktivieren",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Sind Sie sicher, dass Sie das öffentliche Teilen aktivieren möchten? Mitglieder können Seiten öffentlich teilen.",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Sind Sie sicher, dass Sie das öffentliche Teilen deaktivieren möchten? Alle bestehenden Freigabelinks in diesem Arbeitsbereich werden gelöscht.",
"Are you sure you want to enable public sharing for this space?": "Sind Sie sicher, dass Sie das öffentliche Teilen für diesen Bereich aktivieren möchten?",
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Sind Sie sicher, dass Sie das öffentliche Teilen deaktivieren möchten? Alle bestehenden Freigabelinks in diesem Bereich werden gelöscht.",
"Public sharing is disabled": "Öffentliches Teilen ist deaktiviert",
"Public sharing has been disabled at the workspace level.": "Das öffentliche Teilen wurde auf der Arbeitsbereichsebene deaktiviert.",
"Public sharing has been disabled for this space.": "Das öffentliche Teilen wurde für diesen Bereich deaktiviert.",
"Copy page": "Seite kopieren", "Copy page": "Seite kopieren",
"Copy page to a different space.": "Seite in einen anderen Bereich kopieren.", "Copy page to a different space.": "Seite in einen anderen Bereich kopieren.",
"Page copied successfully": "Seite erfolgreich kopiert", "Page copied successfully": "Seite erfolgreich kopiert",
@@ -123,8 +123,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",
@@ -407,21 +405,6 @@
"Share deleted successfully": "Share deleted successfully", "Share deleted successfully": "Share deleted successfully",
"Share not found": "Share not found", "Share not found": "Share not found",
"Failed to share page": "Failed to share page", "Failed to share page": "Failed to share page",
"Disable public sharing": "Disable public sharing",
"Prevent members from sharing pages publicly.": "Prevent members from sharing pages publicly.",
"Toggle public sharing": "Toggle public sharing",
"Toggle space public sharing": "Toggle space public sharing",
"Public sharing is disabled at the workspace level": "Public sharing is disabled at the workspace level",
"Prevent pages in this space from being shared publicly.": "Prevent pages in this space from being shared publicly.",
"Requires an enterprise license": "Requires an enterprise license",
"Enable public sharing": "Enable public sharing",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Are you sure you want to enable public sharing? Members will be able to share pages publicly.",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.",
"Are you sure you want to enable public sharing for this space?": "Are you sure you want to enable public sharing for this space?",
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.",
"Public sharing is disabled": "Public sharing is disabled",
"Public sharing has been disabled at the workspace level.": "Public sharing has been disabled at the workspace level.",
"Public sharing has been disabled for this space.": "Public sharing has been disabled for this space.",
"Copy page": "Copy page", "Copy page": "Copy page",
"Copy page to a different space.": "Copy page to a different space.", "Copy page to a different space.": "Copy page to a different space.",
"Page copied successfully": "Page copied successfully", "Page copied successfully": "Page copied successfully",
@@ -123,8 +123,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",
@@ -407,21 +405,6 @@
"Share deleted successfully": "Compartición eliminada con éxito", "Share deleted successfully": "Compartición eliminada con éxito",
"Share not found": "Compartición no encontrada", "Share not found": "Compartición no encontrada",
"Failed to share page": "Error al compartir la página", "Failed to share page": "Error al compartir la página",
"Disable public sharing": "Desactivar el uso compartido público",
"Prevent members from sharing pages publicly.": "Evitar que los miembros compartan páginas públicamente.",
"Toggle public sharing": "Alternar el uso compartido público",
"Toggle space public sharing": "Alternar el uso compartido público del espacio",
"Public sharing is disabled at the workspace level": "El uso compartido público está desactivado a nivel de espacio de trabajo",
"Prevent pages in this space from being shared publicly.": "Evitar que las páginas en este espacio se compartan públicamente.",
"Requires an enterprise license": "Requiere una licencia empresarial",
"Enable public sharing": "Activar el uso compartido público",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "¿Está seguro de que desea activar el uso compartido público? Los miembros podrán compartir páginas públicamente.",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "¿Está seguro de que desea desactivar el uso compartido público? Todos los enlaces compartidos existentes en este espacio de trabajo se eliminarán.",
"Are you sure you want to enable public sharing for this space?": "¿Está seguro de que desea activar el uso compartido público para este espacio?",
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "¿Está seguro de que desea desactivar el uso compartido público? Todos los enlaces compartidos existentes en este espacio se eliminarán.",
"Public sharing is disabled": "El uso compartido público está desactivado",
"Public sharing has been disabled at the workspace level.": "El uso compartido público se ha desactivado a nivel de espacio de trabajo.",
"Public sharing has been disabled for this space.": "El uso compartido público se ha desactivado para este espacio.",
"Copy page": "Copiar página", "Copy page": "Copiar página",
"Copy page to a different space.": "Copiar página en otro espacio", "Copy page to a different space.": "Copiar página en otro espacio",
"Page copied successfully": "Página copiada exitosamente", "Page copied successfully": "Página copiada exitosamente",
@@ -123,8 +123,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",
@@ -407,21 +405,6 @@
"Share deleted successfully": "Partage supprimé avec succès", "Share deleted successfully": "Partage supprimé avec succès",
"Share not found": "Partage non trouvé", "Share not found": "Partage non trouvé",
"Failed to share page": "Échec du partage de la page", "Failed to share page": "Échec du partage de la page",
"Disable public sharing": "Désactiver le partage public",
"Prevent members from sharing pages publicly.": "Empêcher les membres de partager des pages publiquement.",
"Toggle public sharing": "Basculer le partage public",
"Toggle space public sharing": "Basculer le partage public de l'espace",
"Public sharing is disabled at the workspace level": "Le partage public est désactivé au niveau de l'espace de travail",
"Prevent pages in this space from being shared publicly.": "Empêcher les pages de cet espace d'être partagées publiquement.",
"Requires an enterprise license": "Nécessite une licence d'entreprise",
"Enable public sharing": "Activer le partage public",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Êtes-vous sûr de vouloir activer le partage public ? Les membres pourront partager des pages publiquement.",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Êtes-vous sûr de vouloir désactiver le partage public ? Tous les liens partagés existants dans cet espace de travail seront supprimés.",
"Are you sure you want to enable public sharing for this space?": "Êtes-vous sûr de vouloir activer le partage public pour cet espace ?",
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Êtes-vous sûr de vouloir désactiver le partage public ? Tous les liens partagés existants dans cet espace seront supprimés.",
"Public sharing is disabled": "Le partage public est désactivé",
"Public sharing has been disabled at the workspace level.": "Le partage public a été désactivé au niveau de l'espace de travail.",
"Public sharing has been disabled for this space.": "Le partage public a été désactivé pour cet espace.",
"Copy page": "Copier la page", "Copy page": "Copier la page",
"Copy page to a different space.": "Copier la page dans un autre espace.", "Copy page to a different space.": "Copier la page dans un autre espace.",
"Page copied successfully": "Page copiée avec succès", "Page copied successfully": "Page copiée avec succès",
@@ -123,8 +123,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",
@@ -407,21 +405,6 @@
"Share deleted successfully": "Condivisione eliminata con successo", "Share deleted successfully": "Condivisione eliminata con successo",
"Share not found": "Condivisione non trovata", "Share not found": "Condivisione non trovata",
"Failed to share page": "Condivisione della pagina fallita", "Failed to share page": "Condivisione della pagina fallita",
"Disable public sharing": "Disabilita la condivisione pubblica",
"Prevent members from sharing pages publicly.": "Impedisci ai membri di condividere pubblicamente le pagine.",
"Toggle public sharing": "Attiva/disattiva la condivisione pubblica",
"Toggle space public sharing": "Attiva/disattiva la condivisione pubblica nello spazio",
"Public sharing is disabled at the workspace level": "La condivisione pubblica è disabilitata a livello di area di lavoro",
"Prevent pages in this space from being shared publicly.": "Impedisci che le pagine in questo spazio vengano condivise pubblicamente.",
"Requires an enterprise license": "Richiede una licenza enterprise",
"Enable public sharing": "Abilita la condivisione pubblica",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Sei sicuro di voler abilitare la condivisione pubblica? I membri potranno condividere le pagine pubblicamente.",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Sei sicuro di voler disabilitare la condivisione pubblica? Tutti i link condivisi esistenti in questa area di lavoro verranno eliminati.",
"Are you sure you want to enable public sharing for this space?": "Sei sicuro di voler abilitare la condivisione pubblica per questo spazio?",
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Sei sicuro di voler disabilitare la condivisione pubblica? Tutti i link condivisi esistenti in questo spazio verranno eliminati.",
"Public sharing is disabled": "La condivisione pubblica è disabilitata",
"Public sharing has been disabled at the workspace level.": "La condivisione pubblica è stata disabilitata a livello di area di lavoro.",
"Public sharing has been disabled for this space.": "La condivisione pubblica è stata disabilitata per questo spazio.",
"Copy page": "Copia pagina", "Copy page": "Copia pagina",
"Copy page to a different space.": "Copia pagina in un altro spazio.", "Copy page to a different space.": "Copia pagina in un altro spazio.",
"Page copied successfully": "Pagina copiata con successo", "Page copied successfully": "Pagina copiata con successo",
@@ -123,8 +123,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": "ページ",
@@ -407,21 +405,6 @@
"Share deleted successfully": "共有を削除しました", "Share deleted successfully": "共有を削除しました",
"Share not found": "共有が見つかりません", "Share not found": "共有が見つかりません",
"Failed to share page": "ページの共有に失敗しました", "Failed to share page": "ページの共有に失敗しました",
"Disable public sharing": "公開共有を無効にする",
"Prevent members from sharing pages publicly.": "メンバーがページを公開で共有するのを防ぐ。",
"Toggle public sharing": "公開共有を切り替える",
"Toggle space public sharing": "スペースの公開共有を切り替える",
"Public sharing is disabled at the workspace level": "ワークスペースレベルで公開共有が無効になっています",
"Prevent pages in this space from being shared publicly.": "このスペース内のページが公開で共有されるのを防ぐ。",
"Requires an enterprise license": "エンタープライズライセンスが必要です",
"Enable public sharing": "公開共有を有効にする",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "本当に公開共有を有効にしますか?メンバーはページを公開で共有できるようになります。",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "本当に公開共有を無効にしますか?このワークスペース内のすべての既存の共有リンクが削除されます。",
"Are you sure you want to enable public sharing for this space?": "本当にこのスペースの公開共有を有効にしますか?",
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "本当に公開共有を無効にしますか?このスペースのすべての既存の共有リンクが削除されます。",
"Public sharing is disabled": "公開共有が無効になっています",
"Public sharing has been disabled at the workspace level.": "ワークスペースレベルで公開共有が無効になりました。",
"Public sharing has been disabled for this space.": "このスペースで公開共有が無効になりました。",
"Copy page": "ページをコピー", "Copy page": "ページをコピー",
"Copy page to a different space.": "ページを別のスペースにコピーします", "Copy page to a different space.": "ページを別のスペースにコピーします",
"Page copied successfully": "ページをコピーしました", "Page copied successfully": "ページをコピーしました",
@@ -123,8 +123,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": "페이지",
@@ -407,21 +405,6 @@
"Share deleted successfully": "공유가 성공적으로 삭제되었습니다", "Share deleted successfully": "공유가 성공적으로 삭제되었습니다",
"Share not found": "공유를 찾을 수 없습니다", "Share not found": "공유를 찾을 수 없습니다",
"Failed to share page": "페이지 공유에 실패했습니다", "Failed to share page": "페이지 공유에 실패했습니다",
"Disable public sharing": "공유 비활성화",
"Prevent members from sharing pages publicly.": "멤버들이 페이지를 공개적으로 공유하지 못하도록 방지하십시오.",
"Toggle public sharing": "공유 전환",
"Toggle space public sharing": "공간 공유 전환",
"Public sharing is disabled at the workspace level": "워크스페이스 수준에서 공유가 비활성화되었습니다.",
"Prevent pages in this space from being shared publicly.": "이 공간의 페이지가 공개적으로 공유되지 않도록 방지하십시오.",
"Requires an enterprise license": "기업 라이센스가 필요합니다.",
"Enable public sharing": "공유 활성화",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "공유를 활성화하시겠습니까? 멤버들이 페이지를 공개적으로 공유할 수 있게 됩니다.",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "정말로 공유를 비활성화하시겠습니까? 이 워크스페이스의 모든 기존 공유 링크가 삭제됩니다.",
"Are you sure you want to enable public sharing for this space?": "이 공간의 공유를 활성화하시겠습니까?",
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "정말로 공유를 비활성화하시겠습니까? 이 공간의 모든 기존 공유 링크가 삭제됩니다.",
"Public sharing is disabled": "공유가 비활성화되었습니다.",
"Public sharing has been disabled at the workspace level.": "워크스페이스 수준에서 공유가 비활성화되었습니다.",
"Public sharing has been disabled for this space.": "이 공간의 공유가 비활성화되었습니다.",
"Copy page": "페이지 복사하기", "Copy page": "페이지 복사하기",
"Copy page to a different space.": "다른 공간으로 페이지 복사하기.", "Copy page to a different space.": "다른 공간으로 페이지 복사하기.",
"Page copied successfully": "페이지가 성공적으로 복사되었습니다", "Page copied successfully": "페이지가 성공적으로 복사되었습니다",
@@ -123,8 +123,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",
@@ -407,21 +405,6 @@
"Share deleted successfully": "Delen succesvol verwijderd", "Share deleted successfully": "Delen succesvol verwijderd",
"Share not found": "Delen niet gevonden", "Share not found": "Delen niet gevonden",
"Failed to share page": "Pagina delen mislukt", "Failed to share page": "Pagina delen mislukt",
"Disable public sharing": "Openbaar delen uitschakelen",
"Prevent members from sharing pages publicly.": "Voorkom dat leden pagina's openbaar delen.",
"Toggle public sharing": "Wissel openbaar delen",
"Toggle space public sharing": "Wissel openbaar delen van ruimte",
"Public sharing is disabled at the workspace level": "Openbaar delen is uitgeschakeld op werkruimteniveau",
"Prevent pages in this space from being shared publicly.": "Voorkom dat pagina's in deze ruimte openbaar worden gedeeld.",
"Requires an enterprise license": "Vereist een bedrijfslicentie",
"Enable public sharing": "Openbaar delen inschakelen",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Weet je zeker dat je openbaar delen wilt inschakelen? Leden kunnen pagina's openbaar delen.",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Weet je zeker dat je openbaar delen wilt uitschakelen? Alle bestaande gedeelde links in deze werkruimte zullen worden verwijderd.",
"Are you sure you want to enable public sharing for this space?": "Weet je zeker dat je openbaar delen voor deze ruimte wilt inschakelen?",
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Weet je zeker dat je openbaar delen wilt uitschakelen? Alle bestaande gedeelde links in deze ruimte zullen worden verwijderd.",
"Public sharing is disabled": "Openbaar delen is uitgeschakeld",
"Public sharing has been disabled at the workspace level.": "Openbaar delen is uitgeschakeld op werkruimteniveau.",
"Public sharing has been disabled for this space.": "Openbaar delen is uitgeschakeld voor deze ruimte.",
"Copy page": "Pagina kopiëren", "Copy page": "Pagina kopiëren",
"Copy page to a different space.": "Kopieer pagina naar een andere ruimte.", "Copy page to a different space.": "Kopieer pagina naar een andere ruimte.",
"Page copied successfully": "Pagina succesvol gekopieerd", "Page copied successfully": "Pagina succesvol gekopieerd",
@@ -123,8 +123,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",
@@ -407,21 +405,6 @@
"Share deleted successfully": "Compartilhamento excluído com sucesso", "Share deleted successfully": "Compartilhamento excluído com sucesso",
"Share not found": "Compartilhamento não encontrado", "Share not found": "Compartilhamento não encontrado",
"Failed to share page": "Falha ao compartilhar página", "Failed to share page": "Falha ao compartilhar página",
"Disable public sharing": "Desativar compartilhamento público",
"Prevent members from sharing pages publicly.": "Impedir que os membros compartilhem páginas publicamente.",
"Toggle public sharing": "Alternar compartilhamento público",
"Toggle space public sharing": "Alternar compartilhamento público do espaço",
"Public sharing is disabled at the workspace level": "O compartilhamento público está desativado no nível do espaço de trabalho",
"Prevent pages in this space from being shared publicly.": "Impedir que as páginas neste espaço sejam compartilhadas publicamente.",
"Requires an enterprise license": "Requer uma licença empresarial",
"Enable public sharing": "Ativar compartilhamento público",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Tem certeza de que deseja ativar o compartilhamento público? Os membros poderão compartilhar páginas publicamente.",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Tem certeza de que deseja desativar o compartilhamento público? Todos os links compartilhados existentes neste espaço de trabalho serão excluídos.",
"Are you sure you want to enable public sharing for this space?": "Tem certeza de que deseja ativar o compartilhamento público para este espaço?",
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Tem certeza de que deseja desativar o compartilhamento público? Todos os links compartilhados existentes neste espaço serão excluídos.",
"Public sharing is disabled": "Compartilhamento público está desativado",
"Public sharing has been disabled at the workspace level.": "O compartilhamento público foi desativado no nível do espaço de trabalho.",
"Public sharing has been disabled for this space.": "O compartilhamento público foi desativado para este espaço.",
"Copy page": "Copiar página", "Copy page": "Copiar página",
"Copy page to a different space.": "Copiar página para um espaço diferente.", "Copy page to a different space.": "Copiar página para um espaço diferente.",
"Page copied successfully": "Página copiada com sucesso", "Page copied successfully": "Página copiada com sucesso",
@@ -123,8 +123,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": "страницы",
@@ -407,21 +405,6 @@
"Share deleted successfully": "Общий доступ успешно удален", "Share deleted successfully": "Общий доступ успешно удален",
"Share not found": "Общий доступ не найден", "Share not found": "Общий доступ не найден",
"Failed to share page": "Не удалось поделиться страницей", "Failed to share page": "Не удалось поделиться страницей",
"Disable public sharing": "Отключить общий доступ",
"Prevent members from sharing pages publicly.": "Запретить участникам делиться страницами публично.",
"Toggle public sharing": "Переключить общий доступ",
"Toggle space public sharing": "Переключить общий доступ для пространства",
"Public sharing is disabled at the workspace level": "Общий доступ отключен на уровне рабочего пространства",
"Prevent pages in this space from being shared publicly.": "Запретить делиться страницами в этом пространстве публично.",
"Requires an enterprise license": "Требуется корпоративная лицензия",
"Enable public sharing": "Включить общий доступ",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Вы уверены, что хотите включить общий доступ? Участники смогут делиться страницами публично.",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Вы уверены, что хотите отключить общий доступ? Все существующие ссылки в этом рабочем пространстве будут удалены.",
"Are you sure you want to enable public sharing for this space?": "Вы уверены, что хотите включить общий доступ для этого пространства?",
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Вы уверены, что хотите отключить общий доступ? Все существующие ссылки в этом пространстве будут удалены.",
"Public sharing is disabled": "Общий доступ отключен",
"Public sharing has been disabled at the workspace level.": "Общий доступ был отключен на уровне рабочего пространства.",
"Public sharing has been disabled for this space.": "Общий доступ был отключен для этого пространства.",
"Copy page": "Копировать страницу", "Copy page": "Копировать страницу",
"Copy page to a different space.": "Копировать страницу в другое пространство.", "Copy page to a different space.": "Копировать страницу в другое пространство.",
"Page copied successfully": "Страница успешно скопирована", "Page copied successfully": "Страница успешно скопирована",
@@ -123,8 +123,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": "сторінки",
@@ -407,21 +405,6 @@
"Share deleted successfully": "Спільний доступ успішно видалено", "Share deleted successfully": "Спільний доступ успішно видалено",
"Share not found": "Спільний доступ не знайдено", "Share not found": "Спільний доступ не знайдено",
"Failed to share page": "Не вдалося поділитися сторінкою", "Failed to share page": "Не вдалося поділитися сторінкою",
"Disable public sharing": "Вимкнути публічний доступ",
"Prevent members from sharing pages publicly.": "Перешкодити учасникам публічно ділитися сторінками.",
"Toggle public sharing": "Перемикання публічного доступу",
"Toggle space public sharing": "Перемикання публічного доступу до просторів",
"Public sharing is disabled at the workspace level": "Публічний доступ вимкнуто на рівні робочого простору",
"Prevent pages in this space from being shared publicly.": "Перешкодити публічному поширенню сторінок у цьому просторі.",
"Requires an enterprise license": "Потребує корпоративної ліцензії",
"Enable public sharing": "Увімкнути публічний доступ",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Ви впевнені, що хочете увімкнути публічний доступ? Учасники зможуть публічно ділитися сторінками.",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Ви впевнені, що хочете вимкнути публічний доступ? Усі існуючі посилання для спільного доступу в цьому робочому просторі будуть видалені.",
"Are you sure you want to enable public sharing for this space?": "Ви впевнені, що хочете увімкнути публічний доступ для цього простору?",
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Ви впевнені, що хочете вимкнути публічний доступ? Усі існуючі посилання для спільного доступу в цьому просторі будуть видалені.",
"Public sharing is disabled": "Публічний доступ вимкнуто",
"Public sharing has been disabled at the workspace level.": "Публічний доступ було вимкнено на рівні робочого простору.",
"Public sharing has been disabled for this space.": "Публічний доступ було вимкнено для цього простору.",
"Copy page": "Копіювати сторінки", "Copy page": "Копіювати сторінки",
"Copy page to a different space.": "Скопіювати сторінку в інший простір.", "Copy page to a different space.": "Скопіювати сторінку в інший простір.",
"Page copied successfully": "Сторінку успішно скопійовано", "Page copied successfully": "Сторінку успішно скопійовано",
@@ -123,8 +123,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": "个页面",
@@ -407,21 +405,6 @@
"Share deleted successfully": "分享已成功删除", "Share deleted successfully": "分享已成功删除",
"Share not found": "未找到分享", "Share not found": "未找到分享",
"Failed to share page": "页面分享失败", "Failed to share page": "页面分享失败",
"Disable public sharing": "禁用公开分享",
"Prevent members from sharing pages publicly.": "阻止成员公开分享页面。",
"Toggle public sharing": "切换公开分享",
"Toggle space public sharing": "切换空间公开分享",
"Public sharing is disabled at the workspace level": "公开分享在工作区级别被禁用",
"Prevent pages in this space from being shared publicly.": "阻止此空间中的页面被公开分享。",
"Requires an enterprise license": "需要企业许可证",
"Enable public sharing": "启用公开分享",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "您确定要启用公开分享吗?成员将能够公开分享页面。",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "您确定要禁用公开分享吗?此工作区中的所有现有共享链接都将被删除。",
"Are you sure you want to enable public sharing for this space?": "您确定要为此空间启用公开分享吗?",
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "您确定要禁用公开分享吗?此空间中的所有现有共享链接都将被删除。",
"Public sharing is disabled": "公开分享已被禁用",
"Public sharing has been disabled at the workspace level.": "公开分享已在工作区级别被禁用。",
"Public sharing has been disabled for this space.": "此空间的公开分享已被禁用。",
"Copy page": "复制页面", "Copy page": "复制页面",
"Copy page to a different space.": "将页面复制到不同的空间。", "Copy page to a different space.": "将页面复制到不同的空间。",
"Page copied successfully": "页面复制成功", "Page copied successfully": "页面复制成功",
@@ -1,33 +0,0 @@
// Source: https://github.com/mantinedev/mantine/blob/master/packages/@mantine/core/src/components/CopyButton/CopyButton.tsx - MIT
// modified to use the polyfilled clipboard api
import React from "react";
import { useClipboard } from "@/hooks/use-clipboard";
import { useProps } from "@mantine/core";
interface CopyButtonProps {
/** Children callback, provides current status and copy function as an argument */
children: (payload: { copied: boolean; copy: () => void }) => React.ReactNode;
/** Value that is copied to the clipboard when the button is clicked */
value: string;
/** Copied status timeout in ms @default `1000` */
timeout?: number;
}
const defaultProps = {
timeout: 1000,
} satisfies Partial<CopyButtonProps>;
export function CopyButton(props: CopyButtonProps) {
const { children, timeout, value, ...others } = useProps(
"CopyButton",
defaultProps,
props,
);
const clipboard = useClipboard({ timeout });
const copy = () => clipboard.copy(value);
return <>{children({ copy, copied: clipboard.copied, ...others })}</>;
}
CopyButton.displayName = "@mantine/core/CopyButton";
+1 -2
View File
@@ -1,5 +1,4 @@
import { ActionIcon, Tooltip } from "@mantine/core"; import { ActionIcon, CopyButton, Tooltip } from "@mantine/core";
import { CopyButton } from "@/components/common/copy-button";
import { IconCheck, IconCopy } from "@tabler/icons-react"; import { IconCheck, IconCopy } from "@tabler/icons-react";
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -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}
/> />
)} )}
@@ -1,12 +0,0 @@
import { isCloud } from "@/lib/config";
import useLicense from "@/ee/hooks/use-license";
import usePlan from "@/ee/hooks/use-plan";
const useEnterpriseAccess = () => {
const { hasLicenseKey } = useLicense();
const { isBusiness } = usePlan();
return (isCloud() && isBusiness) || (!isCloud() && hasLicenseKey);
};
export default useEnterpriseAccess;
@@ -8,10 +8,10 @@ import {
Group, Group,
List, List,
Code, Code,
CopyButton,
Alert, Alert,
PasswordInput, PasswordInput,
} from "@mantine/core"; } from "@mantine/core";
import { CopyButton } from "@/components/common/copy-button";
import { import {
IconRefresh, IconRefresh,
IconCopy, IconCopy,
@@ -11,6 +11,7 @@ import {
PinInput, PinInput,
Alert, Alert,
List, List,
CopyButton,
ActionIcon, ActionIcon,
Tooltip, Tooltip,
Paper, Paper,
@@ -19,7 +20,6 @@ import {
Collapse, Collapse,
UnstyledButton, UnstyledButton,
} from "@mantine/core"; } from "@mantine/core";
import { CopyButton } from "@/components/common/copy-button";
import { import {
IconQrcode, IconQrcode,
IconShieldCheck, IconShieldCheck,
@@ -1,88 +0,0 @@
import { Group, Text, Switch, Tooltip } from "@mantine/core";
import { modals } from "@mantine/modals";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx";
export default function DisablePublicSharing() {
const { t } = useTranslation();
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Disable public sharing")}</Text>
<Text size="sm" c="dimmed">
{t("Prevent members from sharing pages publicly.")}
</Text>
</div>
<DisablePublicSharingToggle />
</Group>
);
}
function DisablePublicSharingToggle() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(
workspace?.settings?.sharing?.disabled === true,
);
const hasAccess = useEnterpriseAccess();
const applyChange = async (value: boolean) => {
try {
const updatedWorkspace = await updateWorkspace({
disablePublicSharing: value,
});
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
modals.openConfirmModal({
title: value ? t("Disable public sharing") : t("Enable public sharing"),
children: (
<Text size="sm">
{value
? t(
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.",
)
: t(
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.",
)}
</Text>
),
centered: true,
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
confirmProps: value ? { color: "red" } : {},
onConfirm: () => applyChange(value),
});
};
return (
<Tooltip
label={t("Requires an enterprise license")}
disabled={hasAccess}
refProp="rootRef"
>
<Switch
checked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle public sharing")}
/>
</Tooltip>
);
}
@@ -10,18 +10,23 @@ export default function EnforceMfa() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Group justify="space-between" wrap="nowrap" gap="xl"> <>
<div> <Title order={4} my="sm">
<Text size="md">{t("Enforce two-factor authentication")}</Text> MFA
<Text size="sm" c="dimmed"> </Title>
{t( <Group justify="space-between" wrap="nowrap" gap="xl">
"Once enforced, all members must enable two-factor authentication to access the workspace.", <div>
)} <Text size="md">{t("Enforce two-factor authentication")}</Text>
</Text> <Text size="sm" c="dimmed">
</div> {t(
"Once enforced, all members must enable two-factor authentication to access the workspace.",
)}
</Text>
</div>
<EnforceMfaToggle /> <EnforceMfaToggle />
</Group> </Group>
</>
); );
} }
@@ -1,84 +0,0 @@
import { Group, Text, Switch, Tooltip } from "@mantine/core";
import { modals } from "@mantine/modals";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { ISpace } from "@/features/space/types/space.types.ts";
import { useUpdateSpaceMutation } from "@/features/space/queries/space-query.ts";
type SpacePublicSharingToggleProps = {
space: ISpace;
};
export default function SpacePublicSharingToggle({
space,
}: SpacePublicSharingToggleProps) {
const { t } = useTranslation();
const [workspace] = useAtom(workspaceAtom);
const workspaceDisabled = workspace?.settings?.sharing?.disabled === true;
const [checked, setChecked] = useState(
space.settings?.sharing?.disabled === true,
);
const updateSpaceMutation = useUpdateSpaceMutation();
const applyChange = async (value: boolean) => {
try {
await updateSpaceMutation.mutateAsync({
spaceId: space.id,
disablePublicSharing: value,
});
setChecked(value);
} catch {
// error handled by mutation
}
};
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
modals.openConfirmModal({
title: value ? t("Disable public sharing") : t("Enable public sharing"),
children: (
<Text size="sm">
{value
? t(
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.",
)
: t(
"Are you sure you want to enable public sharing for this space?",
)}
</Text>
),
centered: true,
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
confirmProps: value ? { color: "red" } : {},
onConfirm: () => applyChange(value),
});
};
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Disable public sharing")}</Text>
<Text size="sm" c="dimmed">
{workspaceDisabled
? t("Public sharing is disabled at the workspace level")
: t("Prevent pages in this space from being shared publicly.")}
</Text>
</div>
<Tooltip
label={t("Public sharing is disabled at the workspace level")}
disabled={!workspaceDisabled}
refProp="rootRef"
>
<Switch
checked={checked}
onChange={handleChange}
disabled={workspaceDisabled}
aria-label={t("Toggle space public sharing")}
/>
</Tooltip>
</Group>
);
}
@@ -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>
+10 -26
View File
@@ -9,16 +9,15 @@ import CreateSsoProvider from "@/ee/security/components/create-sso-provider.tsx"
import EnforceSso from "@/ee/security/components/enforce-sso.tsx"; import EnforceSso from "@/ee/security/components/enforce-sso.tsx";
import AllowedDomains from "@/ee/security/components/allowed-domains.tsx"; import AllowedDomains from "@/ee/security/components/allowed-domains.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useLicense from "@/ee/hooks/use-license.tsx";
import usePlan from "@/ee/hooks/use-plan.tsx";
import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx"; import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx";
import DisablePublicSharing from "@/ee/security/components/disable-public-sharing.tsx";
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx";
export default function Security() { export default function Security() {
const { t } = useTranslation(); const { t } = useTranslation();
const { isAdmin } = useUserRole(); const { isAdmin } = useUserRole();
const hasEnterpriseAccess = useEnterpriseAccess(); const { hasLicenseKey } = useLicense();
const isCloudEE = useIsCloudEE(); const { isBusiness } = usePlan();
if (!isAdmin) { if (!isAdmin) {
return null; return null;
@@ -31,41 +30,26 @@ export default function Security() {
</Helmet> </Helmet>
<SettingsTitle title={t("Security")} /> <SettingsTitle title={t("Security")} />
<EnforceMfa /> <AllowedDomains />
<Divider my="lg" /> <Divider my="lg" />
{(!isCloud() || hasEnterpriseAccess) && ( <EnforceMfa />
<>
<DisablePublicSharing /> <Divider my="lg" />
<Divider my="lg" />
</>
)}
<Title order={4} my="lg"> <Title order={4} my="lg">
Single sign-on (SSO) Single sign-on (SSO)
</Title> </Title>
{hasEnterpriseAccess && ( {(isCloud() && isBusiness) || (!isCloud() && hasLicenseKey) ? (
<> <>
<EnforceSso /> <EnforceSso />
<Divider my="lg" /> <Divider my="lg" />
</>
)}
{isCloudEE && (
<>
<AllowedDomains />
<Divider my="lg" />
</>
)}
{hasEnterpriseAccess && (
<>
<CreateSsoProvider /> <CreateSsoProvider />
<Divider size={0} my="lg" /> <Divider size={0} my="lg" />
</> </>
)} ) : null}
<SsoProviderList /> <SsoProviderList />
</> </>
@@ -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;
} }
@@ -84,14 +84,9 @@ const CommentEditor = forwardRef(
autofocus: (autofocus && "end") || false, autofocus: (autofocus && "end") || false,
}); });
// Sync content from props for read-only editors (e.g. when updated via
// websocket on another browser). Skip for editable editors to avoid
// resetting the cursor position on every keystroke.
useEffect(() => { useEffect(() => {
if (!editable && commentEditor && defaultContent) { commentEditor.commands.setContent(defaultContent);
commentEditor.commands.setContent(defaultContent); }, [defaultContent]);
}
}, [defaultContent, editable, commentEditor]);
useEffect(() => { useEffect(() => {
setTimeout(() => { setTimeout(() => {
@@ -1,5 +1,5 @@
import { Group, Text, Box, Badge } from "@mantine/core"; import { Group, Text, Box, Badge } from "@mantine/core";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useState } from "react";
import classes from "./comment.module.css"; import classes from "./comment.module.css";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { timeAgo } from "@/lib/time"; import { timeAgo } from "@/lib/time";
@@ -40,7 +40,6 @@ function CommentListItem({
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const editor = useAtomValue(pageEditorAtom); const editor = useAtomValue(pageEditorAtom);
const [content, setContent] = useState<string>(comment.content); const [content, setContent] = useState<string>(comment.content);
const editContentRef = useRef<any>(null);
const updateCommentMutation = useUpdateCommentMutation(); const updateCommentMutation = useUpdateCommentMutation();
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId); const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
const resolveCommentMutation = useResolveCommentMutation(); const resolveCommentMutation = useResolveCommentMutation();
@@ -57,13 +56,9 @@ function CommentListItem({
setIsLoading(true); setIsLoading(true);
const commentToUpdate = { const commentToUpdate = {
commentId: comment.id, commentId: comment.id,
content: JSON.stringify(editContentRef.current ?? content), content: JSON.stringify(content),
}; };
await updateCommentMutation.mutateAsync(commentToUpdate); await updateCommentMutation.mutateAsync(commentToUpdate);
if (editContentRef.current) {
setContent(editContentRef.current);
editContentRef.current = null;
}
setIsEditing(false); setIsEditing(false);
emit({ emit({
@@ -133,7 +128,6 @@ function CommentListItem({
setIsEditing(true); setIsEditing(true);
} }
function cancelEdit() { function cancelEdit() {
editContentRef.current = null;
setIsEditing(false); setIsEditing(false);
} }
@@ -200,7 +194,7 @@ function CommentListItem({
<CommentEditor <CommentEditor
defaultContent={content} defaultContent={content}
editable={true} editable={true}
onUpdate={(newContent: any) => { editContentRef.current = newContent; }} onUpdate={(newContent: any) => setContent(newContent)}
onSave={handleUpdateComment} onSave={handleUpdateComment}
autofocus={true} autofocus={true}
/> />
@@ -1,6 +1,5 @@
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react"; import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { ActionIcon, Group, Select, Tooltip } from "@mantine/core"; import { ActionIcon, CopyButton, Group, Select, Tooltip } from "@mantine/core";
import { CopyButton } from "@/components/common/copy-button";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { IconCheck, IconCopy } from "@tabler/icons-react"; import { IconCheck, IconCopy } from "@tabler/icons-react";
import classes from "./code-block.module.css"; import classes from "./code-block.module.css";
@@ -170,8 +170,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
input.type = "file"; input.type = "file";
input.accept = "image/*"; input.accept = "image/*";
input.multiple = true; input.multiple = true;
input.style.display = "none";
document.body.appendChild(input);
input.onchange = async () => { input.onchange = async () => {
if (input.files?.length) { if (input.files?.length) {
for (const file of input.files) { for (const file of input.files) {
@@ -181,7 +179,8 @@ const CommandGroups: SlashMenuGroupedItemsType = {
} }
} }
input.remove(); // Reset the input value to allow uploading the same file again if needed
input.value = "";
}; };
input.click(); input.click();
}, },
@@ -203,8 +202,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
input.type = "file"; input.type = "file";
input.accept = "video/*"; input.accept = "video/*";
input.multiple = true; input.multiple = true;
input.style.display = "none";
document.body.appendChild(input);
input.onchange = async () => { input.onchange = async () => {
if (input.files?.length) { if (input.files?.length) {
for (const file of input.files) { for (const file of input.files) {
@@ -214,7 +211,8 @@ const CommandGroups: SlashMenuGroupedItemsType = {
} }
} }
input.remove(); // Reset the input value to allow uploading the same file again if needed
input.value = "";
}; };
input.click(); input.click();
}, },
@@ -236,8 +234,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
input.type = "file"; input.type = "file";
input.accept = ""; input.accept = "";
input.multiple = true; input.multiple = true;
input.style.display = "none";
document.body.appendChild(input);
input.onchange = async () => { input.onchange = async () => {
if (input.files?.length) { if (input.files?.length) {
for (const file of input.files) { for (const file of input.files) {
@@ -247,7 +243,8 @@ const CommandGroups: SlashMenuGroupedItemsType = {
} }
} }
input.remove(); // Reset the input value to allow uploading the same file again if needed
input.value = "";
}; };
input.click(); input.click();
}, },
@@ -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;
} }
@@ -157,9 +157,7 @@ export function TitleEditor({
useEffect(() => { useEffect(() => {
setTimeout(() => { setTimeout(() => {
// guard against Cannot access view['hasFocus'] error titleEditor?.commands.focus("end");
if (!titleEditor?.isInitialized) return;
titleEditor?.commands?.focus("end");
}, 500); }, 500);
}, [titleEditor]); }, [titleEditor]);
@@ -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,36 @@
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 classes from "./history.module.css";
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 && (
className={historyClasses.historyEditor} <EditorContent editor={editor} className={classes.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,49 @@
.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;
}
}
.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;
} }
@@ -17,8 +17,7 @@ import React, { useEffect, useRef, useState } from "react";
import useToggleAside from "@/hooks/use-toggle-aside.tsx"; import useToggleAside from "@/hooks/use-toggle-aside.tsx";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts"; import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
import { useDisclosure, useHotkeys } from "@mantine/hooks"; import { useClipboard, useDisclosure, useHotkeys } from "@mantine/hooks";
import { useClipboard } from "@/hooks/use-clipboard";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts"; import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts";
@@ -11,7 +11,6 @@ import {
IconBrandNotion, IconBrandNotion,
IconCheck, IconCheck,
IconFileCode, IconFileCode,
IconFileTypeDocx,
IconFileTypeZip, IconFileTypeZip,
IconMarkdown, IconMarkdown,
IconX, IconX,
@@ -87,13 +86,11 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
const markdownFileRef = useRef<() => void>(null); const markdownFileRef = useRef<() => void>(null);
const htmlFileRef = useRef<() => void>(null); const htmlFileRef = useRef<() => void>(null);
const docxFileRef = useRef<() => void>(null);
const notionFileRef = useRef<() => void>(null); const notionFileRef = useRef<() => void>(null);
const confluenceFileRef = useRef<() => void>(null); const confluenceFileRef = useRef<() => void>(null);
const zipFileRef = useRef<() => void>(null); const zipFileRef = useRef<() => void>(null);
const canUseConfluence = isCloud() || workspace?.hasLicenseKey; const canUseConfluence = isCloud() || workspace?.hasLicenseKey;
const canUseDocx = isCloud() || workspace?.hasLicenseKey;
const handleZipUpload = async (selectedFile: File, source: string) => { const handleZipUpload = async (selectedFile: File, source: string) => {
if (!selectedFile) { if (!selectedFile) {
@@ -268,7 +265,6 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
// Reset file inputs after successful upload // Reset file inputs after successful upload
if (markdownFileRef.current) markdownFileRef.current(); if (markdownFileRef.current) markdownFileRef.current();
if (htmlFileRef.current) htmlFileRef.current(); if (htmlFileRef.current) htmlFileRef.current();
if (docxFileRef.current) docxFileRef.current();
const pageCountText = const pageCountText =
pageCount === 1 ? `1 ${t("page")}` : `${pageCount} ${t("pages")}`; pageCount === 1 ? `1 ${t("page")}` : `${pageCount} ${t("pages")}`;
@@ -325,30 +321,6 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
)} )}
</FileButton> </FileButton>
<FileButton
onChange={handleFileUpload}
accept=".docx"
multiple
resetRef={docxFileRef}
>
{(props) => (
<Tooltip
label={t("Available in enterprise edition")}
disabled={canUseDocx}
>
<Button
disabled={!canUseDocx}
justify="start"
variant="default"
leftSection={<IconFileTypeDocx size={18} />}
{...props}
>
Word (DOCX)
</Button>
</Tooltip>
)}
</FileButton>
<FileButton <FileButton
onChange={(file) => handleZipUpload(file, "notion")} onChange={(file) => handleZipUpload(file, "notion")}
accept="application/zip" accept="application/zip"
@@ -250,10 +250,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 +263,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,
}); });
} }
@@ -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,
@@ -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>
@@ -54,11 +54,11 @@ import { IPage, SidebarPagesParams } from "@/features/page/types/page.types.ts";
import { queryClient } from "@/main.tsx"; import { queryClient } from "@/main.tsx";
import { OpenMap } from "react-arborist/dist/main/state/open-slice"; import { OpenMap } from "react-arborist/dist/main/state/open-slice";
import { import {
useClipboard,
useDisclosure, useDisclosure,
useElementSize, useElementSize,
useMergedRef, useMergedRef,
} from "@mantine/hooks"; } from "@mantine/hooks";
import { useClipboard } from "@/hooks/use-clipboard";
import { dfs } from "react-arborist/dist/module/utils"; import { dfs } from "react-arborist/dist/module/utils";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts"; import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts";
@@ -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;
} }
@@ -13,7 +13,7 @@ import {
buildPageUrl, buildPageUrl,
buildSharedPageUrl, buildSharedPageUrl,
} from "@/features/page/page.utils.ts"; } from "@/features/page/page.utils.ts";
import { useClipboard } from "@/hooks/use-clipboard"; import { useClipboard } from "@mantine/hooks";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useDeleteShareMutation } from "@/features/share/queries/share-query.ts"; import { useDeleteShareMutation } from "@/features/share/queries/share-query.ts";
@@ -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}
/> />
)} )}
</> </>
@@ -26,9 +26,6 @@ import { getAppUrl, isCloud } from "@/lib/config.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts";
import classes from "@/features/share/components/share.module.css"; import classes from "@/features/share/components/share.module.css";
import useTrial from "@/ee/hooks/use-trial.tsx"; import useTrial from "@/ee/hooks/use-trial.tsx";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
interface ShareModalProps { interface ShareModalProps {
readOnly: boolean; readOnly: boolean;
@@ -43,12 +40,6 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
const { data: share } = useShareForPageQuery(pageId); const { data: share } = useShareForPageQuery(pageId);
const { spaceSlug } = useParams(); const { spaceSlug } = useParams();
const { isTrial } = useTrial(); const { isTrial } = useTrial();
const [workspace] = useAtom(workspaceAtom);
const { data: space } = useSpaceQuery(spaceSlug);
const workspaceDisabled =
workspace?.settings?.sharing?.disabled === true;
const spaceDisabled = space?.settings?.sharing?.disabled === true;
const sharingDisabled = workspaceDisabled || spaceDisabled;
const createShareMutation = useCreateShareMutation(); const createShareMutation = useCreateShareMutation();
const updateShareMutation = useUpdateShareMutation(); const updateShareMutation = useUpdateShareMutation();
const deleteShareMutation = useDeleteShareMutation(); const deleteShareMutation = useDeleteShareMutation();
@@ -173,20 +164,6 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
{t("Upgrade Plan")} {t("Upgrade Plan")}
</Button> </Button>
</> </>
) : sharingDisabled ? (
<>
<Group justify="center" mb="sm">
<IconLock size={20} stroke={1.5} />
</Group>
<Text size="sm" ta="center" fw={500} mb="xs">
{t("Public sharing is disabled")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{workspaceDisabled
? t("Public sharing has been disabled at the workspace level.")
: t("Public sharing has been disabled for this space.")}
</Text>
</>
) : isDescendantShared ? ( ) : isDescendantShared ? (
<> <>
<Text size="sm">{t("Inherits public sharing from")}</Text> <Text size="sm">{t("Inherits public sharing from")}</Text>
@@ -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,
}); });
@@ -18,8 +18,6 @@ import {
ResponsiveSettingsControl, ResponsiveSettingsControl,
ResponsiveSettingsRow, ResponsiveSettingsRow,
} from "@/components/ui/responsive-settings-row.tsx"; } from "@/components/ui/responsive-settings-row.tsx";
import SpacePublicSharingToggle from "@/ee/security/components/space-public-sharing-toggle.tsx";
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx";
interface SpaceDetailsProps { interface SpaceDetailsProps {
spaceId: string; spaceId: string;
@@ -28,8 +26,6 @@ interface SpaceDetailsProps {
export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) { export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: space, isLoading, refetch } = useSpaceQuery(spaceId); const { data: space, isLoading, refetch } = useSpaceQuery(spaceId);
const hasEnterpriseAccess = useEnterpriseAccess();
const showSharingToggle = !readOnly && hasEnterpriseAccess;
const [exportOpened, { open: openExportModal, close: closeExportModal }] = const [exportOpened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false); useDisclosure(false);
const [isIconUploading, setIsIconUploading] = useState(false); const [isIconUploading, setIsIconUploading] = useState(false);
@@ -81,6 +77,7 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
fallbackName={space.name} fallbackName={space.name}
size={"60px"} size={"60px"}
variant="filled" variant="filled"
type={AvatarIconType.SPACE_ICON} type={AvatarIconType.SPACE_ICON}
onUpload={handleIconUpload} onUpload={handleIconUpload}
onRemove={handleIconRemove} onRemove={handleIconRemove}
@@ -91,13 +88,6 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
<EditSpaceForm space={space} readOnly={readOnly} /> <EditSpaceForm space={space} readOnly={readOnly} />
{showSharingToggle && (
<>
<Divider my="lg" />
<SpacePublicSharingToggle space={space} />
</>
)}
{!readOnly && ( {!readOnly && (
<> <>
<Divider my="lg" /> <Divider my="lg" />
@@ -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}
/> />
)} )}
@@ -5,14 +5,6 @@ import {
} from "@/features/space/permissions/permissions.type.ts"; } from "@/features/space/permissions/permissions.type.ts";
import { ExportFormat } from "@/features/page/types/page.types.ts"; import { ExportFormat } from "@/features/page/types/page.types.ts";
export interface ISpaceSharingSettings {
disabled?: boolean;
}
export interface ISpaceSettings {
sharing?: ISpaceSharingSettings;
}
export interface ISpace { export interface ISpace {
id: string; id: string;
name: string; name: string;
@@ -26,9 +18,6 @@ export interface ISpace {
memberCount?: number; memberCount?: number;
spaceId?: string; spaceId?: string;
membership?: IMembership; membership?: IMembership;
settings?: ISpaceSettings;
// for updates
disablePublicSharing?: boolean;
} }
interface IMembership { interface IMembership {
@@ -8,7 +8,7 @@ import {
} from "@/features/workspace/queries/workspace-query.ts"; } from "@/features/workspace/queries/workspace-query.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { useClipboard } from "@/hooks/use-clipboard"; import { useClipboard } from "@mantine/hooks";
import { getInviteLink } from "@/features/workspace/services/workspace-service.ts"; import { getInviteLink } from "@/features/workspace/services/workspace-service.ts";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import { isCloud } from "@/lib/config.ts"; import { isCloud } from "@/lib/config.ts";
@@ -1,8 +1,7 @@
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Button, Group, Text, TextInput } from "@mantine/core"; import { Button, CopyButton, Group, Text, TextInput } from "@mantine/core";
import { CopyButton } from "@/components/common/copy-button";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export default function WorkspaceInviteSection() { export default function WorkspaceInviteSection() {
@@ -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}
/> />
)} )}
</> </>
@@ -42,7 +42,7 @@ export async function deleteWorkspaceMember(data: {
await api.post("/workspace/members/delete", data); await api.post("/workspace/members/delete", data);
} }
export async function updateWorkspace(data: Partial<IWorkspace>) { export async function updateWorkspace(data: Partial<IWorkspace> & { aiSearch?: boolean }) {
const req = await api.post<IWorkspace>("/workspace/update", data); const req = await api.post<IWorkspace>("/workspace/update", data);
return req.data; return req.data;
} }
@@ -66,9 +66,7 @@ export async function createInvitation(data: ICreateInvite) {
return req.data; return req.data;
} }
export async function acceptInvitation( export async function acceptInvitation(data: IAcceptInvite): Promise<{ requiresLogin?: boolean; }> {
data: IAcceptInvite,
): Promise<{ requiresLogin?: boolean }> {
const req = await api.post("/workspace/invites/accept", data); const req = await api.post("/workspace/invites/accept", data);
return req.data; return req.data;
} }
@@ -110,3 +108,4 @@ export async function getAppVersion(): Promise<IVersion> {
const req = await api.post("/version"); const req = await api.post("/version");
return req.data; return req.data;
} }
@@ -22,23 +22,16 @@ export interface IWorkspace {
plan?: string; plan?: string;
hasLicenseKey?: boolean; hasLicenseKey?: boolean;
enforceMfa?: boolean; enforceMfa?: boolean;
aiSearch?: boolean;
disablePublicSharing?: boolean;
} }
export interface IWorkspaceSettings { export interface IWorkspaceSettings {
ai?: IWorkspaceAiSettings; ai?: IWorkspaceAiSettings;
sharing?: IWorkspaceSharingSettings;
} }
export interface IWorkspaceAiSettings { export interface IWorkspaceAiSettings {
search?: boolean; search?: boolean;
} }
export interface IWorkspaceSharingSettings {
disabled?: boolean;
}
export interface ICreateInvite { export interface ICreateInvite {
role: string; role: string;
emails: string[]; emails: string[];
-60
View File
@@ -1,60 +0,0 @@
// Source: https://github.com/mantinedev/mantine/blob/master/packages/@mantine/hooks/src/use-clipboard/use-clipboard.ts
// polyfilled to support execCommand fallback
import { useState } from "react";
import { execCommandCopy } from "@docmost/editor-ext";
export type UseClipboardOptions = {
timeout?: number;
};
export type UseClipboardReturnValue = {
copy: (value: string) => void;
reset: () => void;
error: Error | null;
copied: boolean;
};
export function useClipboard(
options: UseClipboardOptions = { timeout: 2000 },
): UseClipboardReturnValue {
const [error, setError] = useState<Error | null>(null);
const [copied, setCopied] = useState(false);
const [copyTimeout, setCopyTimeout] = useState<number | null>(null);
const handleCopyResult = (value: boolean) => {
window.clearTimeout(copyTimeout!);
setCopyTimeout(window.setTimeout(() => setCopied(false), options.timeout));
setCopied(value);
};
const copy = (value: string) => {
if ("clipboard" in navigator) {
navigator.clipboard
.writeText(value)
.then(() => handleCopyResult(true))
.catch(() => {
try {
execCommandCopy(value);
handleCopyResult(true);
} catch (err) {
setError(err instanceof Error ? err : new Error("Failed to copy"));
}
});
} else {
try {
execCommandCopy(value);
handleCopyResult(true);
} catch (err) {
setError(err instanceof Error ? err : new Error("Failed to copy"));
}
}
};
const reset = () => {
setCopied(false);
setError(null);
window.clearTimeout(copyTimeout!);
};
return { copy, reset, error, copied };
}
@@ -1,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>
+12 -12
View File
@@ -1,6 +1,6 @@
{ {
"name": "server", "name": "server",
"version": "0.25.2", "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",
@@ -92,9 +92,9 @@
"pdfjs-dist": "^5.4.394", "pdfjs-dist": "^5.4.394",
"pg-tsquery": "^8.4.2", "pg-tsquery": "^8.4.2",
"pgvector": "^0.2.1", "pgvector": "^0.2.1",
"postgres": "^3.4.8",
"pino-http": "^11.0.0", "pino-http": "^11.0.0",
"pino-pretty": "^13.1.3", "pino-pretty": "^13.1.3",
"postgres": "^3.4.8",
"postmark": "^4.0.5", "postmark": "^4.0.5",
"react": "^18.3.1", "react": "^18.3.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
@@ -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 ||
@@ -37,8 +37,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}`);
}); });
} }
@@ -17,13 +17,13 @@ import {
UseInterceptors, UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { AttachmentService } from './services/attachment.service'; import { AttachmentService } from './services/attachment.service';
import { FastifyReply, FastifyRequest } from 'fastify'; import { FastifyReply } from 'fastify';
import { FileInterceptor } from '../../common/interceptors/file.interceptor'; import { FileInterceptor } from '../../common/interceptors/file.interceptor';
import * as bytes from 'bytes'; import * as bytes from 'bytes';
import { AuthUser } from '../../common/decorators/auth-user.decorator'; import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { Attachment, User, Workspace } from '@docmost/db/types/entity.types'; import { User, Workspace } from '@docmost/db/types/entity.types';
import { StorageService } from '../../integrations/storage/storage.service'; import { StorageService } from '../../integrations/storage/storage.service';
import { import {
getAttachmentFolderPath, getAttachmentFolderPath,
@@ -151,7 +151,6 @@ export class AttachmentController {
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Get('/files/:fileId/:fileName') @Get('/files/:fileId/:fileName')
async getFile( async getFile(
@Req() req: FastifyRequest,
@Res() res: FastifyReply, @Res() res: FastifyReply,
@AuthUser() user: User, @AuthUser() user: User,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
@@ -182,7 +181,22 @@ export class AttachmentController {
} }
try { try {
return await this.sendFileResponse(req, res, attachment, 'private'); const fileStream = await this.storageService.readStream(
attachment.filePath,
);
res.headers({
'Content-Type': attachment.mimeType,
'Cache-Control': 'private, max-age=3600',
});
if (!inlineFileExtensions.includes(attachment.fileExt)) {
res.header(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(attachment.fileName)}"`,
);
}
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');
@@ -191,7 +205,6 @@ export class AttachmentController {
@Get('/files/public/:fileId/:fileName') @Get('/files/public/:fileId/:fileName')
async getPublicFile( async getPublicFile(
@Req() req: FastifyRequest,
@Res() res: FastifyReply, @Res() res: FastifyReply,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
@Param('fileId') fileId: string, @Param('fileId') fileId: string,
@@ -230,7 +243,22 @@ export class AttachmentController {
} }
try { try {
return await this.sendFileResponse(req, res, attachment, 'public'); const fileStream = await this.storageService.readStream(
attachment.filePath,
);
res.headers({
'Content-Type': attachment.mimeType,
'Cache-Control': 'public, max-age=3600',
});
if (!inlineFileExtensions.includes(attachment.fileExt)) {
res.header(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(attachment.fileName)}"`,
);
}
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');
@@ -405,70 +433,4 @@ export class AttachmentController {
return; return;
} }
} }
private async sendFileResponse(
req: FastifyRequest,
res: FastifyReply,
attachment: Attachment,
cacheScope: 'private' | 'public',
) {
const fileSize = Number(attachment.fileSize);
const rangeHeader = req.headers.range;
res.header('Accept-Ranges', 'bytes');
if (!inlineFileExtensions.includes(attachment.fileExt)) {
res.header(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(attachment.fileName)}"`,
);
}
if (rangeHeader && fileSize) {
const match = rangeHeader.match(/bytes=(\d+)-(\d*)/);
if (match) {
const start = parseInt(match[1], 10);
const end = match[2]
? Math.min(parseInt(match[2], 10), fileSize - 1)
: fileSize - 1;
if (start >= fileSize || start > end) {
res.status(416);
res.header('Content-Range', `bytes */${fileSize}`);
return res.send();
}
const fileStream = await this.storageService.readRangeStream(
attachment.filePath,
{ start, end },
);
res.status(206);
res.headers({
'Content-Type': attachment.mimeType,
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Content-Length': end - start + 1,
'Cache-Control': `${cacheScope}, max-age=3600`,
});
return res.send(fileStream);
}
}
const fileStream = await this.storageService.readStream(
attachment.filePath,
);
res.headers({
'Content-Type': attachment.mimeType,
'Cache-Control': `${cacheScope}, max-age=3600`,
});
const isSvg = attachment.fileExt === '.svg';
if (fileSize && !isSvg) {
res.header('Content-Length', fileSize);
}
return res.send(fileStream);
}
} }
@@ -99,7 +99,6 @@ export class AttachmentService {
if (isUpdate) { if (isUpdate) {
attachment = await this.attachmentRepo.updateAttachment( attachment = await this.attachmentRepo.updateAttachment(
{ {
fileSize: preparedFile.fileSize,
updatedAt: new Date(), updatedAt: new Date(),
}, },
attachmentId, attachmentId,
@@ -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(
+2 -41
View File
@@ -64,18 +64,8 @@ export class ShareController {
throw new BadRequestException(); throw new BadRequestException();
} }
const shareData = await this.shareService.getSharedPage(dto, workspace.id);
const sharingAllowed = await this.shareService.isSharingAllowed(
workspace.id,
shareData.share.spaceId,
);
if (!sharingAllowed) {
throw new NotFoundException('Shared page not found');
}
return { return {
...shareData, ...(await this.shareService.getSharedPage(dto, workspace.id)),
hasLicenseKey: hasLicenseOrEE({ hasLicenseKey: hasLicenseOrEE({
licenseKey: workspace.licenseKey, licenseKey: workspace.licenseKey,
isCloud: this.environmentService.isCloud(), isCloud: this.environmentService.isCloud(),
@@ -96,14 +86,6 @@ export class ShareController {
throw new NotFoundException('Share not found'); throw new NotFoundException('Share not found');
} }
const sharingAllowed = await this.shareService.isSharingAllowed(
share.workspaceId,
share.spaceId,
);
if (!sharingAllowed) {
throw new NotFoundException('Share not found');
}
return share; return share;
} }
@@ -145,14 +127,6 @@ export class ShareController {
throw new ForbiddenException(); throw new ForbiddenException();
} }
const sharingAllowed = await this.shareService.isSharingAllowed(
workspace.id,
page.spaceId,
);
if (!sharingAllowed) {
throw new ForbiddenException('Public sharing is disabled');
}
return this.shareService.createShare({ return this.shareService.createShare({
page, page,
authUserId: user.id, authUserId: user.id,
@@ -202,21 +176,8 @@ export class ShareController {
@Body() dto: ShareIdDto, @Body() dto: ShareIdDto,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
) { ) {
const treeData = await this.shareService.getShareTree(
dto.shareId,
workspace.id,
);
const sharingAllowed = await this.shareService.isSharingAllowed(
workspace.id,
treeData.share.spaceId,
);
if (!sharingAllowed) {
throw new NotFoundException('Share not found');
}
return { return {
...treeData, ...(await this.shareService.getShareTree(dto.shareId, workspace.id)),
hasLicenseKey: hasLicenseOrEE({ hasLicenseKey: hasLicenseOrEE({
licenseKey: workspace.licenseKey, licenseKey: workspace.licenseKey,
isCloud: this.environmentService.isCloud(), isCloud: this.environmentService.isCloud(),
@@ -264,31 +264,6 @@ export class ShareService {
return ancestor; return ancestor;
} }
async isSharingAllowed(
workspaceId: string,
spaceId: string,
): Promise<boolean> {
const result = await this.db
.selectFrom('workspaces')
.innerJoin('spaces', 'spaces.workspaceId', 'workspaces.id')
.select([
'workspaces.settings as workspaceSettings',
'spaces.settings as spaceSettings',
])
.where('workspaces.id', '=', workspaceId)
.where('spaces.id', '=', spaceId)
.executeTakeFirst();
if (!result) return false;
const workspaceDisabled =
(result.workspaceSettings as any)?.sharing?.disabled === true;
const spaceDisabled =
(result.spaceSettings as any)?.sharing?.disabled === true;
return !workspaceDisabled && !spaceDisabled;
}
async updatePublicAttachments(page: Page): Promise<any> { async updatePublicAttachments(page: Page): Promise<any> {
const prosemirrorJson = getProsemirrorContent(page.content); const prosemirrorJson = getProsemirrorContent(page.content);
const attachmentIds = getAttachmentIds(prosemirrorJson); const attachmentIds = getAttachmentIds(prosemirrorJson);
@@ -1,14 +1,10 @@
import { PartialType } from '@nestjs/mapped-types'; import { PartialType } from '@nestjs/mapped-types';
import { CreateSpaceDto } from './create-space.dto'; import { CreateSpaceDto } from './create-space.dto';
import { IsBoolean, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; import { IsNotEmpty, IsString, IsUUID } from 'class-validator';
export class UpdateSpaceDto extends PartialType(CreateSpaceDto) { export class UpdateSpaceDto extends PartialType(CreateSpaceDto) {
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@IsUUID() @IsUUID()
spaceId: string; spaceId: string;
@IsOptional()
@IsBoolean()
disablePublicSharing: boolean;
} }
@@ -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