mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
Merge branch 'main' into feat/content
This commit is contained in:
+7
-1
@@ -46,4 +46,10 @@ DRAWIO_URL=
|
|||||||
DISABLE_TELEMETRY=false
|
DISABLE_TELEMETRY=false
|
||||||
|
|
||||||
# Enable debug logging in production (default: false)
|
# Enable debug logging in production (default: false)
|
||||||
DEBUG_MODE=false
|
DEBUG_MODE=false
|
||||||
|
|
||||||
|
# Log database queries
|
||||||
|
DEBUG_DB=false
|
||||||
|
|
||||||
|
# Log http requests
|
||||||
|
LOG_HTTP=false
|
||||||
|
|||||||
+12
-12
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.24.1",
|
"version": "0.25.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
@@ -14,18 +14,18 @@
|
|||||||
"@docmost/editor-ext": "workspace:*",
|
"@docmost/editor-ext": "workspace:*",
|
||||||
"@emoji-mart/data": "^1.2.1",
|
"@emoji-mart/data": "^1.2.1",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@excalidraw/excalidraw": "0.18.0-c158187",
|
"@excalidraw/excalidraw": "0.18.0-3a5ef40",
|
||||||
"@mantine/core": "^8.3.12",
|
"@mantine/core": "^8.3.14",
|
||||||
"@mantine/dates": "^8.3.12",
|
"@mantine/dates": "^8.3.14",
|
||||||
"@mantine/form": "^8.3.12",
|
"@mantine/form": "^8.3.14",
|
||||||
"@mantine/hooks": "^8.3.12",
|
"@mantine/hooks": "^8.3.14",
|
||||||
"@mantine/modals": "^8.3.12",
|
"@mantine/modals": "^8.3.14",
|
||||||
"@mantine/notifications": "^8.3.12",
|
"@mantine/notifications": "^8.3.14",
|
||||||
"@mantine/spotlight": "^8.3.12",
|
"@mantine/spotlight": "^8.3.14",
|
||||||
"@tabler/icons-react": "^3.36.1",
|
"@tabler/icons-react": "^3.36.1",
|
||||||
"@tanstack/react-query": "^5.90.17",
|
"@tanstack/react-query": "^5.90.17",
|
||||||
"alfaaz": "^1.1.0",
|
"alfaaz": "^1.1.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.5",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"emoji-mart": "^5.6.0",
|
"emoji-mart": "^5.6.0",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
"mantine-form-zod-resolver": "^1.3.0",
|
"mantine-form-zod-resolver": "^1.3.0",
|
||||||
"mermaid": "^11.12.2",
|
"mermaid": "^11.12.2",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"posthog-js": "^1.255.1",
|
"posthog-js": "1.345.5",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-arborist": "3.4.0",
|
"react-arborist": "3.4.0",
|
||||||
"react-clear-modal": "^2.0.17",
|
"react-clear-modal": "^2.0.17",
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
"eslint": "^9.15.0",
|
"eslint": "^9.39.2",
|
||||||
"eslint-plugin-react": "^7.37.2",
|
"eslint-plugin-react": "^7.37.2",
|
||||||
"eslint-plugin-react-hooks": "^5.1.0",
|
"eslint-plugin-react-hooks": "^5.1.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.16",
|
"eslint-plugin-react-refresh": "^0.4.16",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"Choose your preferred interface language.": "Wählen Sie Ihre bevorzugte Benutzersprache.",
|
"Choose your preferred interface language.": "Wählen Sie Ihre bevorzugte Benutzersprache.",
|
||||||
"Choose your preferred page width.": "Wählen Sie Ihre bevorzugte Seitenbreite.",
|
"Choose your preferred page width.": "Wählen Sie Ihre bevorzugte Seitenbreite.",
|
||||||
"Confirm": "Bestätigen",
|
"Confirm": "Bestätigen",
|
||||||
|
"Copy as Markdown": "Als Markdown kopieren",
|
||||||
"Copy link": "Link kopieren",
|
"Copy link": "Link kopieren",
|
||||||
"Create": "Erstellen",
|
"Create": "Erstellen",
|
||||||
"Create group": "Gruppe erstellen",
|
"Create group": "Gruppe erstellen",
|
||||||
@@ -40,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",
|
||||||
@@ -65,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",
|
||||||
@@ -113,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",
|
||||||
@@ -121,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",
|
||||||
@@ -234,7 +237,9 @@
|
|||||||
"Anyone with this link can join this workspace.": "Jeder mit diesem Link kann dem Arbeitsbereich beitreten.",
|
"Anyone with this link can join this workspace.": "Jeder mit diesem Link kann dem Arbeitsbereich beitreten.",
|
||||||
"Invite link": "Einladungslink",
|
"Invite link": "Einladungslink",
|
||||||
"Copy": "Kopieren",
|
"Copy": "Kopieren",
|
||||||
|
"Copy to space": "In Raum kopieren",
|
||||||
"Copied": "Kopiert",
|
"Copied": "Kopiert",
|
||||||
|
"Duplicate": "Duplizieren",
|
||||||
"Select a user": "Benutzer auswählen",
|
"Select a user": "Benutzer auswählen",
|
||||||
"Select a group": "Gruppe auswählen",
|
"Select a group": "Gruppe auswählen",
|
||||||
"Export all pages and attachments in this space.": "Alle Seiten und Anhänge in diesem Bereich exportieren.",
|
"Export all pages and attachments in this space.": "Alle Seiten und Anhänge in diesem Bereich exportieren.",
|
||||||
@@ -251,6 +256,7 @@
|
|||||||
"Export failed:": "Export fehlgeschlagen:",
|
"Export failed:": "Export fehlgeschlagen:",
|
||||||
"export error": "Exportfehler",
|
"export error": "Exportfehler",
|
||||||
"Export page": "Seite exportieren",
|
"Export page": "Seite exportieren",
|
||||||
|
"Export successful": "Export erfolgreich",
|
||||||
"Export space": "Bereich exportieren",
|
"Export space": "Bereich exportieren",
|
||||||
"Export {{type}}": "Exportiere {{type}}",
|
"Export {{type}}": "Exportiere {{type}}",
|
||||||
"File exceeds the {{limit}} attachment limit": "Datei überschreitet das Anhängelimit von {{limit}}",
|
"File exceeds the {{limit}} attachment limit": "Datei überschreitet das Anhängelimit von {{limit}}",
|
||||||
@@ -326,6 +332,8 @@
|
|||||||
"Upload any image from your device.": "Laden Sie ein beliebiges Bild von Ihrem Gerät hoch.",
|
"Upload any image from your device.": "Laden Sie ein beliebiges Bild von Ihrem Gerät hoch.",
|
||||||
"Upload any video from your device.": "Laden Sie ein beliebiges Video von Ihrem Gerät hoch.",
|
"Upload any video from your device.": "Laden Sie ein beliebiges Video von Ihrem Gerät hoch.",
|
||||||
"Upload any file from your device.": "Laden Sie eine beliebige Datei von Ihrem Gerät hoch.",
|
"Upload any file from your device.": "Laden Sie eine beliebige Datei von Ihrem Gerät hoch.",
|
||||||
|
"Uploading {{name}}": "Lade {{name}} hoch",
|
||||||
|
"Uploading file": "Datei wird hochgeladen",
|
||||||
"Table": "Tabelle",
|
"Table": "Tabelle",
|
||||||
"Insert a table.": "Tabelle einfügen.",
|
"Insert a table.": "Tabelle einfügen.",
|
||||||
"Insert collapsible block.": "Einklappbaren Block einfügen.",
|
"Insert collapsible block.": "Einklappbaren Block einfügen.",
|
||||||
@@ -399,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",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"Choose your preferred interface language.": "Choose your preferred interface language.",
|
"Choose your preferred interface language.": "Choose your preferred interface language.",
|
||||||
"Choose your preferred page width.": "Choose your preferred page width.",
|
"Choose your preferred page width.": "Choose your preferred page width.",
|
||||||
"Confirm": "Confirm",
|
"Confirm": "Confirm",
|
||||||
|
"Copy as Markdown": "Copy as Markdown",
|
||||||
"Copy link": "Copy link",
|
"Copy link": "Copy link",
|
||||||
"Create": "Create",
|
"Create": "Create",
|
||||||
"Create group": "Create group",
|
"Create group": "Create group",
|
||||||
@@ -122,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",
|
||||||
@@ -253,6 +256,7 @@
|
|||||||
"Export failed:": "Export failed:",
|
"Export failed:": "Export failed:",
|
||||||
"export error": "export error",
|
"export error": "export error",
|
||||||
"Export page": "Export page",
|
"Export page": "Export page",
|
||||||
|
"Export successful": "Export successful",
|
||||||
"Export space": "Export space",
|
"Export space": "Export space",
|
||||||
"Export {{type}}": "Export {{type}}",
|
"Export {{type}}": "Export {{type}}",
|
||||||
"File exceeds the {{limit}} attachment limit": "File exceeds the {{limit}} attachment limit",
|
"File exceeds the {{limit}} attachment limit": "File exceeds the {{limit}} attachment limit",
|
||||||
@@ -328,6 +332,8 @@
|
|||||||
"Upload any image from your device.": "Upload any image from your device.",
|
"Upload any image from your device.": "Upload any image from your device.",
|
||||||
"Upload any video from your device.": "Upload any video from your device.",
|
"Upload any video from your device.": "Upload any video from your device.",
|
||||||
"Upload any file from your device.": "Upload any file from your device.",
|
"Upload any file from your device.": "Upload any file from your device.",
|
||||||
|
"Uploading {{name}}": "Uploading {{name}}",
|
||||||
|
"Uploading file": "Uploading file",
|
||||||
"Table": "Table",
|
"Table": "Table",
|
||||||
"Insert a table.": "Insert a table.",
|
"Insert a table.": "Insert a table.",
|
||||||
"Insert collapsible block.": "Insert collapsible block.",
|
"Insert collapsible block.": "Insert collapsible block.",
|
||||||
@@ -401,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",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"Choose your preferred interface language.": "Elige tu idioma de interfaz preferido.",
|
"Choose your preferred interface language.": "Elige tu idioma de interfaz preferido.",
|
||||||
"Choose your preferred page width.": "Elige el ancho de página que prefieras.",
|
"Choose your preferred page width.": "Elige el ancho de página que prefieras.",
|
||||||
"Confirm": "Confirmar",
|
"Confirm": "Confirmar",
|
||||||
|
"Copy as Markdown": "Copiar como Markdown",
|
||||||
"Copy link": "Copiar enlace",
|
"Copy link": "Copiar enlace",
|
||||||
"Create": "Crear",
|
"Create": "Crear",
|
||||||
"Create group": "Crear grupo",
|
"Create group": "Crear grupo",
|
||||||
@@ -122,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",
|
||||||
@@ -253,6 +256,7 @@
|
|||||||
"Export failed:": "Exportación fallida:",
|
"Export failed:": "Exportación fallida:",
|
||||||
"export error": "error de exportación",
|
"export error": "error de exportación",
|
||||||
"Export page": "Exportar página",
|
"Export page": "Exportar página",
|
||||||
|
"Export successful": "Exportación exitosa",
|
||||||
"Export space": "Exportar espacio",
|
"Export space": "Exportar espacio",
|
||||||
"Export {{type}}": "Exportar {{type}}",
|
"Export {{type}}": "Exportar {{type}}",
|
||||||
"File exceeds the {{limit}} attachment limit": "El archivo supera el límite de {{limit}} adjuntos",
|
"File exceeds the {{limit}} attachment limit": "El archivo supera el límite de {{limit}} adjuntos",
|
||||||
@@ -328,6 +332,8 @@
|
|||||||
"Upload any image from your device.": "Sube cualquier imagen desde tu dispositivo.",
|
"Upload any image from your device.": "Sube cualquier imagen desde tu dispositivo.",
|
||||||
"Upload any video from your device.": "Sube cualquier video desde tu dispositivo.",
|
"Upload any video from your device.": "Sube cualquier video desde tu dispositivo.",
|
||||||
"Upload any file from your device.": "Sube cualquier archivo desde tu dispositivo.",
|
"Upload any file from your device.": "Sube cualquier archivo desde tu dispositivo.",
|
||||||
|
"Uploading {{name}}": "Subiendo {{name}}",
|
||||||
|
"Uploading file": "Subiendo archivo",
|
||||||
"Table": "Tabla",
|
"Table": "Tabla",
|
||||||
"Insert a table.": "Insertar una tabla.",
|
"Insert a table.": "Insertar una tabla.",
|
||||||
"Insert collapsible block.": "Insertar bloque desplegable.",
|
"Insert collapsible block.": "Insertar bloque desplegable.",
|
||||||
@@ -401,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",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"Choose your preferred interface language.": "Choisissez votre langue d'interface préférée.",
|
"Choose your preferred interface language.": "Choisissez votre langue d'interface préférée.",
|
||||||
"Choose your preferred page width.": "Choisissez votre largeur de page préférée.",
|
"Choose your preferred page width.": "Choisissez votre largeur de page préférée.",
|
||||||
"Confirm": "Confirmer",
|
"Confirm": "Confirmer",
|
||||||
|
"Copy as Markdown": "Copier comme Markdown",
|
||||||
"Copy link": "Copier le lien",
|
"Copy link": "Copier le lien",
|
||||||
"Create": "Créer",
|
"Create": "Créer",
|
||||||
"Create group": "Créer groupe",
|
"Create group": "Créer groupe",
|
||||||
@@ -122,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",
|
||||||
@@ -253,6 +256,7 @@
|
|||||||
"Export failed:": "Échec de l'exportation :",
|
"Export failed:": "Échec de l'exportation :",
|
||||||
"export error": "exporter l'erreur",
|
"export error": "exporter l'erreur",
|
||||||
"Export page": "Exporter la page",
|
"Export page": "Exporter la page",
|
||||||
|
"Export successful": "Exportation réussie",
|
||||||
"Export space": "Exporter l'espace",
|
"Export space": "Exporter l'espace",
|
||||||
"Export {{type}}": "Exporter {{type}}",
|
"Export {{type}}": "Exporter {{type}}",
|
||||||
"File exceeds the {{limit}} attachment limit": "Le fichier dépasse la limite de {{limit}} pièces jointes",
|
"File exceeds the {{limit}} attachment limit": "Le fichier dépasse la limite de {{limit}} pièces jointes",
|
||||||
@@ -328,6 +332,8 @@
|
|||||||
"Upload any image from your device.": "Téléchargez n'importe quelle image depuis votre appareil.",
|
"Upload any image from your device.": "Téléchargez n'importe quelle image depuis votre appareil.",
|
||||||
"Upload any video from your device.": "Téléchargez n'importe quelle vidéo depuis votre appareil.",
|
"Upload any video from your device.": "Téléchargez n'importe quelle vidéo depuis votre appareil.",
|
||||||
"Upload any file from your device.": "Téléchargez n'importe quel fichier depuis votre appareil.",
|
"Upload any file from your device.": "Téléchargez n'importe quel fichier depuis votre appareil.",
|
||||||
|
"Uploading {{name}}": "Téléchargement de {{name}}",
|
||||||
|
"Uploading file": "Téléchargement du fichier",
|
||||||
"Table": "Tableau",
|
"Table": "Tableau",
|
||||||
"Insert a table.": "Insérez un tableau.",
|
"Insert a table.": "Insérez un tableau.",
|
||||||
"Insert collapsible block.": "Insérer un bloc repliable.",
|
"Insert collapsible block.": "Insérer un bloc repliable.",
|
||||||
@@ -401,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",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"Choose your preferred interface language.": "Scegli la lingua da utilizzare per l'interfaccia.",
|
"Choose your preferred interface language.": "Scegli la lingua da utilizzare per l'interfaccia.",
|
||||||
"Choose your preferred page width.": "Scegli la larghezza della pagina che preferisci.",
|
"Choose your preferred page width.": "Scegli la larghezza della pagina che preferisci.",
|
||||||
"Confirm": "Conferma",
|
"Confirm": "Conferma",
|
||||||
|
"Copy as Markdown": "Copia come Markdown",
|
||||||
"Copy link": "Copia link",
|
"Copy link": "Copia link",
|
||||||
"Create": "Crea",
|
"Create": "Crea",
|
||||||
"Create group": "Crea gruppo",
|
"Create group": "Crea gruppo",
|
||||||
@@ -122,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",
|
||||||
@@ -253,6 +256,7 @@
|
|||||||
"Export failed:": "Esportazione fallita:",
|
"Export failed:": "Esportazione fallita:",
|
||||||
"export error": "errore di esportazione",
|
"export error": "errore di esportazione",
|
||||||
"Export page": "Esporta pagina",
|
"Export page": "Esporta pagina",
|
||||||
|
"Export successful": "Esportazione riuscita",
|
||||||
"Export space": "Esporta spazio",
|
"Export space": "Esporta spazio",
|
||||||
"Export {{type}}": "Esporta {{type}}",
|
"Export {{type}}": "Esporta {{type}}",
|
||||||
"File exceeds the {{limit}} attachment limit": "Il file supera il limite per gli allegati di {{limit}}",
|
"File exceeds the {{limit}} attachment limit": "Il file supera il limite per gli allegati di {{limit}}",
|
||||||
@@ -328,6 +332,8 @@
|
|||||||
"Upload any image from your device.": "Carica un'immagine dal tuo dispositivo.",
|
"Upload any image from your device.": "Carica un'immagine dal tuo dispositivo.",
|
||||||
"Upload any video from your device.": "Carica qualsiasi video dal tuo dispositivo.",
|
"Upload any video from your device.": "Carica qualsiasi video dal tuo dispositivo.",
|
||||||
"Upload any file from your device.": "Carica qualsiasi file dal tuo dispositivo.",
|
"Upload any file from your device.": "Carica qualsiasi file dal tuo dispositivo.",
|
||||||
|
"Uploading {{name}}": "Caricamento di {{name}}",
|
||||||
|
"Uploading file": "Caricamento file",
|
||||||
"Table": "Tabella",
|
"Table": "Tabella",
|
||||||
"Insert a table.": "Inserisci una tabella.",
|
"Insert a table.": "Inserisci una tabella.",
|
||||||
"Insert collapsible block.": "Inserisci blocco comprimibile.",
|
"Insert collapsible block.": "Inserisci blocco comprimibile.",
|
||||||
@@ -401,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",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"Choose your preferred interface language.": "お好みの言語を選択してください",
|
"Choose your preferred interface language.": "お好みの言語を選択してください",
|
||||||
"Choose your preferred page width.": "お好みのページ幅を選択してください",
|
"Choose your preferred page width.": "お好みのページ幅を選択してください",
|
||||||
"Confirm": "確認",
|
"Confirm": "確認",
|
||||||
|
"Copy as Markdown": "Markdownとしてコピー",
|
||||||
"Copy link": "リンクをコピー",
|
"Copy link": "リンクをコピー",
|
||||||
"Create": "新規作成",
|
"Create": "新規作成",
|
||||||
"Create group": "グループを作成",
|
"Create group": "グループを作成",
|
||||||
@@ -122,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": "ページ",
|
||||||
@@ -253,6 +256,7 @@
|
|||||||
"Export failed:": "エクスポートに失敗しました:",
|
"Export failed:": "エクスポートに失敗しました:",
|
||||||
"export error": "エクスポートエラー",
|
"export error": "エクスポートエラー",
|
||||||
"Export page": "エクスポートページ",
|
"Export page": "エクスポートページ",
|
||||||
|
"Export successful": "エクスポート成功",
|
||||||
"Export space": "エクスポートスペース",
|
"Export space": "エクスポートスペース",
|
||||||
"Export {{type}}": "{{type}}をエクスポート",
|
"Export {{type}}": "{{type}}をエクスポート",
|
||||||
"File exceeds the {{limit}} attachment limit": "ファイルが{{limit}}の添付制限を超えています",
|
"File exceeds the {{limit}} attachment limit": "ファイルが{{limit}}の添付制限を超えています",
|
||||||
@@ -328,6 +332,8 @@
|
|||||||
"Upload any image from your device.": "デバイスから画像をアップロードします",
|
"Upload any image from your device.": "デバイスから画像をアップロードします",
|
||||||
"Upload any video from your device.": "デバイスから動画をアップロードします",
|
"Upload any video from your device.": "デバイスから動画をアップロードします",
|
||||||
"Upload any file from your device.": "デバイスからファイルをアップロードします",
|
"Upload any file from your device.": "デバイスからファイルをアップロードします",
|
||||||
|
"Uploading {{name}}": "{{name}} をアップロード中",
|
||||||
|
"Uploading file": "ファイルをアップロード中",
|
||||||
"Table": "テーブル",
|
"Table": "テーブル",
|
||||||
"Insert a table.": "テーブルを挿入します",
|
"Insert a table.": "テーブルを挿入します",
|
||||||
"Insert collapsible block.": "折りたたみブロックを挿入します",
|
"Insert collapsible block.": "折りたたみブロックを挿入します",
|
||||||
@@ -401,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": "ページをコピーしました",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"Choose your preferred interface language.": "선호하는 인터페이스 언어를 선택하세요.",
|
"Choose your preferred interface language.": "선호하는 인터페이스 언어를 선택하세요.",
|
||||||
"Choose your preferred page width.": "선호하는 페이지 너비를 선택하세요.",
|
"Choose your preferred page width.": "선호하는 페이지 너비를 선택하세요.",
|
||||||
"Confirm": "확인",
|
"Confirm": "확인",
|
||||||
|
"Copy as Markdown": "Markdown으로 복사",
|
||||||
"Copy link": "링크 복사",
|
"Copy link": "링크 복사",
|
||||||
"Create": "생성",
|
"Create": "생성",
|
||||||
"Create group": "팀 생성",
|
"Create group": "팀 생성",
|
||||||
@@ -122,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": "페이지",
|
||||||
@@ -253,6 +256,7 @@
|
|||||||
"Export failed:": "내보내기 실패:",
|
"Export failed:": "내보내기 실패:",
|
||||||
"export error": "내보내기 오류",
|
"export error": "내보내기 오류",
|
||||||
"Export page": "페이지 내보내기",
|
"Export page": "페이지 내보내기",
|
||||||
|
"Export successful": "내보내기 성공",
|
||||||
"Export space": "Space 내보내기",
|
"Export space": "Space 내보내기",
|
||||||
"Export {{type}}": "{{type}} 내보내기",
|
"Export {{type}}": "{{type}} 내보내기",
|
||||||
"File exceeds the {{limit}} attachment limit": "첨부 파일 크기 제한 {{limit}}을 초과했습니다",
|
"File exceeds the {{limit}} attachment limit": "첨부 파일 크기 제한 {{limit}}을 초과했습니다",
|
||||||
@@ -328,6 +332,8 @@
|
|||||||
"Upload any image from your device.": "기기에서 이미지를 업로드하세요.",
|
"Upload any image from your device.": "기기에서 이미지를 업로드하세요.",
|
||||||
"Upload any video from your device.": "기기에서 비디오를 업로드하세요.",
|
"Upload any video from your device.": "기기에서 비디오를 업로드하세요.",
|
||||||
"Upload any file from your device.": "기기에서 파일을 업로드하세요.",
|
"Upload any file from your device.": "기기에서 파일을 업로드하세요.",
|
||||||
|
"Uploading {{name}}": "{{name}} 업로드 중",
|
||||||
|
"Uploading file": "파일 업로드 중",
|
||||||
"Table": "테이블",
|
"Table": "테이블",
|
||||||
"Insert a table.": "테이블 삽입.",
|
"Insert a table.": "테이블 삽입.",
|
||||||
"Insert collapsible block.": "접을 수 있는 블록 삽입.",
|
"Insert collapsible block.": "접을 수 있는 블록 삽입.",
|
||||||
@@ -401,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": "페이지가 성공적으로 복사되었습니다",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"Choose your preferred interface language.": "Kies uw gewenste interfacetaal.",
|
"Choose your preferred interface language.": "Kies uw gewenste interfacetaal.",
|
||||||
"Choose your preferred page width.": "Kies uw gewenste paginabreedte.",
|
"Choose your preferred page width.": "Kies uw gewenste paginabreedte.",
|
||||||
"Confirm": "Bevestig",
|
"Confirm": "Bevestig",
|
||||||
|
"Copy as Markdown": "Kopiëren als Markdown",
|
||||||
"Copy link": "Link kopiëren",
|
"Copy link": "Link kopiëren",
|
||||||
"Create": "Aanmaken",
|
"Create": "Aanmaken",
|
||||||
"Create group": "Groep aanmaken",
|
"Create group": "Groep aanmaken",
|
||||||
@@ -122,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",
|
||||||
@@ -253,6 +256,7 @@
|
|||||||
"Export failed:": "Exporteren mislukt:",
|
"Export failed:": "Exporteren mislukt:",
|
||||||
"export error": "Exporteer fout",
|
"export error": "Exporteer fout",
|
||||||
"Export page": "Exporteer pagina",
|
"Export page": "Exporteer pagina",
|
||||||
|
"Export successful": "Export succesvol",
|
||||||
"Export space": "Exporteer ruimte",
|
"Export space": "Exporteer ruimte",
|
||||||
"Export {{type}}": "Exporteer {{type}}",
|
"Export {{type}}": "Exporteer {{type}}",
|
||||||
"File exceeds the {{limit}} attachment limit": "Bestand overschrijdt de bijlagelimiet van {{limit}}",
|
"File exceeds the {{limit}} attachment limit": "Bestand overschrijdt de bijlagelimiet van {{limit}}",
|
||||||
@@ -328,6 +332,8 @@
|
|||||||
"Upload any image from your device.": "Upload een afbeelding vanaf uw apparaat.",
|
"Upload any image from your device.": "Upload een afbeelding vanaf uw apparaat.",
|
||||||
"Upload any video from your device.": "Upload een video vanaf uw apparaat.",
|
"Upload any video from your device.": "Upload een video vanaf uw apparaat.",
|
||||||
"Upload any file from your device.": "Upload een bestand vanaf uw apparaat.",
|
"Upload any file from your device.": "Upload een bestand vanaf uw apparaat.",
|
||||||
|
"Uploading {{name}}": "Uploaden {{name}}",
|
||||||
|
"Uploading file": "Bestand uploaden",
|
||||||
"Table": "Tabel",
|
"Table": "Tabel",
|
||||||
"Insert a table.": "Voeg een tabel in.",
|
"Insert a table.": "Voeg een tabel in.",
|
||||||
"Insert collapsible block.": "Inklapbaar blok invoegen.",
|
"Insert collapsible block.": "Inklapbaar blok invoegen.",
|
||||||
@@ -401,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",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"Choose your preferred interface language.": "Escolha o idioma da interface.",
|
"Choose your preferred interface language.": "Escolha o idioma da interface.",
|
||||||
"Choose your preferred page width.": "Escolha a largura preferida da página.",
|
"Choose your preferred page width.": "Escolha a largura preferida da página.",
|
||||||
"Confirm": "Confirmar",
|
"Confirm": "Confirmar",
|
||||||
|
"Copy as Markdown": "Copiar como Markdown",
|
||||||
"Copy link": "Copiar link",
|
"Copy link": "Copiar link",
|
||||||
"Create": "Criar",
|
"Create": "Criar",
|
||||||
"Create group": "Criar grupo",
|
"Create group": "Criar grupo",
|
||||||
@@ -122,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",
|
||||||
@@ -253,6 +256,7 @@
|
|||||||
"Export failed:": "Falha ao exportar:",
|
"Export failed:": "Falha ao exportar:",
|
||||||
"export error": "erro de exportação",
|
"export error": "erro de exportação",
|
||||||
"Export page": "Exportar página",
|
"Export page": "Exportar página",
|
||||||
|
"Export successful": "Exportação bem-sucedida",
|
||||||
"Export space": "Exportar espaço",
|
"Export space": "Exportar espaço",
|
||||||
"Export {{type}}": "Exportar para {{type}}",
|
"Export {{type}}": "Exportar para {{type}}",
|
||||||
"File exceeds the {{limit}} attachment limit": "O arquivo excede o limite de anexos {{limit}}",
|
"File exceeds the {{limit}} attachment limit": "O arquivo excede o limite de anexos {{limit}}",
|
||||||
@@ -328,6 +332,8 @@
|
|||||||
"Upload any image from your device.": "Envie qualquer imagem do seu dispositivo.",
|
"Upload any image from your device.": "Envie qualquer imagem do seu dispositivo.",
|
||||||
"Upload any video from your device.": "Envie qualquer vídeo do seu dispositivo.",
|
"Upload any video from your device.": "Envie qualquer vídeo do seu dispositivo.",
|
||||||
"Upload any file from your device.": "Envie qualquer arquivo do seu dispositivo.",
|
"Upload any file from your device.": "Envie qualquer arquivo do seu dispositivo.",
|
||||||
|
"Uploading {{name}}": "Enviando {{name}}",
|
||||||
|
"Uploading file": "Enviando arquivo",
|
||||||
"Table": "Tabela",
|
"Table": "Tabela",
|
||||||
"Insert a table.": "Insira uma tabela.",
|
"Insert a table.": "Insira uma tabela.",
|
||||||
"Insert collapsible block.": "Insira um bloco colapsável.",
|
"Insert collapsible block.": "Insira um bloco colapsável.",
|
||||||
@@ -401,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",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"Choose your preferred interface language.": "Выберите предпочитаемый язык интерфейса.",
|
"Choose your preferred interface language.": "Выберите предпочитаемый язык интерфейса.",
|
||||||
"Choose your preferred page width.": "Выберите предпочитаемую ширину страницы.",
|
"Choose your preferred page width.": "Выберите предпочитаемую ширину страницы.",
|
||||||
"Confirm": "Подтвердить",
|
"Confirm": "Подтвердить",
|
||||||
|
"Copy as Markdown": "Копировать как Markdown",
|
||||||
"Copy link": "Копировать ссылку",
|
"Copy link": "Копировать ссылку",
|
||||||
"Create": "Создать",
|
"Create": "Создать",
|
||||||
"Create group": "Создать группу",
|
"Create group": "Создать группу",
|
||||||
@@ -122,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": "страницы",
|
||||||
@@ -253,6 +256,7 @@
|
|||||||
"Export failed:": "Экспортирование не удалось:",
|
"Export failed:": "Экспортирование не удалось:",
|
||||||
"export error": "ошибка экспорта",
|
"export error": "ошибка экспорта",
|
||||||
"Export page": "Экспорт страницы",
|
"Export page": "Экспорт страницы",
|
||||||
|
"Export successful": "Экспорт выполнен успешно",
|
||||||
"Export space": "Экспорт пространства",
|
"Export space": "Экспорт пространства",
|
||||||
"Export {{type}}": "Экспорт {{type}}",
|
"Export {{type}}": "Экспорт {{type}}",
|
||||||
"File exceeds the {{limit}} attachment limit": "Файл превышает лимит вложений {{limit}}",
|
"File exceeds the {{limit}} attachment limit": "Файл превышает лимит вложений {{limit}}",
|
||||||
@@ -328,6 +332,8 @@
|
|||||||
"Upload any image from your device.": "Загрузить любое изображение с вашего устройства.",
|
"Upload any image from your device.": "Загрузить любое изображение с вашего устройства.",
|
||||||
"Upload any video from your device.": "Загрузить любое видео с вашего устройства.",
|
"Upload any video from your device.": "Загрузить любое видео с вашего устройства.",
|
||||||
"Upload any file from your device.": "Загрузить любой файл с вашего устройства.",
|
"Upload any file from your device.": "Загрузить любой файл с вашего устройства.",
|
||||||
|
"Uploading {{name}}": "Загрузка {{name}}",
|
||||||
|
"Uploading file": "Загрузка файла",
|
||||||
"Table": "Таблица",
|
"Table": "Таблица",
|
||||||
"Insert a table.": "Вставить таблицу.",
|
"Insert a table.": "Вставить таблицу.",
|
||||||
"Insert collapsible block.": "Вставить сворачиваемый блок.",
|
"Insert collapsible block.": "Вставить сворачиваемый блок.",
|
||||||
@@ -401,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": "Страница успешно скопирована",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"Choose your preferred interface language.": "Оберіть бажану мову інтерфейсу.",
|
"Choose your preferred interface language.": "Оберіть бажану мову інтерфейсу.",
|
||||||
"Choose your preferred page width.": "Оберіть бажану ширину сторінки.",
|
"Choose your preferred page width.": "Оберіть бажану ширину сторінки.",
|
||||||
"Confirm": "Підтвердити",
|
"Confirm": "Підтвердити",
|
||||||
|
"Copy as Markdown": "Скопіювати як Markdown",
|
||||||
"Copy link": "Копіювати посилання",
|
"Copy link": "Копіювати посилання",
|
||||||
"Create": "Створити",
|
"Create": "Створити",
|
||||||
"Create group": "Створити групу",
|
"Create group": "Створити групу",
|
||||||
@@ -122,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": "сторінки",
|
||||||
@@ -253,6 +256,7 @@
|
|||||||
"Export failed:": "Експортування не вдалося:",
|
"Export failed:": "Експортування не вдалося:",
|
||||||
"export error": "помилка експорту",
|
"export error": "помилка експорту",
|
||||||
"Export page": "Експорт сторінки",
|
"Export page": "Експорт сторінки",
|
||||||
|
"Export successful": "Експорт виконано успішно",
|
||||||
"Export space": "Експорт простору",
|
"Export space": "Експорт простору",
|
||||||
"Export {{type}}": "Експорт {{type}}",
|
"Export {{type}}": "Експорт {{type}}",
|
||||||
"File exceeds the {{limit}} attachment limit": "Файл перевищує ліміт вкладень {{limit}}",
|
"File exceeds the {{limit}} attachment limit": "Файл перевищує ліміт вкладень {{limit}}",
|
||||||
@@ -328,6 +332,8 @@
|
|||||||
"Upload any image from your device.": "Завантажити будь-яке зображення з вашого пристрою.",
|
"Upload any image from your device.": "Завантажити будь-яке зображення з вашого пристрою.",
|
||||||
"Upload any video from your device.": "Завантажити будь-яке відео з вашого пристрою.",
|
"Upload any video from your device.": "Завантажити будь-яке відео з вашого пристрою.",
|
||||||
"Upload any file from your device.": "Завантажити будь-який файл з вашого пристрою.",
|
"Upload any file from your device.": "Завантажити будь-який файл з вашого пристрою.",
|
||||||
|
"Uploading {{name}}": "Завантаження {{name}}",
|
||||||
|
"Uploading file": "Завантаження файлу",
|
||||||
"Table": "Таблиця",
|
"Table": "Таблиця",
|
||||||
"Insert a table.": "Вставити таблицю.",
|
"Insert a table.": "Вставити таблицю.",
|
||||||
"Insert collapsible block.": "Вставити блок, що згортається.",
|
"Insert collapsible block.": "Вставити блок, що згортається.",
|
||||||
@@ -401,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": "Сторінку успішно скопійовано",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"Choose your preferred interface language.": "选择您喜欢的界面语言。",
|
"Choose your preferred interface language.": "选择您喜欢的界面语言。",
|
||||||
"Choose your preferred page width.": "选择您喜欢的页面宽度。",
|
"Choose your preferred page width.": "选择您喜欢的页面宽度。",
|
||||||
"Confirm": "确认",
|
"Confirm": "确认",
|
||||||
|
"Copy as Markdown": "复制为Markdown",
|
||||||
"Copy link": "复制链接",
|
"Copy link": "复制链接",
|
||||||
"Create": "创建",
|
"Create": "创建",
|
||||||
"Create group": "创建群组",
|
"Create group": "创建群组",
|
||||||
@@ -122,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": "个页面",
|
||||||
@@ -253,6 +256,7 @@
|
|||||||
"Export failed:": "导出失败:",
|
"Export failed:": "导出失败:",
|
||||||
"export error": "导出出错",
|
"export error": "导出出错",
|
||||||
"Export page": "导出页面",
|
"Export page": "导出页面",
|
||||||
|
"Export successful": "导出成功",
|
||||||
"Export space": "导出空间",
|
"Export space": "导出空间",
|
||||||
"Export {{type}}": "导出为 {{type}}",
|
"Export {{type}}": "导出为 {{type}}",
|
||||||
"File exceeds the {{limit}} attachment limit": "文件超出了 {{limit}} 类型附件限制",
|
"File exceeds the {{limit}} attachment limit": "文件超出了 {{limit}} 类型附件限制",
|
||||||
@@ -328,6 +332,8 @@
|
|||||||
"Upload any image from your device.": "从设备上传任何图像",
|
"Upload any image from your device.": "从设备上传任何图像",
|
||||||
"Upload any video from your device.": "从设备上传任何视频",
|
"Upload any video from your device.": "从设备上传任何视频",
|
||||||
"Upload any file from your device.": "从设备上传任何文件",
|
"Upload any file from your device.": "从设备上传任何文件",
|
||||||
|
"Uploading {{name}}": "正在上传{{name}}",
|
||||||
|
"Uploading file": "正在上传文件",
|
||||||
"Table": "表格",
|
"Table": "表格",
|
||||||
"Insert a table.": "插入一个表格",
|
"Insert a table.": "插入一个表格",
|
||||||
"Insert collapsible block.": "插入一个折叠块",
|
"Insert collapsible block.": "插入一个折叠块",
|
||||||
@@ -401,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,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<CopyButtonProps>;
|
||||||
|
|
||||||
|
export function CopyButton(props: CopyButtonProps) {
|
||||||
|
const { children, timeout, value, ...others } = useProps(
|
||||||
|
"CopyButton",
|
||||||
|
defaultProps,
|
||||||
|
props,
|
||||||
|
);
|
||||||
|
const clipboard = useClipboard({ timeout });
|
||||||
|
const copy = () => clipboard.copy(value);
|
||||||
|
return <>{children({ copy, copied: clipboard.copied, ...others })}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
CopyButton.displayName = "@mantine/core/CopyButton";
|
||||||
@@ -1,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 { IconCheck, IconCopy } from "@tabler/icons-react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|||||||
@@ -30,9 +30,11 @@ export default function ExportModal({
|
|||||||
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
|
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
|
||||||
const [includeChildren, setIncludeChildren] = useState<boolean>(false);
|
const [includeChildren, setIncludeChildren] = useState<boolean>(false);
|
||||||
const [includeAttachments, setIncludeAttachments] = useState<boolean>(false);
|
const [includeAttachments, setIncludeAttachments] = useState<boolean>(false);
|
||||||
|
const [isExporting, setIsExporting] = useState<boolean>(false);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
|
setIsExporting(true);
|
||||||
try {
|
try {
|
||||||
if (type === "page") {
|
if (type === "page") {
|
||||||
await exportPage({
|
await exportPage({
|
||||||
@@ -45,6 +47,9 @@ export default function ExportModal({
|
|||||||
if (type === "space") {
|
if (type === "space") {
|
||||||
await exportSpace({ spaceId: id, format, includeAttachments });
|
await exportSpace({ spaceId: id, format, includeAttachments });
|
||||||
}
|
}
|
||||||
|
notifications.show({
|
||||||
|
message: t("Export successful"),
|
||||||
|
});
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
@@ -52,6 +57,8 @@ export default function ExportModal({
|
|||||||
color: "red",
|
color: "red",
|
||||||
});
|
});
|
||||||
console.error("export error", err);
|
console.error("export error", err);
|
||||||
|
} finally {
|
||||||
|
setIsExporting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -136,7 +143,7 @@ export default function ExportModal({
|
|||||||
<Button onClick={onClose} variant="default">
|
<Button onClick={onClose} variant="default">
|
||||||
{t("Cancel")}
|
{t("Cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleExport}>{t("Export")}</Button>
|
<Button onClick={handleExport} loading={isExporting}>{t("Export")}</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
</Modal.Content>
|
</Modal.Content>
|
||||||
|
|||||||
@@ -2,17 +2,17 @@ import { Button, Group } from "@mantine/core";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export interface PagePaginationProps {
|
export interface PagePaginationProps {
|
||||||
currentPage: number;
|
|
||||||
hasPrevPage: boolean;
|
hasPrevPage: boolean;
|
||||||
hasNextPage: boolean;
|
hasNextPage: boolean;
|
||||||
onPageChange: (newPage: number) => void;
|
onPrev: () => void;
|
||||||
|
onNext: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Paginate({
|
export default function Paginate({
|
||||||
currentPage,
|
|
||||||
hasPrevPage,
|
hasPrevPage,
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
onPageChange,
|
onPrev,
|
||||||
|
onNext,
|
||||||
}: PagePaginationProps) {
|
}: PagePaginationProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ export default function Paginate({
|
|||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="compact-sm"
|
size="compact-sm"
|
||||||
onClick={() => onPageChange(currentPage - 1)}
|
onClick={onPrev}
|
||||||
disabled={!hasPrevPage}
|
disabled={!hasPrevPage}
|
||||||
>
|
>
|
||||||
{t("Prev")}
|
{t("Prev")}
|
||||||
@@ -34,7 +34,7 @@ export default function Paginate({
|
|||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="compact-sm"
|
size="compact-sm"
|
||||||
onClick={() => onPageChange(currentPage + 1)}
|
onClick={onNext}
|
||||||
disabled={!hasNextPage}
|
disabled={!hasNextPage}
|
||||||
>
|
>
|
||||||
{t("Next")}
|
{t("Next")}
|
||||||
|
|||||||
@@ -5,26 +5,27 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
Table,
|
Table,
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
} from '@mantine/core';
|
} from "@mantine/core";
|
||||||
import {Link} from 'react-router-dom';
|
import { Link } from "react-router-dom";
|
||||||
import PageListSkeleton from '@/components/ui/page-list-skeleton.tsx';
|
import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx";
|
||||||
import { buildPageUrl } from '@/features/page/page.utils.ts';
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
import { formattedDate } from '@/lib/time.ts';
|
import { formattedDate } from "@/lib/time.ts";
|
||||||
import { useRecentChangesQuery } from '@/features/page/queries/page-query.ts';
|
import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts";
|
||||||
import { IconFileDescription } from '@tabler/icons-react';
|
import { IconFileDescription } from "@tabler/icons-react";
|
||||||
import { getSpaceUrl } from '@/lib/config.ts';
|
import { getSpaceUrl } from "@/lib/config.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { getInitialsColor } from "@/lib/get-initials-color.ts";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
spaceId?: string;
|
spaceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RecentChanges({spaceId}: Props) {
|
export default function RecentChanges({ spaceId }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const {data: pages, isLoading, isError} = useRecentChangesQuery(spaceId);
|
const { data: pages, isLoading, isError } = useRecentChangesQuery(spaceId);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <PageListSkeleton/>;
|
return <PageListSkeleton />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
@@ -44,8 +45,8 @@ export default function RecentChanges({spaceId}: Props) {
|
|||||||
>
|
>
|
||||||
<Group wrap="nowrap">
|
<Group wrap="nowrap">
|
||||||
{page.icon || (
|
{page.icon || (
|
||||||
<ActionIcon variant='transparent' color='gray' size={18}>
|
<ActionIcon variant="transparent" color="gray" size={18}>
|
||||||
<IconFileDescription size={18}/>
|
<IconFileDescription size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -58,18 +59,23 @@ export default function RecentChanges({spaceId}: Props) {
|
|||||||
{!spaceId && (
|
{!spaceId && (
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge
|
<Badge
|
||||||
color="blue"
|
color={getInitialsColor(page?.space.name)}
|
||||||
variant="light"
|
variant="light"
|
||||||
component={Link}
|
component={Link}
|
||||||
to={getSpaceUrl(page?.space.slug)}
|
to={getSpaceUrl(page?.space.slug)}
|
||||||
style={{cursor: 'pointer'}}
|
style={{ cursor: "pointer" }}
|
||||||
>
|
>
|
||||||
{page?.space.name}
|
{page?.space.name}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
)}
|
)}
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text c="dimmed" style={{whiteSpace: 'nowrap'}} size="xs" fw={500}>
|
<Text
|
||||||
|
c="dimmed"
|
||||||
|
style={{ whiteSpace: "nowrap" }}
|
||||||
|
size="xs"
|
||||||
|
fw={500}
|
||||||
|
>
|
||||||
{formattedDate(page.updatedAt)}
|
{formattedDate(page.updatedAt)}
|
||||||
</Text>
|
</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { getShares } from "@/features/share/services/share-service.ts";
|
|||||||
import { getApiKeys } from "@/ee/api-key";
|
import { getApiKeys } from "@/ee/api-key";
|
||||||
|
|
||||||
export const prefetchWorkspaceMembers = () => {
|
export const prefetchWorkspaceMembers = () => {
|
||||||
const params = { limit: 100, page: 1, query: "" } as QueryParams;
|
const params: QueryParams = { limit: 100, query: "" };
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
queryKey: ["workspaceMembers", params],
|
queryKey: ["workspaceMembers", params],
|
||||||
queryFn: () => getWorkspaceMembers(params),
|
queryFn: () => getWorkspaceMembers(params),
|
||||||
@@ -22,15 +22,15 @@ export const prefetchWorkspaceMembers = () => {
|
|||||||
|
|
||||||
export const prefetchSpaces = () => {
|
export const prefetchSpaces = () => {
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
queryKey: ["spaces", { page: 1 }],
|
queryKey: ["spaces", {}],
|
||||||
queryFn: () => getSpaces({ page: 1 }),
|
queryFn: () => getSpaces({}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const prefetchGroups = () => {
|
export const prefetchGroups = () => {
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
queryKey: ["groups", { page: 1 }],
|
queryKey: ["groups", {}],
|
||||||
queryFn: () => getGroups({ page: 1 }),
|
queryFn: () => getGroups({}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,21 +62,21 @@ export const prefetchSsoProviders = () => {
|
|||||||
|
|
||||||
export const prefetchShares = () => {
|
export const prefetchShares = () => {
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
queryKey: ["share-list", { page: 1 }],
|
queryKey: ["share-list", {}],
|
||||||
queryFn: () => getShares({ page: 1, limit: 100 }),
|
queryFn: () => getShares({}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const prefetchApiKeys = () => {
|
export const prefetchApiKeys = () => {
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
queryKey: ["api-key-list", { page: 1 }],
|
queryKey: ["api-key-list", {}],
|
||||||
queryFn: () => getApiKeys({ page: 1 }),
|
queryFn: () => getApiKeys({}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const prefetchApiKeyManagement = () => {
|
export const prefetchApiKeyManagement = () => {
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
queryKey: ["api-key-list", { page: 1 }],
|
queryKey: ["api-key-list", { adminView: true }],
|
||||||
queryFn: () => getApiKeys({ page: 1, adminView: true }),
|
queryFn: () => getApiKeys({ adminView: true }),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { useRef, useState, ReactNode } from "react";
|
||||||
|
import { Text, TextProps, Tooltip } from "@mantine/core";
|
||||||
|
|
||||||
|
type AutoTooltipTextProps = TextProps & {
|
||||||
|
children: ReactNode;
|
||||||
|
tooltipLabel?: string;
|
||||||
|
tooltipProps?: Omit<
|
||||||
|
React.ComponentProps<typeof Tooltip>,
|
||||||
|
"children" | "label"
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AutoTooltipText({
|
||||||
|
children,
|
||||||
|
tooltipLabel,
|
||||||
|
tooltipProps,
|
||||||
|
...textProps
|
||||||
|
}: AutoTooltipTextProps) {
|
||||||
|
const textRef = useRef<HTMLParagraphElement>(null);
|
||||||
|
const [isTruncated, setIsTruncated] = useState(false);
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
const element = textRef.current;
|
||||||
|
if (element) {
|
||||||
|
setIsTruncated(element.scrollWidth > element.clientWidth);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const label = tooltipLabel ?? (typeof children === "string" ? children : "");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
label={label}
|
||||||
|
disabled={!isTruncated || !label}
|
||||||
|
multiline
|
||||||
|
withArrow
|
||||||
|
{...tooltipProps}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
ref={textRef}
|
||||||
|
truncate
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
{...textProps}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,19 +10,19 @@ import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-moda
|
|||||||
import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
|
import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
|
||||||
import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
|
import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
|
||||||
import Paginate from "@/components/common/paginate";
|
import Paginate from "@/components/common/paginate";
|
||||||
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
|
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
||||||
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
|
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
|
||||||
import { IApiKey } from "@/ee/api-key";
|
import { IApiKey } from "@/ee/api-key";
|
||||||
|
|
||||||
export default function UserApiKeys() {
|
export default function UserApiKeys() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { page, setPage } = usePaginateAndSearch();
|
const { cursor, goNext, goPrev } = useCursorPaginate();
|
||||||
const [createModalOpened, setCreateModalOpened] = useState(false);
|
const [createModalOpened, setCreateModalOpened] = useState(false);
|
||||||
const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null);
|
const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null);
|
||||||
const [updateModalOpened, setUpdateModalOpened] = useState(false);
|
const [updateModalOpened, setUpdateModalOpened] = useState(false);
|
||||||
const [revokeModalOpened, setRevokeModalOpened] = useState(false);
|
const [revokeModalOpened, setRevokeModalOpened] = useState(false);
|
||||||
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
|
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
|
||||||
const { data, isLoading } = useGetApiKeysQuery({ page });
|
const { data, isLoading } = useGetApiKeysQuery({ cursor });
|
||||||
|
|
||||||
const handleCreateSuccess = (response: IApiKey) => {
|
const handleCreateSuccess = (response: IApiKey) => {
|
||||||
setCreatedApiKey(response);
|
setCreatedApiKey(response);
|
||||||
@@ -65,10 +65,10 @@ export default function UserApiKeys() {
|
|||||||
|
|
||||||
{data?.items.length > 0 && (
|
{data?.items.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
currentPage={page}
|
hasPrevPage={data?.meta?.hasPrevPage}
|
||||||
hasPrevPage={data?.meta.hasPrevPage}
|
hasNextPage={data?.meta?.hasNextPage}
|
||||||
hasNextPage={data?.meta.hasNextPage}
|
onNext={() => goNext(data?.meta?.nextCursor)}
|
||||||
onPageChange={setPage}
|
onPrev={goPrev}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -10,20 +10,20 @@ import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-moda
|
|||||||
import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
|
import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
|
||||||
import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
|
import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
|
||||||
import Paginate from "@/components/common/paginate";
|
import Paginate from "@/components/common/paginate";
|
||||||
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
|
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
||||||
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
|
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
|
||||||
import { IApiKey } from "@/ee/api-key";
|
import { IApiKey } from "@/ee/api-key";
|
||||||
import useUserRole from '@/hooks/use-user-role.tsx';
|
import useUserRole from '@/hooks/use-user-role.tsx';
|
||||||
|
|
||||||
export default function WorkspaceApiKeys() {
|
export default function WorkspaceApiKeys() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { page, setPage } = usePaginateAndSearch();
|
const { cursor, goNext, goPrev } = useCursorPaginate();
|
||||||
const [createModalOpened, setCreateModalOpened] = useState(false);
|
const [createModalOpened, setCreateModalOpened] = useState(false);
|
||||||
const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null);
|
const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null);
|
||||||
const [updateModalOpened, setUpdateModalOpened] = useState(false);
|
const [updateModalOpened, setUpdateModalOpened] = useState(false);
|
||||||
const [revokeModalOpened, setRevokeModalOpened] = useState(false);
|
const [revokeModalOpened, setRevokeModalOpened] = useState(false);
|
||||||
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
|
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
|
||||||
const { data, isLoading } = useGetApiKeysQuery({ page, adminView: true });
|
const { data, isLoading } = useGetApiKeysQuery({ cursor, adminView: true });
|
||||||
const { isAdmin } = useUserRole();
|
const { isAdmin } = useUserRole();
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
@@ -76,10 +76,10 @@ export default function WorkspaceApiKeys() {
|
|||||||
|
|
||||||
{data?.items.length > 0 && (
|
{data?.items.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
currentPage={page}
|
hasPrevPage={data?.meta?.hasPrevPage}
|
||||||
hasPrevPage={data?.meta.hasPrevPage}
|
hasNextPage={data?.meta?.hasNextPage}
|
||||||
hasNextPage={data?.meta.hasNextPage}
|
onNext={() => goNext(data?.meta?.nextCursor)}
|
||||||
onPageChange={setPage}
|
onPrev={goPrev}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -8,10 +8,10 @@ import {
|
|||||||
Group,
|
Group,
|
||||||
List,
|
List,
|
||||||
Code,
|
Code,
|
||||||
CopyButton,
|
|
||||||
Alert,
|
Alert,
|
||||||
PasswordInput,
|
PasswordInput,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import { CopyButton } from "@/components/common/copy-button";
|
||||||
import {
|
import {
|
||||||
IconRefresh,
|
IconRefresh,
|
||||||
IconCopy,
|
IconCopy,
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
PinInput,
|
PinInput,
|
||||||
Alert,
|
Alert,
|
||||||
List,
|
List,
|
||||||
CopyButton,
|
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Paper,
|
Paper,
|
||||||
@@ -20,6 +19,7 @@ import {
|
|||||||
Collapse,
|
Collapse,
|
||||||
UnstyledButton,
|
UnstyledButton,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import { CopyButton } from "@/components/common/copy-button";
|
||||||
import {
|
import {
|
||||||
IconQrcode,
|
IconQrcode,
|
||||||
IconShieldCheck,
|
IconShieldCheck,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -43,7 +43,7 @@ export default function SsoProviderList() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data?.length === 0) {
|
if (data?.items.length === 0) {
|
||||||
return <Text c="dimmed">{t("No SSO providers found.")}</Text>;
|
return <Text c="dimmed">{t("No SSO providers found.")}</Text>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ export default function SsoProviderList() {
|
|||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{data
|
{data?.items
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const enabledDiff = Number(b.isEnabled) - Number(a.isEnabled);
|
const enabledDiff = Number(b.isEnabled) - Number(a.isEnabled);
|
||||||
if (enabledDiff !== 0) return enabledDiff;
|
if (enabledDiff !== 0) return enabledDiff;
|
||||||
@@ -104,7 +104,11 @@ export default function SsoProviderList() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge color={"gray"} variant="light" style={{ whiteSpace: "nowrap" }}>
|
<Badge
|
||||||
|
color={"gray"}
|
||||||
|
variant="light"
|
||||||
|
style={{ whiteSpace: "nowrap" }}
|
||||||
|
>
|
||||||
{provider.type.toUpperCase()}
|
{provider.type.toUpperCase()}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
@@ -134,41 +138,41 @@ export default function SsoProviderList() {
|
|||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group gap="xs" wrap="nowrap">
|
<Group gap="xs" wrap="nowrap">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
onClick={() => handleEdit(provider)}
|
onClick={() => handleEdit(provider)}
|
||||||
>
|
>
|
||||||
<IconPencil size={16} />
|
<IconPencil size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<Menu
|
<Menu
|
||||||
transitionProps={{ transition: "pop" }}
|
transitionProps={{ transition: "pop" }}
|
||||||
withArrow
|
withArrow
|
||||||
position="bottom-end"
|
position="bottom-end"
|
||||||
withinPortal
|
withinPortal
|
||||||
>
|
>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<ActionIcon variant="subtle" color="gray">
|
<ActionIcon variant="subtle" color="gray">
|
||||||
<IconDots size={16} />
|
<IconDots size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
onClick={() => handleEdit(provider)}
|
onClick={() => handleEdit(provider)}
|
||||||
leftSection={<IconPencil size={16} />}
|
leftSection={<IconPencil size={16} />}
|
||||||
>
|
>
|
||||||
{t("Edit")}
|
{t("Edit")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
onClick={() => openDeleteModal(provider.id)}
|
onClick={() => openDeleteModal(provider.id)}
|
||||||
leftSection={<IconTrash size={16} />}
|
leftSection={<IconTrash size={16} />}
|
||||||
color="red"
|
color="red"
|
||||||
disabled={provider.type === SSO_PROVIDER.GOOGLE}
|
disabled={provider.type === SSO_PROVIDER.GOOGLE}
|
||||||
>
|
>
|
||||||
{t("Delete")}
|
{t("Delete")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
</Group>
|
</Group>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
|
|||||||
@@ -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 />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ import {
|
|||||||
} from "@/ee/security/services/security-service.ts";
|
} from "@/ee/security/services/security-service.ts";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
|
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
|
||||||
|
import { IPagination } from "@/lib/types.ts";
|
||||||
|
|
||||||
export function useGetSsoProviders(): UseQueryResult<IAuthProvider[], Error> {
|
export function useGetSsoProviders(): UseQueryResult<IPagination<IAuthProvider>, Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["sso-providers"],
|
queryKey: ["sso-providers"],
|
||||||
queryFn: () => getSsoProviders(),
|
queryFn: () => getSsoProviders(),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import api from "@/lib/api-client.ts";
|
import api from "@/lib/api-client.ts";
|
||||||
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
|
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
|
||||||
|
import { IPagination } from "@/lib/types.ts";
|
||||||
|
|
||||||
export async function getSsoProviderById(data: {
|
export async function getSsoProviderById(data: {
|
||||||
providerId: string;
|
providerId: string;
|
||||||
@@ -8,8 +9,8 @@ export async function getSsoProviderById(data: {
|
|||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSsoProviders(): Promise<IAuthProvider[]> {
|
export async function getSsoProviders(): Promise<IPagination<IAuthProvider>> {
|
||||||
const req = await api.post<IAuthProvider[]>("/sso/providers");
|
const req = await api.post<IPagination<IAuthProvider>>("/sso/providers");
|
||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,9 +84,14 @@ const CommentEditor = forwardRef(
|
|||||||
autofocus: (autofocus && "end") || false,
|
autofocus: (autofocus && "end") || false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sync content from props for read-only editors (e.g. when updated via
|
||||||
|
// websocket on another browser). Skip for editable editors to avoid
|
||||||
|
// resetting the cursor position on every keystroke.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
commentEditor.commands.setContent(defaultContent);
|
if (!editable && commentEditor && defaultContent) {
|
||||||
}, [defaultContent]);
|
commentEditor.commands.setContent(defaultContent);
|
||||||
|
}
|
||||||
|
}, [defaultContent, editable, commentEditor]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Group, Text, Box, Badge } from "@mantine/core";
|
import { Group, Text, Box, Badge } from "@mantine/core";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import classes from "./comment.module.css";
|
import classes from "./comment.module.css";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { timeAgo } from "@/lib/time";
|
import { timeAgo } from "@/lib/time";
|
||||||
@@ -40,6 +40,7 @@ function CommentListItem({
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const editor = useAtomValue(pageEditorAtom);
|
const editor = useAtomValue(pageEditorAtom);
|
||||||
const [content, setContent] = useState<string>(comment.content);
|
const [content, setContent] = useState<string>(comment.content);
|
||||||
|
const editContentRef = useRef<any>(null);
|
||||||
const updateCommentMutation = useUpdateCommentMutation();
|
const updateCommentMutation = useUpdateCommentMutation();
|
||||||
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
||||||
const resolveCommentMutation = useResolveCommentMutation();
|
const resolveCommentMutation = useResolveCommentMutation();
|
||||||
@@ -56,9 +57,13 @@ function CommentListItem({
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const commentToUpdate = {
|
const commentToUpdate = {
|
||||||
commentId: comment.id,
|
commentId: comment.id,
|
||||||
content: JSON.stringify(content),
|
content: JSON.stringify(editContentRef.current ?? content),
|
||||||
};
|
};
|
||||||
await updateCommentMutation.mutateAsync(commentToUpdate);
|
await updateCommentMutation.mutateAsync(commentToUpdate);
|
||||||
|
if (editContentRef.current) {
|
||||||
|
setContent(editContentRef.current);
|
||||||
|
editContentRef.current = null;
|
||||||
|
}
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
|
|
||||||
emit({
|
emit({
|
||||||
@@ -128,6 +133,7 @@ function CommentListItem({
|
|||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
}
|
}
|
||||||
function cancelEdit() {
|
function cancelEdit() {
|
||||||
|
editContentRef.current = null;
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,7 +200,7 @@ function CommentListItem({
|
|||||||
<CommentEditor
|
<CommentEditor
|
||||||
defaultContent={content}
|
defaultContent={content}
|
||||||
editable={true}
|
editable={true}
|
||||||
onUpdate={(newContent: any) => setContent(newContent)}
|
onUpdate={(newContent: any) => { editContentRef.current = newContent; }}
|
||||||
onSave={handleUpdateComment}
|
onSave={handleUpdateComment}
|
||||||
autofocus={true}
|
autofocus={true}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
import { Group, Text, Paper, ActionIcon } from "@mantine/core";
|
import { Group, Text, Paper, ActionIcon, Loader } from "@mantine/core";
|
||||||
import { getFileUrl } from "@/lib/config.ts";
|
import { getFileUrl } from "@/lib/config.ts";
|
||||||
import { IconDownload, IconPaperclip } from "@tabler/icons-react";
|
import { IconDownload, IconPaperclip } from "@tabler/icons-react";
|
||||||
import { useHover } from "@mantine/hooks";
|
import { useHover } from "@mantine/hooks";
|
||||||
import { formatBytes } from "@/lib";
|
import { formatBytes } from "@/lib";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function AttachmentView(props: NodeViewProps) {
|
export default function AttachmentView(props: NodeViewProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { node, selected } = props;
|
const { node, selected } = props;
|
||||||
const { url, name, size } = node.attrs;
|
const { url, name, size } = node.attrs;
|
||||||
const { hovered, ref } = useHover();
|
const { hovered, ref } = useHover();
|
||||||
@@ -20,26 +22,28 @@ export default function AttachmentView(props: NodeViewProps) {
|
|||||||
wrap="nowrap"
|
wrap="nowrap"
|
||||||
h={25}
|
h={25}
|
||||||
>
|
>
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group wrap="nowrap" gap="sm" style={{ minWidth: 0, flex: 1 }}>
|
||||||
<IconPaperclip size={20} />
|
{url ? (
|
||||||
|
<IconPaperclip size={20} style={{ flexShrink: 0 }} />
|
||||||
|
) : (
|
||||||
|
<Loader size={20} style={{ flexShrink: 0 }} />
|
||||||
|
)}
|
||||||
|
|
||||||
<Text component="span" size="md" truncate="end">
|
<Text component="span" size="md" truncate="end" style={{ minWidth: 0 }}>
|
||||||
{name}
|
{url ? name : t("Uploading {{name}}", { name })}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text component="span" size="sm" c="dimmed" inline>
|
<Text component="span" size="sm" c="dimmed" style={{ flexShrink: 0 }}>
|
||||||
{formatBytes(size)}
|
{formatBytes(size)}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{selected || hovered ? (
|
{url && (selected || hovered) && (
|
||||||
<a href={getFileUrl(url)} target="_blank">
|
<a href={getFileUrl(url)} target="_blank">
|
||||||
<ActionIcon variant="default" aria-label="download file">
|
<ActionIcon variant="default" aria-label="download file">
|
||||||
<IconDownload size={18} />
|
<IconDownload size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</a>
|
</a>
|
||||||
) : (
|
|
||||||
""
|
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
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 { useEffect, useState } from "react";
|
||||||
import { IconCheck, IconCopy } from "@tabler/icons-react";
|
import { IconCheck, IconCopy } from "@tabler/icons-react";
|
||||||
import classes from "./code-block.module.css";
|
import classes from "./code-block.module.css";
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import type { EditorView } from "@tiptap/pm/view";
|
|
||||||
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
|
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
|
||||||
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
|
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
|
||||||
import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
|
import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
|
||||||
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
|
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
|
||||||
import { Slice } from "@tiptap/pm/model";
|
|
||||||
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
|
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
|
||||||
|
import { Editor } from "@tiptap/core";
|
||||||
|
|
||||||
export const handlePaste = (
|
export const handlePaste = (
|
||||||
view: EditorView,
|
editor: Editor,
|
||||||
event: ClipboardEvent,
|
event: ClipboardEvent,
|
||||||
pageId: string,
|
pageId: string,
|
||||||
creatorId?: string,
|
creatorId?: string,
|
||||||
@@ -18,7 +17,7 @@ export const handlePaste = (
|
|||||||
// we have to do this validation here to allow the default link extension to takeover if needs be
|
// we have to do this validation here to allow the default link extension to takeover if needs be
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const url = clipboardData.trim();
|
const url = clipboardData.trim();
|
||||||
const { from: pos, empty } = view.state.selection;
|
const { from: pos, empty } = editor.state.selection;
|
||||||
const match = INTERNAL_LINK_REGEX.exec(url);
|
const match = INTERNAL_LINK_REGEX.exec(url);
|
||||||
const currentPageMatch = INTERNAL_LINK_REGEX.exec(window.location.href);
|
const currentPageMatch = INTERNAL_LINK_REGEX.exec(window.location.href);
|
||||||
|
|
||||||
@@ -34,19 +33,27 @@ export const handlePaste = (
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const anchorId = match[6] ? match[6].split('#')[0] : undefined;
|
const anchorId = match[6] ? match[6].split("#")[0] : undefined;
|
||||||
const urlWithoutAnchor = anchorId ? url.substring(0, url.indexOf("#")) : url;
|
const urlWithoutAnchor = anchorId
|
||||||
createMentionAction(urlWithoutAnchor, view, pos, creatorId, anchorId);
|
? url.substring(0, url.indexOf("#"))
|
||||||
|
: url;
|
||||||
|
createMentionAction(
|
||||||
|
urlWithoutAnchor,
|
||||||
|
editor.view,
|
||||||
|
pos,
|
||||||
|
creatorId,
|
||||||
|
anchorId,
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.clipboardData?.files.length) {
|
if (event.clipboardData?.files.length) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
for (const file of event.clipboardData.files) {
|
for (const file of event.clipboardData.files) {
|
||||||
const pos = view.state.selection.from;
|
const pos = editor.state.selection.from;
|
||||||
uploadImageAction(file, view, pos, pageId);
|
uploadImageAction(file, editor, pos, pageId);
|
||||||
uploadVideoAction(file, view, pos, pageId);
|
uploadVideoAction(file, editor, pos, pageId);
|
||||||
uploadAttachmentAction(file, view, pos, pageId);
|
uploadAttachmentAction(file, editor, pos, pageId);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -54,7 +61,7 @@ export const handlePaste = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const handleFileDrop = (
|
export const handleFileDrop = (
|
||||||
view: EditorView,
|
editor: Editor,
|
||||||
event: DragEvent,
|
event: DragEvent,
|
||||||
moved: boolean,
|
moved: boolean,
|
||||||
pageId: string,
|
pageId: string,
|
||||||
@@ -63,14 +70,14 @@ export const handleFileDrop = (
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
for (const file of event.dataTransfer.files) {
|
for (const file of event.dataTransfer.files) {
|
||||||
const coordinates = view.posAtCoords({
|
const coordinates = editor.view.posAtCoords({
|
||||||
left: event.clientX,
|
left: event.clientX,
|
||||||
top: event.clientY,
|
top: event.clientY,
|
||||||
});
|
});
|
||||||
|
|
||||||
uploadImageAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
|
uploadImageAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
|
||||||
uploadVideoAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
|
uploadVideoAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
|
||||||
uploadAttachmentAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
|
uploadAttachmentAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return editor.isActive("image");
|
return editor.isActive("image") && editor.getAttributes("image").src;
|
||||||
},
|
},
|
||||||
[editor],
|
[editor],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
.imageWrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: pulse 1.2s ease-in-out infinite;
|
||||||
|
|
||||||
|
@mixin light {
|
||||||
|
background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%);
|
||||||
|
background-size: 400% 400%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin dark {
|
||||||
|
background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%);
|
||||||
|
background-size: 400% 400%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
background-position: 0% 0%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -135% 0%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,30 +1,70 @@
|
|||||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
|
import { Group, Image, Loader, Text } from "@mantine/core";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Image } from "@mantine/core";
|
|
||||||
import { getFileUrl } from "@/lib/config.ts";
|
import { getFileUrl } from "@/lib/config.ts";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import classes from "./image-view.module.css";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function ImageView(props: NodeViewProps) {
|
export default function ImageView(props: NodeViewProps) {
|
||||||
const { node, selected } = props;
|
const { t } = useTranslation();
|
||||||
const { src, width, align, title } = node.attrs;
|
const { editor, node, selected } = props;
|
||||||
|
const { src, width, align, title, aspectRatio, placeholder } = node.attrs;
|
||||||
const alignClass = useMemo(() => {
|
const alignClass = useMemo(() => {
|
||||||
if (align === "left") return "alignLeft";
|
if (align === "left") return "alignLeft";
|
||||||
if (align === "right") return "alignRight";
|
if (align === "right") return "alignRight";
|
||||||
if (align === "center") return "alignCenter";
|
if (align === "center") return "alignCenter";
|
||||||
return "alignCenter";
|
return "alignCenter";
|
||||||
}, [align]);
|
}, [align]);
|
||||||
|
const previewSrc = useMemo(() => {
|
||||||
|
editor.storage.shared.imagePreviews =
|
||||||
|
editor.storage.shared.imagePreviews || {};
|
||||||
|
|
||||||
|
if (placeholder?.id) {
|
||||||
|
return editor.storage.shared.imagePreviews[placeholder.id];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, [placeholder, editor]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper data-drag-handle>
|
<NodeViewWrapper data-drag-handle>
|
||||||
<Image
|
<div
|
||||||
radius="md"
|
className={clsx(
|
||||||
fit="contain"
|
selected && "ProseMirror-selectednode",
|
||||||
w={width}
|
classes.imageWrapper,
|
||||||
src={getFileUrl(src)}
|
alignClass,
|
||||||
alt={title}
|
)}
|
||||||
className={clsx(selected ? "ProseMirror-selectednode" : "", alignClass)}
|
style={{
|
||||||
/>
|
aspectRatio: aspectRatio ? aspectRatio : src ? undefined : "16 / 9",
|
||||||
|
width,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{src && (
|
||||||
|
<Image radius="md" fit="contain" src={getFileUrl(src)} alt={title} />
|
||||||
|
)}
|
||||||
|
{!src && previewSrc && (
|
||||||
|
<Group pos="relative" h="100%" w="100%">
|
||||||
|
<Image
|
||||||
|
radius="md"
|
||||||
|
fit="contain"
|
||||||
|
src={previewSrc}
|
||||||
|
alt={placeholder?.name}
|
||||||
|
/>
|
||||||
|
<Loader size={20} pos="absolute" bottom={6} right={6} />
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
{!src && !previewSrc && (
|
||||||
|
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md">
|
||||||
|
<Loader size={20} style={{ flexShrink: 0 }} />
|
||||||
|
<Text component="span" size="sm" truncate="end">
|
||||||
|
{placeholder?.name
|
||||||
|
? t("Uploading {{name}}", { name: placeholder.name })
|
||||||
|
: t("Uploading file")}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
|
|
||||||
const enterHandler = () => {
|
const enterHandler = () => {
|
||||||
if (!renderItems.length) return;
|
if (!renderItems.length) return;
|
||||||
if (renderItems[selectedIndex].entityType !== "header") {
|
if (renderItems[selectedIndex]?.entityType !== "header") {
|
||||||
selectItem(selectedIndex);
|
selectItem(selectedIndex);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -204,7 +204,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
parentPageId: page.id || null,
|
parentPageId: page.id || null,
|
||||||
title: title
|
title: title
|
||||||
};
|
};
|
||||||
|
|
||||||
let createdPage: IPage;
|
let createdPage: IPage;
|
||||||
try {
|
try {
|
||||||
createdPage = await createPageMutation.mutateAsync(payload);
|
createdPage = await createPageMutation.mutateAsync(payload);
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ const mentionRenderItems = () => {
|
|||||||
{
|
{
|
||||||
placement: "bottom-start",
|
placement: "bottom-start",
|
||||||
middleware: [offset(0), flip(), shift()],
|
middleware: [offset(0), flip(), shift()],
|
||||||
}
|
},
|
||||||
).then(({ x, y }) => {
|
).then(({ x, y }) => {
|
||||||
Object.assign(element.style, {
|
Object.assign(element.style, {
|
||||||
left: `${x}px`,
|
left: `${x}px`,
|
||||||
@@ -86,7 +86,7 @@ const mentionRenderItems = () => {
|
|||||||
zIndex: "9999",
|
zIndex: "9999",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onUpdate: (props: {
|
onUpdate: (props: {
|
||||||
@@ -115,23 +115,30 @@ const mentionRenderItems = () => {
|
|||||||
|
|
||||||
// destroy component if space is greater 3 without a match
|
// destroy component if space is greater 3 without a match
|
||||||
if (
|
if (
|
||||||
whitespaceCount > 3 &&
|
whitespaceCount > 4 &&
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
props.editor.storage.mentionItems.length === 0
|
props.editor.storage.mentionItems.length === 1
|
||||||
) {
|
) {
|
||||||
destroy();
|
destroy();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// fallback exit
|
||||||
|
if (whitespaceCount > 7) {
|
||||||
|
destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||||
if (props.event.key)
|
if (props.event.key === "Escape") {
|
||||||
if (
|
destroy();
|
||||||
props.event.key === "Escape" ||
|
return true;
|
||||||
(props.event.key === "Enter" && !component)
|
}
|
||||||
) {
|
|
||||||
destroy();
|
if (props.event.key === "Enter" && !component) {
|
||||||
return false;
|
destroy();
|
||||||
}
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return (component?.ref as any)?.onKeyDown(props);
|
return (component?.ref as any)?.onKeyDown(props);
|
||||||
},
|
},
|
||||||
onExit: () => {
|
onExit: () => {
|
||||||
|
|||||||
@@ -170,13 +170,18 @@ 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) {
|
||||||
const pos = editor.view.state.selection.from;
|
const pos = editor.view.state.selection.from;
|
||||||
uploadImageAction(file, editor.view, pos, pageId);
|
|
||||||
|
uploadImageAction(file, editor, pos, pageId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input.remove();
|
||||||
};
|
};
|
||||||
input.click();
|
input.click();
|
||||||
},
|
},
|
||||||
@@ -197,12 +202,19 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
const input = document.createElement("input");
|
const input = document.createElement("input");
|
||||||
input.type = "file";
|
input.type = "file";
|
||||||
input.accept = "video/*";
|
input.accept = "video/*";
|
||||||
|
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) {
|
||||||
const file = input.files[0];
|
for (const file of input.files) {
|
||||||
const pos = editor.view.state.selection.from;
|
const pos = editor.view.state.selection.from;
|
||||||
uploadVideoAction(file, editor.view, pos, pageId);
|
|
||||||
|
uploadVideoAction(file, editor, pos, pageId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input.remove();
|
||||||
};
|
};
|
||||||
input.click();
|
input.click();
|
||||||
},
|
},
|
||||||
@@ -223,12 +235,19 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
const input = document.createElement("input");
|
const input = document.createElement("input");
|
||||||
input.type = "file";
|
input.type = "file";
|
||||||
input.accept = "";
|
input.accept = "";
|
||||||
|
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) {
|
||||||
const file = input.files[0];
|
for (const file of input.files) {
|
||||||
const pos = editor.view.state.selection.from;
|
const pos = editor.view.state.selection.from;
|
||||||
uploadAttachmentAction(file, editor.view, pos, pageId, true);
|
|
||||||
|
uploadAttachmentAction(file, editor, pos, pageId, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input.remove();
|
||||||
};
|
};
|
||||||
input.click();
|
input.click();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
|
|
||||||
const editorState = useEditorState({
|
const editorState = useEditorState({
|
||||||
editor,
|
editor,
|
||||||
selector: ctx => {
|
selector: (ctx) => {
|
||||||
if (!ctx.editor) {
|
if (!ctx.editor) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -43,7 +43,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return editor.isActive("video");
|
return editor.isActive("video") && editor.getAttributes("video").src;
|
||||||
},
|
},
|
||||||
[editor],
|
[editor],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
.videoWrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: pulse 1.2s ease-in-out infinite;
|
||||||
|
|
||||||
|
@mixin light {
|
||||||
|
background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%);
|
||||||
|
background-size: 400% 400%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin dark {
|
||||||
|
background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%);
|
||||||
|
background-size: 400% 400%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
background-position: 0% 0%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -135% 0%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.video {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
@@ -1,29 +1,75 @@
|
|||||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
|
import { Group, Loader, Text } from "@mantine/core";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { getFileUrl } from "@/lib/config.ts";
|
import { getFileUrl } from "@/lib/config.ts";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import classes from "./video-view.module.css";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function VideoView(props: NodeViewProps) {
|
export default function VideoView(props: NodeViewProps) {
|
||||||
const { node, selected } = props;
|
const { t } = useTranslation();
|
||||||
const { src, width, align } = node.attrs;
|
const { editor, node, selected } = props;
|
||||||
|
const { src, width, align, aspectRatio, placeholder } = node.attrs;
|
||||||
const alignClass = useMemo(() => {
|
const alignClass = useMemo(() => {
|
||||||
if (align === "left") return "alignLeft";
|
if (align === "left") return "alignLeft";
|
||||||
if (align === "right") return "alignRight";
|
if (align === "right") return "alignRight";
|
||||||
if (align === "center") return "alignCenter";
|
if (align === "center") return "alignCenter";
|
||||||
return "alignCenter";
|
return "alignCenter";
|
||||||
}, [align]);
|
}, [align]);
|
||||||
|
const previewSrc = useMemo(() => {
|
||||||
|
editor.storage.shared.videoPreviews =
|
||||||
|
editor.storage.shared.videoPreviews || {};
|
||||||
|
|
||||||
|
if (placeholder?.id) {
|
||||||
|
return editor.storage.shared.videoPreviews[placeholder.id];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, [placeholder, editor]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper data-drag-handle>
|
<NodeViewWrapper data-drag-handle>
|
||||||
<video
|
<div
|
||||||
preload="metadata"
|
className={clsx(
|
||||||
width={width}
|
selected && "ProseMirror-selectednode",
|
||||||
controls
|
classes.videoWrapper,
|
||||||
src={getFileUrl(src)}
|
alignClass,
|
||||||
className={clsx(selected ? "ProseMirror-selectednode" : "", alignClass)}
|
)}
|
||||||
style={{ display: "block" }}
|
style={{
|
||||||
/>
|
aspectRatio: aspectRatio ? aspectRatio : src ? undefined : "16 / 9",
|
||||||
|
width,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{src && (
|
||||||
|
<video
|
||||||
|
className={classes.video}
|
||||||
|
preload="metadata"
|
||||||
|
controls
|
||||||
|
src={getFileUrl(src)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!src && previewSrc && (
|
||||||
|
<Group pos="relative" h="100%" w="100%">
|
||||||
|
<video
|
||||||
|
className={classes.video}
|
||||||
|
preload="metadata"
|
||||||
|
controls
|
||||||
|
src={previewSrc}
|
||||||
|
/>
|
||||||
|
<Loader size={20} pos="absolute" top={6} right={6} />
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
{!src && !previewSrc && (
|
||||||
|
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md">
|
||||||
|
<Loader size={20} style={{ flexShrink: 0 }} />
|
||||||
|
<Text component="span" size="sm" truncate="end">
|
||||||
|
{placeholder?.name
|
||||||
|
? t("Uploading {{name}}", { name: placeholder.name })
|
||||||
|
: t("Uploading file")}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
Heading,
|
Heading,
|
||||||
Highlight,
|
Highlight,
|
||||||
UniqueID,
|
UniqueID,
|
||||||
|
SharedStorage,
|
||||||
} from "@docmost/editor-ext";
|
} from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
randomElement,
|
randomElement,
|
||||||
@@ -107,6 +108,7 @@ export const mainExtensions = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
SharedStorage,
|
||||||
Heading,
|
Heading,
|
||||||
UniqueID.configure({
|
UniqueID.configure({
|
||||||
types: ["heading", "paragraph"],
|
types: ["heading", "paragraph"],
|
||||||
@@ -246,7 +248,7 @@ export const mainExtensions = [
|
|||||||
Escape: () => {
|
Escape: () => {
|
||||||
const event = new CustomEvent("closeFindDialogFromEditor", {});
|
const event = new CustomEvent("closeFindDialogFromEditor", {});
|
||||||
document.dispatchEvent(event);
|
document.dispatchEvent(event);
|
||||||
return true;
|
return false;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
onSyncedParameters,
|
onSyncedParameters,
|
||||||
} from "@hocuspocus/provider";
|
} from "@hocuspocus/provider";
|
||||||
import {
|
import {
|
||||||
|
Editor,
|
||||||
EditorContent,
|
EditorContent,
|
||||||
EditorProvider,
|
EditorProvider,
|
||||||
useEditor,
|
useEditor,
|
||||||
@@ -79,7 +80,7 @@ export default function PageEditor({
|
|||||||
}: PageEditorProps) {
|
}: PageEditorProps) {
|
||||||
const collaborationURL = useCollaborationUrl();
|
const collaborationURL = useCollaborationUrl();
|
||||||
const isComponentMounted = useRef(false);
|
const isComponentMounted = useRef(false);
|
||||||
const editorCreated = useRef(false);
|
const editorRef = useRef<Editor | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isComponentMounted.current = true;
|
isComponentMounted.current = true;
|
||||||
@@ -93,7 +94,7 @@ export default function PageEditor({
|
|||||||
const [isLocalSynced, setIsLocalSynced] = useState(false);
|
const [isLocalSynced, setIsLocalSynced] = useState(false);
|
||||||
const [isRemoteSynced, setIsRemoteSynced] = useState(false);
|
const [isRemoteSynced, setIsRemoteSynced] = useState(false);
|
||||||
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
||||||
yjsConnectionStatusAtom
|
yjsConnectionStatusAtom,
|
||||||
);
|
);
|
||||||
const menuContainerRef = useRef(null);
|
const menuContainerRef = useRef(null);
|
||||||
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
|
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
|
||||||
@@ -104,8 +105,8 @@ export default function PageEditor({
|
|||||||
const userPageEditMode =
|
const userPageEditMode =
|
||||||
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
||||||
const canScroll = useCallback(
|
const canScroll = useCallback(
|
||||||
() => isComponentMounted.current && editorCreated.current,
|
() => Boolean(isComponentMounted.current && editorRef.current),
|
||||||
[isComponentMounted, editorCreated]
|
[isComponentMounted],
|
||||||
);
|
);
|
||||||
const { handleScrollTo } = useEditorScroll({ canScroll });
|
const { handleScrollTo } = useEditorScroll({ canScroll });
|
||||||
// Providers only created once per pageId
|
// Providers only created once per pageId
|
||||||
@@ -253,10 +254,21 @@ export default function PageEditor({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
handlePaste: (view, event, slice) =>
|
handlePaste: (_view, event) => {
|
||||||
handlePaste(view, event, pageId, currentUser?.user.id),
|
if (!editorRef.current) return false;
|
||||||
handleDrop: (view, event, _slice, moved) =>
|
|
||||||
handleFileDrop(view, event, moved, pageId),
|
return handlePaste(
|
||||||
|
editorRef.current,
|
||||||
|
event,
|
||||||
|
pageId,
|
||||||
|
currentUser?.user.id,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
handleDrop: (_view, event, _slice, moved) => {
|
||||||
|
if (!editorRef.current) return false;
|
||||||
|
|
||||||
|
return handleFileDrop(editorRef.current, event, moved, pageId);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
onCreate({ editor }) {
|
onCreate({ editor }) {
|
||||||
if (editor) {
|
if (editor) {
|
||||||
@@ -265,7 +277,7 @@ export default function PageEditor({
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
editor.storage.pageId = pageId;
|
editor.storage.pageId = pageId;
|
||||||
handleScrollTo(editor);
|
handleScrollTo(editor);
|
||||||
editorCreated.current = true;
|
editorRef.current = editor;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onUpdate({ editor }) {
|
onUpdate({ editor }) {
|
||||||
@@ -275,7 +287,7 @@ export default function PageEditor({
|
|||||||
debouncedUpdateContent(editorJson);
|
debouncedUpdateContent(editorJson);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[pageId, editable, extensions]
|
[pageId, editable, extensions],
|
||||||
);
|
);
|
||||||
|
|
||||||
const editorIsEditable = useEditorState({
|
const editorIsEditable = useEditorState({
|
||||||
@@ -320,7 +332,7 @@ export default function PageEditor({
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener(
|
document.removeEventListener(
|
||||||
"ACTIVE_COMMENT_EVENT",
|
"ACTIVE_COMMENT_EVENT",
|
||||||
handleActiveCommentEvent
|
handleActiveCommentEvent,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -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,25 +1,25 @@
|
|||||||
import { Table, Group, Text, Anchor } from "@mantine/core";
|
import { Table, Group, Text, Anchor } from "@mantine/core";
|
||||||
import { useGetGroupsQuery } from "@/features/group/queries/group-query";
|
import { useGetGroupsQuery } from "@/features/group/queries/group-query";
|
||||||
import { useState } from "react";
|
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
||||||
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
|
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { formatMemberCount } from "@/lib";
|
import { formatMemberCount } from "@/lib";
|
||||||
import { IGroup } from "@/features/group/types/group.types.ts";
|
import { IGroup } from "@/features/group/types/group.types.ts";
|
||||||
import Paginate from "@/components/common/paginate.tsx";
|
import Paginate from "@/components/common/paginate.tsx";
|
||||||
import { queryClient } from "@/main.tsx";
|
import { queryClient } from "@/main.tsx";
|
||||||
import { getSpaces } from "@/features/space/services/space-service.ts";
|
|
||||||
import { getGroupMembers } from "@/features/group/services/group-service.ts";
|
import { getGroupMembers } from "@/features/group/services/group-service.ts";
|
||||||
|
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
|
||||||
|
|
||||||
export default function GroupList() {
|
export default function GroupList() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [page, setPage] = useState(1);
|
const { cursor, goNext, goPrev } = useCursorPaginate();
|
||||||
const { data, isLoading } = useGetGroupsQuery({ page });
|
const { data, isLoading } = useGetGroupsQuery({ cursor });
|
||||||
|
|
||||||
const prefetchGroupMembers = (groupId: string) => {
|
const prefetchGroupMembers = (groupId: string) => {
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
queryKey: ["groupMembers", groupId, { page: 1 }],
|
queryKey: ["groupMembers", groupId, {}],
|
||||||
queryFn: () => getGroupMembers(groupId, { page: 1 }),
|
queryFn: () => getGroupMembers(groupId, {}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -51,9 +51,9 @@ export default function GroupList() {
|
|||||||
<Group gap="sm" wrap="nowrap">
|
<Group gap="sm" wrap="nowrap">
|
||||||
<IconGroupCircle />
|
<IconGroupCircle />
|
||||||
<div style={{ minWidth: 0, overflow: "hidden" }}>
|
<div style={{ minWidth: 0, overflow: "hidden" }}>
|
||||||
<Text fz="sm" fw={500} lineClamp={1}>
|
<AutoTooltipText fz="sm" fw={500} lineClamp={1}>
|
||||||
{group.name}
|
{group.name}
|
||||||
</Text>
|
</AutoTooltipText>
|
||||||
<Text fz="xs" c="dimmed" lineClamp={2}>
|
<Text fz="xs" c="dimmed" lineClamp={2}>
|
||||||
{group.description}
|
{group.description}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -84,10 +84,10 @@ export default function GroupList() {
|
|||||||
|
|
||||||
{data?.items.length > 0 && (
|
{data?.items.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
currentPage={page}
|
hasPrevPage={data?.meta?.hasPrevPage}
|
||||||
hasPrevPage={data?.meta.hasPrevPage}
|
hasNextPage={data?.meta?.hasNextPage}
|
||||||
hasNextPage={data?.meta.hasNextPage}
|
onNext={() => goNext(data?.meta?.nextCursor)}
|
||||||
onPageChange={setPage}
|
onPrev={goPrev}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
useRemoveGroupMemberMutation,
|
useRemoveGroupMemberMutation,
|
||||||
} from "@/features/group/queries/group-query";
|
} from "@/features/group/queries/group-query";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import React, { useState } from "react";
|
import React from "react";
|
||||||
import { IconDots } from "@tabler/icons-react";
|
import { IconDots } from "@tabler/icons-react";
|
||||||
import { modals } from "@mantine/modals";
|
import { modals } from "@mantine/modals";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
@@ -12,12 +12,13 @@ import useUserRole from "@/hooks/use-user-role.tsx";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { IUser } from "@/features/user/types/user.types.ts";
|
import { IUser } from "@/features/user/types/user.types.ts";
|
||||||
import Paginate from "@/components/common/paginate.tsx";
|
import Paginate from "@/components/common/paginate.tsx";
|
||||||
|
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
||||||
|
|
||||||
export default function GroupMembersList() {
|
export default function GroupMembersList() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { groupId } = useParams();
|
const { groupId } = useParams();
|
||||||
const [page, setPage] = useState(1);
|
const { cursor, goNext, goPrev } = useCursorPaginate();
|
||||||
const { data, isLoading } = useGroupMembersQuery(groupId, { page });
|
const { data, isLoading } = useGroupMembersQuery(groupId, { cursor });
|
||||||
const removeGroupMember = useRemoveGroupMemberMutation();
|
const removeGroupMember = useRemoveGroupMemberMutation();
|
||||||
const { isAdmin } = useUserRole();
|
const { isAdmin } = useUserRole();
|
||||||
|
|
||||||
@@ -107,10 +108,10 @@ export default function GroupMembersList() {
|
|||||||
|
|
||||||
{data?.items.length > 0 && (
|
{data?.items.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
currentPage={page}
|
hasPrevPage={data?.meta?.hasPrevPage}
|
||||||
hasPrevPage={data?.meta.hasPrevPage}
|
hasNextPage={data?.meta?.hasNextPage}
|
||||||
hasNextPage={data?.meta.hasNextPage}
|
onNext={() => goNext(data?.meta?.nextCursor)}
|
||||||
onPageChange={setPage}
|
onPrev={goPrev}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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,33 +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 { 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 && <EditorContent editor={editor} />}
|
editor={editor}
|
||||||
</div>
|
className={historyClasses.historyEditor}
|
||||||
</>
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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";
|
||||||
|
|
||||||
|
const MAX_VISIBLE_AVATARS = 5;
|
||||||
|
|
||||||
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]);
|
||||||
|
|
||||||
|
const contributors = historyItem.contributors;
|
||||||
|
const hasContributors = contributors && contributors.length > 0;
|
||||||
|
|
||||||
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">
|
<Text size="sm">{formattedDate(new Date(historyItem.createdAt))}</Text>
|
||||||
<div>
|
|
||||||
<Text size="sm">
|
|
||||||
{formattedDate(new Date(historyItem.createdAt))}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<div style={{ flex: 1 }}>
|
<Group gap={6} wrap="nowrap" mt={4}>
|
||||||
<Group gap={4} wrap="nowrap">
|
{hasContributors ? (
|
||||||
<CustomAvatar
|
<>
|
||||||
size="sm"
|
<Tooltip.Group openDelay={300} closeDelay={100}>
|
||||||
avatarUrl={historyItem.lastUpdatedBy.avatarUrl}
|
<Avatar.Group spacing={8}>
|
||||||
name={historyItem.lastUpdatedBy.name}
|
{contributors.slice(0, MAX_VISIBLE_AVATARS).map((contributor) => (
|
||||||
/>
|
<Tooltip key={contributor.id} label={contributor.name} withArrow>
|
||||||
|
<CustomAvatar
|
||||||
|
size="sm"
|
||||||
|
avatarUrl={contributor.avatarUrl}
|
||||||
|
name={contributor.name}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
{contributors.length > MAX_VISIBLE_AVATARS && (
|
||||||
|
<Tooltip
|
||||||
|
withArrow
|
||||||
|
label={contributors.slice(MAX_VISIBLE_AVATARS).map((c) => (
|
||||||
|
<div key={c.id}>{c.name}</div>
|
||||||
|
))}
|
||||||
|
>
|
||||||
|
<Avatar size="sm" color="gray">
|
||||||
|
+{contributors.length - MAX_VISIBLE_AVATARS}
|
||||||
|
</Avatar>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Avatar.Group>
|
||||||
|
</Tooltip.Group>
|
||||||
|
{contributors.length === 1 && (
|
||||||
<Text size="sm" c="dimmed" lineClamp={1}>
|
<Text size="sm" c="dimmed" lineClamp={1}>
|
||||||
{historyItem.lastUpdatedBy.name}
|
{contributors[0].name}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
)}
|
||||||
</div>
|
</>
|
||||||
</div>
|
) : (
|
||||||
|
<>
|
||||||
|
<CustomAvatar
|
||||||
|
size="sm"
|
||||||
|
avatarUrl={historyItem.lastUpdatedBy?.avatarUrl}
|
||||||
|
name={historyItem.lastUpdatedBy?.name}
|
||||||
|
/>
|
||||||
|
<Text size="sm" c="dimmed" lineClamp={1}>
|
||||||
|
{historyItem.lastUpdatedBy?.name}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</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,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<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) => {
|
||||||
|
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 (
|
||||||
|
<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,37 +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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,4 +18,5 @@ export interface IPageHistory {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
lastUpdatedBy: IPageHistoryUser;
|
lastUpdatedBy: IPageHistoryUser;
|
||||||
|
contributors?: IPageHistoryUser[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,22 +7,18 @@ import {
|
|||||||
IconHistory,
|
IconHistory,
|
||||||
IconLink,
|
IconLink,
|
||||||
IconList,
|
IconList,
|
||||||
|
IconMarkdown,
|
||||||
IconMessage,
|
IconMessage,
|
||||||
IconPrinter,
|
IconPrinter,
|
||||||
IconSearch,
|
|
||||||
IconTrash,
|
IconTrash,
|
||||||
IconWifiOff,
|
IconWifiOff,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import React from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||||
import {
|
import { useDisclosure, useHotkeys } from "@mantine/hooks";
|
||||||
getHotkeyHandler,
|
import { useClipboard } from "@/hooks/use-clipboard";
|
||||||
useClipboard,
|
|
||||||
useDisclosure,
|
|
||||||
useHotkeys,
|
|
||||||
} from "@mantine/hooks";
|
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
@@ -34,12 +30,12 @@ import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.
|
|||||||
import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
|
import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import ExportModal from "@/components/common/export-modal";
|
import ExportModal from "@/components/common/export-modal";
|
||||||
|
import { htmlToMarkdown } from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||||
import { searchAndReplaceStateAtom } from "@/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts";
|
import { formattedDate } from "@/lib/time.ts";
|
||||||
import { formattedDate, timeAgo } from "@/lib/time.ts";
|
|
||||||
import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx";
|
import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx";
|
||||||
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
||||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||||
@@ -51,7 +47,6 @@ interface PageHeaderMenuProps {
|
|||||||
export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const toggleAside = useToggleAside();
|
const toggleAside = useToggleAside();
|
||||||
const [yjsConnectionStatus] = useAtom(yjsConnectionStatusAtom);
|
|
||||||
|
|
||||||
useHotkeys(
|
useHotkeys(
|
||||||
[
|
[
|
||||||
@@ -68,6 +63,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
|||||||
const event = new CustomEvent("closeFindDialogFromEditor", {});
|
const event = new CustomEvent("closeFindDialogFromEditor", {});
|
||||||
document.dispatchEvent(event);
|
document.dispatchEvent(event);
|
||||||
},
|
},
|
||||||
|
{ preventDefault: false },
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
@@ -75,17 +71,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{yjsConnectionStatus === "disconnected" && (
|
<ConnectionWarning />
|
||||||
<Tooltip
|
|
||||||
label={t("Real-time editor connection lost. Retrying...")}
|
|
||||||
openDelay={250}
|
|
||||||
withArrow
|
|
||||||
>
|
|
||||||
<ActionIcon variant="default" c="red" style={{ border: "none" }}>
|
|
||||||
<IconWifiOff size={20} stroke={2} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!readOnly && <PageStateSegmentedControl size="xs" />}
|
{!readOnly && <PageStateSegmentedControl size="xs" />}
|
||||||
|
|
||||||
@@ -146,6 +132,15 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
notifications.show({ message: t("Link copied") });
|
notifications.show({ message: t("Link copied") });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCopyAsMarkdown = () => {
|
||||||
|
if (!pageEditor) return;
|
||||||
|
const html = pageEditor.getHTML();
|
||||||
|
const markdown = htmlToMarkdown(html);
|
||||||
|
const title = page?.title ? `# ${page.title}\n\n` : "";
|
||||||
|
clipboard.copy(`${title}${markdown}`);
|
||||||
|
notifications.show({ message: t("Copied") });
|
||||||
|
};
|
||||||
|
|
||||||
const handlePrint = () => {
|
const handlePrint = () => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.print();
|
window.print();
|
||||||
@@ -183,6 +178,13 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
>
|
>
|
||||||
{t("Copy link")}
|
{t("Copy link")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconMarkdown size={16} />}
|
||||||
|
onClick={handleCopyAsMarkdown}
|
||||||
|
>
|
||||||
|
{t("Copy as Markdown")}
|
||||||
|
</Menu.Item>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|
||||||
<Menu.Item leftSection={<IconArrowsHorizontal size={16} />}>
|
<Menu.Item leftSection={<IconArrowsHorizontal size={16} />}>
|
||||||
@@ -290,3 +292,51 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ConnectionWarning() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const yjsConnectionStatus = useAtomValue(yjsConnectionStatusAtom);
|
||||||
|
const [showWarning, setShowWarning] = useState(false);
|
||||||
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const isDisconnected = ["disconnected", "connecting"].includes(
|
||||||
|
yjsConnectionStatus,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isDisconnected) {
|
||||||
|
if (!timeoutRef.current) {
|
||||||
|
timeoutRef.current = setTimeout(() => setShowWarning(true), 5000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}
|
||||||
|
setShowWarning(false);
|
||||||
|
}
|
||||||
|
}, [yjsConnectionStatus]);
|
||||||
|
|
||||||
|
// Cleanup only on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!showWarning) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
label={t("Real-time editor connection lost. Retrying...")}
|
||||||
|
openDelay={250}
|
||||||
|
withArrow
|
||||||
|
>
|
||||||
|
<ActionIcon variant="default" c="red" style={{ border: "none" }}>
|
||||||
|
<IconWifiOff size={20} stroke={2} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -172,6 +175,10 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
queryKey: ["root-sidebar-pages", fileTask.spaceId],
|
queryKey: ["root-sidebar-pages", fileTask.spaceId],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: ["recent-changes", fileTask.spaceId],
|
||||||
|
});
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
emit({
|
emit({
|
||||||
operation: "refetchRootTreeNodeEvent",
|
operation: "refetchRootTreeNodeEvent",
|
||||||
@@ -261,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")}`;
|
||||||
@@ -317,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"
|
||||||
|
|||||||
@@ -163,9 +163,6 @@ export function useDeletePageMutation() {
|
|||||||
export function useMovePageMutation() {
|
export function useMovePageMutation() {
|
||||||
return useMutation<void, Error, IMovePage>({
|
return useMutation<void, Error, IMovePage>({
|
||||||
mutationFn: (data) => movePage(data),
|
mutationFn: (data) => movePage(data),
|
||||||
onSuccess: () => {
|
|
||||||
invalidateOnMovePage();
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,12 +250,10 @@ export function useGetSidebarPagesQuery(
|
|||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: ["sidebar-pages", data],
|
queryKey: ["sidebar-pages", data],
|
||||||
enabled: !!data?.pageId || !!data?.spaceId,
|
enabled: !!data?.pageId || !!data?.spaceId,
|
||||||
queryFn: ({ pageParam }) => getSidebarPages({ ...data, page: pageParam }),
|
queryFn: ({ pageParam }) => getSidebarPages({ ...data, cursor: pageParam }),
|
||||||
initialPageParam: 1,
|
initialPageParam: undefined,
|
||||||
getPreviousPageParam: (firstPage) =>
|
|
||||||
firstPage.meta.hasPrevPage ? firstPage.meta.page - 1 : undefined,
|
|
||||||
getNextPageParam: (lastPage) =>
|
getNextPageParam: (lastPage) =>
|
||||||
lastPage.meta.hasNextPage ? lastPage.meta.page + 1 : undefined,
|
lastPage.meta?.nextCursor ?? undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,13 +261,11 @@ export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) {
|
|||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: ["root-sidebar-pages", data.spaceId],
|
queryKey: ["root-sidebar-pages", data.spaceId],
|
||||||
queryFn: async ({ pageParam }) => {
|
queryFn: async ({ pageParam }) => {
|
||||||
return getSidebarPages({ spaceId: data.spaceId, page: pageParam });
|
return getSidebarPages({ spaceId: data.spaceId, cursor: pageParam });
|
||||||
},
|
},
|
||||||
initialPageParam: 1,
|
initialPageParam: undefined,
|
||||||
getPreviousPageParam: (firstPage) =>
|
|
||||||
firstPage.meta.hasPrevPage ? firstPage.meta.page - 1 : undefined,
|
|
||||||
getNextPageParam: (lastPage) =>
|
getNextPageParam: (lastPage) =>
|
||||||
lastPage.meta.hasNextPage ? lastPage.meta.page + 1 : undefined,
|
lastPage.meta?.nextCursor ?? undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,17 +451,127 @@ export function invalidateOnUpdatePage(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function invalidateOnMovePage() {
|
export function updateCacheOnMovePage(
|
||||||
//for move invalidate all sidebars for now (how to do???)
|
spaceId: string,
|
||||||
//invalidate all root sidebar pages
|
pageId: string,
|
||||||
queryClient.invalidateQueries({
|
oldParentId: string | null,
|
||||||
queryKey: ["root-sidebar-pages"],
|
newParentId: string | null,
|
||||||
});
|
pageData: Partial<IPage>,
|
||||||
//invalidate all sub sidebar pages
|
) {
|
||||||
queryClient.invalidateQueries({
|
// Remove page from old parent's cache
|
||||||
queryKey: ["sidebar-pages"],
|
const oldQueryKey =
|
||||||
});
|
oldParentId === null
|
||||||
// ---
|
? ["root-sidebar-pages", spaceId]
|
||||||
|
: ["sidebar-pages", { pageId: oldParentId, spaceId }];
|
||||||
|
|
||||||
|
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
|
||||||
|
oldQueryKey,
|
||||||
|
(old) => {
|
||||||
|
if (!old) return old;
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
pages: old.pages.map((page) => ({
|
||||||
|
...page,
|
||||||
|
items: page.items.filter((item) => item.id !== pageId),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update old parent's hasChildren flag if it has no more children
|
||||||
|
if (oldParentId !== null) {
|
||||||
|
const oldParentCache = queryClient.getQueryData<
|
||||||
|
InfiniteData<IPagination<IPage>>
|
||||||
|
>(["sidebar-pages", { pageId: oldParentId, spaceId }]);
|
||||||
|
|
||||||
|
const remainingChildren =
|
||||||
|
oldParentCache?.pages.flatMap((p) => p.items).length ?? 0;
|
||||||
|
|
||||||
|
if (remainingChildren === 0) {
|
||||||
|
// Update hasChildren in all caches where old parent appears
|
||||||
|
const allSideBarMatches = queryClient.getQueriesData({
|
||||||
|
predicate: (query) =>
|
||||||
|
query.queryKey[0] === "root-sidebar-pages" ||
|
||||||
|
query.queryKey[0] === "sidebar-pages",
|
||||||
|
});
|
||||||
|
|
||||||
|
allSideBarMatches.forEach(([key]) => {
|
||||||
|
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
|
||||||
|
key,
|
||||||
|
(old) => {
|
||||||
|
if (!old) return old;
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
pages: old.pages.map((page) => ({
|
||||||
|
...page,
|
||||||
|
items: page.items.map((item) =>
|
||||||
|
item.id === oldParentId
|
||||||
|
? { ...item, hasChildren: false }
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add page to new parent's cache
|
||||||
|
const newQueryKey =
|
||||||
|
newParentId === null
|
||||||
|
? ["root-sidebar-pages", spaceId]
|
||||||
|
: ["sidebar-pages", { pageId: newParentId, spaceId }];
|
||||||
|
|
||||||
|
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(
|
||||||
|
newQueryKey,
|
||||||
|
(old) => {
|
||||||
|
if (!old) return old;
|
||||||
|
|
||||||
|
// Check if page already exists in new location
|
||||||
|
const exists = old.pages.some((page) =>
|
||||||
|
page.items.some((item) => item.id === pageId),
|
||||||
|
);
|
||||||
|
if (exists) return old;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
pages: old.pages.map((page, index) => {
|
||||||
|
if (index === old.pages.length - 1) {
|
||||||
|
return {
|
||||||
|
...page,
|
||||||
|
items: [...page.items, pageData],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return page;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update new parent's hasChildren flag
|
||||||
|
if (newParentId !== null) {
|
||||||
|
const allSideBarMatches = queryClient.getQueriesData({
|
||||||
|
predicate: (query) =>
|
||||||
|
query.queryKey[0] === "root-sidebar-pages" ||
|
||||||
|
query.queryKey[0] === "sidebar-pages",
|
||||||
|
});
|
||||||
|
|
||||||
|
allSideBarMatches.forEach(([key]) => {
|
||||||
|
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(key, (old) => {
|
||||||
|
if (!old) return old;
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
pages: old.pages.map((page) => ({
|
||||||
|
...page,
|
||||||
|
items: page.items.map((item) =>
|
||||||
|
item.id === newParentId ? { ...item, hasChildren: true } : item,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function invalidateOnDeletePage(pageId: string) {
|
export function invalidateOnDeletePage(pageId: string) {
|
||||||
|
|||||||
@@ -72,22 +72,19 @@ export async function getSidebarPages(
|
|||||||
export async function getAllSidebarPages(
|
export async function getAllSidebarPages(
|
||||||
params: SidebarPagesParams,
|
params: SidebarPagesParams,
|
||||||
): Promise<InfiniteData<IPagination<IPage>, unknown>> {
|
): Promise<InfiniteData<IPagination<IPage>, unknown>> {
|
||||||
let page = 1;
|
let cursor: string | undefined = undefined;
|
||||||
let hasNextPage = false;
|
|
||||||
const pages: IPagination<IPage>[] = [];
|
const pages: IPagination<IPage>[] = [];
|
||||||
const pageParams: number[] = [];
|
const pageParams: (string | undefined)[] = [];
|
||||||
|
|
||||||
do {
|
do {
|
||||||
const req = await api.post("/pages/sidebar-pages", { ...params, page: page });
|
const req = await api.post("/pages/sidebar-pages", { ...params, cursor });
|
||||||
|
|
||||||
const data: IPagination<IPage> = req.data;
|
const data: IPagination<IPage> = req.data;
|
||||||
pages.push(data);
|
pages.push(data);
|
||||||
pageParams.push(page);
|
pageParams.push(cursor);
|
||||||
|
|
||||||
hasNextPage = data.meta.hasNextPage;
|
cursor = data.meta.nextCursor ?? undefined;
|
||||||
|
} while (cursor);
|
||||||
page += 1;
|
|
||||||
} while (hasNextPage);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pageParams,
|
pageParams,
|
||||||
@@ -118,7 +115,14 @@ export async function exportPage(data: IExportPageParams): Promise<void> {
|
|||||||
.split("filename=")[1]
|
.split("filename=")[1]
|
||||||
.replace(/"/g, "");
|
.replace(/"/g, "");
|
||||||
|
|
||||||
saveAs(req.data, decodeURIComponent(fileName));
|
let decodedFileName = fileName;
|
||||||
|
try {
|
||||||
|
decodedFileName = decodeURIComponent(fileName);
|
||||||
|
} catch (err) {
|
||||||
|
// fallback to raw filename
|
||||||
|
}
|
||||||
|
|
||||||
|
saveAs(req.data, decodedFileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function importPage(file: File, spaceId: string) {
|
export async function importPage(file: File, spaceId: string) {
|
||||||
|
|||||||
@@ -30,15 +30,15 @@ import { useState } from "react";
|
|||||||
import TrashPageContentModal from "@/features/page/trash/components/trash-page-content-modal";
|
import TrashPageContentModal from "@/features/page/trash/components/trash-page-content-modal";
|
||||||
import { UserInfo } from "@/components/common/user-info.tsx";
|
import { UserInfo } from "@/components/common/user-info.tsx";
|
||||||
import Paginate from "@/components/common/paginate.tsx";
|
import Paginate from "@/components/common/paginate.tsx";
|
||||||
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
|
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
||||||
|
|
||||||
export default function Trash() {
|
export default function Trash() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { spaceSlug } = useParams();
|
const { spaceSlug } = useParams();
|
||||||
const { page, setPage } = usePaginateAndSearch();
|
const { cursor, goNext, goPrev } = useCursorPaginate();
|
||||||
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
||||||
const { data: deletedPages, isLoading } = useDeletedPagesQuery(space?.id, {
|
const { data: deletedPages, isLoading } = useDeletedPagesQuery(space?.id, {
|
||||||
page, limit: 50
|
cursor, limit: 50
|
||||||
});
|
});
|
||||||
const restorePageMutation = useRestorePageMutation();
|
const restorePageMutation = useRestorePageMutation();
|
||||||
const deletePageMutation = useDeletePageMutation();
|
const deletePageMutation = useDeletePageMutation();
|
||||||
@@ -206,10 +206,10 @@ export default function Trash() {
|
|||||||
|
|
||||||
{deletedPages && deletedPages.items.length > 0 && (
|
{deletedPages && deletedPages.items.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
currentPage={page}
|
hasPrevPage={deletedPages.meta?.hasPrevPage}
|
||||||
hasPrevPage={deletedPages.meta.hasPrevPage}
|
hasNextPage={deletedPages.meta?.hasNextPage}
|
||||||
hasNextPage={deletedPages.meta.hasNextPage}
|
onNext={() => goNext(deletedPages.meta?.nextCursor)}
|
||||||
onPageChange={setPage}
|
onPrev={goPrev}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -54,11 +54,11 @@ import { IPage, SidebarPagesParams } from "@/features/page/types/page.types.ts";
|
|||||||
import { queryClient } from "@/main.tsx";
|
import { queryClient } from "@/main.tsx";
|
||||||
import { OpenMap } from "react-arborist/dist/main/state/open-slice";
|
import { OpenMap } from "react-arborist/dist/main/state/open-slice";
|
||||||
import {
|
import {
|
||||||
useClipboard,
|
|
||||||
useDisclosure,
|
useDisclosure,
|
||||||
useElementSize,
|
useElementSize,
|
||||||
useMergedRef,
|
useMergedRef,
|
||||||
} from "@mantine/hooks";
|
} from "@mantine/hooks";
|
||||||
|
import { useClipboard } from "@/hooks/use-clipboard";
|
||||||
import { dfs } from "react-arborist/dist/module/utils";
|
import { dfs } from "react-arborist/dist/module/utils";
|
||||||
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
useRemovePageMutation,
|
useRemovePageMutation,
|
||||||
useMovePageMutation,
|
useMovePageMutation,
|
||||||
useUpdatePageMutation,
|
useUpdatePageMutation,
|
||||||
|
updateCacheOnMovePage,
|
||||||
} from "@/features/page/queries/page-query.ts";
|
} from "@/features/page/queries/page-query.ts";
|
||||||
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
|
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
|
||||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||||
@@ -175,9 +176,25 @@ export function useTreeMutation<T>(spaceId: string) {
|
|||||||
parentPageId: args.parentId,
|
parentPageId: args.parentId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const draggedNode = args.dragNodes[0];
|
||||||
|
const nodeData = draggedNode.data as SpaceTreeNode;
|
||||||
|
const oldParentId = nodeData.parentPageId ?? null;
|
||||||
|
const pageData = {
|
||||||
|
id: nodeData.id,
|
||||||
|
slugId: nodeData.slugId,
|
||||||
|
title: nodeData.name,
|
||||||
|
icon: nodeData.icon,
|
||||||
|
position: newPosition,
|
||||||
|
spaceId: nodeData.spaceId,
|
||||||
|
parentPageId: args.parentId,
|
||||||
|
hasChildren: nodeData.hasChildren,
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await movePageMutation.mutateAsync(payload);
|
await movePageMutation.mutateAsync(payload);
|
||||||
|
|
||||||
|
updateCacheOnMovePage(spaceId, draggedNodeId, oldParentId, args.parentId, pageData);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
emit({
|
emit({
|
||||||
operation: "moveTreeNode",
|
operation: "moveTreeNode",
|
||||||
@@ -185,8 +202,10 @@ export function useTreeMutation<T>(spaceId: string) {
|
|||||||
payload: {
|
payload: {
|
||||||
id: draggedNodeId,
|
id: draggedNodeId,
|
||||||
parentId: args.parentId,
|
parentId: args.parentId,
|
||||||
|
oldParentId,
|
||||||
index: args.index,
|
index: args.index,
|
||||||
position: newPosition,
|
position: newPosition,
|
||||||
|
pageData,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, 50);
|
}, 50);
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export interface ICopyPageToSpace {
|
|||||||
export interface SidebarPagesParams {
|
export interface SidebarPagesParams {
|
||||||
spaceId?: string;
|
spaceId?: string;
|
||||||
pageId?: string;
|
pageId?: string;
|
||||||
page?: number; // pagination
|
cursor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IPageInput {
|
export interface IPageInput {
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
IconFileDescription,
|
IconFileDescription,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
IconCheck,
|
IconCheck,
|
||||||
IconSparkles,
|
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
@@ -26,7 +25,7 @@ import { useGetSpacesQuery } from "@/features/space/queries/space-query";
|
|||||||
import { useLicense } from "@/ee/hooks/use-license";
|
import { useLicense } from "@/ee/hooks/use-license";
|
||||||
import classes from "./search-spotlight-filters.module.css";
|
import classes from "./search-spotlight-filters.module.css";
|
||||||
import { isCloud } from "@/lib/config.ts";
|
import { isCloud } from "@/lib/config.ts";
|
||||||
import { useAtom } from "jotai/index";
|
import { useAtom } from "jotai";
|
||||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
|
|
||||||
interface SearchSpotlightFiltersProps {
|
interface SearchSpotlightFiltersProps {
|
||||||
@@ -53,7 +52,6 @@ export function SearchSpotlightFilters({
|
|||||||
const [workspace] = useAtom(workspaceAtom);
|
const [workspace] = useAtom(workspaceAtom);
|
||||||
|
|
||||||
const { data: spacesData } = useGetSpacesQuery({
|
const { data: spacesData } = useGetSpacesQuery({
|
||||||
page: 1,
|
|
||||||
limit: 100,
|
limit: 100,
|
||||||
query: debouncedSpaceQuery,
|
query: debouncedSpaceQuery,
|
||||||
});
|
});
|
||||||
@@ -265,7 +263,9 @@ export function SearchSpotlightFilters({
|
|||||||
contentType !== option.value &&
|
contentType !== option.value &&
|
||||||
handleFilterChange("contentType", option.value)
|
handleFilterChange("contentType", option.value)
|
||||||
}
|
}
|
||||||
disabled={option.disabled || (isAiMode && option.value === "attachment")}
|
disabled={
|
||||||
|
option.disabled || (isAiMode && option.value === "attachment")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Group flex="1" gap="xs">
|
<Group flex="1" gap="xs">
|
||||||
<div>
|
<div>
|
||||||
@@ -275,11 +275,13 @@ export function SearchSpotlightFilters({
|
|||||||
{t("Enterprise")}
|
{t("Enterprise")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{!option.disabled && isAiMode && option.value === "attachment" && (
|
{!option.disabled &&
|
||||||
<Text size="xs" mt={4}>
|
isAiMode &&
|
||||||
{t("Ask AI not available for attachments")}
|
option.value === "attachment" && (
|
||||||
</Text>
|
<Text size="xs" mt={4}>
|
||||||
)}
|
{t("Ask AI not available for attachments")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{contentType === option.value && <IconCheck size={20} />}
|
{contentType === option.value && <IconCheck size={20} />}
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import {
|
|||||||
export async function searchPage(
|
export async function searchPage(
|
||||||
params: IPageSearchParams,
|
params: IPageSearchParams,
|
||||||
): Promise<IPageSearch[]> {
|
): Promise<IPageSearch[]> {
|
||||||
const req = await api.post<IPageSearch[]>("/search", params);
|
const req = await api.post<{ items: IPageSearch[] }>("/search", params);
|
||||||
return req.data;
|
return req.data.items;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchSuggestions(
|
export async function searchSuggestions(
|
||||||
@@ -24,13 +24,13 @@ export async function searchSuggestions(
|
|||||||
export async function searchShare(
|
export async function searchShare(
|
||||||
params: IPageSearchParams,
|
params: IPageSearchParams,
|
||||||
): Promise<IPageSearch[]> {
|
): Promise<IPageSearch[]> {
|
||||||
const req = await api.post<IPageSearch[]>("/search/share-search", params);
|
const req = await api.post<{ items: IPageSearch[] }>("/search/share-search", params);
|
||||||
return req.data;
|
return req.data.items;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchAttachments(
|
export async function searchAttachments(
|
||||||
params: IPageSearchParams,
|
params: IPageSearchParams,
|
||||||
): Promise<IAttachmentSearch[]> {
|
): Promise<IAttachmentSearch[]> {
|
||||||
const req = await api.post<IAttachmentSearch[]>("/search-attachments", params);
|
const req = await api.post<{ items: IAttachmentSearch[] }>("/search-attachments", params);
|
||||||
return req.data;
|
return req.data.items;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
buildPageUrl,
|
buildPageUrl,
|
||||||
buildSharedPageUrl,
|
buildSharedPageUrl,
|
||||||
} from "@/features/page/page.utils.ts";
|
} from "@/features/page/page.utils.ts";
|
||||||
import { useClipboard } from "@mantine/hooks";
|
import { useClipboard } from "@/hooks/use-clipboard";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useDeleteShareMutation } from "@/features/share/queries/share-query.ts";
|
import { useDeleteShareMutation } from "@/features/share/queries/share-query.ts";
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Table, Group, Text, Anchor } from "@mantine/core";
|
import { Table, Group, Text, Anchor } from "@mantine/core";
|
||||||
import React, { useState } from "react";
|
import React from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import Paginate from "@/components/common/paginate.tsx";
|
import Paginate from "@/components/common/paginate.tsx";
|
||||||
|
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
||||||
import { useGetSharesQuery } from "@/features/share/queries/share-query.ts";
|
import { useGetSharesQuery } from "@/features/share/queries/share-query.ts";
|
||||||
import { ISharedItem } from "@/features/share/types/share.types.ts";
|
import { ISharedItem } from "@/features/share/types/share.types.ts";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
@@ -14,8 +15,8 @@ import classes from "./share.module.css";
|
|||||||
|
|
||||||
export default function ShareList() {
|
export default function ShareList() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [page, setPage] = useState(1);
|
const { cursor, goNext, goPrev } = useCursorPaginate();
|
||||||
const { data, isLoading } = useGetSharesQuery({ page });
|
const { data, isLoading } = useGetSharesQuery({ cursor });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -86,10 +87,10 @@ export default function ShareList() {
|
|||||||
|
|
||||||
{data?.items.length > 0 && (
|
{data?.items.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
currentPage={page}
|
hasPrevPage={data?.meta?.hasPrevPage}
|
||||||
hasPrevPage={data?.meta.hasPrevPage}
|
hasNextPage={data?.meta?.hasNextPage}
|
||||||
hasNextPage={data?.meta.hasNextPage}
|
onNext={() => goNext(data?.meta?.nextCursor)}
|
||||||
onPageChange={setPage}
|
onPrev={goPrev}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export function useGetSharesQuery(
|
|||||||
params?: QueryParams,
|
params?: QueryParams,
|
||||||
): UseQueryResult<IPagination<ISharedItem>, Error> {
|
): UseQueryResult<IPagination<ISharedItem>, Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["share-list"],
|
queryKey: ["share-list", params],
|
||||||
queryFn: () => getShares(params),
|
queryFn: () => getShares(params),
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ISpace } from "../types/space.types";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import APP_ROUTE from "@/lib/app-route";
|
import APP_ROUTE from "@/lib/app-route";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
interface DeleteSpaceModalProps {
|
interface DeleteSpaceModalProps {
|
||||||
space: ISpace;
|
space: ISpace;
|
||||||
@@ -14,6 +15,7 @@ interface DeleteSpaceModalProps {
|
|||||||
export default function DeleteSpaceModal({ space }: DeleteSpaceModalProps) {
|
export default function DeleteSpaceModal({ space }: DeleteSpaceModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
const deleteSpaceMutation = useDeleteSpaceMutation();
|
const deleteSpaceMutation = useDeleteSpaceMutation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -35,12 +37,15 @@ export default function DeleteSpaceModal({ space }: DeleteSpaceModalProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setIsDeleting(true);
|
||||||
try {
|
try {
|
||||||
// pass slug too so we can clear the local cache
|
// pass slug too so we can clear the local cache
|
||||||
await deleteSpaceMutation.mutateAsync({ id: space.id, slug: space.slug });
|
await deleteSpaceMutation.mutateAsync({ id: space.id, slug: space.slug });
|
||||||
navigate(APP_ROUTE.HOME);
|
navigate(APP_ROUTE.HOME);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to delete space", error);
|
console.error("Failed to delete space", error);
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -79,7 +84,7 @@ export default function DeleteSpaceModal({ space }: DeleteSpaceModalProps) {
|
|||||||
<Button onClick={close} variant="default">
|
<Button onClick={close} variant="default">
|
||||||
{t("Cancel")}
|
{t("Cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleDelete} color="red">
|
<Button onClick={handleDelete} color="red" loading={isDeleting}>
|
||||||
{t("Confirm")}
|
{t("Confirm")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { SpaceSelect } from "./space-select";
|
import { SpaceSelect } from "./space-select";
|
||||||
import { getSpaceUrl } from "@/lib/config";
|
import { getSpaceUrl } from "@/lib/config";
|
||||||
import { Button, Popover, Text } from "@mantine/core";
|
import { Button, Popover, Text } from "@mantine/core";
|
||||||
import { IconChevronDown } from "@tabler/icons-react";
|
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||||
@@ -21,7 +21,7 @@ export function SwitchSpace({
|
|||||||
spaceIcon,
|
spaceIcon,
|
||||||
}: SwitchSpaceProps) {
|
}: SwitchSpaceProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [opened, { close, open, toggle }] = useDisclosure(false);
|
const [opened, { close, toggle }] = useDisclosure(false);
|
||||||
|
|
||||||
const handleSelect = (value: string) => {
|
const handleSelect = (value: string) => {
|
||||||
if (value) {
|
if (value) {
|
||||||
@@ -44,9 +44,9 @@ export function SwitchSpace({
|
|||||||
variant="subtle"
|
variant="subtle"
|
||||||
fullWidth
|
fullWidth
|
||||||
justify="space-between"
|
justify="space-between"
|
||||||
rightSection={<IconChevronDown size={18} />}
|
rightSection={opened ? <IconChevronUp size={18} /> : <IconChevronDown size={18} />}
|
||||||
color="gray"
|
color="gray"
|
||||||
onClick={open}
|
onClick={toggle}
|
||||||
>
|
>
|
||||||
<CustomAvatar
|
<CustomAvatar
|
||||||
name={spaceName}
|
name={spaceName}
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts
|
|||||||
|
|
||||||
export default function SpaceGrid() {
|
export default function SpaceGrid() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data, isLoading } = useGetSpacesQuery({ page: 1, limit: 10 });
|
const { data, isLoading } = useGetSpacesQuery({ limit: 10 });
|
||||||
|
|
||||||
const cards = data?.items.slice(0, 9).map((space, index) => (
|
const cards = data?.items.slice(0, 9).map((space, index) => (
|
||||||
<Card
|
<Card
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Group, Table, Text } from "@mantine/core";
|
import { Group, Table, Text } from "@mantine/core";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
||||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
||||||
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
|
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
@@ -8,11 +9,12 @@ import { useTranslation } from "react-i18next";
|
|||||||
import Paginate from "@/components/common/paginate.tsx";
|
import Paginate from "@/components/common/paginate.tsx";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||||
|
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
|
||||||
|
|
||||||
export default function SpaceList() {
|
export default function SpaceList() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [page, setPage] = useState(1);
|
const { cursor, goNext, goPrev } = useCursorPaginate();
|
||||||
const { data, isLoading } = useGetSpacesQuery({ page });
|
const { data, isLoading } = useGetSpacesQuery({ cursor });
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
const [selectedSpaceId, setSelectedSpaceId] = useState<string>(null);
|
const [selectedSpaceId, setSelectedSpaceId] = useState<string>(null);
|
||||||
|
|
||||||
@@ -49,9 +51,9 @@ export default function SpaceList() {
|
|||||||
name={space.name}
|
name={space.name}
|
||||||
/>
|
/>
|
||||||
<div style={{ minWidth: 0, overflow: "hidden" }}>
|
<div style={{ minWidth: 0, overflow: "hidden" }}>
|
||||||
<Text fz="sm" fw={500} lineClamp={1}>
|
<AutoTooltipText fz="sm" fw={500} lineClamp={1}>
|
||||||
{space.name}
|
{space.name}
|
||||||
</Text>
|
</AutoTooltipText>
|
||||||
<Text fz="xs" c="dimmed" lineClamp={2}>
|
<Text fz="xs" c="dimmed" lineClamp={2}>
|
||||||
{space.description}
|
{space.description}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -71,10 +73,10 @@ export default function SpaceList() {
|
|||||||
|
|
||||||
{data?.items.length > 0 && (
|
{data?.items.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
currentPage={page}
|
hasPrevPage={data?.meta?.hasPrevPage}
|
||||||
hasPrevPage={data?.meta.hasPrevPage}
|
hasNextPage={data?.meta?.hasNextPage}
|
||||||
hasNextPage={data?.meta.hasNextPage}
|
onNext={() => goNext(data?.meta?.nextCursor)}
|
||||||
onPageChange={setPage}
|
onPrev={goPrev}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import Paginate from "@/components/common/paginate.tsx";
|
import Paginate from "@/components/common/paginate.tsx";
|
||||||
import { SearchInput } from "@/components/common/search-input.tsx";
|
import { SearchInput } from "@/components/common/search-input.tsx";
|
||||||
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search.tsx";
|
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search.tsx";
|
||||||
|
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
|
||||||
|
|
||||||
type MemberType = "user" | "group";
|
type MemberType = "user" | "group";
|
||||||
|
|
||||||
@@ -40,9 +41,9 @@ export default function SpaceMembersList({
|
|||||||
readOnly,
|
readOnly,
|
||||||
}: SpaceMembersProps) {
|
}: SpaceMembersProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { search, page, setPage, handleSearch } = usePaginateAndSearch();
|
const { search, cursor, goNext, goPrev, handleSearch } = usePaginateAndSearch();
|
||||||
const { data, isLoading } = useSpaceMembersQuery(spaceId, {
|
const { data, isLoading } = useSpaceMembersQuery(spaceId, {
|
||||||
page,
|
cursor,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
query: search,
|
query: search,
|
||||||
});
|
});
|
||||||
@@ -138,10 +139,10 @@ export default function SpaceMembersList({
|
|||||||
|
|
||||||
{member.type === "group" && <IconGroupCircle />}
|
{member.type === "group" && <IconGroupCircle />}
|
||||||
|
|
||||||
<div>
|
<div style={{ minWidth: 0, overflow: "hidden", maxWidth: 260 }}>
|
||||||
<Text fz="sm" fw={500} lineClamp={1}>
|
<AutoTooltipText fz="sm" fw={500}>
|
||||||
{member?.name}
|
{member?.name}
|
||||||
</Text>
|
</AutoTooltipText>
|
||||||
<Text fz="xs" c="dimmed">
|
<Text fz="xs" c="dimmed">
|
||||||
{member.type == "user" && member?.email}
|
{member.type == "user" && member?.email}
|
||||||
|
|
||||||
@@ -205,10 +206,10 @@ export default function SpaceMembersList({
|
|||||||
|
|
||||||
{data?.items.length > 0 && (
|
{data?.items.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
currentPage={page}
|
hasPrevPage={data?.meta?.hasPrevPage}
|
||||||
hasPrevPage={data?.meta.hasPrevPage}
|
hasNextPage={data?.meta?.hasNextPage}
|
||||||
hasNextPage={data?.meta.hasNextPage}
|
onNext={() => goNext(data?.meta?.nextCursor)}
|
||||||
onPageChange={setPage}
|
onPrev={goPrev}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -23,23 +23,24 @@ import SpaceSettingsModal from "@/features/space/components/settings-modal";
|
|||||||
import classes from "./all-spaces-list.module.css";
|
import classes from "./all-spaces-list.module.css";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||||
|
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
|
||||||
|
|
||||||
interface AllSpacesListProps {
|
interface AllSpacesListProps {
|
||||||
spaces: any[];
|
spaces: any[];
|
||||||
onSearch: (query: string) => void;
|
onSearch: (query: string) => void;
|
||||||
page: number;
|
|
||||||
hasPrevPage?: boolean;
|
hasPrevPage?: boolean;
|
||||||
hasNextPage?: boolean;
|
hasNextPage?: boolean;
|
||||||
onPageChange: (page: number) => void;
|
onNext: () => void;
|
||||||
|
onPrev: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AllSpacesList({
|
export default function AllSpacesList({
|
||||||
spaces,
|
spaces,
|
||||||
onSearch,
|
onSearch,
|
||||||
page,
|
|
||||||
hasPrevPage,
|
hasPrevPage,
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
onPageChange,
|
onNext,
|
||||||
|
onPrev,
|
||||||
}: AllSpacesListProps) {
|
}: AllSpacesListProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [settingsOpened, { open: openSettings, close: closeSettings }] =
|
const [settingsOpened, { open: openSettings, close: closeSettings }] =
|
||||||
@@ -96,10 +97,10 @@ export default function AllSpacesList({
|
|||||||
variant="filled"
|
variant="filled"
|
||||||
size="md"
|
size="md"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div style={{ minWidth: 0, overflow: "hidden", maxWidth: 350 }}>
|
||||||
<Text fz="sm" fw={500} lineClamp={1}>
|
<AutoTooltipText fz="sm" fw={500} lineClamp={1}>
|
||||||
{space.name}
|
{space.name}
|
||||||
</Text>
|
</AutoTooltipText>
|
||||||
{space.description && (
|
{space.description && (
|
||||||
<Text fz="xs" c="dimmed" lineClamp={2}>
|
<Text fz="xs" c="dimmed" lineClamp={2}>
|
||||||
{space.description}
|
{space.description}
|
||||||
@@ -144,10 +145,10 @@ export default function AllSpacesList({
|
|||||||
|
|
||||||
{spaces.length > 0 && (
|
{spaces.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
currentPage={page}
|
|
||||||
hasPrevPage={hasPrevPage}
|
hasPrevPage={hasPrevPage}
|
||||||
hasNextPage={hasNextPage}
|
hasNextPage={hasNextPage}
|
||||||
onPageChange={onPageChange}
|
onNext={onNext}
|
||||||
|
onPrev={onPrev}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -69,5 +69,12 @@ export async function exportSpace(data: IExportSpaceParams): Promise<void> {
|
|||||||
.split("filename=")[1]
|
.split("filename=")[1]
|
||||||
.replace(/"/g, "");
|
.replace(/"/g, "");
|
||||||
|
|
||||||
saveAs(req.data, decodeURIComponent(fileName));
|
let decodedFileName = fileName;
|
||||||
|
try {
|
||||||
|
decodedFileName = decodeURIComponent(fileName);
|
||||||
|
} catch (err) {
|
||||||
|
// fallback to raw filename
|
||||||
|
}
|
||||||
|
|
||||||
|
saveAs(req.data, decodedFileName);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -45,8 +45,10 @@ export type MoveTreeNodeEvent = {
|
|||||||
payload: {
|
payload: {
|
||||||
id: string;
|
id: string;
|
||||||
parentId: string;
|
parentId: string;
|
||||||
|
oldParentId: string | null;
|
||||||
index: number;
|
index: number;
|
||||||
position: string;
|
position: string;
|
||||||
|
pageData: Partial<IPage>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { IPagination } from "@/lib/types";
|
|||||||
import {
|
import {
|
||||||
invalidateOnCreatePage,
|
invalidateOnCreatePage,
|
||||||
invalidateOnDeletePage,
|
invalidateOnDeletePage,
|
||||||
invalidateOnMovePage,
|
updateCacheOnMovePage,
|
||||||
invalidateOnUpdatePage,
|
invalidateOnUpdatePage,
|
||||||
} from "../page/queries/page-query";
|
} from "../page/queries/page-query";
|
||||||
import { RQ_KEY } from "../comment/queries/comment-query";
|
import { RQ_KEY } from "../comment/queries/comment-query";
|
||||||
@@ -41,7 +41,13 @@ export const useQuerySubscription = () => {
|
|||||||
invalidateOnCreatePage(data.payload.data);
|
invalidateOnCreatePage(data.payload.data);
|
||||||
break;
|
break;
|
||||||
case "moveTreeNode":
|
case "moveTreeNode":
|
||||||
invalidateOnMovePage();
|
updateCacheOnMovePage(
|
||||||
|
data.spaceId,
|
||||||
|
data.payload.id,
|
||||||
|
data.payload.oldParentId,
|
||||||
|
data.payload.parentId,
|
||||||
|
data.payload.pageData,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case "deleteTreeNode":
|
case "deleteTreeNode":
|
||||||
invalidateOnDeletePage(data.payload.node.id);
|
invalidateOnDeletePage(data.payload.node.id);
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ import {
|
|||||||
} from "@/features/workspace/queries/workspace-query.ts";
|
} from "@/features/workspace/queries/workspace-query.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { useClipboard } from "@mantine/hooks";
|
import { useClipboard } from "@/hooks/use-clipboard";
|
||||||
import { getInviteLink } from "@/features/workspace/services/workspace-service.ts";
|
import { getInviteLink } from "@/features/workspace/services/workspace-service.ts";
|
||||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
import { isCloud } from "@/lib/config.ts";
|
import { isCloud } from "@/lib/config.ts";
|
||||||
|
|||||||
+2
-1
@@ -1,7 +1,8 @@
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Button, CopyButton, Group, Text, TextInput } from "@mantine/core";
|
import { Button, Group, Text, TextInput } from "@mantine/core";
|
||||||
|
import { CopyButton } from "@/components/common/copy-button";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function WorkspaceInviteSection() {
|
export default function WorkspaceInviteSection() {
|
||||||
|
|||||||
+8
-7
@@ -1,6 +1,6 @@
|
|||||||
import { Group, Table, Avatar, Text, Alert } from "@mantine/core";
|
import { Group, Table, Avatar, Text, Alert } from "@mantine/core";
|
||||||
import { useWorkspaceInvitationsQuery } from "@/features/workspace/queries/workspace-query.ts";
|
import { useWorkspaceInvitationsQuery } from "@/features/workspace/queries/workspace-query.ts";
|
||||||
import React, { useState } from "react";
|
import React from "react";
|
||||||
import { getUserRoleLabel } from "@/features/workspace/types/user-role-data.ts";
|
import { getUserRoleLabel } from "@/features/workspace/types/user-role-data.ts";
|
||||||
import InviteActionMenu from "@/features/workspace/components/members/components/invite-action-menu.tsx";
|
import InviteActionMenu from "@/features/workspace/components/members/components/invite-action-menu.tsx";
|
||||||
import { IconInfoCircle } from "@tabler/icons-react";
|
import { IconInfoCircle } from "@tabler/icons-react";
|
||||||
@@ -8,12 +8,13 @@ import { timeAgo } from "@/lib/time.ts";
|
|||||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import Paginate from "@/components/common/paginate.tsx";
|
import Paginate from "@/components/common/paginate.tsx";
|
||||||
|
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
||||||
|
|
||||||
export default function WorkspaceInvitesTable() {
|
export default function WorkspaceInvitesTable() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [page, setPage] = useState(1);
|
const { cursor, goNext, goPrev } = useCursorPaginate();
|
||||||
const { data, isLoading } = useWorkspaceInvitationsQuery({
|
const { data, isLoading } = useWorkspaceInvitationsQuery({
|
||||||
page,
|
cursor,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
});
|
});
|
||||||
const { isAdmin } = useUserRole();
|
const { isAdmin } = useUserRole();
|
||||||
@@ -65,10 +66,10 @@ export default function WorkspaceInvitesTable() {
|
|||||||
|
|
||||||
{data?.items.length > 0 && (
|
{data?.items.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
currentPage={page}
|
hasPrevPage={data?.meta?.hasPrevPage}
|
||||||
hasPrevPage={data?.meta.hasPrevPage}
|
hasNextPage={data?.meta?.hasNextPage}
|
||||||
hasNextPage={data?.meta.hasNextPage}
|
onNext={() => goNext(data?.meta?.nextCursor)}
|
||||||
onPageChange={setPage}
|
onPrev={goPrev}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user