mirror of
https://github.com/docmost/docmost.git
synced 2026-05-08 07:13:06 +08:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3cb70f0696 | |||
| fbb44df548 | |||
| bc3ce893c4 | |||
| ae96352189 | |||
| 1ad53c2581 | |||
| 2f97a3debc | |||
| 40b5346f9e | |||
| d6b4573b79 | |||
| 4878850b25 | |||
| 5c3942c159 | |||
| e0809e7104 | |||
| da6793ac87 | |||
| 08e94eb3c1 | |||
| 5a14186f1c | |||
| 6a0bb8d4cb | |||
| fba9f4cb2b | |||
| d8f7c4a822 | |||
| 202685b39f | |||
| fc4a428208 | |||
| 5506eb194b |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.25.0-beta.1",
|
"version": "0.25.2",
|
||||||
"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? Dadurch werden ihre Unterseiten und die Seitengeschichte gelöscht. Diese Aktion ist unwiderruflich.",
|
"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.",
|
||||||
"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 der Seitengeschichte.",
|
"Error loading page history.": "Fehler beim Laden des Seitenverlaufs.",
|
||||||
"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 keine Seitengeschichte gespeichert.",
|
"No page history saved yet.": "Es wurde noch kein Seitenverlauf 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,7 +122,9 @@
|
|||||||
"Owner": "Besitzer",
|
"Owner": "Besitzer",
|
||||||
"page": "Seite",
|
"page": "Seite",
|
||||||
"Page deleted successfully": "Seite erfolgreich gelöscht",
|
"Page deleted successfully": "Seite erfolgreich gelöscht",
|
||||||
"Page history": "Seitengeschichte",
|
"Page history": "Seitenverlauf",
|
||||||
|
"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",
|
||||||
@@ -405,6 +407,21 @@
|
|||||||
"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,6 +123,8 @@
|
|||||||
"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",
|
||||||
@@ -405,6 +407,21 @@
|
|||||||
"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,6 +123,8 @@
|
|||||||
"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",
|
||||||
@@ -405,6 +407,21 @@
|
|||||||
"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,6 +123,8 @@
|
|||||||
"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",
|
||||||
@@ -405,6 +407,21 @@
|
|||||||
"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,6 +123,8 @@
|
|||||||
"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",
|
||||||
@@ -405,6 +407,21 @@
|
|||||||
"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,6 +123,8 @@
|
|||||||
"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": "ページ",
|
||||||
@@ -405,6 +407,21 @@
|
|||||||
"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,6 +123,8 @@
|
|||||||
"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": "페이지",
|
||||||
@@ -405,6 +407,21 @@
|
|||||||
"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,6 +123,8 @@
|
|||||||
"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",
|
||||||
@@ -405,6 +407,21 @@
|
|||||||
"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,6 +123,8 @@
|
|||||||
"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",
|
||||||
@@ -405,6 +407,21 @@
|
|||||||
"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,6 +123,8 @@
|
|||||||
"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": "страницы",
|
||||||
@@ -405,6 +407,21 @@
|
|||||||
"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,6 +123,8 @@
|
|||||||
"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": "сторінки",
|
||||||
@@ -405,6 +407,21 @@
|
|||||||
"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,6 +123,8 @@
|
|||||||
"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": "个页面",
|
||||||
@@ -405,6 +407,21 @@
|
|||||||
"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": "页面复制成功",
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
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,23 +10,18 @@ export default function EnforceMfa() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||||
<Title order={4} my="sm">
|
<div>
|
||||||
MFA
|
<Text size="md">{t("Enforce two-factor authentication")}</Text>
|
||||||
</Title>
|
<Text size="sm" c="dimmed">
|
||||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
{t(
|
||||||
<div>
|
"Once enforced, all members must enable two-factor authentication to access the workspace.",
|
||||||
<Text size="md">{t("Enforce two-factor authentication")}</Text>
|
)}
|
||||||
<Text size="sm" c="dimmed">
|
</Text>
|
||||||
{t(
|
</div>
|
||||||
"Once enforced, all members must enable two-factor authentication to access the workspace.",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<EnforceMfaToggle />
|
<EnforceMfaToggle />
|
||||||
</Group>
|
</Group>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,15 +9,16 @@ 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 { hasLicenseKey } = useLicense();
|
const hasEnterpriseAccess = useEnterpriseAccess();
|
||||||
const { isBusiness } = usePlan();
|
const isCloudEE = useIsCloudEE();
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return null;
|
return null;
|
||||||
@@ -30,26 +31,41 @@ export default function Security() {
|
|||||||
</Helmet>
|
</Helmet>
|
||||||
<SettingsTitle title={t("Security")} />
|
<SettingsTitle title={t("Security")} />
|
||||||
|
|
||||||
<AllowedDomains />
|
|
||||||
|
|
||||||
<Divider my="lg" />
|
|
||||||
|
|
||||||
<EnforceMfa />
|
<EnforceMfa />
|
||||||
|
|
||||||
<Divider my="lg" />
|
<Divider my="lg" />
|
||||||
|
|
||||||
|
{(!isCloud() || hasEnterpriseAccess) && (
|
||||||
|
<>
|
||||||
|
<DisablePublicSharing />
|
||||||
|
<Divider my="lg" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Title order={4} my="lg">
|
<Title order={4} my="lg">
|
||||||
Single sign-on (SSO)
|
Single sign-on (SSO)
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
{(isCloud() && isBusiness) || (!isCloud() && hasLicenseKey) ? (
|
{hasEnterpriseAccess && (
|
||||||
<>
|
<>
|
||||||
<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 />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -170,6 +170,8 @@ 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) {
|
||||||
@@ -179,8 +181,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset the input value to allow uploading the same file again if needed
|
input.remove();
|
||||||
input.value = "";
|
|
||||||
};
|
};
|
||||||
input.click();
|
input.click();
|
||||||
},
|
},
|
||||||
@@ -202,6 +203,8 @@ 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) {
|
||||||
@@ -211,8 +214,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset the input value to allow uploading the same file again if needed
|
input.remove();
|
||||||
input.value = "";
|
|
||||||
};
|
};
|
||||||
input.click();
|
input.click();
|
||||||
},
|
},
|
||||||
@@ -234,6 +236,8 @@ 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) {
|
||||||
@@ -243,8 +247,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset the input value to allow uploading the same file again if needed
|
input.remove();
|
||||||
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, generateNodeId, UniqueID } from "@docmost/editor-ext";
|
import { Heading, 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-top: 0 !important;
|
padding: 0 !important;
|
||||||
min-height: auto !important;
|
min-height: auto !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -157,7 +157,9 @@ export function TitleEditor({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
titleEditor?.commands.focus("end");
|
// guard against Cannot access view['hasFocus'] error
|
||||||
|
if (!titleEditor?.isInitialized) return;
|
||||||
|
titleEditor?.commands?.focus("end");
|
||||||
}, 500);
|
}, 500);
|
||||||
}, [titleEditor]);
|
}, [titleEditor]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
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);
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
.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,36 +1,203 @@
|
|||||||
import "@/features/editor/styles/index.css";
|
import "@/features/editor/styles/index.css";
|
||||||
import React, { useEffect } from "react";
|
import { 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 classes from "./history.module.css";
|
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||||
|
import historyClasses from "./css/history.module.css";
|
||||||
|
import { recreateTransform } from "@docmost/editor-ext";
|
||||||
|
import { DOMSerializer, Node } from "@tiptap/pm/model";
|
||||||
|
import { ChangeSet, simplifyChanges } from "@tiptap/pm/changeset";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import {
|
||||||
|
diffCountsAtom,
|
||||||
|
highlightChangesAtom,
|
||||||
|
} from "@/features/page-history/atoms/history-atoms";
|
||||||
|
|
||||||
export interface HistoryEditorProps {
|
export interface HistoryEditorProps {
|
||||||
title: string;
|
title: string;
|
||||||
content: any;
|
content: any;
|
||||||
|
previousContent?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HistoryEditor({ title, content }: HistoryEditorProps) {
|
export function HistoryEditor({
|
||||||
|
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) {
|
if (!editor || !content) return;
|
||||||
|
|
||||||
|
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>
|
||||||
<div>
|
<Title order={1}>{title}</Title>
|
||||||
<Title order={1}>{title}</Title>
|
{editor && (
|
||||||
|
<EditorContent
|
||||||
{editor && (
|
editor={editor}
|
||||||
<EditorContent editor={editor} className={classes.historyEditor} />
|
className={historyClasses.historyEditor}
|
||||||
)}
|
/>
|
||||||
</div>
|
)}
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,42 @@
|
|||||||
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 "./history.module.css";
|
import classes from "./css/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: any;
|
historyItem: IPageHistory;
|
||||||
onSelect: (id: string) => void;
|
index: number;
|
||||||
|
onSelect: (id: string, index: number) => void;
|
||||||
|
onHover?: (id: string, index: number) => void;
|
||||||
|
onHoverEnd?: () => void;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function HistoryItem({ historyItem, onSelect, isActive }: HistoryItemProps) {
|
const HistoryItem = memo(function HistoryItem({
|
||||||
|
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={() => onSelect(historyItem.id)}
|
onClick={handleClick}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={onHoverEnd}
|
||||||
className={clsx(classes.history, { [classes.active]: isActive })}
|
className={clsx(classes.history, { [classes.active]: isActive })}
|
||||||
>
|
>
|
||||||
<Group wrap="nowrap">
|
<Group wrap="nowrap">
|
||||||
@@ -27,11 +49,11 @@ function HistoryItem({ historyItem, onSelect, isActive }: HistoryItemProps) {
|
|||||||
<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>
|
||||||
@@ -39,6 +61,6 @@ function HistoryItem({ historyItem, onSelect, isActive }: HistoryItemProps) {
|
|||||||
</Group>
|
</Group>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
export default HistoryItem;
|
export default HistoryItem;
|
||||||
|
|||||||
@@ -1,29 +1,27 @@
|
|||||||
import {
|
import {
|
||||||
usePageHistoryListQuery,
|
usePageHistoryListQuery,
|
||||||
usePageHistoryQuery,
|
prefetchPageHistory,
|
||||||
} 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 } from "jotai";
|
import { useAtom, useSetAtom } from "jotai";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import { Button, ScrollArea, Group, Divider, Text } from "@mantine/core";
|
|
||||||
import {
|
import {
|
||||||
pageEditorAtom,
|
Button,
|
||||||
titleEditorAtom,
|
ScrollArea,
|
||||||
} from "@/features/editor/atoms/editor-atoms";
|
Group,
|
||||||
import { modals } from "@mantine/modals";
|
Divider,
|
||||||
import { notifications } from "@mantine/notifications";
|
Loader,
|
||||||
|
Center,
|
||||||
|
} from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
import { useHistoryRestore } from "@/features/page-history/hooks";
|
||||||
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
|
|
||||||
import { useParams } from "react-router-dom";
|
const PREFETCH_DELAY_MS = 150;
|
||||||
import {
|
|
||||||
SpaceCaslAction,
|
|
||||||
SpaceCaslSubject,
|
|
||||||
} from "@/features/space/permissions/permissions.type.ts";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@@ -32,62 +30,89 @@ 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: pageHistoryList,
|
data: pageHistoryData,
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
} = usePageHistoryListQuery(pageId);
|
} = usePageHistoryListQuery(pageId);
|
||||||
const { data: activeHistoryData } = usePageHistoryQuery(activeHistoryId);
|
|
||||||
|
|
||||||
const [mainEditor] = useAtom(pageEditorAtom);
|
const historyItems = useMemo(
|
||||||
const [mainEditorTitle] = useAtom(titleEditorAtom);
|
() => pageHistoryData?.pages.flatMap((page) => page.items) ?? [],
|
||||||
const [, setHistoryModalOpen] = useAtom(historyAtoms);
|
[pageHistoryData],
|
||||||
|
);
|
||||||
|
|
||||||
const { spaceSlug } = useParams();
|
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||||
const { data: space } = useSpaceQuery(spaceSlug);
|
const prefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const spaceRules = space?.membership?.permissions;
|
|
||||||
const spaceAbility = useSpaceAbility(spaceRules);
|
|
||||||
|
|
||||||
const confirmModal = () =>
|
const { canRestore, confirmRestore } = useHistoryRestore();
|
||||||
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 handleRestore = useCallback(() => {
|
const clearPrefetchTimeout = useCallback(() => {
|
||||||
if (activeHistoryData) {
|
if (prefetchTimeoutRef.current) {
|
||||||
mainEditorTitle
|
clearTimeout(prefetchTimeoutRef.current);
|
||||||
.chain()
|
prefetchTimeoutRef.current = null;
|
||||||
.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(() => {
|
||||||
if (
|
return clearPrefetchTimeout;
|
||||||
pageHistoryList &&
|
}, [clearPrefetchTimeout]);
|
||||||
pageHistoryList.items.length > 0 &&
|
|
||||||
!activeHistoryId
|
const handleSelect = useCallback(
|
||||||
) {
|
(id: string, index: number) => {
|
||||||
setActiveHistoryId(pageHistoryList.items[0].id);
|
setActiveHistoryId(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 <></>;
|
||||||
@@ -97,34 +122,36 @@ function HistoryList({ pageId }: Props) {
|
|||||||
return <div>{t("Error loading page history.")}</div>;
|
return <div>{t("Error loading page history.")}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!pageHistoryList || pageHistoryList.items.length === 0) {
|
if (historyItems.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}>
|
||||||
{pageHistoryList &&
|
{historyItems.map((historyItem, index) => (
|
||||||
pageHistoryList.items.map((historyItem, index) => (
|
<HistoryItem
|
||||||
<HistoryItem
|
key={historyItem.id}
|
||||||
key={index}
|
historyItem={historyItem}
|
||||||
historyItem={historyItem}
|
index={index}
|
||||||
onSelect={setActiveHistoryId}
|
onSelect={handleSelect}
|
||||||
isActive={historyItem.id === activeHistoryId}
|
onHover={handleHover}
|
||||||
/>
|
onHoverEnd={clearPrefetchTimeout}
|
||||||
))}
|
isActive={historyItem.id === activeHistoryId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{hasNextPage && <div ref={loadMoreRef} style={{ height: 1 }} />}
|
||||||
|
{isFetchingNextPage && (
|
||||||
|
<Center py="sm">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
{spaceAbility.cannot(
|
{canRestore && (
|
||||||
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"
|
||||||
@@ -132,6 +159,9 @@ function HistoryList({ pageId }: Props) {
|
|||||||
>
|
>
|
||||||
{t("Cancel")}
|
{t("Cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button size="compact-md" onClick={confirmRestore}>
|
||||||
|
{t("Restore")}
|
||||||
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,21 +1,45 @@
|
|||||||
import { ScrollArea } from "@mantine/core";
|
import {
|
||||||
|
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 "./history.module.css";
|
import classes from "./css/history.module.css";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { activeHistoryIdAtom } from "@/features/page-history/atoms/history-atoms";
|
import {
|
||||||
|
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 { useEffect } from "react";
|
import { useRef } 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 [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
|
const { t } = useTranslation();
|
||||||
|
const scrollViewportRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const activeHistoryId = useAtomValue(activeHistoryIdAtom);
|
||||||
setActiveHistoryId("");
|
const activeHistoryPrevId = useAtomValue(activeHistoryPrevIdAtom);
|
||||||
}, [pageId]);
|
const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom);
|
||||||
|
const diffCounts = useAtomValue(diffCountsAtom);
|
||||||
|
|
||||||
|
useHistoryReset(pageId);
|
||||||
|
const { currentChangeIndex, handlePrevChange, handleNextChange } =
|
||||||
|
useDiffNavigation(scrollViewportRef);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.sidebarFlex}>
|
<div className={classes.sidebarFlex}>
|
||||||
@@ -25,11 +49,63 @@ export default function HistoryModalBody({ pageId }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<ScrollArea h="650" w="100%" scrollbarSize={5}>
|
<div style={{ position: "relative", flex: 1 }}>
|
||||||
<div className={classes.sidebarRightSection}>
|
<ScrollArea
|
||||||
{activeHistoryId && <HistoryView historyId={activeHistoryId} />}
|
h={650}
|
||||||
</div>
|
w="100%"
|
||||||
</ScrollArea>
|
scrollbarSize={5}
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
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,21 +2,26 @@ 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)");
|
||||||
|
|
||||||
return (
|
if (isMobile) {
|
||||||
<>
|
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" }}>
|
||||||
@@ -28,11 +33,37 @@ export default function HistoryModal({ pageId }: Props) {
|
|||||||
</Modal.Title>
|
</Modal.Title>
|
||||||
<Modal.CloseButton />
|
<Modal.CloseButton />
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
<Modal.Body>
|
<Modal.Body
|
||||||
<HistoryModalBody pageId={pageId} />
|
p={0}
|
||||||
|
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,29 +1,44 @@
|
|||||||
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";
|
||||||
|
|
||||||
interface HistoryProps {
|
function HistoryView() {
|
||||||
historyId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function HistoryView({ historyId }: HistoryProps) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data, isLoading, isError } = usePageHistoryQuery(historyId);
|
const historyId = useAtomValue(activeHistoryIdAtom);
|
||||||
|
const prevHistoryId = useAtomValue(activeHistoryPrevIdAtom);
|
||||||
|
|
||||||
if (isLoading) {
|
const {
|
||||||
|
data,
|
||||||
|
isLoading: isLoadingCurrent,
|
||||||
|
isError: isErrorCurrent,
|
||||||
|
} = usePageHistoryQuery(historyId);
|
||||||
|
const {
|
||||||
|
data: prevData,
|
||||||
|
isLoading: isLoadingPrev,
|
||||||
|
isError: isErrorPrev,
|
||||||
|
} = usePageHistoryQuery(prevHistoryId);
|
||||||
|
|
||||||
|
if (isLoadingCurrent || isLoadingPrev) {
|
||||||
return <></>;
|
return <></>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isError || !data) {
|
if (isErrorCurrent || !data) {
|
||||||
return <div>{t("Error fetching page data.")}</div>;
|
return <div>{t("Error fetching page data.")}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
data && (
|
<div>
|
||||||
<div>
|
<HistoryEditor
|
||||||
<HistoryEditor content={data.content} title={data.title} />
|
content={data.content}
|
||||||
</div>
|
title={data.title}
|
||||||
)
|
previousContent={!isErrorPrev ? prevData?.content : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,49 +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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { useDiffNavigation } from "./use-diff-navigation";
|
||||||
|
export { useHistoryRestore } from "./use-history-restore";
|
||||||
|
export { useHistoryReset } from "./use-history-reset";
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
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,19 +1,38 @@
|
|||||||
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
import {
|
||||||
|
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,
|
||||||
): UseQueryResult<IPagination<IPageHistory>, Error> {
|
): UseInfiniteQueryResult<InfiniteData<IPagination<IPageHistory>, unknown>> {
|
||||||
return useQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: ["page-history-list", pageId],
|
queryKey: ["page-history-list", pageId],
|
||||||
queryFn: () => getPageHistoryList(pageId),
|
queryFn: ({ pageParam }) => getPageHistoryList(pageId, pageParam),
|
||||||
enabled: !!pageId,
|
enabled: !!pageId,
|
||||||
gcTime: 0,
|
gcTime: 0,
|
||||||
|
initialPageParam: undefined,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.meta?.nextCursor ?? undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,6 +43,6 @@ export function usePageHistoryQuery(
|
|||||||
queryKey: ["page-history", historyId],
|
queryKey: ["page-history", historyId],
|
||||||
queryFn: () => getPageHistoryById(historyId),
|
queryFn: () => getPageHistoryById(historyId),
|
||||||
enabled: !!historyId,
|
enabled: !!historyId,
|
||||||
staleTime: 10 * 60 * 1000,
|
staleTime: HISTORY_STALE_TIME,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
IconBrandNotion,
|
IconBrandNotion,
|
||||||
IconCheck,
|
IconCheck,
|
||||||
IconFileCode,
|
IconFileCode,
|
||||||
|
IconFileTypeDocx,
|
||||||
IconFileTypeZip,
|
IconFileTypeZip,
|
||||||
IconMarkdown,
|
IconMarkdown,
|
||||||
IconX,
|
IconX,
|
||||||
@@ -86,11 +87,13 @@ 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) {
|
||||||
@@ -265,6 +268,7 @@ 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")}`;
|
||||||
@@ -321,6 +325,30 @@ 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"
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ 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;
|
||||||
@@ -40,6 +43,12 @@ 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();
|
||||||
@@ -164,6 +173,20 @@ 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>
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ 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;
|
||||||
@@ -26,6 +28,8 @@ 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);
|
||||||
@@ -77,7 +81,6 @@ 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}
|
||||||
@@ -88,6 +91,13 @@ 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" />
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ 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;
|
||||||
@@ -18,6 +26,9 @@ 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 {
|
||||||
|
|||||||
@@ -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> & { aiSearch?: boolean }) {
|
export async function updateWorkspace(data: Partial<IWorkspace>) {
|
||||||
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,7 +66,9 @@ export async function createInvitation(data: ICreateInvite) {
|
|||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function acceptInvitation(data: IAcceptInvite): Promise<{ requiresLogin?: boolean; }> {
|
export async function acceptInvitation(
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
@@ -108,4 +110,3 @@ 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,16 +22,23 @@ 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[];
|
||||||
|
|||||||
+12
-12
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "server",
|
"name": "server",
|
||||||
"version": "0.25.0-beta.1",
|
"version": "0.25.2",
|
||||||
"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.701.0",
|
"@aws-sdk/client-s3": "3.982.0",
|
||||||
"@aws-sdk/lib-storage": "3.701.0",
|
"@aws-sdk/lib-storage": "3.982.0",
|
||||||
"@aws-sdk/s3-request-presigner": "3.701.0",
|
"@aws-sdk/s3-request-presigner": "3.982.0",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/multipart": "^9.3.0",
|
"@fastify/multipart": "^9.4.0",
|
||||||
"@fastify/static": "^8.3.0",
|
"@fastify/static": "^9.0.0",
|
||||||
"@langchain/core": "1.1.13",
|
"@langchain/core": "1.1.18",
|
||||||
"@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.11",
|
"@nestjs/core": "^11.1.13",
|
||||||
"@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.11",
|
"@nestjs/platform-fastify": "^11.1.13",
|
||||||
"@nestjs/platform-socket.io": "^11.1.11",
|
"@nestjs/platform-socket.io": "^11.1.13",
|
||||||
"@nestjs/schedule": "^6.1.0",
|
"@nestjs/schedule": "^6.1.0",
|
||||||
"@nestjs/terminus": "^11.0.0",
|
"@nestjs/terminus": "^11.0.0",
|
||||||
"@nestjs/websockets": "^11.1.11",
|
"@nestjs/websockets": "^11.1.13",
|
||||||
"@node-saml/passport-saml": "^5.1.0",
|
"@node-saml/passport-saml": "^5.1.0",
|
||||||
"@react-email/components": "0.0.28",
|
"@react-email/components": "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,6 +3,7 @@ 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;
|
||||||
@@ -12,7 +13,10 @@ export class UpdatedPageEvent {
|
|||||||
export class HistoryListener {
|
export class HistoryListener {
|
||||||
private readonly logger = new Logger(HistoryListener.name);
|
private readonly logger = new Logger(HistoryListener.name);
|
||||||
|
|
||||||
constructor(private readonly pageHistoryRepo: PageHistoryRepo) {}
|
constructor(
|
||||||
|
private readonly pageHistoryRepo: PageHistoryRepo,
|
||||||
|
private readonly environmentService: EnvironmentService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@OnEvent('collab.page.updated')
|
@OnEvent('collab.page.updated')
|
||||||
async handleCreatePageHistory(event: UpdatedPageEvent) {
|
async handleCreatePageHistory(event: UpdatedPageEvent) {
|
||||||
@@ -20,13 +24,17 @@ 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 = 5 * 60 * 1000;
|
const FIVE_MINUTES = this.environmentService.isDevelopment()
|
||||||
|
? 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 ||
|
||||||
|
|||||||
@@ -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 } from 'fastify';
|
import { FastifyReply, FastifyRequest } 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 { User, Workspace } from '@docmost/db/types/entity.types';
|
import { Attachment, 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,6 +151,7 @@ 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,
|
||||||
@@ -181,22 +182,7 @@ export class AttachmentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fileStream = await this.storageService.readStream(
|
return await this.sendFileResponse(req, res, attachment, 'private');
|
||||||
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');
|
||||||
@@ -205,6 +191,7 @@ 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,
|
||||||
@@ -243,22 +230,7 @@ export class AttachmentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fileStream = await this.storageService.readStream(
|
return await this.sendFileResponse(req, res, attachment, 'public');
|
||||||
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');
|
||||||
@@ -433,4 +405,69 @@ 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`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fileSize) {
|
||||||
|
res.header('Content-Length', fileSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.send(fileStream);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -215,7 +215,6 @@ export class PageController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: scope to workspaces
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('/history')
|
@Post('/history')
|
||||||
async getPageHistory(
|
async getPageHistory(
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ 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(
|
||||||
|
|||||||
@@ -64,8 +64,18 @@ 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 {
|
||||||
...(await this.shareService.getSharedPage(dto, workspace.id)),
|
...shareData,
|
||||||
hasLicenseKey: hasLicenseOrEE({
|
hasLicenseKey: hasLicenseOrEE({
|
||||||
licenseKey: workspace.licenseKey,
|
licenseKey: workspace.licenseKey,
|
||||||
isCloud: this.environmentService.isCloud(),
|
isCloud: this.environmentService.isCloud(),
|
||||||
@@ -86,6 +96,14 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +145,14 @@ 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,
|
||||||
@@ -176,8 +202,21 @@ 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 {
|
||||||
...(await this.shareService.getShareTree(dto.shareId, workspace.id)),
|
...treeData,
|
||||||
hasLicenseKey: hasLicenseOrEE({
|
hasLicenseKey: hasLicenseOrEE({
|
||||||
licenseKey: workspace.licenseKey,
|
licenseKey: workspace.licenseKey,
|
||||||
isCloud: this.environmentService.isCloud(),
|
isCloud: this.environmentService.isCloud(),
|
||||||
|
|||||||
@@ -264,6 +264,31 @@ 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,10 +1,14 @@
|
|||||||
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 { IsNotEmpty, IsString, IsUUID } from 'class-validator';
|
import { IsBoolean, IsNotEmpty, IsOptional, 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -17,12 +18,18 @@ import { QueueJob, QueueName } from 'src/integrations/queue/constants';
|
|||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
import { InjectQueue } from '@nestjs/bullmq';
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
||||||
|
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||||
|
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||||
|
import { LicenseCheckService } from '../../../integrations/environment/license-check.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SpaceService {
|
export class SpaceService {
|
||||||
constructor(
|
constructor(
|
||||||
private spaceRepo: SpaceRepo,
|
private spaceRepo: SpaceRepo,
|
||||||
private spaceMemberService: SpaceMemberService,
|
private spaceMemberService: SpaceMemberService,
|
||||||
|
private shareRepo: ShareRepo,
|
||||||
|
private workspaceRepo: WorkspaceRepo,
|
||||||
|
private licenseCheckService: LicenseCheckService,
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
||||||
) {}
|
) {}
|
||||||
@@ -105,6 +112,31 @@ export class SpaceService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof updateSpaceDto.disablePublicSharing !== 'undefined') {
|
||||||
|
const workspace = await this.workspaceRepo.findById(workspaceId, {
|
||||||
|
withLicenseKey: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
!this.licenseCheckService.isValidEELicense(workspace.licenseKey)
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'This feature requires a valid enterprise license',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.spaceRepo.updateSharingSettings(
|
||||||
|
updateSpaceDto.spaceId,
|
||||||
|
workspaceId,
|
||||||
|
'disabled',
|
||||||
|
updateSpaceDto.disablePublicSharing,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (updateSpaceDto.disablePublicSharing) {
|
||||||
|
await this.shareRepo.deleteBySpaceId(updateSpaceDto.spaceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return await this.spaceRepo.updateSpace(
|
return await this.spaceRepo.updateSpace(
|
||||||
{
|
{
|
||||||
name: updateSpaceDto.name,
|
name: updateSpaceDto.name,
|
||||||
|
|||||||
@@ -30,4 +30,8 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
generativeAi: boolean;
|
generativeAi: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
disablePublicSharing: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { LicenseCheckService } from '../../../integrations/environment/license-check.service';
|
||||||
import { CreateWorkspaceDto } from '../dto/create-workspace.dto';
|
import { CreateWorkspaceDto } from '../dto/create-workspace.dto';
|
||||||
import { UpdateWorkspaceDto } from '../dto/update-workspace.dto';
|
import { UpdateWorkspaceDto } from '../dto/update-workspace.dto';
|
||||||
import { SpaceService } from '../../space/services/space.service';
|
import { SpaceService } from '../../space/services/space.service';
|
||||||
@@ -33,6 +34,7 @@ import { Queue } from 'bullmq';
|
|||||||
import { generateRandomSuffixNumbers } from '../../../common/helpers';
|
import { generateRandomSuffixNumbers } from '../../../common/helpers';
|
||||||
import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers';
|
import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers';
|
||||||
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
||||||
|
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WorkspaceService {
|
export class WorkspaceService {
|
||||||
@@ -47,6 +49,8 @@ export class WorkspaceService {
|
|||||||
private userRepo: UserRepo,
|
private userRepo: UserRepo,
|
||||||
private environmentService: EnvironmentService,
|
private environmentService: EnvironmentService,
|
||||||
private domainService: DomainService,
|
private domainService: DomainService,
|
||||||
|
private licenseCheckService: LicenseCheckService,
|
||||||
|
private shareRepo: ShareRepo,
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
||||||
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
|
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
|
||||||
@@ -358,6 +362,32 @@ export class WorkspaceService {
|
|||||||
delete updateWorkspaceDto.generativeAi;
|
delete updateWorkspaceDto.generativeAi;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof updateWorkspaceDto.disablePublicSharing !== 'undefined') {
|
||||||
|
const currentWorkspace = await this.workspaceRepo.findById(workspaceId, {
|
||||||
|
withLicenseKey: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
!this.licenseCheckService.isValidEELicense(currentWorkspace.licenseKey)
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'This feature requires a valid enterprise license',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.workspaceRepo.updateSharingSettings(
|
||||||
|
workspaceId,
|
||||||
|
'disabled',
|
||||||
|
updateWorkspaceDto.disablePublicSharing,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (updateWorkspaceDto.disablePublicSharing) {
|
||||||
|
await this.shareRepo.deleteByWorkspaceId(workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete updateWorkspaceDto.disablePublicSharing;
|
||||||
|
}
|
||||||
|
|
||||||
await this.workspaceRepo.updateWorkspace(updateWorkspaceDto, workspaceId);
|
await this.workspaceRepo.updateWorkspace(updateWorkspaceDto, workspaceId);
|
||||||
|
|
||||||
const workspace = await this.workspaceRepo.findById(workspaceId, {
|
const workspace = await this.workspaceRepo.findById(workspaceId, {
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Kysely } from 'kysely';
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema.alterTable('spaces').addColumn('settings', 'jsonb').execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema.alterTable('spaces').dropColumn('settings').execute();
|
||||||
|
}
|
||||||
@@ -17,15 +17,32 @@ import { DB } from '@docmost/db/types/db';
|
|||||||
export class PageHistoryRepo {
|
export class PageHistoryRepo {
|
||||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||||
|
|
||||||
|
private baseFields: Array<keyof PageHistory> = [
|
||||||
|
'id',
|
||||||
|
'pageId',
|
||||||
|
'slugId',
|
||||||
|
'title',
|
||||||
|
'icon',
|
||||||
|
'coverPhoto',
|
||||||
|
'lastUpdatedById',
|
||||||
|
'spaceId',
|
||||||
|
'workspaceId',
|
||||||
|
'createdAt',
|
||||||
|
];
|
||||||
|
|
||||||
async findById(
|
async findById(
|
||||||
pageHistoryId: string,
|
pageHistoryId: string,
|
||||||
trx?: KyselyTransaction,
|
opts?: {
|
||||||
|
includeContent?: boolean;
|
||||||
|
trx?: KyselyTransaction;
|
||||||
|
},
|
||||||
): Promise<PageHistory> {
|
): Promise<PageHistory> {
|
||||||
const db = dbOrTx(this.db, trx);
|
const db = dbOrTx(this.db, opts?.trx);
|
||||||
|
|
||||||
return await db
|
return await db
|
||||||
.selectFrom('pageHistory')
|
.selectFrom('pageHistory')
|
||||||
.selectAll()
|
.select(this.baseFields)
|
||||||
|
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
||||||
.select((eb) => this.withLastUpdatedBy(eb))
|
.select((eb) => this.withLastUpdatedBy(eb))
|
||||||
.where('id', '=', pageHistoryId)
|
.where('id', '=', pageHistoryId)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
@@ -63,7 +80,7 @@ export class PageHistoryRepo {
|
|||||||
async findPageHistoryByPageId(pageId: string, pagination: PaginationOptions) {
|
async findPageHistoryByPageId(pageId: string, pagination: PaginationOptions) {
|
||||||
const query = this.db
|
const query = this.db
|
||||||
.selectFrom('pageHistory')
|
.selectFrom('pageHistory')
|
||||||
.selectAll()
|
.select(this.baseFields)
|
||||||
.select((eb) => this.withLastUpdatedBy(eb))
|
.select((eb) => this.withLastUpdatedBy(eb))
|
||||||
.where('pageId', '=', pageId);
|
.where('pageId', '=', pageId);
|
||||||
|
|
||||||
@@ -76,12 +93,19 @@ export class PageHistoryRepo {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findPageLastHistory(pageId: string, trx?: KyselyTransaction) {
|
async findPageLastHistory(
|
||||||
const db = dbOrTx(this.db, trx);
|
pageId: string,
|
||||||
|
opts?: {
|
||||||
|
includeContent?: boolean;
|
||||||
|
trx?: KyselyTransaction;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const db = dbOrTx(this.db, opts?.trx);
|
||||||
|
|
||||||
return await db
|
return await db
|
||||||
.selectFrom('pageHistory')
|
.selectFrom('pageHistory')
|
||||||
.selectAll()
|
.select(this.baseFields)
|
||||||
|
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
||||||
.where('pageId', '=', pageId)
|
.where('pageId', '=', pageId)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.orderBy('createdAt', 'desc')
|
.orderBy('createdAt', 'desc')
|
||||||
|
|||||||
@@ -136,6 +136,20 @@ export class ShareRepo {
|
|||||||
await query.execute();
|
await query.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteBySpaceId(spaceId: string): Promise<void> {
|
||||||
|
await this.db
|
||||||
|
.deleteFrom('shares')
|
||||||
|
.where('spaceId', '=', spaceId)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteByWorkspaceId(workspaceId: string): Promise<void> {
|
||||||
|
await this.db
|
||||||
|
.deleteFrom('shares')
|
||||||
|
.where('workspaceId', '=', workspaceId)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
async getShares(userId: string, pagination: PaginationOptions) {
|
async getShares(userId: string, pagination: PaginationOptions) {
|
||||||
const query = this.db
|
const query = this.db
|
||||||
.selectFrom('shares')
|
.selectFrom('shares')
|
||||||
|
|||||||
@@ -89,6 +89,26 @@ export class SpaceRepo {
|
|||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateSharingSettings(
|
||||||
|
spaceId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
prefKey: string,
|
||||||
|
prefValue: string | boolean,
|
||||||
|
) {
|
||||||
|
return this.db
|
||||||
|
.updateTable('spaces')
|
||||||
|
.set({
|
||||||
|
settings: sql`COALESCE(settings, '{}'::jsonb)
|
||||||
|
|| jsonb_build_object('sharing', COALESCE(settings->'sharing', '{}'::jsonb)
|
||||||
|
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where('id', '=', spaceId)
|
||||||
|
.where('workspaceId', '=', workspaceId)
|
||||||
|
.returningAll()
|
||||||
|
.executeTakeFirst();
|
||||||
|
}
|
||||||
|
|
||||||
async insertSpace(
|
async insertSpace(
|
||||||
insertableSpace: InsertableSpace,
|
insertableSpace: InsertableSpace,
|
||||||
trx?: KyselyTransaction,
|
trx?: KyselyTransaction,
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ export class WorkspaceRepo {
|
|||||||
.updateTable('workspaces')
|
.updateTable('workspaces')
|
||||||
.set({
|
.set({
|
||||||
settings: sql`COALESCE(settings, '{}'::jsonb)
|
settings: sql`COALESCE(settings, '{}'::jsonb)
|
||||||
|| jsonb_build_object('api', COALESCE(settings->'api', '{}'::jsonb)
|
|| jsonb_build_object('api', COALESCE(settings->'api', '{}'::jsonb)
|
||||||
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
|
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
@@ -185,7 +185,25 @@ export class WorkspaceRepo {
|
|||||||
.updateTable('workspaces')
|
.updateTable('workspaces')
|
||||||
.set({
|
.set({
|
||||||
settings: sql`COALESCE(settings, '{}'::jsonb)
|
settings: sql`COALESCE(settings, '{}'::jsonb)
|
||||||
|| jsonb_build_object('ai', COALESCE(settings->'ai', '{}'::jsonb)
|
|| jsonb_build_object('ai', COALESCE(settings->'ai', '{}'::jsonb)
|
||||||
|
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where('id', '=', workspaceId)
|
||||||
|
.returning(this.baseFields)
|
||||||
|
.executeTakeFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSharingSettings(
|
||||||
|
workspaceId: string,
|
||||||
|
prefKey: string,
|
||||||
|
prefValue: string | boolean,
|
||||||
|
) {
|
||||||
|
return this.db
|
||||||
|
.updateTable('workspaces')
|
||||||
|
.set({
|
||||||
|
settings: sql`COALESCE(settings, '{}'::jsonb)
|
||||||
|
|| jsonb_build_object('sharing', COALESCE(settings->'sharing', '{}'::jsonb)
|
||||||
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
|
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
|
|||||||
+1
@@ -273,6 +273,7 @@ export interface Spaces {
|
|||||||
id: Generated<string>;
|
id: Generated<string>;
|
||||||
logo: string | null;
|
logo: string | null;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
|
settings: Json | null;
|
||||||
slug: string;
|
slug: string;
|
||||||
updatedAt: Generated<Timestamp>;
|
updatedAt: Generated<Timestamp>;
|
||||||
visibility: Generated<string>;
|
visibility: Generated<string>;
|
||||||
|
|||||||
+1
-1
Submodule apps/server/src/ee updated: 6d3eb76d4e...247e5eb7d1
@@ -4,6 +4,7 @@ import { ConfigModule } from '@nestjs/config';
|
|||||||
import { validate } from './environment.validation';
|
import { validate } from './environment.validation';
|
||||||
import { envPath } from '../../common/helpers';
|
import { envPath } from '../../common/helpers';
|
||||||
import { DomainService } from './domain.service';
|
import { DomainService } from './domain.service';
|
||||||
|
import { LicenseCheckService } from './license-check.service';
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
@@ -15,7 +16,7 @@ import { DomainService } from './domain.service';
|
|||||||
validate,
|
validate,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
providers: [EnvironmentService, DomainService],
|
providers: [EnvironmentService, DomainService, LicenseCheckService],
|
||||||
exports: [EnvironmentService, DomainService],
|
exports: [EnvironmentService, DomainService, LicenseCheckService],
|
||||||
})
|
})
|
||||||
export class EnvironmentModule {}
|
export class EnvironmentModule {}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ModuleRef } from '@nestjs/core';
|
||||||
|
import { EnvironmentService } from './environment.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LicenseCheckService {
|
||||||
|
constructor(
|
||||||
|
private moduleRef: ModuleRef,
|
||||||
|
private environmentService: EnvironmentService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
isValidEELicense(licenseKey: string): boolean {
|
||||||
|
if (this.environmentService.isCloud()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
const LicenseModule = require('../../ee/licence/license.service');
|
||||||
|
const licenseService = this.moduleRef.get(LicenseModule.LicenseService, {
|
||||||
|
strict: false,
|
||||||
|
});
|
||||||
|
return licenseService.isValidEELicense(licenseKey);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,7 +44,7 @@ export class ImportController {
|
|||||||
@AuthUser() user: User,
|
@AuthUser() user: User,
|
||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
) {
|
) {
|
||||||
const validFileExtensions = ['.md', '.html'];
|
const validFileExtensions = ['.md', '.html', '.docx'];
|
||||||
|
|
||||||
const maxFileSize = bytes('10mb');
|
const maxFileSize = bytes('10mb');
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { StorageService } from '../../storage/storage.service';
|
|||||||
import { InjectQueue } from '@nestjs/bullmq';
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
import { QueueJob, QueueName } from '../../queue/constants';
|
import { QueueJob, QueueName } from '../../queue/constants';
|
||||||
|
import { ModuleRef } from '@nestjs/core';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ImportService {
|
export class ImportService {
|
||||||
@@ -40,6 +41,7 @@ export class ImportService {
|
|||||||
@InjectKysely() private readonly db: KyselyDB,
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
@InjectQueue(QueueName.FILE_TASK_QUEUE)
|
@InjectQueue(QueueName.FILE_TASK_QUEUE)
|
||||||
private readonly fileTaskQueue: Queue,
|
private readonly fileTaskQueue: Queue,
|
||||||
|
private moduleRef: ModuleRef,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async importPage(
|
async importPage(
|
||||||
@@ -59,11 +61,22 @@ export class ImportService {
|
|||||||
let prosemirrorState = null;
|
let prosemirrorState = null;
|
||||||
let createdPage = null;
|
let createdPage = null;
|
||||||
|
|
||||||
|
// For DOCX, we need the page ID upfront so images can reference it
|
||||||
|
const pageId = fileExtension === '.docx' ? uuid7() : undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (fileExtension.endsWith('.md')) {
|
if (fileExtension.endsWith('.md')) {
|
||||||
prosemirrorState = await this.processMarkdown(fileContent);
|
prosemirrorState = await this.processMarkdown(fileContent);
|
||||||
} else if (fileExtension.endsWith('.html')) {
|
} else if (fileExtension.endsWith('.html')) {
|
||||||
prosemirrorState = await this.processHTML(fileContent);
|
prosemirrorState = await this.processHTML(fileContent);
|
||||||
|
} else if (fileExtension.endsWith('.docx')) {
|
||||||
|
prosemirrorState = await this.processDocx(
|
||||||
|
fileBuffer,
|
||||||
|
workspaceId,
|
||||||
|
spaceId,
|
||||||
|
pageId,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = 'Error processing file content';
|
const message = 'Error processing file content';
|
||||||
@@ -87,6 +100,7 @@ export class ImportService {
|
|||||||
const pagePosition = await this.getNewPagePosition(spaceId);
|
const pagePosition = await this.getNewPagePosition(spaceId);
|
||||||
|
|
||||||
createdPage = await this.pageRepo.insertPage({
|
createdPage = await this.pageRepo.insertPage({
|
||||||
|
...(pageId ? { id: pageId } : {}),
|
||||||
slugId: generateSlugId(),
|
slugId: generateSlugId(),
|
||||||
title: pageTitle,
|
title: pageTitle,
|
||||||
content: prosemirrorJson,
|
content: prosemirrorJson,
|
||||||
@@ -129,6 +143,42 @@ export class ImportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async processDocx(
|
||||||
|
fileBuffer: Buffer,
|
||||||
|
workspaceId: string,
|
||||||
|
spaceId: string,
|
||||||
|
pageId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<any> {
|
||||||
|
let DocxImportModule: any;
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
DocxImportModule = require('./../../../ee/docx-import/docx-import.service');
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
'DOCX import requested but EE module not bundled in this build',
|
||||||
|
);
|
||||||
|
throw new BadRequestException(
|
||||||
|
'This feature requires a valid enterprise license.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const docxImportService = this.moduleRef.get(
|
||||||
|
DocxImportModule.DocxImportService,
|
||||||
|
{ strict: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const html = await docxImportService.convertDocxToHtml(
|
||||||
|
fileBuffer,
|
||||||
|
workspaceId,
|
||||||
|
spaceId,
|
||||||
|
pageId,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.processHTML(html);
|
||||||
|
}
|
||||||
|
|
||||||
async createYdoc(prosemirrorJson: any): Promise<Buffer | null> {
|
async createYdoc(prosemirrorJson: any): Promise<Buffer | null> {
|
||||||
if (prosemirrorJson) {
|
if (prosemirrorJson) {
|
||||||
// this.logger.debug(`Converting prosemirror json state to ydoc`);
|
// this.logger.debug(`Converting prosemirror json state to ydoc`);
|
||||||
|
|||||||
@@ -73,6 +73,20 @@ export class LocalDriver implements StorageDriver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async readRangeStream(
|
||||||
|
filePath: string,
|
||||||
|
range: { start: number; end: number },
|
||||||
|
): Promise<Readable> {
|
||||||
|
try {
|
||||||
|
return createReadStream(this._fullPath(filePath), {
|
||||||
|
start: range.start,
|
||||||
|
end: range.end,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Failed to read file: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async exists(filePath: string): Promise<boolean> {
|
async exists(filePath: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
return await fs.pathExists(this._fullPath(filePath));
|
return await fs.pathExists(this._fullPath(filePath));
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Readable } from 'stream';
|
|||||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||||
import { getMimeType } from '../../../common/helpers';
|
import { getMimeType } from '../../../common/helpers';
|
||||||
import { Upload } from '@aws-sdk/lib-storage';
|
import { Upload } from '@aws-sdk/lib-storage';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
|
||||||
export class S3Driver implements StorageDriver {
|
export class S3Driver implements StorageDriver {
|
||||||
private readonly s3Client: S3Client;
|
private readonly s3Client: S3Client;
|
||||||
@@ -39,6 +40,7 @@ export class S3Driver implements StorageDriver {
|
|||||||
|
|
||||||
await upload.done();
|
await upload.done();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
Logger.error(err);
|
||||||
throw new Error(`Failed to upload file: ${(err as Error).message}`);
|
throw new Error(`Failed to upload file: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,6 +75,7 @@ export class S3Driver implements StorageDriver {
|
|||||||
|
|
||||||
await upload.done();
|
await upload.done();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
Logger.error(err);
|
||||||
throw new Error(`Failed to upload file: ${(err as Error).message}`);
|
throw new Error(`Failed to upload file: ${(err as Error).message}`);
|
||||||
} finally {
|
} finally {
|
||||||
if (shouldDestroyClient && clientToUse) {
|
if (shouldDestroyClient && clientToUse) {
|
||||||
@@ -127,6 +130,25 @@ export class S3Driver implements StorageDriver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async readRangeStream(
|
||||||
|
filePath: string,
|
||||||
|
range: { start: number; end: number },
|
||||||
|
): Promise<Readable> {
|
||||||
|
try {
|
||||||
|
const command = new GetObjectCommand({
|
||||||
|
Bucket: this.config.bucket,
|
||||||
|
Key: filePath,
|
||||||
|
Range: `bytes=${range.start}-${range.end}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await this.s3Client.send(command);
|
||||||
|
|
||||||
|
return response.Body as Readable;
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Failed to read file from S3: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async exists(filePath: string): Promise<boolean> {
|
async exists(filePath: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const command = new HeadObjectCommand({
|
const command = new HeadObjectCommand({
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ export interface StorageDriver {
|
|||||||
|
|
||||||
readStream(filePath: string): Promise<Readable>;
|
readStream(filePath: string): Promise<Readable>;
|
||||||
|
|
||||||
|
readRangeStream(
|
||||||
|
filePath: string,
|
||||||
|
range: { start: number; end: number },
|
||||||
|
): Promise<Readable>;
|
||||||
|
|
||||||
exists(filePath: string): Promise<boolean>;
|
exists(filePath: string): Promise<boolean>;
|
||||||
|
|
||||||
getUrl(filePath: string): string;
|
getUrl(filePath: string): string;
|
||||||
|
|||||||
@@ -33,6 +33,13 @@ export class StorageService {
|
|||||||
return this.storageDriver.readStream(filePath);
|
return this.storageDriver.readStream(filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async readRangeStream(
|
||||||
|
filePath: string,
|
||||||
|
range: { start: number; end: number },
|
||||||
|
): Promise<Readable> {
|
||||||
|
return this.storageDriver.readRangeStream(filePath, range);
|
||||||
|
}
|
||||||
|
|
||||||
async exists(filePath: string): Promise<boolean> {
|
async exists(filePath: string): Promise<boolean> {
|
||||||
return this.storageDriver.exists(filePath);
|
return this.storageDriver.exists(filePath);
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-4
@@ -7,7 +7,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
APP_URL: 'http://localhost:3000'
|
APP_URL: 'http://localhost:3000'
|
||||||
APP_SECRET: 'REPLACE_WITH_LONG_SECRET'
|
APP_SECRET: 'REPLACE_WITH_LONG_SECRET'
|
||||||
DATABASE_URL: 'postgresql://docmost:STRONG_DB_PASSWORD@db:5432/docmost?schema=public'
|
DATABASE_URL: 'postgresql://docmost:STRONG_DB_PASSWORD@db:5432/docmost'
|
||||||
REDIS_URL: 'redis://redis:6379'
|
REDIS_URL: 'redis://redis:6379'
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
@@ -16,17 +16,18 @@ services:
|
|||||||
- docmost:/app/data/storage
|
- docmost:/app/data/storage
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:18
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: docmost
|
POSTGRES_DB: docmost
|
||||||
POSTGRES_USER: docmost
|
POSTGRES_USER: docmost
|
||||||
POSTGRES_PASSWORD: STRONG_DB_PASSWORD
|
POSTGRES_PASSWORD: STRONG_DB_PASSWORD
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- db_data:/var/lib/postgresql/data
|
- db_data:/var/lib/postgresql
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7.2-alpine
|
image: redis:8
|
||||||
|
command: ["redis-server", "--appendonly", "yes", "--maxmemory-policy", "noeviction"]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- redis_data:/data
|
||||||
|
|||||||
+7
-4
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "docmost",
|
"name": "docmost",
|
||||||
"homepage": "https://docmost.com",
|
"homepage": "https://docmost.com",
|
||||||
"version": "0.25.0-beta.1",
|
"version": "0.25.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nx run-many -t build",
|
"build": "nx run-many -t build",
|
||||||
@@ -23,9 +23,9 @@
|
|||||||
"@casl/ability": "6.8.0",
|
"@casl/ability": "6.8.0",
|
||||||
"@docmost/editor-ext": "workspace:*",
|
"@docmost/editor-ext": "workspace:*",
|
||||||
"@floating-ui/dom": "^1.7.3",
|
"@floating-ui/dom": "^1.7.3",
|
||||||
"@hocuspocus/provider": "3.4.3",
|
"@hocuspocus/provider": "3.4.4",
|
||||||
"@hocuspocus/server": "3.4.3",
|
"@hocuspocus/server": "3.4.4",
|
||||||
"@hocuspocus/transformer": "3.4.3",
|
"@hocuspocus/transformer": "3.4.4",
|
||||||
"@joplin/turndown": "^4.0.74",
|
"@joplin/turndown": "^4.0.74",
|
||||||
"@joplin/turndown-plugin-gfm": "^1.0.56",
|
"@joplin/turndown-plugin-gfm": "^1.0.56",
|
||||||
"@sindresorhus/slugify": "1.1.0",
|
"@sindresorhus/slugify": "1.1.0",
|
||||||
@@ -60,6 +60,7 @@
|
|||||||
"bytes": "^3.1.2",
|
"bytes": "^3.1.2",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"diff": "8.0.3",
|
||||||
"dompurify": "^3.2.6",
|
"dompurify": "^3.2.6",
|
||||||
"fractional-indexing-jittered": "^1.0.0",
|
"fractional-indexing-jittered": "^1.0.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
@@ -70,6 +71,7 @@
|
|||||||
"marked": "13.0.3",
|
"marked": "13.0.3",
|
||||||
"ms": "3.0.0-canary.1",
|
"ms": "3.0.0-canary.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
"rfc6902": "5.1.2",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
"y-indexeddb": "^9.0.12",
|
"y-indexeddb": "^9.0.12",
|
||||||
"y-prosemirror": "1.3.7",
|
"y-prosemirror": "1.3.7",
|
||||||
@@ -98,6 +100,7 @@
|
|||||||
"overrides": {
|
"overrides": {
|
||||||
"jsdom": "25.0.1",
|
"jsdom": "25.0.1",
|
||||||
"jsonwebtoken": "9.0.3",
|
"jsonwebtoken": "9.0.3",
|
||||||
|
"prosemirror-changeset": "2.3.1",
|
||||||
"y-prosemirror": "1.3.7"
|
"y-prosemirror": "1.3.7"
|
||||||
},
|
},
|
||||||
"neverBuiltDependencies": []
|
"neverBuiltDependencies": []
|
||||||
|
|||||||
@@ -8,5 +8,6 @@
|
|||||||
},
|
},
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"module": "./src/index.ts",
|
"module": "./src/index.ts",
|
||||||
"types": "dist/index.d.ts"
|
"types": "dist/index.d.ts",
|
||||||
|
"dependencies": {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,3 +24,4 @@ export * from "./lib/highlight";
|
|||||||
export * from "./lib/heading/heading";
|
export * from "./lib/heading/heading";
|
||||||
export * from "./lib/unique-id";
|
export * from "./lib/unique-id";
|
||||||
export * from "./lib/shared-storage";
|
export * from "./lib/shared-storage";
|
||||||
|
export * from "./lib/recreate-transform";
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { imageDimensionsFromStream } from "image-dimensions";
|
import { imageDimensionsFromData } from 'image-dimensions';
|
||||||
import { MediaUploadOptions, UploadFn } from "../media-utils";
|
import { MediaUploadOptions, UploadFn } from '../media-utils';
|
||||||
import { IAttachment } from "../types";
|
import { IAttachment } from '../types';
|
||||||
import { generateNodeId } from "../utils";
|
import { generateNodeId } from '../utils';
|
||||||
import { Node } from "@tiptap/pm/model";
|
import { Node } from '@tiptap/pm/model';
|
||||||
import { Command } from "@tiptap/core";
|
import { Command } from '@tiptap/core';
|
||||||
|
|
||||||
const findImageNodeByPlaceholderId = (
|
const findImageNodeByPlaceholderId = (
|
||||||
doc: Node,
|
doc: Node,
|
||||||
@@ -14,7 +14,7 @@ const findImageNodeByPlaceholderId = (
|
|||||||
doc.descendants((node, pos) => {
|
doc.descendants((node, pos) => {
|
||||||
if (result) return false;
|
if (result) return false;
|
||||||
if (
|
if (
|
||||||
node.type.name === "image" &&
|
node.type.name === 'image' &&
|
||||||
node.attrs.placeholder?.id === placeholderId
|
node.attrs.placeholder?.id === placeholderId
|
||||||
) {
|
) {
|
||||||
result = { node, pos };
|
result = { node, pos };
|
||||||
@@ -34,7 +34,11 @@ const handleImageUpload =
|
|||||||
if (!validated) return;
|
if (!validated) return;
|
||||||
|
|
||||||
const objectUrl = URL.createObjectURL(file);
|
const objectUrl = URL.createObjectURL(file);
|
||||||
const imageDimensions = await imageDimensionsFromStream(file.stream());
|
|
||||||
|
const imageDimensions = imageDimensionsFromData(
|
||||||
|
new Uint8Array(await file.arrayBuffer()),
|
||||||
|
);
|
||||||
|
|
||||||
const placeholderId = generateNodeId();
|
const placeholderId = generateNodeId();
|
||||||
const aspectRatio = imageDimensions
|
const aspectRatio = imageDimensions
|
||||||
? imageDimensions.width / imageDimensions.height
|
? imageDimensions.width / imageDimensions.height
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function copy<T>(value: T): T {
|
||||||
|
return JSON.parse(JSON.stringify(value));
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { AnyObject } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get target value from json-pointer (e.g. /content/0/content)
|
||||||
|
* @param {AnyObject} obj object to resolve path into
|
||||||
|
* @param {string} path json-pointer
|
||||||
|
* @return {any} target value
|
||||||
|
*/
|
||||||
|
export function getFromPath(obj: AnyObject, path: string): any {
|
||||||
|
const pathParts = path.split("/");
|
||||||
|
pathParts.shift(); // remove root-entry
|
||||||
|
while (pathParts.length) {
|
||||||
|
const property = pathParts.shift();
|
||||||
|
obj = obj[property];
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { ReplaceStep } from "@tiptap/pm/transform";
|
||||||
|
import { Node } from "@tiptap/pm/model";
|
||||||
|
|
||||||
|
export function getReplaceStep(fromDoc: Node, toDoc: Node) {
|
||||||
|
let start = toDoc.content.findDiffStart(fromDoc.content);
|
||||||
|
if (start === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore property access to content
|
||||||
|
let { a: endA, b: endB } = toDoc.content.findDiffEnd(fromDoc.content);
|
||||||
|
const overlap = start - Math.min(endA, endB);
|
||||||
|
if (overlap > 0) {
|
||||||
|
// If there is an overlap, there is some freedom of choice in how to calculate the
|
||||||
|
// start/end boundary. for an inserted/removed slice. We choose the extreme with
|
||||||
|
// the lowest depth value.
|
||||||
|
if (
|
||||||
|
fromDoc.resolve(start - overlap).depth <
|
||||||
|
toDoc.resolve(endA + overlap).depth
|
||||||
|
) {
|
||||||
|
start -= overlap;
|
||||||
|
} else {
|
||||||
|
endA += overlap;
|
||||||
|
endB += overlap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ReplaceStep(start, endB, toDoc.slice(start, endA));
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// https://gitlab.com/mpapp-public/prosemirror-recreate-steps - MIT
|
||||||
|
// https://github.com/sueddeutsche/prosemirror-recreate-transform - MIT
|
||||||
|
export { recreateTransform, RecreateTransform } from "./recreateTransform";
|
||||||
|
export type { Options } from "./recreateTransform";
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import { Transform } from "@tiptap/pm/transform";
|
||||||
|
import { Node, Schema } from "@tiptap/pm/model";
|
||||||
|
import { applyPatch, createPatch, Operation } from "rfc6902";
|
||||||
|
import { diffWordsWithSpace, diffChars } from "diff";
|
||||||
|
import { AnyObject } from "./types";
|
||||||
|
import { getReplaceStep } from "./getReplaceStep";
|
||||||
|
import { simplifyTransform } from "./simplifyTransform";
|
||||||
|
import { removeMarks } from "./removeMarks";
|
||||||
|
import { getFromPath } from "./getFromPath";
|
||||||
|
import { copy } from "./copy";
|
||||||
|
|
||||||
|
export interface Options {
|
||||||
|
complexSteps?: boolean;
|
||||||
|
wordDiffs?: boolean;
|
||||||
|
simplifyDiff?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RecreateTransform {
|
||||||
|
fromDoc: Node;
|
||||||
|
toDoc: Node;
|
||||||
|
complexSteps: boolean;
|
||||||
|
wordDiffs: boolean;
|
||||||
|
simplifyDiff: boolean;
|
||||||
|
schema: Schema;
|
||||||
|
tr: Transform;
|
||||||
|
/* current working document data, may get updated while recalculating node steps */
|
||||||
|
currentJSON: AnyObject;
|
||||||
|
/* final document as json data */
|
||||||
|
finalJSON: AnyObject;
|
||||||
|
ops: Array<Operation>;
|
||||||
|
|
||||||
|
constructor(fromDoc: Node, toDoc: Node, options: Options = {}) {
|
||||||
|
const o = {
|
||||||
|
complexSteps: true,
|
||||||
|
wordDiffs: false,
|
||||||
|
simplifyDiff: true,
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.fromDoc = fromDoc;
|
||||||
|
this.toDoc = toDoc;
|
||||||
|
this.complexSteps = o.complexSteps; // Whether to return steps other than ReplaceSteps
|
||||||
|
this.wordDiffs = o.wordDiffs; // Whether to make text diffs cover entire words
|
||||||
|
this.simplifyDiff = o.simplifyDiff;
|
||||||
|
this.schema = fromDoc.type.schema;
|
||||||
|
this.tr = new Transform(fromDoc);
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
if (this.complexSteps) {
|
||||||
|
// For First steps: we create versions of the documents without marks as
|
||||||
|
// these will only confuse the diffing mechanism and marks won't cause
|
||||||
|
// any mapping changes anyway.
|
||||||
|
this.currentJSON = removeMarks(this.fromDoc).toJSON();
|
||||||
|
this.finalJSON = removeMarks(this.toDoc).toJSON();
|
||||||
|
this.ops = createPatch(this.currentJSON, this.finalJSON);
|
||||||
|
this.recreateChangeContentSteps();
|
||||||
|
this.recreateChangeMarkSteps();
|
||||||
|
} else {
|
||||||
|
// We don't differentiate between mark changes and other changes.
|
||||||
|
this.currentJSON = this.fromDoc.toJSON();
|
||||||
|
this.finalJSON = this.toDoc.toJSON();
|
||||||
|
this.ops = createPatch(this.currentJSON, this.finalJSON);
|
||||||
|
this.recreateChangeContentSteps();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.simplifyDiff) {
|
||||||
|
this.tr = simplifyTransform(this.tr) || this.tr;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.tr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** convert json-diff to prosemirror steps */
|
||||||
|
recreateChangeContentSteps() {
|
||||||
|
// First step: find content changing steps.
|
||||||
|
let ops = [];
|
||||||
|
while (this.ops.length) {
|
||||||
|
// get next
|
||||||
|
let op = this.ops.shift();
|
||||||
|
ops.push(op);
|
||||||
|
|
||||||
|
let toDoc;
|
||||||
|
const afterStepJSON = copy(this.currentJSON); // working document receiving patches
|
||||||
|
const pathParts = op.path.split("/");
|
||||||
|
|
||||||
|
// collect operations until we receive a valid document:
|
||||||
|
// apply ops-patches until a valid prosemirror document is retrieved,
|
||||||
|
// then try to create a transformation step or retry with next operation
|
||||||
|
while (toDoc == null) {
|
||||||
|
applyPatch(afterStepJSON, [op]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
toDoc = this.schema.nodeFromJSON(afterStepJSON);
|
||||||
|
toDoc.check();
|
||||||
|
} catch (error) {
|
||||||
|
toDoc = null;
|
||||||
|
if (this.ops.length > 0) {
|
||||||
|
op = this.ops.shift();
|
||||||
|
ops.push(op);
|
||||||
|
} else {
|
||||||
|
throw new Error(`No valid diff possible applying ${op.path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// apply operation (ignoring afterStepJSON)
|
||||||
|
if (
|
||||||
|
this.complexSteps &&
|
||||||
|
ops.length === 1 &&
|
||||||
|
(pathParts.includes("attrs") || pathParts.includes("type"))
|
||||||
|
) {
|
||||||
|
// Node markup is changing
|
||||||
|
this.addSetNodeMarkup(); // a lost update is ignored
|
||||||
|
ops = [];
|
||||||
|
// console.log("%cop", logStyle, "- update node", ops);
|
||||||
|
} else if (
|
||||||
|
ops.length === 1 &&
|
||||||
|
op.op === "replace" &&
|
||||||
|
pathParts[pathParts.length - 1] === "text"
|
||||||
|
) {
|
||||||
|
// Text is being replaced, we apply text diffing to find the smallest possible diffs.
|
||||||
|
this.addReplaceTextSteps(op, afterStepJSON);
|
||||||
|
ops = [];
|
||||||
|
// console.log("%cop", logStyle, "- replace", ops);
|
||||||
|
} else if (this.addReplaceStep(toDoc, afterStepJSON)) {
|
||||||
|
// operations have been applied
|
||||||
|
ops = [];
|
||||||
|
// console.log("%cop", logStyle, "- other", ops);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** update node with attrs and marks, may also change type */
|
||||||
|
addSetNodeMarkup() {
|
||||||
|
// first diff in document is supposed to be a node-change (in type and/or attributes)
|
||||||
|
// thus simply find the first change and apply a node change step, then recalculate the diff
|
||||||
|
// after updating the document
|
||||||
|
const fromDoc = this.schema.nodeFromJSON(this.currentJSON);
|
||||||
|
const toDoc = this.schema.nodeFromJSON(this.finalJSON);
|
||||||
|
const start = toDoc.content.findDiffStart(fromDoc.content);
|
||||||
|
// @note start is the same (first) position for current and target document
|
||||||
|
const fromNode = fromDoc.nodeAt(start);
|
||||||
|
const toNode = toDoc.nodeAt(start);
|
||||||
|
|
||||||
|
if (start != null) {
|
||||||
|
// @note this completly updates all attributes in one step, by completely replacing node
|
||||||
|
const nodeType = fromNode.type === toNode.type ? null : toNode.type;
|
||||||
|
try {
|
||||||
|
this.tr.setNodeMarkup(start, nodeType, toNode.attrs, toNode.marks);
|
||||||
|
} catch (e) {
|
||||||
|
// if nodetypes differ, the updated node-type and contents might not be compatible
|
||||||
|
// with schema and requires a replace
|
||||||
|
if (nodeType && e.message.includes("Invalid content")) {
|
||||||
|
// @todo add test-case for this scenario
|
||||||
|
this.tr.replaceWith(start, start + fromNode.nodeSize, toNode);
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.currentJSON = removeMarks(this.tr.doc).toJSON();
|
||||||
|
// setting the node markup may have invalidated the following ops, so we calculate them again.
|
||||||
|
this.ops = createPatch(this.currentJSON, this.finalJSON);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
recreateChangeMarkSteps() {
|
||||||
|
// Now the documents should be the same, except their marks, so everything should map 1:1.
|
||||||
|
// Second step: Iterate through the toDoc and make sure all marks are the same in tr.doc
|
||||||
|
this.toDoc.descendants((tNode, tPos) => {
|
||||||
|
if (!tNode.isInline) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.tr.doc.nodesBetween(tPos, tPos + tNode.nodeSize, (fNode, fPos) => {
|
||||||
|
if (!fNode.isInline) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const from = Math.max(tPos, fPos);
|
||||||
|
const to = Math.min(tPos + tNode.nodeSize, fPos + fNode.nodeSize);
|
||||||
|
fNode.marks.forEach((nodeMark) => {
|
||||||
|
if (!nodeMark.isInSet(tNode.marks)) {
|
||||||
|
this.tr.removeMark(from, to, nodeMark);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tNode.marks.forEach((nodeMark) => {
|
||||||
|
if (!nodeMark.isInSet(fNode.marks)) {
|
||||||
|
this.tr.addMark(from, to, nodeMark);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* retrieve and possibly apply replace-step based from doc changes
|
||||||
|
* From http://prosemirror.net/examples/footnote/
|
||||||
|
*/
|
||||||
|
addReplaceStep(toDoc: Node, afterStepJSON: AnyObject) {
|
||||||
|
const fromDoc = this.schema.nodeFromJSON(this.currentJSON);
|
||||||
|
const step = getReplaceStep(fromDoc, toDoc);
|
||||||
|
|
||||||
|
if (!step) {
|
||||||
|
return false;
|
||||||
|
} else if (!this.tr.maybeStep(step).failed) {
|
||||||
|
this.currentJSON = afterStepJSON;
|
||||||
|
return true; // @change previously null
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("No valid step found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** retrieve and possibly apply text replace-steps based from doc changes */
|
||||||
|
addReplaceTextSteps(op, afterStepJSON) {
|
||||||
|
// We find the position number of the first character in the string
|
||||||
|
const op1 = { ...op, value: "xx" };
|
||||||
|
const op2 = { ...op, value: "yy" };
|
||||||
|
const afterOP1JSON = copy(this.currentJSON);
|
||||||
|
const afterOP2JSON = copy(this.currentJSON);
|
||||||
|
applyPatch(afterOP1JSON, [op1]);
|
||||||
|
applyPatch(afterOP2JSON, [op2]);
|
||||||
|
const op1Doc = this.schema.nodeFromJSON(afterOP1JSON);
|
||||||
|
const op2Doc = this.schema.nodeFromJSON(afterOP2JSON);
|
||||||
|
|
||||||
|
// get text diffs
|
||||||
|
const finalText = op.value;
|
||||||
|
const currentText = getFromPath(this.currentJSON, op.path);
|
||||||
|
const textDiffs = this.wordDiffs
|
||||||
|
? diffWordsWithSpace(currentText, finalText)
|
||||||
|
: diffChars(currentText, finalText);
|
||||||
|
|
||||||
|
let offset = op1Doc.content.findDiffStart(op2Doc.content);
|
||||||
|
const marks = op1Doc.resolve(offset + 1).marks();
|
||||||
|
|
||||||
|
while (textDiffs.length) {
|
||||||
|
const diff = textDiffs.shift();
|
||||||
|
|
||||||
|
if (diff.added) {
|
||||||
|
const textNode = this.schema
|
||||||
|
.nodeFromJSON({ type: "text", text: diff.value })
|
||||||
|
.mark(marks);
|
||||||
|
|
||||||
|
if (textDiffs.length && textDiffs[0].removed) {
|
||||||
|
const nextDiff = textDiffs.shift();
|
||||||
|
this.tr.replaceWith(offset, offset + nextDiff.value.length, textNode);
|
||||||
|
} else {
|
||||||
|
this.tr.insert(offset, textNode);
|
||||||
|
}
|
||||||
|
offset += diff.value.length;
|
||||||
|
} else if (diff.removed) {
|
||||||
|
if (textDiffs.length && textDiffs[0].added) {
|
||||||
|
const nextDiff = textDiffs.shift();
|
||||||
|
const textNode = this.schema
|
||||||
|
.nodeFromJSON({ type: "text", text: nextDiff.value })
|
||||||
|
.mark(marks);
|
||||||
|
this.tr.replaceWith(offset, offset + diff.value.length, textNode);
|
||||||
|
offset += nextDiff.value.length;
|
||||||
|
} else {
|
||||||
|
this.tr.delete(offset, offset + diff.value.length);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
offset += diff.value.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentJSON = afterStepJSON;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recreateTransform(
|
||||||
|
fromDoc: Node,
|
||||||
|
toDoc: Node,
|
||||||
|
options: Options = {},
|
||||||
|
): Transform {
|
||||||
|
const recreator = new RecreateTransform(fromDoc, toDoc, options);
|
||||||
|
return recreator.init();
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Transform } from "@tiptap/pm/transform";
|
||||||
|
import { Node } from "@tiptap/pm/model";
|
||||||
|
|
||||||
|
export function removeMarks(doc: Node) {
|
||||||
|
const tr = new Transform(doc);
|
||||||
|
tr.removeMark(0, doc.nodeSize - 2);
|
||||||
|
return tr.doc;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Transform, ReplaceStep, Step } from "@tiptap/pm/transform";
|
||||||
|
import { getReplaceStep } from "./getReplaceStep";
|
||||||
|
|
||||||
|
// join adjacent ReplaceSteps
|
||||||
|
export function simplifyTransform(tr: Transform) {
|
||||||
|
if (!tr.steps.length) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTr = new Transform(tr.docs[0]);
|
||||||
|
const oldSteps = tr.steps.slice();
|
||||||
|
|
||||||
|
while (oldSteps.length) {
|
||||||
|
let step = oldSteps.shift();
|
||||||
|
while (oldSteps.length && step.merge(oldSteps[0])) {
|
||||||
|
const addedStep = oldSteps.shift();
|
||||||
|
if (step instanceof ReplaceStep && addedStep instanceof ReplaceStep) {
|
||||||
|
step = getReplaceStep(
|
||||||
|
newTr.doc,
|
||||||
|
addedStep.apply(step.apply(newTr.doc).doc).doc,
|
||||||
|
// @ts-ignore
|
||||||
|
) as Step<any>;
|
||||||
|
} else {
|
||||||
|
step = step.merge(addedStep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newTr.step(step);
|
||||||
|
}
|
||||||
|
return newTr;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export interface AnyObject {
|
||||||
|
[p: string]: any;
|
||||||
|
}
|
||||||
Generated
+1112
-1083
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user