diff --git a/apps/client/package.json b/apps/client/package.json index 751bfa43..f6db1328 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -1,7 +1,7 @@ { "name": "client", "private": true, - "version": "0.25.0-beta.1", + "version": "0.25.3", "scripts": { "dev": "vite", "build": "tsc && vite build", @@ -25,7 +25,7 @@ "@tabler/icons-react": "^3.36.1", "@tanstack/react-query": "^5.90.17", "alfaaz": "^1.1.0", - "axios": "^1.13.2", + "axios": "^1.13.5", "clsx": "^2.1.1", "emoji-mart": "^5.6.0", "file-saver": "^2.0.5", diff --git a/apps/client/public/locales/de-DE/translation.json b/apps/client/public/locales/de-DE/translation.json index 93c6f265..16df8599 100644 --- a/apps/client/public/locales/de-DE/translation.json +++ b/apps/client/public/locales/de-DE/translation.json @@ -41,7 +41,7 @@ "Date": "Datum", "Delete": "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", "Details": "Details", "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 password": "Geben Sie Ihr Passwort ein", "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", "Failed to create page": "Erstellung der Seite fehlgeschlagen", "Failed to delete page": "Löschen der Seite fehlgeschlagen", @@ -114,7 +114,7 @@ "New page": "Neue Seite", "New password": "Neues Passwort", "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 results found...": "Keine Ergebnisse gefunden...", "No user found": "Kein Benutzer gefunden", @@ -122,7 +122,9 @@ "Owner": "Besitzer", "page": "Seite", "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.", "Pages": "Seiten", "pages": "Seiten", @@ -405,6 +407,21 @@ "Share deleted successfully": "Freigabe erfolgreich gelöscht", "Share not found": "Freigabe nicht gefunden", "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 to a different space.": "Seite in einen anderen Bereich kopieren.", "Page copied successfully": "Seite erfolgreich kopiert", diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index c0578d2b..fa040002 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -123,6 +123,8 @@ "page": "page", "Page deleted successfully": "Page deleted successfully", "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.", "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": "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 to a different space.": "Copy page to a different space.", "Page copied successfully": "Page copied successfully", diff --git a/apps/client/public/locales/es-ES/translation.json b/apps/client/public/locales/es-ES/translation.json index af02c493..742bf751 100644 --- a/apps/client/public/locales/es-ES/translation.json +++ b/apps/client/public/locales/es-ES/translation.json @@ -123,6 +123,8 @@ "page": "página", "Page deleted successfully": "Página eliminada con éxito", "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.", "Pages": "Páginas", "pages": "páginas", @@ -405,6 +407,21 @@ "Share deleted successfully": "Compartición eliminada con éxito", "Share not found": "Compartición no encontrada", "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 to a different space.": "Copiar página en otro espacio", "Page copied successfully": "Página copiada exitosamente", diff --git a/apps/client/public/locales/fr-FR/translation.json b/apps/client/public/locales/fr-FR/translation.json index 40a1e68a..5f9bf40f 100644 --- a/apps/client/public/locales/fr-FR/translation.json +++ b/apps/client/public/locales/fr-FR/translation.json @@ -123,6 +123,8 @@ "page": "page", "Page deleted successfully": "Page supprimée avec succès", "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.", "Pages": "Pages", "pages": "pages", @@ -405,6 +407,21 @@ "Share deleted successfully": "Partage supprimé avec succès", "Share not found": "Partage non trouvé", "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 to a different space.": "Copier la page dans un autre espace.", "Page copied successfully": "Page copiée avec succès", diff --git a/apps/client/public/locales/it-IT/translation.json b/apps/client/public/locales/it-IT/translation.json index ff80df0f..1e848aa6 100644 --- a/apps/client/public/locales/it-IT/translation.json +++ b/apps/client/public/locales/it-IT/translation.json @@ -123,6 +123,8 @@ "page": "pagina", "Page deleted successfully": "Pagina eliminata con successo", "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.", "Pages": "Pagine", "pages": "pagine", @@ -405,6 +407,21 @@ "Share deleted successfully": "Condivisione eliminata con successo", "Share not found": "Condivisione non trovata", "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 to a different space.": "Copia pagina in un altro spazio.", "Page copied successfully": "Pagina copiata con successo", diff --git a/apps/client/public/locales/ja-JP/translation.json b/apps/client/public/locales/ja-JP/translation.json index 4d18e074..9402cdc5 100644 --- a/apps/client/public/locales/ja-JP/translation.json +++ b/apps/client/public/locales/ja-JP/translation.json @@ -123,6 +123,8 @@ "page": "ページ", "Page deleted successfully": "ページを削除しました", "Page history": "ページ履歴", + "Select version": "バージョンを選択", + "Highlight changes": "変更を強調表示", "Page import is in progress. Please do not close this tab.": "ページをインポート中です。このタブを閉じないでください", "Pages": "ページ", "pages": "ページ", @@ -405,6 +407,21 @@ "Share deleted successfully": "共有を削除しました", "Share not found": "共有が見つかりません", "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 to a different space.": "ページを別のスペースにコピーします", "Page copied successfully": "ページをコピーしました", diff --git a/apps/client/public/locales/ko-KR/translation.json b/apps/client/public/locales/ko-KR/translation.json index d9b48b04..d8ce59c9 100644 --- a/apps/client/public/locales/ko-KR/translation.json +++ b/apps/client/public/locales/ko-KR/translation.json @@ -123,6 +123,8 @@ "page": "페이지", "Page deleted successfully": "페이지 삭제 완료", "Page history": "페이지 기록", + "Select version": "버전 선택", + "Highlight changes": "변경 사항 강조", "Page import is in progress. Please do not close this tab.": "페이지 가져오기가 진행 중입니다. 이 탭을 닫지 마세요.", "Pages": "페이지", "pages": "페이지", @@ -405,6 +407,21 @@ "Share deleted successfully": "공유가 성공적으로 삭제되었습니다", "Share not found": "공유를 찾을 수 없습니다", "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 to a different space.": "다른 공간으로 페이지 복사하기.", "Page copied successfully": "페이지가 성공적으로 복사되었습니다", diff --git a/apps/client/public/locales/nl-NL/translation.json b/apps/client/public/locales/nl-NL/translation.json index a7923b98..036e8eeb 100644 --- a/apps/client/public/locales/nl-NL/translation.json +++ b/apps/client/public/locales/nl-NL/translation.json @@ -123,6 +123,8 @@ "page": "pagina", "Page deleted successfully": "Pagina succesvol verwijderd", "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.", "Pages": "Pagina's", "pages": "pagina's", @@ -405,6 +407,21 @@ "Share deleted successfully": "Delen succesvol verwijderd", "Share not found": "Delen niet gevonden", "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 to a different space.": "Kopieer pagina naar een andere ruimte.", "Page copied successfully": "Pagina succesvol gekopieerd", diff --git a/apps/client/public/locales/pt-BR/translation.json b/apps/client/public/locales/pt-BR/translation.json index 30cc0b21..4bc8a70c 100644 --- a/apps/client/public/locales/pt-BR/translation.json +++ b/apps/client/public/locales/pt-BR/translation.json @@ -123,6 +123,8 @@ "page": "página", "Page deleted successfully": "Página excluída com sucesso", "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.", "Pages": "Páginas", "pages": "páginas", @@ -405,6 +407,21 @@ "Share deleted successfully": "Compartilhamento excluído com sucesso", "Share not found": "Compartilhamento não encontrado", "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 to a different space.": "Copiar página para um espaço diferente.", "Page copied successfully": "Página copiada com sucesso", diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index 88e1f701..ba39b1c8 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -123,6 +123,8 @@ "page": "страница", "Page deleted successfully": "Страница успешно удалена", "Page history": "История страницы", + "Select version": "Выбрать версию", + "Highlight changes": "Выделить изменения", "Page import is in progress. Please do not close this tab.": "Импорт страницы в процессе. Пожалуйста, не закрывайте эту вкладку.", "Pages": "Страницы", "pages": "страницы", @@ -405,6 +407,21 @@ "Share deleted successfully": "Общий доступ успешно удален", "Share not found": "Общий доступ не найден", "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 to a different space.": "Копировать страницу в другое пространство.", "Page copied successfully": "Страница успешно скопирована", diff --git a/apps/client/public/locales/uk-UA/translation.json b/apps/client/public/locales/uk-UA/translation.json index e5cdaa40..99407057 100644 --- a/apps/client/public/locales/uk-UA/translation.json +++ b/apps/client/public/locales/uk-UA/translation.json @@ -123,6 +123,8 @@ "page": "сторінка", "Page deleted successfully": "Сторінку успішно видалено", "Page history": "Історія сторінки", + "Select version": "Вибрати версію", + "Highlight changes": "Підсвітити зміни", "Page import is in progress. Please do not close this tab.": "Імпорт сторінки в процесі. Будь ласка, не закривайте цю вкладку.", "Pages": "Сторінки", "pages": "сторінки", @@ -405,6 +407,21 @@ "Share deleted successfully": "Спільний доступ успішно видалено", "Share not found": "Спільний доступ не знайдено", "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 to a different space.": "Скопіювати сторінку в інший простір.", "Page copied successfully": "Сторінку успішно скопійовано", diff --git a/apps/client/public/locales/zh-CN/translation.json b/apps/client/public/locales/zh-CN/translation.json index a5eb84f1..0ea65913 100644 --- a/apps/client/public/locales/zh-CN/translation.json +++ b/apps/client/public/locales/zh-CN/translation.json @@ -123,6 +123,8 @@ "page": "个页面", "Page deleted successfully": "页面已成功删除", "Page history": "页面历史", + "Select version": "选择版本", + "Highlight changes": "突出显示更改", "Page import is in progress. Please do not close this tab.": "页面导入正在进行中。请不要关闭此标签页。", "Pages": "页面", "pages": "个页面", @@ -405,6 +407,21 @@ "Share deleted successfully": "分享已成功删除", "Share not found": "未找到分享", "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 to a different space.": "将页面复制到不同的空间。", "Page copied successfully": "页面复制成功", diff --git a/apps/client/src/components/common/copy-button.tsx b/apps/client/src/components/common/copy-button.tsx new file mode 100644 index 00000000..eb0721d7 --- /dev/null +++ b/apps/client/src/components/common/copy-button.tsx @@ -0,0 +1,33 @@ +// Source: https://github.com/mantinedev/mantine/blob/master/packages/@mantine/core/src/components/CopyButton/CopyButton.tsx - MIT +// modified to use the polyfilled clipboard api +import React from "react"; +import { useClipboard } from "@/hooks/use-clipboard"; +import { useProps } from "@mantine/core"; + +interface CopyButtonProps { + /** Children callback, provides current status and copy function as an argument */ + children: (payload: { copied: boolean; copy: () => void }) => React.ReactNode; + + /** Value that is copied to the clipboard when the button is clicked */ + value: string; + + /** Copied status timeout in ms @default `1000` */ + timeout?: number; +} + +const defaultProps = { + timeout: 1000, +} satisfies Partial; + +export function CopyButton(props: CopyButtonProps) { + const { children, timeout, value, ...others } = useProps( + "CopyButton", + defaultProps, + props, + ); + const clipboard = useClipboard({ timeout }); + const copy = () => clipboard.copy(value); + return <>{children({ copy, copied: clipboard.copied, ...others })}; +} + +CopyButton.displayName = "@mantine/core/CopyButton"; diff --git a/apps/client/src/components/common/copy.tsx b/apps/client/src/components/common/copy.tsx index efae5750..81a70771 100644 --- a/apps/client/src/components/common/copy.tsx +++ b/apps/client/src/components/common/copy.tsx @@ -1,4 +1,5 @@ -import { ActionIcon, CopyButton, Tooltip } from "@mantine/core"; +import { ActionIcon, Tooltip } from "@mantine/core"; +import { CopyButton } from "@/components/common/copy-button"; import { IconCheck, IconCopy } from "@tabler/icons-react"; import React from "react"; import { useTranslation } from "react-i18next"; diff --git a/apps/client/src/ee/hooks/use-enterprise-access.tsx b/apps/client/src/ee/hooks/use-enterprise-access.tsx new file mode 100644 index 00000000..b7746d6f --- /dev/null +++ b/apps/client/src/ee/hooks/use-enterprise-access.tsx @@ -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; diff --git a/apps/client/src/ee/mfa/components/mfa-backup-codes-modal.tsx b/apps/client/src/ee/mfa/components/mfa-backup-codes-modal.tsx index c24638fe..6b439ef5 100644 --- a/apps/client/src/ee/mfa/components/mfa-backup-codes-modal.tsx +++ b/apps/client/src/ee/mfa/components/mfa-backup-codes-modal.tsx @@ -8,10 +8,10 @@ import { Group, List, Code, - CopyButton, Alert, PasswordInput, } from "@mantine/core"; +import { CopyButton } from "@/components/common/copy-button"; import { IconRefresh, IconCopy, diff --git a/apps/client/src/ee/mfa/components/mfa-setup-modal.tsx b/apps/client/src/ee/mfa/components/mfa-setup-modal.tsx index d01f2c9f..89d479d7 100644 --- a/apps/client/src/ee/mfa/components/mfa-setup-modal.tsx +++ b/apps/client/src/ee/mfa/components/mfa-setup-modal.tsx @@ -11,7 +11,6 @@ import { PinInput, Alert, List, - CopyButton, ActionIcon, Tooltip, Paper, @@ -20,6 +19,7 @@ import { Collapse, UnstyledButton, } from "@mantine/core"; +import { CopyButton } from "@/components/common/copy-button"; import { IconQrcode, IconShieldCheck, diff --git a/apps/client/src/ee/security/components/disable-public-sharing.tsx b/apps/client/src/ee/security/components/disable-public-sharing.tsx new file mode 100644 index 00000000..a5d9f34c --- /dev/null +++ b/apps/client/src/ee/security/components/disable-public-sharing.tsx @@ -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 ( + +
+ {t("Disable public sharing")} + + {t("Prevent members from sharing pages publicly.")} + +
+ + +
+ ); +} + +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) => { + const value = event.currentTarget.checked; + + modals.openConfirmModal({ + title: value ? t("Disable public sharing") : t("Enable public sharing"), + children: ( + + {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.", + )} + + ), + centered: true, + labels: { confirm: t("Confirm"), cancel: t("Cancel") }, + confirmProps: value ? { color: "red" } : {}, + onConfirm: () => applyChange(value), + }); + }; + + return ( + + + + ); +} diff --git a/apps/client/src/ee/security/components/enforce-mfa.tsx b/apps/client/src/ee/security/components/enforce-mfa.tsx index 37cf5152..b716e200 100644 --- a/apps/client/src/ee/security/components/enforce-mfa.tsx +++ b/apps/client/src/ee/security/components/enforce-mfa.tsx @@ -10,23 +10,18 @@ export default function EnforceMfa() { const { t } = useTranslation(); return ( - <> - - MFA - - -
- {t("Enforce two-factor authentication")} - - {t( - "Once enforced, all members must enable two-factor authentication to access the workspace.", - )} - -
+ +
+ {t("Enforce two-factor authentication")} + + {t( + "Once enforced, all members must enable two-factor authentication to access the workspace.", + )} + +
- -
- + +
); } diff --git a/apps/client/src/ee/security/components/space-public-sharing-toggle.tsx b/apps/client/src/ee/security/components/space-public-sharing-toggle.tsx new file mode 100644 index 00000000..f03d18f2 --- /dev/null +++ b/apps/client/src/ee/security/components/space-public-sharing-toggle.tsx @@ -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) => { + const value = event.currentTarget.checked; + + modals.openConfirmModal({ + title: value ? t("Disable public sharing") : t("Enable public sharing"), + children: ( + + {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?", + )} + + ), + centered: true, + labels: { confirm: t("Confirm"), cancel: t("Cancel") }, + confirmProps: value ? { color: "red" } : {}, + onConfirm: () => applyChange(value), + }); + }; + + return ( + +
+ {t("Disable public sharing")} + + {workspaceDisabled + ? t("Public sharing is disabled at the workspace level") + : t("Prevent pages in this space from being shared publicly.")} + +
+ + + +
+ ); +} diff --git a/apps/client/src/ee/security/pages/security.tsx b/apps/client/src/ee/security/pages/security.tsx index 82d8640f..a32c5867 100644 --- a/apps/client/src/ee/security/pages/security.tsx +++ b/apps/client/src/ee/security/pages/security.tsx @@ -9,15 +9,16 @@ import CreateSsoProvider from "@/ee/security/components/create-sso-provider.tsx" import EnforceSso from "@/ee/security/components/enforce-sso.tsx"; import AllowedDomains from "@/ee/security/components/allowed-domains.tsx"; 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 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() { const { t } = useTranslation(); const { isAdmin } = useUserRole(); - const { hasLicenseKey } = useLicense(); - const { isBusiness } = usePlan(); + const hasEnterpriseAccess = useEnterpriseAccess(); + const isCloudEE = useIsCloudEE(); if (!isAdmin) { return null; @@ -30,26 +31,41 @@ export default function Security() { - - - - + {(!isCloud() || hasEnterpriseAccess) && ( + <> + + + + )} + Single sign-on (SSO) - {(isCloud() && isBusiness) || (!isCloud() && hasLicenseKey) ? ( + {hasEnterpriseAccess && ( <> + + )} + + {isCloudEE && ( + <> + + + + )} + + {hasEnterpriseAccess && ( + <> - ) : null} + )} diff --git a/apps/client/src/features/comment/components/comment-editor.tsx b/apps/client/src/features/comment/components/comment-editor.tsx index a0489cdc..efb0c586 100644 --- a/apps/client/src/features/comment/components/comment-editor.tsx +++ b/apps/client/src/features/comment/components/comment-editor.tsx @@ -84,9 +84,14 @@ const CommentEditor = forwardRef( autofocus: (autofocus && "end") || false, }); + // Sync content from props for read-only editors (e.g. when updated via + // websocket on another browser). Skip for editable editors to avoid + // resetting the cursor position on every keystroke. useEffect(() => { - commentEditor.commands.setContent(defaultContent); - }, [defaultContent]); + if (!editable && commentEditor && defaultContent) { + commentEditor.commands.setContent(defaultContent); + } + }, [defaultContent, editable, commentEditor]); useEffect(() => { setTimeout(() => { diff --git a/apps/client/src/features/comment/components/comment-list-item.tsx b/apps/client/src/features/comment/components/comment-list-item.tsx index ebf27196..738f2e4f 100644 --- a/apps/client/src/features/comment/components/comment-list-item.tsx +++ b/apps/client/src/features/comment/components/comment-list-item.tsx @@ -1,5 +1,5 @@ import { Group, Text, Box, Badge } from "@mantine/core"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import classes from "./comment.module.css"; import { useAtom, useAtomValue } from "jotai"; import { timeAgo } from "@/lib/time"; @@ -40,6 +40,7 @@ function CommentListItem({ const [isLoading, setIsLoading] = useState(false); const editor = useAtomValue(pageEditorAtom); const [content, setContent] = useState(comment.content); + const editContentRef = useRef(null); const updateCommentMutation = useUpdateCommentMutation(); const deleteCommentMutation = useDeleteCommentMutation(comment.pageId); const resolveCommentMutation = useResolveCommentMutation(); @@ -56,9 +57,13 @@ function CommentListItem({ setIsLoading(true); const commentToUpdate = { commentId: comment.id, - content: JSON.stringify(content), + content: JSON.stringify(editContentRef.current ?? content), }; await updateCommentMutation.mutateAsync(commentToUpdate); + if (editContentRef.current) { + setContent(editContentRef.current); + editContentRef.current = null; + } setIsEditing(false); emit({ @@ -128,6 +133,7 @@ function CommentListItem({ setIsEditing(true); } function cancelEdit() { + editContentRef.current = null; setIsEditing(false); } @@ -194,7 +200,7 @@ function CommentListItem({ setContent(newContent)} + onUpdate={(newContent: any) => { editContentRef.current = newContent; }} onSave={handleUpdateComment} autofocus={true} /> diff --git a/apps/client/src/features/editor/components/code-block/code-block-view.tsx b/apps/client/src/features/editor/components/code-block/code-block-view.tsx index 130016a3..0ff2fe36 100644 --- a/apps/client/src/features/editor/components/code-block/code-block-view.tsx +++ b/apps/client/src/features/editor/components/code-block/code-block-view.tsx @@ -1,5 +1,6 @@ import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react"; -import { ActionIcon, CopyButton, Group, Select, Tooltip } from "@mantine/core"; +import { ActionIcon, Group, Select, Tooltip } from "@mantine/core"; +import { CopyButton } from "@/components/common/copy-button"; import { useEffect, useState } from "react"; import { IconCheck, IconCopy } from "@tabler/icons-react"; import classes from "./code-block.module.css"; diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.ts index bebefed4..27793f62 100644 --- a/apps/client/src/features/editor/components/slash-menu/menu-items.ts +++ b/apps/client/src/features/editor/components/slash-menu/menu-items.ts @@ -170,6 +170,8 @@ const CommandGroups: SlashMenuGroupedItemsType = { input.type = "file"; input.accept = "image/*"; input.multiple = true; + input.style.display = "none"; + document.body.appendChild(input); input.onchange = async () => { if (input.files?.length) { 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.value = ""; + input.remove(); }; input.click(); }, @@ -202,6 +203,8 @@ const CommandGroups: SlashMenuGroupedItemsType = { input.type = "file"; input.accept = "video/*"; input.multiple = true; + input.style.display = "none"; + document.body.appendChild(input); input.onchange = async () => { if (input.files?.length) { 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.value = ""; + input.remove(); }; input.click(); }, @@ -234,6 +236,8 @@ const CommandGroups: SlashMenuGroupedItemsType = { input.type = "file"; input.accept = ""; input.multiple = true; + input.style.display = "none"; + document.body.appendChild(input); input.onchange = async () => { if (input.files?.length) { 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.value = ""; + input.remove(); }; input.click(); }, diff --git a/apps/client/src/features/editor/readonly-page-editor.tsx b/apps/client/src/features/editor/readonly-page-editor.tsx index 77496fcd..b2ab829a 100644 --- a/apps/client/src/features/editor/readonly-page-editor.tsx +++ b/apps/client/src/features/editor/readonly-page-editor.tsx @@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef } from "react"; import { EditorProvider } from "@tiptap/react"; import { mainExtensions } from "@/features/editor/extensions/extensions"; 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 { Placeholder } from "@tiptap/extension-placeholder"; import { useAtom } from "jotai"; diff --git a/apps/client/src/features/editor/styles/print.css b/apps/client/src/features/editor/styles/print.css index c63e376b..5cf32a30 100644 --- a/apps/client/src/features/editor/styles/print.css +++ b/apps/client/src/features/editor/styles/print.css @@ -8,7 +8,7 @@ } .mantine-AppShell-main { - padding-top: 0 !important; + padding: 0 !important; min-height: auto !important; } diff --git a/apps/client/src/features/editor/title-editor.tsx b/apps/client/src/features/editor/title-editor.tsx index c3610394..33d1984e 100644 --- a/apps/client/src/features/editor/title-editor.tsx +++ b/apps/client/src/features/editor/title-editor.tsx @@ -157,7 +157,9 @@ export function TitleEditor({ useEffect(() => { setTimeout(() => { - titleEditor?.commands.focus("end"); + // guard against Cannot access view['hasFocus'] error + if (!titleEditor?.isInitialized) return; + titleEditor?.commands?.focus("end"); }, 500); }, [titleEditor]); diff --git a/apps/client/src/features/page-history/atoms/history-atoms.ts b/apps/client/src/features/page-history/atoms/history-atoms.ts index 023aaa36..2acf163d 100644 --- a/apps/client/src/features/page-history/atoms/history-atoms.ts +++ b/apps/client/src/features/page-history/atoms/history-atoms.ts @@ -1,4 +1,9 @@ import { atom } from "jotai"; export const historyAtoms = atom(false); -export const activeHistoryIdAtom = atom(''); +export const activeHistoryIdAtom = atom(""); +export const activeHistoryPrevIdAtom = atom(""); +export const highlightChangesAtom = atom(true); + +export type DiffCounts = { added: number; deleted: number; total: number }; +export const diffCountsAtom = atom(null); diff --git a/apps/client/src/features/page-history/components/css/history-mobile.module.css b/apps/client/src/features/page-history/components/css/history-mobile.module.css new file mode 100644 index 00000000..2db6d10c --- /dev/null +++ b/apps/client/src/features/page-history/components/css/history-mobile.module.css @@ -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; +} diff --git a/apps/client/src/features/page-history/components/css/history.module.css b/apps/client/src/features/page-history/components/css/history.module.css new file mode 100644 index 00000000..a4be3819 --- /dev/null +++ b/apps/client/src/features/page-history/components/css/history.module.css @@ -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); +} diff --git a/apps/client/src/features/page-history/components/history-editor.tsx b/apps/client/src/features/page-history/components/history-editor.tsx index 5fa8cf42..d071abc3 100644 --- a/apps/client/src/features/page-history/components/history-editor.tsx +++ b/apps/client/src/features/page-history/components/history-editor.tsx @@ -1,36 +1,203 @@ import "@/features/editor/styles/index.css"; -import React, { useEffect } from "react"; +import { useEffect } from "react"; import { EditorContent, useEditor } from "@tiptap/react"; import { mainExtensions } from "@/features/editor/extensions/extensions"; 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 { title: string; 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({ extensions: mainExtensions, editable: false, }); 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); } - }, [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 ( - <> -
- {title} - - {editor && ( - - )} -
- +
+ {title} + {editor && ( + + )} +
); } diff --git a/apps/client/src/features/page-history/components/history-item.tsx b/apps/client/src/features/page-history/components/history-item.tsx index eb348bd6..cc56b191 100644 --- a/apps/client/src/features/page-history/components/history-item.tsx +++ b/apps/client/src/features/page-history/components/history-item.tsx @@ -1,44 +1,100 @@ -import { Text, Group, UnstyledButton } from "@mantine/core"; +import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { formattedDate } from "@/lib/time"; -import classes from "./history.module.css"; +import classes from "./css/history.module.css"; import clsx from "clsx"; +import { IPageHistory } from "@/features/page-history/types/page.types"; +import { memo, useCallback } from "react"; + +const MAX_VISIBLE_AVATARS = 5; interface HistoryItemProps { - historyItem: any; - onSelect: (id: string) => void; + historyItem: IPageHistory; + index: number; + onSelect: (id: string, index: number) => void; + onHover?: (id: string, index: number) => void; + onHoverEnd?: () => void; 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]); + + const contributors = historyItem.contributors; + const hasContributors = contributors && contributors.length > 0; + return ( onSelect(historyItem.id)} + onClick={handleClick} + onMouseEnter={handleMouseEnter} + onMouseLeave={onHoverEnd} className={clsx(classes.history, { [classes.active]: isActive })} > - -
- - {formattedDate(new Date(historyItem.createdAt))} - + {formattedDate(new Date(historyItem.createdAt))} -
- - + + {hasContributors ? ( + <> + + + {contributors.slice(0, MAX_VISIBLE_AVATARS).map((contributor) => ( + + + + ))} + {contributors.length > MAX_VISIBLE_AVATARS && ( + ( +
{c.name}
+ ))} + > + + +{contributors.length - MAX_VISIBLE_AVATARS} + +
+ )} +
+
+ {contributors.length === 1 && ( - {historyItem.lastUpdatedBy.name} + {contributors[0].name} -
-
-
+ )} + + ) : ( + <> + + + {historyItem.lastUpdatedBy?.name} + + + )}
); -} +}); export default HistoryItem; diff --git a/apps/client/src/features/page-history/components/history-list.tsx b/apps/client/src/features/page-history/components/history-list.tsx index 7b0d9ea2..4024901b 100644 --- a/apps/client/src/features/page-history/components/history-list.tsx +++ b/apps/client/src/features/page-history/components/history-list.tsx @@ -1,29 +1,27 @@ import { usePageHistoryListQuery, - usePageHistoryQuery, + prefetchPageHistory, } from "@/features/page-history/queries/page-history-query"; import HistoryItem from "@/features/page-history/components/history-item"; import { activeHistoryIdAtom, + activeHistoryPrevIdAtom, historyAtoms, } from "@/features/page-history/atoms/history-atoms"; -import { useAtom } from "jotai"; -import { useCallback, useEffect } from "react"; -import { Button, ScrollArea, Group, Divider, Text } from "@mantine/core"; +import { useAtom, useSetAtom } from "jotai"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { - pageEditorAtom, - titleEditorAtom, -} from "@/features/editor/atoms/editor-atoms"; -import { modals } from "@mantine/modals"; -import { notifications } from "@mantine/notifications"; + Button, + ScrollArea, + Group, + Divider, + Loader, + Center, +} from "@mantine/core"; import { useTranslation } from "react-i18next"; -import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts"; -import { useSpaceQuery } from "@/features/space/queries/space-query.ts"; -import { useParams } from "react-router-dom"; -import { - SpaceCaslAction, - SpaceCaslSubject, -} from "@/features/space/permissions/permissions.type.ts"; +import { useHistoryRestore } from "@/features/page-history/hooks"; + +const PREFETCH_DELAY_MS = 150; interface Props { pageId: string; @@ -32,62 +30,89 @@ interface Props { function HistoryList({ pageId }: Props) { const { t } = useTranslation(); const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom); + const setActiveHistoryPrevId = useSetAtom(activeHistoryPrevIdAtom); + const setHistoryModalOpen = useSetAtom(historyAtoms); + const { - data: pageHistoryList, + data: pageHistoryData, isLoading, isError, + fetchNextPage, + hasNextPage, + isFetchingNextPage, } = usePageHistoryListQuery(pageId); - const { data: activeHistoryData } = usePageHistoryQuery(activeHistoryId); - const [mainEditor] = useAtom(pageEditorAtom); - const [mainEditorTitle] = useAtom(titleEditorAtom); - const [, setHistoryModalOpen] = useAtom(historyAtoms); + const historyItems = useMemo( + () => pageHistoryData?.pages.flatMap((page) => page.items) ?? [], + [pageHistoryData], + ); - const { spaceSlug } = useParams(); - const { data: space } = useSpaceQuery(spaceSlug); - const spaceRules = space?.membership?.permissions; - const spaceAbility = useSpaceAbility(spaceRules); + const loadMoreRef = useRef(null); + const prefetchTimeoutRef = useRef | null>(null); - const confirmModal = () => - modals.openConfirmModal({ - title: t("Please confirm your action"), - children: ( - - {t( - "Are you sure you want to restore this version? Any changes not versioned will be lost.", - )} - - ), - labels: { confirm: t("Confirm"), cancel: t("Cancel") }, - onConfirm: handleRestore, - }); + const { canRestore, confirmRestore } = useHistoryRestore(); - const handleRestore = useCallback(() => { - if (activeHistoryData) { - mainEditorTitle - .chain() - .clearContent() - .setContent(activeHistoryData.title, { emitUpdate: true }) - .run(); - mainEditor - .chain() - .clearContent() - .setContent(activeHistoryData.content) - .run(); - setHistoryModalOpen(false); - notifications.show({ message: t("Successfully restored") }); + const clearPrefetchTimeout = useCallback(() => { + if (prefetchTimeoutRef.current) { + clearTimeout(prefetchTimeoutRef.current); + prefetchTimeoutRef.current = null; } - }, [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(() => { - if ( - pageHistoryList && - pageHistoryList.items.length > 0 && - !activeHistoryId - ) { - setActiveHistoryId(pageHistoryList.items[0].id); + return clearPrefetchTimeout; + }, [clearPrefetchTimeout]); + + const handleSelect = useCallback( + (id: string, index: number) => { + 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) { return <>; @@ -97,34 +122,36 @@ function HistoryList({ pageId }: Props) { return
{t("Error loading page history.")}
; } - if (!pageHistoryList || pageHistoryList.items.length === 0) { + if (historyItems.length === 0) { return <>{t("No page history saved yet.")}; } return (
- {pageHistoryList && - pageHistoryList.items.map((historyItem, index) => ( - - ))} + {historyItems.map((historyItem, index) => ( + + ))} + {hasNextPage &&
} + {isFetchingNextPage && ( +
+ +
+ )} - {spaceAbility.cannot( - SpaceCaslAction.Manage, - SpaceCaslSubject.Page, - ) ? null : ( + {canRestore && ( <> - + )} diff --git a/apps/client/src/features/page-history/components/history-modal-body.tsx b/apps/client/src/features/page-history/components/history-modal-body.tsx index 199601fc..5673c82a 100644 --- a/apps/client/src/features/page-history/components/history-modal-body.tsx +++ b/apps/client/src/features/page-history/components/history-modal-body.tsx @@ -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 classes from "./history.module.css"; -import { useAtom } from "jotai"; -import { activeHistoryIdAtom } from "@/features/page-history/atoms/history-atoms"; +import classes from "./css/history.module.css"; +import { useAtom, useAtomValue } from "jotai"; +import { + activeHistoryIdAtom, + activeHistoryPrevIdAtom, + diffCountsAtom, + highlightChangesAtom, +} from "@/features/page-history/atoms/history-atoms"; 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 { pageId: string; } export default function HistoryModalBody({ pageId }: Props) { - const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom); + const { t } = useTranslation(); + const scrollViewportRef = useRef(null); - useEffect(() => { - setActiveHistoryId(""); - }, [pageId]); + const activeHistoryId = useAtomValue(activeHistoryIdAtom); + const activeHistoryPrevId = useAtomValue(activeHistoryPrevIdAtom); + const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom); + const diffCounts = useAtomValue(diffCountsAtom); + + useHistoryReset(pageId); + const { currentChangeIndex, handlePrevChange, handleNextChange } = + useDiffNavigation(scrollViewportRef); return (
@@ -25,11 +49,63 @@ export default function HistoryModalBody({ pageId }: Props) {
- -
- {activeHistoryId && } -
-
+
+ +
+ {activeHistoryId && } +
+
+ + {activeHistoryId && activeHistoryPrevId && ( + + + setHighlightChanges(e.currentTarget.checked)} + styles={{ label: { userSelect: "none", whiteSpace: "nowrap" } }} + /> + {highlightChanges && diffCounts && diffCounts.total > 0 && ( + + + {currentChangeIndex} of {diffCounts.total} + + + + + + + + + )} + + + )} +
); } diff --git a/apps/client/src/features/page-history/components/history-modal-mobile.tsx b/apps/client/src/features/page-history/components/history-modal-mobile.tsx new file mode 100644 index 00000000..b73695da --- /dev/null +++ b/apps/client/src/features/page-history/components/history-modal-mobile.tsx @@ -0,0 +1,215 @@ +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(null); + const dropdownViewportRef = useRef(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) => { + const contributors = item.contributors; + const hasContributors = contributors && contributors.length > 0; + const names = hasContributors + ? contributors.map((c) => c.name).join(", ") + : item.lastUpdatedBy?.name; + return { + value: item.id, + label: formattedDate(new Date(item.createdAt)), + userName: names, + }; + }), + [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 ( + + +