mirror of
https://github.com/docmost/docmost.git
synced 2025-11-13 19:22:38 +10:00
Compare commits
2 Commits
tiered-bil
...
esm-packag
| Author | SHA1 | Date | |
|---|---|---|---|
| 7778482377 | |||
| 674769df02 |
18
README.md
18
README.md
@ -4,15 +4,14 @@
|
|||||||
Open-source collaborative wiki and documentation software.
|
Open-source collaborative wiki and documentation software.
|
||||||
<br />
|
<br />
|
||||||
<a href="https://docmost.com"><strong>Website</strong></a> |
|
<a href="https://docmost.com"><strong>Website</strong></a> |
|
||||||
<a href="https://docmost.com/docs"><strong>Documentation</strong></a> |
|
<a href="https://docmost.com/docs"><strong>Documentation</strong></a>
|
||||||
<a href="https://twitter.com/DocmostHQ"><strong>Twitter / X</strong></a>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
To get started with Docmost, please refer to our [documentation](https://docmost.com/docs) or try our [cloud version](https://docmost.com/pricing) .
|
To get started with Docmost, please refer to our [documentation](https://docmost.com/docs).
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@ -47,16 +46,3 @@ All files in the following directories are licensed under the Docmost Enterprise
|
|||||||
### Contributing
|
### Contributing
|
||||||
|
|
||||||
See the [development documentation](https://docmost.com/docs/self-hosting/development)
|
See the [development documentation](https://docmost.com/docs/self-hosting/development)
|
||||||
|
|
||||||
## Thanks
|
|
||||||
Special thanks to;
|
|
||||||
|
|
||||||
<img width="100" alt="Crowdin" src="https://github.com/user-attachments/assets/a6c3d352-e41b-448d-b6cd-3fbca3109f07" />
|
|
||||||
|
|
||||||
[Crowdin](https://crowdin.com/) for providing access to their localization platform.
|
|
||||||
|
|
||||||
|
|
||||||
<img width="48" alt="Algolia-mark-square-white" src="https://github.com/user-attachments/assets/6ccad04a-9589-4965-b6a1-d5cb1f4f9e94" />
|
|
||||||
|
|
||||||
[Algolia](https://www.algolia.com/) for providing full-text search to the docs.
|
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Docmost</title>
|
<title>Docmost</title>
|
||||||
<!--meta-tags-->
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.21.0",
|
"version": "0.10.2",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
@ -15,46 +15,44 @@
|
|||||||
"@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-864353b",
|
"@excalidraw/excalidraw": "^0.17.6",
|
||||||
"@mantine/core": "^7.17.0",
|
"@mantine/core": "^7.17.0",
|
||||||
"@mantine/form": "^7.17.0",
|
"@mantine/form": "^7.17.0",
|
||||||
"@mantine/hooks": "^7.17.0",
|
"@mantine/hooks": "^7.17.0",
|
||||||
"@mantine/modals": "^7.17.0",
|
"@mantine/modals": "^7.17.0",
|
||||||
"@mantine/notifications": "^7.17.0",
|
"@mantine/notifications": "^7.17.0",
|
||||||
"@mantine/spotlight": "^7.17.0",
|
"@mantine/spotlight": "^7.17.0",
|
||||||
"@tabler/icons-react": "^3.34.0",
|
"@tabler/icons-react": "^3.22.0",
|
||||||
"@tanstack/react-query": "^5.80.6",
|
"@tanstack/react-query": "^5.61.4",
|
||||||
"@tiptap/extension-character-count": "^2.10.3",
|
"@tiptap/extension-character-count": "^2.11.5",
|
||||||
"alfaaz": "^1.1.0",
|
"axios": "^1.7.9",
|
||||||
"axios": "^1.9.0",
|
|
||||||
"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",
|
||||||
"highlightjs-sap-abap": "^0.3.0",
|
|
||||||
"i18next": "^23.14.0",
|
"i18next": "^23.14.0",
|
||||||
"i18next-http-backend": "^2.6.1",
|
"i18next-http-backend": "^2.6.1",
|
||||||
"jotai": "^2.12.5",
|
"jotai": "^2.12.1",
|
||||||
"jotai-optics": "^0.4.0",
|
"jotai-optics": "^0.4.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"katex": "0.16.22",
|
"katex": "0.16.21",
|
||||||
"lowlight": "^3.3.0",
|
"lowlight": "^3.2.0",
|
||||||
"mermaid": "^11.6.0",
|
"mermaid": "^11.4.1",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-arborist": "3.4.0",
|
"react-arborist": "3.4.0",
|
||||||
"react-clear-modal": "^2.0.15",
|
"react-clear-modal": "^2.0.11",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-drawio": "^1.0.1",
|
"react-drawio": "^1.0.1",
|
||||||
"react-error-boundary": "^4.1.2",
|
"react-error-boundary": "^4.1.2",
|
||||||
"react-helmet-async": "^2.0.5",
|
"react-helmet-async": "^2.0.5",
|
||||||
"react-i18next": "^15.0.1",
|
"react-i18next": "^15.0.1",
|
||||||
"react-router-dom": "^7.0.1",
|
"react-router-dom": "^7.0.1",
|
||||||
"semver": "^7.7.2",
|
"semver": "^7.7.1",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
"tiptap-extension-global-drag-handle": "^0.1.18",
|
"tiptap-extension-global-drag-handle": "^0.1.18",
|
||||||
"zod": "^3.25.56"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.16.0",
|
"@eslint/js": "^9.16.0",
|
||||||
@ -65,7 +63,7 @@
|
|||||||
"@types/node": "22.10.0",
|
"@types/node": "22.10.0",
|
||||||
"@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": "^4.4.1",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"eslint": "^9.15.0",
|
"eslint": "^9.15.0",
|
||||||
"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",
|
||||||
@ -78,6 +76,6 @@
|
|||||||
"prettier": "^3.4.1",
|
"prettier": "^3.4.1",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"typescript-eslint": "^8.17.0",
|
"typescript-eslint": "^8.17.0",
|
||||||
"vite": "^6.3.5"
|
"vite": "^6.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -362,29 +362,5 @@
|
|||||||
"Move page to a different space.": "Seite in einen anderen Bereich verschieben.",
|
"Move page to a different space.": "Seite in einen anderen Bereich verschieben.",
|
||||||
"Real-time editor connection lost. Retrying...": "Echtzeit-Editor-Verbindung verloren. Wiederholen...",
|
"Real-time editor connection lost. Retrying...": "Echtzeit-Editor-Verbindung verloren. Wiederholen...",
|
||||||
"Table of contents": "Inhaltsverzeichnis",
|
"Table of contents": "Inhaltsverzeichnis",
|
||||||
"Add headings (H1, H2, H3) to generate a table of contents.": "Fügen Sie Überschriften (H1, H2, H3) hinzu, um ein Inhaltsverzeichnis zu erstellen.",
|
"Add headings (H1, H2, H3) to generate a table of contents.": "Fügen Sie Überschriften (H1, H2, H3) hinzu, um ein Inhaltsverzeichnis zu erstellen."
|
||||||
"Share": "Teilen",
|
|
||||||
"Public sharing": "Öffentliches Teilen",
|
|
||||||
"Shared by": "Geteilt von",
|
|
||||||
"Shared at": "Geteilt am",
|
|
||||||
"Inherits public sharing from": "Erbt das öffentliche Teilen von",
|
|
||||||
"Share to web": "Im Web teilen",
|
|
||||||
"Shared to web": "Im Web geteilt",
|
|
||||||
"Anyone with the link can view this page": "Jeder mit dem Link kann diese Seite ansehen",
|
|
||||||
"Make this page publicly accessible": "Diese Seite öffentlich zugänglich machen",
|
|
||||||
"Include sub-pages": "Unterseiten einbeziehen",
|
|
||||||
"Make sub-pages public too": "Unterseiten auch öffentlich machen",
|
|
||||||
"Allow search engines to index page": "Suchmaschinen erlauben, die Seite zu indexieren",
|
|
||||||
"Open page": "Seite öffnen",
|
|
||||||
"Page": "Seite",
|
|
||||||
"Delete public share link": "Öffentlichen Freigabelink löschen",
|
|
||||||
"Delete share": "Freigabe löschen",
|
|
||||||
"Are you sure you want to delete this shared link?": "Möchten Sie diesen Freigabelink wirklich löschen?",
|
|
||||||
"Publicly shared pages from spaces you are a member of will appear here": "Öffentlich geteilte Seiten aus Bereichen, in denen Sie Mitglied sind, erscheinen hier",
|
|
||||||
"Share deleted successfully": "Freigabe erfolgreich gelöscht",
|
|
||||||
"Share not found": "Freigabe nicht gefunden",
|
|
||||||
"Failed to share page": "Fehler beim Teilen der Seite",
|
|
||||||
"Copy page": "Seite kopieren",
|
|
||||||
"Copy page to a different space.": "Seite in einen anderen Bereich kopieren.",
|
|
||||||
"Page copied successfully": "Seite erfolgreich kopiert"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -354,9 +354,6 @@
|
|||||||
"Character count: {{characterCount}}": "Character count: {{characterCount}}",
|
"Character count: {{characterCount}}": "Character count: {{characterCount}}",
|
||||||
"New update": "New update",
|
"New update": "New update",
|
||||||
"{{latestVersion}} is available": "{{latestVersion}} is available",
|
"{{latestVersion}} is available": "{{latestVersion}} is available",
|
||||||
"Default page edit mode": "Default page edit mode",
|
|
||||||
"Choose your preferred page edit mode. Avoid accidental edits.": "Choose your preferred page edit mode. Avoid accidental edits.",
|
|
||||||
"Reading": "Reading"
|
|
||||||
"Delete member": "Delete member",
|
"Delete member": "Delete member",
|
||||||
"Member deleted successfully": "Member deleted successfully",
|
"Member deleted successfully": "Member deleted successfully",
|
||||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "Are you sure you want to delete this workspace member? This action is irreversible.",
|
"Are you sure you want to delete this workspace member? This action is irreversible.": "Are you sure you want to delete this workspace member? This action is irreversible.",
|
||||||
@ -365,29 +362,5 @@
|
|||||||
"Move page to a different space.": "Move page to a different space.",
|
"Move page to a different space.": "Move page to a different space.",
|
||||||
"Real-time editor connection lost. Retrying...": "Real-time editor connection lost. Retrying...",
|
"Real-time editor connection lost. Retrying...": "Real-time editor connection lost. Retrying...",
|
||||||
"Table of contents": "Table of contents",
|
"Table of contents": "Table of contents",
|
||||||
"Add headings (H1, H2, H3) to generate a table of contents.": "Add headings (H1, H2, H3) to generate a table of contents.",
|
"Add headings (H1, H2, H3) to generate a table of contents.": "Add headings (H1, H2, H3) to generate a table of contents."
|
||||||
"Share": "Share",
|
|
||||||
"Public sharing": "Public sharing",
|
|
||||||
"Shared by": "Shared by",
|
|
||||||
"Shared at": "Shared at",
|
|
||||||
"Inherits public sharing from": "Inherits public sharing from",
|
|
||||||
"Share to web": "Share to web",
|
|
||||||
"Shared to web": "Shared to web",
|
|
||||||
"Anyone with the link can view this page": "Anyone with the link can view this page",
|
|
||||||
"Make this page publicly accessible": "Make this page publicly accessible",
|
|
||||||
"Include sub-pages": "Include sub-pages",
|
|
||||||
"Make sub-pages public too": "Make sub-pages public too",
|
|
||||||
"Allow search engines to index page": "Allow search engines to index page",
|
|
||||||
"Open page": "Open page",
|
|
||||||
"Page": "Page",
|
|
||||||
"Delete public share link": "Delete public share link",
|
|
||||||
"Delete share": "Delete share",
|
|
||||||
"Are you sure you want to delete this shared link?": "Are you sure you want to delete this shared link?",
|
|
||||||
"Publicly shared pages from spaces you are a member of will appear here": "Publicly shared pages from spaces you are a member of will appear here",
|
|
||||||
"Share deleted successfully": "Share deleted successfully",
|
|
||||||
"Share not found": "Share not found",
|
|
||||||
"Failed to share page": "Failed to share page",
|
|
||||||
"Copy page": "Copy page",
|
|
||||||
"Copy page to a different space.": "Copy page to a different space.",
|
|
||||||
"Page copied successfully": "Page copied successfully"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -94,7 +94,7 @@
|
|||||||
"Invited members will be granted access to spaces the groups can access": "Los miembros invitados recibirán acceso a los espacios a los que los grupos pueden acceder",
|
"Invited members will be granted access to spaces the groups can access": "Los miembros invitados recibirán acceso a los espacios a los que los grupos pueden acceder",
|
||||||
"Join the workspace": "Unirse al espacio de trabajo",
|
"Join the workspace": "Unirse al espacio de trabajo",
|
||||||
"Language": "Idioma",
|
"Language": "Idioma",
|
||||||
"Light": "Claro",
|
"Light": "Ligero",
|
||||||
"Link copied": "Enlace copiado",
|
"Link copied": "Enlace copiado",
|
||||||
"Login": "Iniciar sesión",
|
"Login": "Iniciar sesión",
|
||||||
"Logout": "Cerrar sesión",
|
"Logout": "Cerrar sesión",
|
||||||
@ -362,29 +362,5 @@
|
|||||||
"Move page to a different space.": "Mover página a un espacio diferente.",
|
"Move page to a different space.": "Mover página a un espacio diferente.",
|
||||||
"Real-time editor connection lost. Retrying...": "Conexión del editor en tiempo real perdida. Reintentando...",
|
"Real-time editor connection lost. Retrying...": "Conexión del editor en tiempo real perdida. Reintentando...",
|
||||||
"Table of contents": "Índice de contenidos",
|
"Table of contents": "Índice de contenidos",
|
||||||
"Add headings (H1, H2, H3) to generate a table of contents.": "Añadir encabezados (H1, H2, H3) para generar un índice de contenidos.",
|
"Add headings (H1, H2, H3) to generate a table of contents.": "Añadir encabezados (H1, H2, H3) para generar un índice de contenidos."
|
||||||
"Share": "Compartir",
|
|
||||||
"Public sharing": "Compartición pública",
|
|
||||||
"Shared by": "Compartido por",
|
|
||||||
"Shared at": "Compartido en",
|
|
||||||
"Inherits public sharing from": "Hereda la compartición pública de",
|
|
||||||
"Share to web": "Compartir en la web",
|
|
||||||
"Shared to web": "Compartido en la web",
|
|
||||||
"Anyone with the link can view this page": "Cualquiera con el enlace puede ver esta página",
|
|
||||||
"Make this page publicly accessible": "Hacer esta página accesible públicamente",
|
|
||||||
"Include sub-pages": "Incluir subpáginas",
|
|
||||||
"Make sub-pages public too": "Hacer públicas también las subpáginas",
|
|
||||||
"Allow search engines to index page": "Permitir a los motores de búsqueda indexar la página",
|
|
||||||
"Open page": "Abrir página",
|
|
||||||
"Page": "Página",
|
|
||||||
"Delete public share link": "Eliminar enlace de compartición pública",
|
|
||||||
"Delete share": "Eliminar compartición",
|
|
||||||
"Are you sure you want to delete this shared link?": "¿Está seguro de que desea eliminar este enlace compartido?",
|
|
||||||
"Publicly shared pages from spaces you are a member of will appear here": "Las páginas compartidas públicamente de los espacios a los que pertenece aparecerán aquí",
|
|
||||||
"Share deleted successfully": "Compartición eliminada con éxito",
|
|
||||||
"Share not found": "Compartición no encontrada",
|
|
||||||
"Failed to share page": "Error al compartir la página",
|
|
||||||
"Copy page": "Copy page",
|
|
||||||
"Copy page to a different space.": "Copy page to a different space.",
|
|
||||||
"Page copied successfully": "Page copied successfully"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -362,29 +362,5 @@
|
|||||||
"Move page to a different space.": "Déplacer la page vers un autre espace.",
|
"Move page to a different space.": "Déplacer la page vers un autre espace.",
|
||||||
"Real-time editor connection lost. Retrying...": "Connexion avec l'éditeur en temps réel perdue. Nouvelle tentative...",
|
"Real-time editor connection lost. Retrying...": "Connexion avec l'éditeur en temps réel perdue. Nouvelle tentative...",
|
||||||
"Table of contents": "",
|
"Table of contents": "",
|
||||||
"Add headings (H1, H2, H3) to generate a table of contents.": "Ajoutez des titres (H1, H2, H3) pour générer une table des matières.",
|
"Add headings (H1, H2, H3) to generate a table of contents.": "Ajoutez des titres (H1, H2, H3) pour générer une table des matières."
|
||||||
"Share": "Partager",
|
|
||||||
"Public sharing": "Partage public",
|
|
||||||
"Shared by": "Partagé par",
|
|
||||||
"Shared at": "Partagé à",
|
|
||||||
"Inherits public sharing from": "Hérite du partage public de",
|
|
||||||
"Share to web": "Partager sur le web",
|
|
||||||
"Shared to web": "Partagé sur le web",
|
|
||||||
"Anyone with the link can view this page": "Toute personne avec le lien peut voir cette page",
|
|
||||||
"Make this page publicly accessible": "Rendre cette page accessible au public",
|
|
||||||
"Include sub-pages": "Inclure les sous-pages",
|
|
||||||
"Make sub-pages public too": "Rendre également les sous-pages publiques",
|
|
||||||
"Allow search engines to index page": "Autoriser les moteurs de recherche à indexer la page",
|
|
||||||
"Open page": "Ouvrir la page",
|
|
||||||
"Page": "Page",
|
|
||||||
"Delete public share link": "Supprimer le lien de partage public",
|
|
||||||
"Delete share": "Supprimer le partage",
|
|
||||||
"Are you sure you want to delete this shared link?": "Êtes-vous sûr de vouloir supprimer ce lien partagé ?",
|
|
||||||
"Publicly shared pages from spaces you are a member of will appear here": "Les pages partagées publiquement des espaces dont vous êtes membre apparaîtront ici",
|
|
||||||
"Share deleted successfully": "Partage supprimé avec succès",
|
|
||||||
"Share not found": "Partage non trouvé",
|
|
||||||
"Failed to share page": "Échec du partage de la page",
|
|
||||||
"Copy page": "Copier la page",
|
|
||||||
"Copy page to a different space.": "Copier la page dans un autre espace.",
|
|
||||||
"Page copied successfully": "Page copiée avec succès"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -362,29 +362,5 @@
|
|||||||
"Move page to a different space.": "Sposta la pagina in un altro spazio.",
|
"Move page to a different space.": "Sposta la pagina in un altro spazio.",
|
||||||
"Real-time editor connection lost. Retrying...": "Connessione all'editor in tempo reale persa. Riprovo...",
|
"Real-time editor connection lost. Retrying...": "Connessione all'editor in tempo reale persa. Riprovo...",
|
||||||
"Table of contents": "Indice dei contenuti",
|
"Table of contents": "Indice dei contenuti",
|
||||||
"Add headings (H1, H2, H3) to generate a table of contents.": "Aggiungi intestazioni (H1, H2, H3) per generare un sommario.",
|
"Add headings (H1, H2, H3) to generate a table of contents.": "Aggiungi intestazioni (H1, H2, H3) per generare un sommario."
|
||||||
"Share": "Condividi",
|
|
||||||
"Public sharing": "Condivisione pubblica",
|
|
||||||
"Shared by": "Condiviso da",
|
|
||||||
"Shared at": "Condiviso il",
|
|
||||||
"Inherits public sharing from": "Eredita la condivisione pubblica da",
|
|
||||||
"Share to web": "Condividi su web",
|
|
||||||
"Shared to web": "Condiviso su web",
|
|
||||||
"Anyone with the link can view this page": "Chiunque abbia il link può visualizzare questa pagina",
|
|
||||||
"Make this page publicly accessible": "Rendi questa pagina accessibile pubblicamente",
|
|
||||||
"Include sub-pages": "Includi sotto-pagine",
|
|
||||||
"Make sub-pages public too": "Rendi pubbliche anche le sotto-pagine",
|
|
||||||
"Allow search engines to index page": "Permetti ai motori di ricerca di indicizzare la pagina",
|
|
||||||
"Open page": "Apri pagina",
|
|
||||||
"Page": "Pagina",
|
|
||||||
"Delete public share link": "Elimina il link di condivisione pubblica",
|
|
||||||
"Delete share": "Elimina condivisione",
|
|
||||||
"Are you sure you want to delete this shared link?": "Sei sicuro di voler eliminare questo link condiviso?",
|
|
||||||
"Publicly shared pages from spaces you are a member of will appear here": "Le pagine condivise pubblicamente dagli spazi di cui sei membro appariranno qui",
|
|
||||||
"Share deleted successfully": "Condivisione eliminata con successo",
|
|
||||||
"Share not found": "Condivisione non trovata",
|
|
||||||
"Failed to share page": "Condivisione della pagina fallita",
|
|
||||||
"Copy page": "Copia pagina",
|
|
||||||
"Copy page to a different space.": "Copia pagina in un altro spazio.",
|
|
||||||
"Page copied successfully": "Pagina copiata con successo"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -347,7 +347,7 @@
|
|||||||
"Members added successfully": "メンバーを追加しました",
|
"Members added successfully": "メンバーを追加しました",
|
||||||
"Member removed successfully": "メンバーが削除されました",
|
"Member removed successfully": "メンバーが削除されました",
|
||||||
"Member role updated successfully": "メンバーのロールを更新しました",
|
"Member role updated successfully": "メンバーのロールを更新しました",
|
||||||
"Created by: <b>{{creatorName}}</b>": "作成者: <b>{{creatorName}}</b>",
|
"Created by: <b>{{creatorName}}</b>": "作成者: <b>{{creatorName}}</b>",
|
||||||
"Created at: {{time}}": "が作成しました:{{time}}",
|
"Created at: {{time}}": "が作成しました:{{time}}",
|
||||||
"Edited by {{name}} {{time}}": "最終編集: {{name}} {{time}}",
|
"Edited by {{name}} {{time}}": "最終編集: {{name}} {{time}}",
|
||||||
"Word count: {{wordCount}}": "ワード数: {{wordCount}}",
|
"Word count: {{wordCount}}": "ワード数: {{wordCount}}",
|
||||||
@ -362,29 +362,5 @@
|
|||||||
"Move page to a different space.": "ページを別のスペースに移動します。",
|
"Move page to a different space.": "ページを別のスペースに移動します。",
|
||||||
"Real-time editor connection lost. Retrying...": "リアルタイムエディターの接続が失われました。再試行しています…",
|
"Real-time editor connection lost. Retrying...": "リアルタイムエディターの接続が失われました。再試行しています…",
|
||||||
"Table of contents": "目次",
|
"Table of contents": "目次",
|
||||||
"Add headings (H1, H2, H3) to generate a table of contents.": "見出し(H1、H2、H3)を追加して目次を生成します。",
|
"Add headings (H1, H2, H3) to generate a table of contents.": "見出し(H1、H2、H3)を追加して目次を生成します。"
|
||||||
"Share": "共有",
|
|
||||||
"Public sharing": "公開共有",
|
|
||||||
"Shared by": "共有者",
|
|
||||||
"Shared at": "共有日時",
|
|
||||||
"Inherits public sharing from": "から公開共有を継承する",
|
|
||||||
"Share to web": "ウェブで共有",
|
|
||||||
"Shared to web": "ウェブに共有済み",
|
|
||||||
"Anyone with the link can view this page": "リンクを持っている人はこのページを閲覧できます",
|
|
||||||
"Make this page publicly accessible": "このページを公開します",
|
|
||||||
"Include sub-pages": "サブページを含む",
|
|
||||||
"Make sub-pages public too": "サブページも公開する",
|
|
||||||
"Allow search engines to index page": "検索エンジンにページのインデックス作成を許可する",
|
|
||||||
"Open page": "ページを開く",
|
|
||||||
"Page": "ページ",
|
|
||||||
"Delete public share link": "公開リンクを削除",
|
|
||||||
"Delete share": "共有を削除",
|
|
||||||
"Are you sure you want to delete this shared link?": "この共有リンクを削除してもよろしいですか?",
|
|
||||||
"Publicly shared pages from spaces you are a member of will appear here": "メンバーであるスペースからの公開ページがここに表示されます",
|
|
||||||
"Share deleted successfully": "共有が正常に削除されました",
|
|
||||||
"Share not found": "共有が見つかりません",
|
|
||||||
"Failed to share page": "ページの共有に失敗しました",
|
|
||||||
"Copy page": "ページをコピー",
|
|
||||||
"Copy page to a different space.": "ページを別のスペースにコピーします。",
|
|
||||||
"Page copied successfully": "ページのコピーに成功しました"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,7 +58,7 @@
|
|||||||
"Enter a strong password": "강력한 비밀번호를 입력하세요",
|
"Enter a strong password": "강력한 비밀번호를 입력하세요",
|
||||||
"Enter valid email addresses separated by comma or space max_50": "유효한 이메일 주소를 쉼표나 공백으로 구분하여 입력하세요 [최대: 50]",
|
"Enter valid email addresses separated by comma or space max_50": "유효한 이메일 주소를 쉼표나 공백으로 구분하여 입력하세요 [최대: 50]",
|
||||||
"enter valid emails addresses": "유효한 이메일 주소를 입력하세요",
|
"enter valid emails addresses": "유효한 이메일 주소를 입력하세요",
|
||||||
"Enter your current password": "기존 비밀번호를 입력하세요",
|
"Enter your current password": "현재 비밀번호를 입력하세요",
|
||||||
"enter your full name": "전체 이름을 입력하세요",
|
"enter your full name": "전체 이름을 입력하세요",
|
||||||
"Enter your new password": "새 비밀번호를 입력하세요",
|
"Enter your new password": "새 비밀번호를 입력하세요",
|
||||||
"Enter your new preferred email": "새로운 이메일을 입력하세요",
|
"Enter your new preferred email": "새로운 이메일을 입력하세요",
|
||||||
@ -170,7 +170,7 @@
|
|||||||
"Successfully restored": "복원 완료",
|
"Successfully restored": "복원 완료",
|
||||||
"System settings": "시스템 설정",
|
"System settings": "시스템 설정",
|
||||||
"Theme": "배경",
|
"Theme": "배경",
|
||||||
"To change your email, you have to enter your password and new email.": "이메일을 변경하려면 기존 비밀번호와 새 이메일을 입력해야 합니다.",
|
"To change your email, you have to enter your password and new email.": "이메일을 변경하려면 현재 비밀번호와 새 이메일을 입력해야 합니다.",
|
||||||
"Toggle full page width": "전체 페이지 너비 전환",
|
"Toggle full page width": "전체 페이지 너비 전환",
|
||||||
"Unable to import pages. Please try again.": "페이지를 가져올 수 없습니다. 다시 시도해주세요.",
|
"Unable to import pages. Please try again.": "페이지를 가져올 수 없습니다. 다시 시도해주세요.",
|
||||||
"untitled": "제목 없음",
|
"untitled": "제목 없음",
|
||||||
@ -362,29 +362,5 @@
|
|||||||
"Move page to a different space.": "페이지를 다른 공간으로 이동합니다.",
|
"Move page to a different space.": "페이지를 다른 공간으로 이동합니다.",
|
||||||
"Real-time editor connection lost. Retrying...": "실시간 편집기 연결이 끊어졌습니다. 재시도 중...",
|
"Real-time editor connection lost. Retrying...": "실시간 편집기 연결이 끊어졌습니다. 재시도 중...",
|
||||||
"Table of contents": "목차",
|
"Table of contents": "목차",
|
||||||
"Add headings (H1, H2, H3) to generate a table of contents.": "목차를 생성하려면 제목 (H1, H2, H3)을 추가하세요.",
|
"Add headings (H1, H2, H3) to generate a table of contents.": "목차를 생성하려면 제목 (H1, H2, H3)을 추가하세요."
|
||||||
"Share": "공유",
|
|
||||||
"Public sharing": "공개 공유",
|
|
||||||
"Shared by": "공유자",
|
|
||||||
"Shared at": "공유 시간",
|
|
||||||
"Inherits public sharing from": "로부터 공개 공유를 상속함",
|
|
||||||
"Share to web": "웹에 공유",
|
|
||||||
"Shared to web": "웹에 공유됨",
|
|
||||||
"Anyone with the link can view this page": "링크가 있는 사람은 이 페이지를 볼 수 있습니다",
|
|
||||||
"Make this page publicly accessible": "이 페이지를 공개적으로 접근 가능하게 만들기",
|
|
||||||
"Include sub-pages": "하위 페이지 포함",
|
|
||||||
"Make sub-pages public too": "하위 페이지도 공개로 설정",
|
|
||||||
"Allow search engines to index page": "검색 엔진이 페이지를 색인할 수 있도록 허용",
|
|
||||||
"Open page": "페이지 열기",
|
|
||||||
"Page": "페이지",
|
|
||||||
"Delete public share link": "공유 링크 삭제",
|
|
||||||
"Delete share": "공유 삭제",
|
|
||||||
"Are you sure you want to delete this shared link?": "이 공유 링크를 삭제하시겠습니까?",
|
|
||||||
"Publicly shared pages from spaces you are a member of will appear here": "회원인 공간의 공개 공유된 페이지가 여기에 표시됩니다",
|
|
||||||
"Share deleted successfully": "공유가 성공적으로 삭제되었습니다",
|
|
||||||
"Share not found": "공유를 찾을 수 없습니다",
|
|
||||||
"Failed to share page": "페이지 공유에 실패했습니다",
|
|
||||||
"Copy page": "Copy page",
|
|
||||||
"Copy page to a different space.": "Copy page to a different space.",
|
|
||||||
"Page copied successfully": "Page copied successfully"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -362,29 +362,5 @@
|
|||||||
"Move page to a different space.": "Verplaats pagina naar een andere ruimte.",
|
"Move page to a different space.": "Verplaats pagina naar een andere ruimte.",
|
||||||
"Real-time editor connection lost. Retrying...": "Realtime editorverbinding verloren. Opnieuw proberen...",
|
"Real-time editor connection lost. Retrying...": "Realtime editorverbinding verloren. Opnieuw proberen...",
|
||||||
"Table of contents": "Inhoudsopgave",
|
"Table of contents": "Inhoudsopgave",
|
||||||
"Add headings (H1, H2, H3) to generate a table of contents.": "Voeg koppen (H1, H2, H3) toe om een inhoudsopgave te genereren.",
|
"Add headings (H1, H2, H3) to generate a table of contents.": "Voeg koppen (H1, H2, H3) toe om een inhoudsopgave te genereren."
|
||||||
"Share": "Delen",
|
|
||||||
"Public sharing": "Openbaar delen",
|
|
||||||
"Shared by": "Gedeeld door",
|
|
||||||
"Shared at": "Gedeeld op",
|
|
||||||
"Inherits public sharing from": "Erft openbaar delen van",
|
|
||||||
"Share to web": "Delen naar web",
|
|
||||||
"Shared to web": "Gedeeld naar web",
|
|
||||||
"Anyone with the link can view this page": "Iedereen met de link kan deze pagina bekijken",
|
|
||||||
"Make this page publicly accessible": "Maak deze pagina openbaar toegankelijk",
|
|
||||||
"Include sub-pages": "Inclusief subpagina's",
|
|
||||||
"Make sub-pages public too": "Maak subpagina's ook openbaar",
|
|
||||||
"Allow search engines to index page": "Sta zoekmachines toe om pagina te indexeren",
|
|
||||||
"Open page": "Pagina openen",
|
|
||||||
"Page": "Pagina",
|
|
||||||
"Delete public share link": "Verwijder openbare deel-link",
|
|
||||||
"Delete share": "Verwijder deel",
|
|
||||||
"Are you sure you want to delete this shared link?": "Weet u zeker dat u deze gedeelde link wilt verwijderen?",
|
|
||||||
"Publicly shared pages from spaces you are a member of will appear here": "Openbaar gedeelde pagina's van ruimtes waarvan u lid bent, verschijnen hier",
|
|
||||||
"Share deleted successfully": "Delen succesvol verwijderd",
|
|
||||||
"Share not found": "Delen niet gevonden",
|
|
||||||
"Failed to share page": "Pagina delen mislukt",
|
|
||||||
"Copy page": "Copy page",
|
|
||||||
"Copy page to a different space.": "Copy page to a different space.",
|
|
||||||
"Page copied successfully": "Page copied successfully"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -362,29 +362,5 @@
|
|||||||
"Move page to a different space.": "Mover página para um espaço diferente.",
|
"Move page to a different space.": "Mover página para um espaço diferente.",
|
||||||
"Real-time editor connection lost. Retrying...": "Conexão do editor em tempo real perdida. Tentando novamente...",
|
"Real-time editor connection lost. Retrying...": "Conexão do editor em tempo real perdida. Tentando novamente...",
|
||||||
"Table of contents": "Tabela de conteúdos",
|
"Table of contents": "Tabela de conteúdos",
|
||||||
"Add headings (H1, H2, H3) to generate a table of contents.": "Adicionar títulos (H1, H2, H3) para gerar uma tabela de conteúdo.",
|
"Add headings (H1, H2, H3) to generate a table of contents.": "Adicionar títulos (H1, H2, H3) para gerar uma tabela de conteúdo."
|
||||||
"Share": "Compartilhar",
|
|
||||||
"Public sharing": "Compartilhamento público",
|
|
||||||
"Shared by": "Compartilhado por",
|
|
||||||
"Shared at": "Compartilhado em",
|
|
||||||
"Inherits public sharing from": "Herdado do compartilhamento público de",
|
|
||||||
"Share to web": "Compartilhar na web",
|
|
||||||
"Shared to web": "Compartilhado na web",
|
|
||||||
"Anyone with the link can view this page": "Qualquer um com o link pode ver esta página",
|
|
||||||
"Make this page publicly accessible": "Tornar esta página publicamente acessível",
|
|
||||||
"Include sub-pages": "Incluir sub-páginas",
|
|
||||||
"Make sub-pages public too": "Tornar as sub-páginas públicas também",
|
|
||||||
"Allow search engines to index page": "Permitir que mecanismos de busca indexem a página",
|
|
||||||
"Open page": "Abrir página",
|
|
||||||
"Page": "Página",
|
|
||||||
"Delete public share link": "Excluir o link público compartilhado",
|
|
||||||
"Delete share": "Excluir compartilhamento",
|
|
||||||
"Are you sure you want to delete this shared link?": "Tem certeza de que deseja excluir este link compartilhado?",
|
|
||||||
"Publicly shared pages from spaces you are a member of will appear here": "Páginas compartilhadas publicamente de espaços que você é membro aparecerão aqui",
|
|
||||||
"Share deleted successfully": "Compartilhamento excluído com sucesso",
|
|
||||||
"Share not found": "Compartilhamento não encontrado",
|
|
||||||
"Failed to share page": "Falha ao compartilhar página",
|
|
||||||
"Copy page": "Copy page",
|
|
||||||
"Copy page to a different space.": "Copy page to a different space.",
|
|
||||||
"Page copied successfully": "Page copied successfully"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,11 +13,11 @@
|
|||||||
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Вы уверены, что хотите удалить этого пользователя из группы? Пользователь потеряет доступ к материалам, к которым у этой группы есть доступ.",
|
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Вы уверены, что хотите удалить этого пользователя из группы? Пользователь потеряет доступ к материалам, к которым у этой группы есть доступ.",
|
||||||
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Вы уверены, что хотите удалить этого пользователя из пространства? Пользователь потеряет весь доступ к этому пространству.",
|
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Вы уверены, что хотите удалить этого пользователя из пространства? Пользователь потеряет весь доступ к этому пространству.",
|
||||||
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Вы уверены, что хотите восстановить эту версию? Все не зафиксированные изменения будут потеряны.",
|
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Вы уверены, что хотите восстановить эту версию? Все не зафиксированные изменения будут потеряны.",
|
||||||
"Can become members of groups and spaces in workspace": "Могут становиться участниками групп и пространств в рабочей области",
|
"Can become members of groups and spaces in workspace": "Могут становиться участниками групп и пространств в рабочем пространстве",
|
||||||
"Can create and edit pages in space.": "Может создавать и редактировать страницы в пространстве.",
|
"Can create and edit pages in space.": "Может создавать и редактировать страницы в пространстве.",
|
||||||
"Can edit": "Может изменять",
|
"Can edit": "Может изменять",
|
||||||
"Can manage workspace": "Может управлять рабочей областью",
|
"Can manage workspace": "Может управлять рабочим пространством",
|
||||||
"Can manage workspace but cannot delete it": "Может управлять рабочей областью, но не может ее удалить",
|
"Can manage workspace but cannot delete it": "Может управлять рабочим пространством, но не может его удалить",
|
||||||
"Can view": "Может просматривать",
|
"Can view": "Может просматривать",
|
||||||
"Can view pages in space but not edit.": "Может просматривать страницы в пространстве, но не может их редактировать.",
|
"Can view pages in space but not edit.": "Может просматривать страницы в пространстве, но не может их редактировать.",
|
||||||
"Cancel": "Отменить",
|
"Cancel": "Отменить",
|
||||||
@ -34,7 +34,7 @@
|
|||||||
"Create group": "Создать группу",
|
"Create group": "Создать группу",
|
||||||
"Create page": "Создать страницу",
|
"Create page": "Создать страницу",
|
||||||
"Create space": "Создать пространство",
|
"Create space": "Создать пространство",
|
||||||
"Create workspace": "Создать рабочую область",
|
"Create workspace": "Создать рабочее пространство",
|
||||||
"Current password": "Текущий пароль",
|
"Current password": "Текущий пароль",
|
||||||
"Dark": "Темная",
|
"Dark": "Темная",
|
||||||
"Date": "Дата",
|
"Date": "Дата",
|
||||||
@ -92,7 +92,7 @@
|
|||||||
"Invite new members": "Пригласить новых участников",
|
"Invite new members": "Пригласить новых участников",
|
||||||
"Invited members who are yet to accept their invitation will appear here.": "Приглашённые участники, которые ещё не приняли приглашение, появятся здесь.",
|
"Invited members who are yet to accept their invitation will appear here.": "Приглашённые участники, которые ещё не приняли приглашение, появятся здесь.",
|
||||||
"Invited members will be granted access to spaces the groups can access": "Приглашённые участники получат доступ к пространствам, доступ к которым есть у группы",
|
"Invited members will be granted access to spaces the groups can access": "Приглашённые участники получат доступ к пространствам, доступ к которым есть у группы",
|
||||||
"Join the workspace": "Присоединиться к рабочей области",
|
"Join the workspace": "Присоединиться к рабочему пространству",
|
||||||
"Language": "Язык",
|
"Language": "Язык",
|
||||||
"Light": "Светлая",
|
"Light": "Светлая",
|
||||||
"Link copied": "Ссылка скопирована",
|
"Link copied": "Ссылка скопирована",
|
||||||
@ -150,7 +150,7 @@
|
|||||||
"Send invitation": "Отправить приглашение",
|
"Send invitation": "Отправить приглашение",
|
||||||
"Invitation sent": "Приглашение отправлено",
|
"Invitation sent": "Приглашение отправлено",
|
||||||
"Settings": "Настройки",
|
"Settings": "Настройки",
|
||||||
"Setup workspace": "Настроить рабочую область",
|
"Setup workspace": "Настроить рабочее пространство",
|
||||||
"Sign In": "Вход",
|
"Sign In": "Вход",
|
||||||
"Sign Up": "Регистрация",
|
"Sign Up": "Регистрация",
|
||||||
"Slug": "Slug",
|
"Slug": "Slug",
|
||||||
@ -177,9 +177,9 @@
|
|||||||
"Untitled": "Без названия",
|
"Untitled": "Без названия",
|
||||||
"Updated successfully": "Обновлено успешно",
|
"Updated successfully": "Обновлено успешно",
|
||||||
"User": "Пользователь",
|
"User": "Пользователь",
|
||||||
"Workspace": "Рабочая область",
|
"Workspace": "Рабочее пространство",
|
||||||
"Workspace Name": "Имя рабочей области",
|
"Workspace Name": "Имя рабочего пространства",
|
||||||
"Workspace settings": "Настройки рабочей области",
|
"Workspace settings": "Настройки рабочего пространства",
|
||||||
"You can change your password here.": "Вы можете изменить свой пароль здесь.",
|
"You can change your password here.": "Вы можете изменить свой пароль здесь.",
|
||||||
"Your Email": "Ваш адрес электронной почты",
|
"Your Email": "Ваш адрес электронной почты",
|
||||||
"Your import is complete.": "Ваш импорт завершен.",
|
"Your import is complete.": "Ваш импорт завершен.",
|
||||||
@ -217,9 +217,9 @@
|
|||||||
"Revoke invitation": "Отозвать приглашение",
|
"Revoke invitation": "Отозвать приглашение",
|
||||||
"Revoke": "Отозвать",
|
"Revoke": "Отозвать",
|
||||||
"Don't": "Нет",
|
"Don't": "Нет",
|
||||||
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "Вы уверены, что хотите отозвать это приглашение? Пользователь не сможет присоединиться к рабочей области.",
|
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "Вы уверены, что хотите отозвать это приглашение? Пользователь не сможет присоединиться к рабочему пространству.",
|
||||||
"Resend invitation": "Отправить приглашение повторно",
|
"Resend invitation": "Отправить приглашение повторно",
|
||||||
"Anyone with this link can join this workspace.": "Любой, у кого есть данная ссылка, может присоединиться к этой рабочей области.",
|
"Anyone with this link can join this workspace.": "Любой, у кого есть эта ссылка, может присоединиться к этому рабочему пространству.",
|
||||||
"Invite link": "Ссылка для приглашения",
|
"Invite link": "Ссылка для приглашения",
|
||||||
"Copy": "Копировать",
|
"Copy": "Копировать",
|
||||||
"Copied": "Скопировано",
|
"Copied": "Скопировано",
|
||||||
@ -362,29 +362,5 @@
|
|||||||
"Move page to a different space.": "Переместите страницу в другое пространство.",
|
"Move page to a different space.": "Переместите страницу в другое пространство.",
|
||||||
"Real-time editor connection lost. Retrying...": "Соединение с редактором в реальном времени потеряно. Повторная попытка...",
|
"Real-time editor connection lost. Retrying...": "Соединение с редактором в реальном времени потеряно. Повторная попытка...",
|
||||||
"Table of contents": "Содержание",
|
"Table of contents": "Содержание",
|
||||||
"Add headings (H1, H2, H3) to generate a table of contents.": "Добавьте заголовки (H1, H2, H3), чтобы создать оглавление.",
|
"Add headings (H1, H2, H3) to generate a table of contents.": "Добавьте заголовки (H1, H2, H3), чтобы создать оглавление."
|
||||||
"Share": "Поделиться",
|
|
||||||
"Public sharing": "Общий доступ",
|
|
||||||
"Shared by": "Поделился",
|
|
||||||
"Shared at": "Поделился в",
|
|
||||||
"Inherits public sharing from": "Наследует общий доступ от",
|
|
||||||
"Share to web": "Поделиться в интернете",
|
|
||||||
"Shared to web": "Размещено в интернете",
|
|
||||||
"Anyone with the link can view this page": "Любой, у кого есть ссылка, может просмотреть эту страницу",
|
|
||||||
"Make this page publicly accessible": "Сделать эту страницу общедоступной",
|
|
||||||
"Include sub-pages": "Включить подстраницы",
|
|
||||||
"Make sub-pages public too": "Сделать подстраницы также общедоступными",
|
|
||||||
"Allow search engines to index page": "Разрешить поисковым системам индексировать страницу",
|
|
||||||
"Open page": "Открыть страницу",
|
|
||||||
"Page": "Страница",
|
|
||||||
"Delete public share link": "Удалить ссылку на общий доступ",
|
|
||||||
"Delete share": "Удалить общий доступ",
|
|
||||||
"Are you sure you want to delete this shared link?": "Вы уверены, что хотите удалить эту ссылку общего доступа?",
|
|
||||||
"Publicly shared pages from spaces you are a member of will appear here": "Общие страницы из пространств, участником которых вы являетесь, появятся здесь",
|
|
||||||
"Share deleted successfully": "Общий доступ успешно удален",
|
|
||||||
"Share not found": "Общий доступ не найден",
|
|
||||||
"Failed to share page": "Не удалось поделиться страницей",
|
|
||||||
"Copy page": "Копировать страницу",
|
|
||||||
"Copy page to a different space.": "Копировать страницу в другое пространство.",
|
|
||||||
"Page copied successfully": "Страница успешно скопирована"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,390 +0,0 @@
|
|||||||
{
|
|
||||||
"Account": "Обліковий запис",
|
|
||||||
"Active": "Активний",
|
|
||||||
"Add": "Додати",
|
|
||||||
"Add group members": "Додати учасників групи",
|
|
||||||
"Add groups": "Додати групи",
|
|
||||||
"Add members": "Додати учасників",
|
|
||||||
"Add to groups": "Додати до груп",
|
|
||||||
"Add space members": "Додати учасників простору",
|
|
||||||
"Admin": "Адміністратор",
|
|
||||||
"Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "Ви впевнені, що хочете видалити цю групу? Учасники втратять доступ до матеріалів, до яких ця група має доступ.",
|
|
||||||
"Are you sure you want to delete this page?": "Ви впевнені, що хочете видалити цю сторінку?",
|
|
||||||
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Ви впевнені, що хочете видалити цього користувача з групи? Користувач втратить доступ до матеріалів, до яких ця група має доступ.",
|
|
||||||
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Ви впевнені, що хочете видалити цього користувача з простору? Користувач втратить весь доступ до цього простору.",
|
|
||||||
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Ви впевнені, що хочете відновити цю версію? Усі не збережені зміни будуть втрачені.",
|
|
||||||
"Can become members of groups and spaces in workspace": "Можуть ставати учасниками груп та просторів у робочій області",
|
|
||||||
"Can create and edit pages in space.": "Може створювати та редагувати сторінки в просторі.",
|
|
||||||
"Can edit": "Може редагувати",
|
|
||||||
"Can manage workspace": "Може керувати робочою областю",
|
|
||||||
"Can manage workspace but cannot delete it": "Може керувати робочою областю, але не може її видалити",
|
|
||||||
"Can view": "Може переглядати",
|
|
||||||
"Can view pages in space but not edit.": "Може переглядати сторінки в просторі, але не може їх редагувати.",
|
|
||||||
"Cancel": "Скасувати",
|
|
||||||
"Change email": "Змінити електронну пошту",
|
|
||||||
"Change password": "Змінити пароль",
|
|
||||||
"Change photo": "Змінити фото",
|
|
||||||
"Choose a role": "Оберіть роль",
|
|
||||||
"Choose your preferred color scheme.": "Оберіть бажану кольорову схему.",
|
|
||||||
"Choose your preferred interface language.": "Оберіть бажану мову інтерфейсу.",
|
|
||||||
"Choose your preferred page width.": "Оберіть бажану ширину сторінки.",
|
|
||||||
"Confirm": "Підтвердити",
|
|
||||||
"Copy link": "Копіювати посилання",
|
|
||||||
"Create": "Створити",
|
|
||||||
"Create group": "Створити групу",
|
|
||||||
"Create page": "Створити сторінку",
|
|
||||||
"Create space": "Створити простір",
|
|
||||||
"Create workspace": "Створити робочу область",
|
|
||||||
"Current password": "Поточний пароль",
|
|
||||||
"Dark": "Темна",
|
|
||||||
"Date": "Дата",
|
|
||||||
"Delete": "Видалити",
|
|
||||||
"Delete group": "Видалити групу",
|
|
||||||
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Ви впевнені, що хочете видалити цю сторінку? Це видалить її дочірні сторінки, а також історію сторінки. Ця дія необоротна.",
|
|
||||||
"Description": "Опис",
|
|
||||||
"Details": "Деталі",
|
|
||||||
"e.g ACME": "наприклад, ACME",
|
|
||||||
"e.g ACME Inc": "наприклад, ACME Inc",
|
|
||||||
"e.g Developers": "наприклад, Розробники",
|
|
||||||
"e.g Group for developers": "наприклад, Група для розробників",
|
|
||||||
"e.g product": "наприклад, продукт",
|
|
||||||
"e.g Product Team": "наприклад, Продуктова команда",
|
|
||||||
"e.g Sales": "наприклад, Продажі",
|
|
||||||
"e.g Space for product team": "наприклад, Простір для продуктової команди",
|
|
||||||
"e.g Space for sales team to collaborate": "наприклад, Простір для спільної роботи команди продажів",
|
|
||||||
"Edit": "Редагувати",
|
|
||||||
"Edit group": "Редагувати групу",
|
|
||||||
"Email": "Електронна пошта",
|
|
||||||
"Enter a strong password": "Введіть надійний пароль",
|
|
||||||
"Enter valid email addresses separated by comma or space max_50": "Введіть дійсні адреси електронної пошти, розділені комою або пробілом [макс: 50]",
|
|
||||||
"enter valid emails addresses": "введіть дійсні адреси електронної пошти",
|
|
||||||
"Enter your current password": "Введіть ваш поточний пароль",
|
|
||||||
"enter your full name": "введіть ваше повне ім'я",
|
|
||||||
"Enter your new password": "Введіть ваш новий пароль",
|
|
||||||
"Enter your new preferred email": "Введіть вашу нову бажану електронну пошту",
|
|
||||||
"Enter your password": "Введіть ваш пароль",
|
|
||||||
"Error fetching page data.": "Помилка при завантаженні даних сторінки.",
|
|
||||||
"Error loading page history.": "Помилка при завантаженні історії сторінки.",
|
|
||||||
"Export": "Експорт",
|
|
||||||
"Failed to create page": "Не вдалося створити сторінку",
|
|
||||||
"Failed to delete page": "Не вдалося видалити сторінку",
|
|
||||||
"Failed to fetch recent pages": "Не вдалося отримати нещодавні сторінки",
|
|
||||||
"Failed to import pages": "Не вдалося імпортувати сторінки",
|
|
||||||
"Failed to load page. An error occurred.": "Не вдалося завантажити сторінку. Сталася помилка.",
|
|
||||||
"Failed to update data": "Не вдалося оновити дані",
|
|
||||||
"Full access": "Повний доступ",
|
|
||||||
"Full page width": "Ширина на всю сторінку",
|
|
||||||
"Full width": "На всю ширину",
|
|
||||||
"General": "Загальні",
|
|
||||||
"Group": "Група",
|
|
||||||
"Group description": "Опис групи",
|
|
||||||
"Group name": "Назва групи",
|
|
||||||
"Groups": "Групи",
|
|
||||||
"Has full access to space settings and pages.": "Має повний доступ до налаштувань простору та сторінок.",
|
|
||||||
"Home": "Головна",
|
|
||||||
"Import pages": "Імпорт сторінок",
|
|
||||||
"Import pages & space settings": "Імпорт сторінок і налаштування простору",
|
|
||||||
"Importing pages": "Імпортування сторінок",
|
|
||||||
"invalid invitation link": "посилання на запрошення недійсне",
|
|
||||||
"Invitation signup": "Реєстрація за запрошенням",
|
|
||||||
"Invite by email": "Запросити електронною поштою",
|
|
||||||
"Invite members": "Запросити учасників",
|
|
||||||
"Invite new members": "Запросити нових учасників",
|
|
||||||
"Invited members who are yet to accept their invitation will appear here.": "Запрошені учасники, які ще не прийняли запрошення, з'являться тут.",
|
|
||||||
"Invited members will be granted access to spaces the groups can access": "Запрошені учасники отримають доступ до просторів, доступ до яких має група",
|
|
||||||
"Join the workspace": "Приєднатися до робочої області",
|
|
||||||
"Language": "Мова",
|
|
||||||
"Light": "Світла",
|
|
||||||
"Link copied": "Посилання скопійовано",
|
|
||||||
"Login": "Увійти",
|
|
||||||
"Logout": "Вийти",
|
|
||||||
"Manage Group": "Керування групою",
|
|
||||||
"Manage members": "Керування учасниками",
|
|
||||||
"member": "учасник",
|
|
||||||
"Member": "Учасник",
|
|
||||||
"members": "учасники",
|
|
||||||
"Members": "Учасники",
|
|
||||||
"My preferences": "Мої налаштування",
|
|
||||||
"My Profile": "Мій профіль",
|
|
||||||
"My profile": "Мій профіль",
|
|
||||||
"Name": "Ім'я",
|
|
||||||
"New email": "Нова електронна адреса",
|
|
||||||
"New page": "Нова сторінка",
|
|
||||||
"New password": "Новий пароль",
|
|
||||||
"No group found": "Групу не знайдено",
|
|
||||||
"No page history saved yet.": "Історія сторінок ще не збережена.",
|
|
||||||
"No pages yet": "Сторінок поки немає",
|
|
||||||
"No results found...": "Результати не знайдено...",
|
|
||||||
"No user found": "Користувача не знайдено",
|
|
||||||
"Overview": "Огляд",
|
|
||||||
"Owner": "Власник",
|
|
||||||
"page": "сторінка",
|
|
||||||
"Page deleted successfully": "Сторінку успішно видалено",
|
|
||||||
"Page history": "Історія сторінки",
|
|
||||||
"Page import is in progress. Please do not close this tab.": "Імпорт сторінки в процесі. Будь ласка, не закривайте цю вкладку.",
|
|
||||||
"Pages": "Сторінки",
|
|
||||||
"pages": "сторінки",
|
|
||||||
"Password": "Пароль",
|
|
||||||
"Password changed successfully": "Пароль успішно змінено",
|
|
||||||
"Pending": "В очікуванні",
|
|
||||||
"Please confirm your action": "Будь ласка, підтвердіть вашу дію",
|
|
||||||
"Preferences": "Налаштування",
|
|
||||||
"Print PDF": "Друк PDF",
|
|
||||||
"Profile": "Профіль",
|
|
||||||
"Recently updated": "Нещодавно оновлено",
|
|
||||||
"Remove": "Видалити",
|
|
||||||
"Remove group member": "Видалити учасника групи",
|
|
||||||
"Remove space member": "Видалити учасника простору",
|
|
||||||
"Restore": "Відновити",
|
|
||||||
"Role": "Роль",
|
|
||||||
"Save": "Зберегти",
|
|
||||||
"Search": "Пошук",
|
|
||||||
"Search for groups": "Пошук груп",
|
|
||||||
"Search for users": "Пошук користувачів",
|
|
||||||
"Search for users and groups": "Пошук користувачів та груп",
|
|
||||||
"Search...": "Пошук...",
|
|
||||||
"Select language": "Оберіть мову",
|
|
||||||
"Select role": "Оберіть роль",
|
|
||||||
"Select role to assign to all invited members": "Оберіть роль для всіх запрошених учасників",
|
|
||||||
"Select theme": "Оберіть тему",
|
|
||||||
"Send invitation": "Надіслати запрошення",
|
|
||||||
"Invitation sent": "Запрошення надіслано",
|
|
||||||
"Settings": "Налаштування",
|
|
||||||
"Setup workspace": "Налаштувати робочу область",
|
|
||||||
"Sign In": "Вхід",
|
|
||||||
"Sign Up": "Реєстрація",
|
|
||||||
"Slug": "Slug",
|
|
||||||
"Space": "Простір",
|
|
||||||
"Space description": "Опис простору",
|
|
||||||
"Space menu": "Меню простору",
|
|
||||||
"Space name": "Назва простору",
|
|
||||||
"Space settings": "Налаштування простору",
|
|
||||||
"Space slug": "Slug простору",
|
|
||||||
"Spaces": "Простори",
|
|
||||||
"Spaces you belong to": "Простори, до яких ви належите",
|
|
||||||
"No space found": "Простори не знайдено",
|
|
||||||
"Search for spaces": "Пошук просторів",
|
|
||||||
"Start typing to search...": "Почніть вводити для пошуку...",
|
|
||||||
"Status": "Статус",
|
|
||||||
"Successfully imported": "Успішно імпортовано",
|
|
||||||
"Successfully restored": "Успішно відновлено",
|
|
||||||
"System settings": "Системні налаштування",
|
|
||||||
"Theme": "Тема",
|
|
||||||
"To change your email, you have to enter your password and new email.": "Щоб змінити електронну пошту, вам потрібно ввести пароль і нову адресу.",
|
|
||||||
"Toggle full page width": "Перемкнути ширину на всю сторінку",
|
|
||||||
"Unable to import pages. Please try again.": "Не вдалося імпортувати сторінки. Будь ласка, спробуйте ще раз.",
|
|
||||||
"untitled": "без назви",
|
|
||||||
"Untitled": "Без назви",
|
|
||||||
"Updated successfully": "Оновлено успішно",
|
|
||||||
"User": "Користувач",
|
|
||||||
"Workspace": "Робоча область",
|
|
||||||
"Workspace Name": "Ім'я робочої області",
|
|
||||||
"Workspace settings": "Налаштування робочої області",
|
|
||||||
"You can change your password here.": "Ви можете змінити свій пароль тут.",
|
|
||||||
"Your Email": "Ваша електронна пошта",
|
|
||||||
"Your import is complete.": "Ваш імпорт завершено.",
|
|
||||||
"Your name": "Ваше ім'я",
|
|
||||||
"Your Name": "Ваше ім'я",
|
|
||||||
"Your password": "Ваш пароль",
|
|
||||||
"Your password must be a minimum of 8 characters.": "Ваш пароль повинен містити мінімум 8 символів.",
|
|
||||||
"Sidebar toggle": "Перемкнути бічну панель",
|
|
||||||
"Comments": "Коментарі",
|
|
||||||
"404 page not found": "404 сторінку не знайдено",
|
|
||||||
"Sorry, we can't find the page you are looking for.": "На жаль, ми не можемо знайти сторінку, яку ви шукаєте.",
|
|
||||||
"Take me back to homepage": "Повернутися на головну сторінку",
|
|
||||||
"Forgot password": "Забули пароль",
|
|
||||||
"Forgot your password?": "Забули пароль?",
|
|
||||||
"A password reset link has been sent to your email. Please check your inbox.": "Посилання для скидання пароля було надіслано на вашу електронну адресу. Будь ласка, перевірте вхідні повідомлення.",
|
|
||||||
"Send reset link": "Надіслати посилання для скидання",
|
|
||||||
"Password reset": "Скидання пароля",
|
|
||||||
"Your new password": "Ваш новий пароль",
|
|
||||||
"Set password": "Встановити пароль",
|
|
||||||
"Write a comment": "Написати коментар",
|
|
||||||
"Reply...": "Відповісти...",
|
|
||||||
"Error loading comments.": "Помилка при завантаженні коментарів.",
|
|
||||||
"No comments yet.": "Коментарів поки немає.",
|
|
||||||
"Edit comment": "Редагувати коментар",
|
|
||||||
"Delete comment": "Видалити коментар",
|
|
||||||
"Are you sure you want to delete this comment?": "Ви впевнені, що хочете видалити цей коментар?",
|
|
||||||
"Comment created successfully": "Коментар успішно створено",
|
|
||||||
"Error creating comment": "Помилка при створенні коментаря",
|
|
||||||
"Comment updated successfully": "Коментар успішно оновлено",
|
|
||||||
"Failed to update comment": "Не вдалося оновити коментар",
|
|
||||||
"Comment deleted successfully": "Коментар успішно видалено",
|
|
||||||
"Failed to delete comment": "Не вдалося видалити коментар",
|
|
||||||
"Comment resolved successfully": "Коментар успішно вирішено",
|
|
||||||
"Failed to resolve comment": "Не вдалося вирішити коментар",
|
|
||||||
"Revoke invitation": "Відкликати запрошення",
|
|
||||||
"Revoke": "Відкликати",
|
|
||||||
"Don't": "Ні",
|
|
||||||
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "Ви впевнені, що хочете відкликати це запрошення? Користувач не зможе приєднатися до робочої області.",
|
|
||||||
"Resend invitation": "Надіслати запрошення повторно",
|
|
||||||
"Anyone with this link can join this workspace.": "Будь-хто, хто має це посилання, може приєднатися до цієї робочої області.",
|
|
||||||
"Invite link": "Посилання для запрошення",
|
|
||||||
"Copy": "Копіювати",
|
|
||||||
"Copied": "Скопійовано",
|
|
||||||
"Select a user": "Оберіть користувача",
|
|
||||||
"Select a group": "Оберіть групу",
|
|
||||||
"Export all pages and attachments in this space.": "Експортувати всі сторінки та вкладення в цьому просторі.",
|
|
||||||
"Delete space": "Видалити простір",
|
|
||||||
"Are you sure you want to delete this space?": "Ви впевнені, що хочете видалити цей простір?",
|
|
||||||
"Delete this space with all its pages and data.": "Видалити цей простір з усіма його сторінками та даними.",
|
|
||||||
"All pages, comments, attachments and permissions in this space will be deleted irreversibly.": "Усі сторінки, коментарі, вкладення та дозволи в цьому просторі будуть видалені безповоротно.",
|
|
||||||
"Confirm space name": "Підтвердіть назву простору",
|
|
||||||
"Type the space name <b>{{spaceName}}</b> to confirm your action.": "Введіть назву простору <b>{{spaceName}}</b>, щоб підтвердити вашу дію.",
|
|
||||||
"Format": "Формат",
|
|
||||||
"Include subpages": "Включити вкладені сторінки",
|
|
||||||
"Include attachments": "Включити вкладення",
|
|
||||||
"Select export format": "Виберіть формат експорту",
|
|
||||||
"Export failed:": "Експортування не вдалося:",
|
|
||||||
"export error": "помилка експорту",
|
|
||||||
"Export page": "Експорт сторінки",
|
|
||||||
"Export space": "Експорт простору",
|
|
||||||
"Export {{type}}": "Експорт {{type}}",
|
|
||||||
"File exceeds the {{limit}} attachment limit": "Файл перевищує ліміт вкладень {{limit}}",
|
|
||||||
"Align left": "По лівому краю",
|
|
||||||
"Align right": "По правому краю",
|
|
||||||
"Align center": "По центру",
|
|
||||||
"Justify": "По ширині",
|
|
||||||
"Merge cells": "Об'єднати комірки",
|
|
||||||
"Split cell": "Розділити комірку",
|
|
||||||
"Delete column": "Видалити стовпець",
|
|
||||||
"Delete row": "Видалити рядок",
|
|
||||||
"Add left column": "Додати стовпець ліворуч",
|
|
||||||
"Add right column": "Додати стовпець праворуч",
|
|
||||||
"Add row above": "Додати рядок вище",
|
|
||||||
"Add row below": "Додати рядок нижче",
|
|
||||||
"Delete table": "Видалити таблицю",
|
|
||||||
"Info": "Інформація",
|
|
||||||
"Success": "Успішно",
|
|
||||||
"Warning": "Попередження",
|
|
||||||
"Danger": "Важливо",
|
|
||||||
"Mermaid diagram error:": "Помилка діаграми Mermaid:",
|
|
||||||
"Invalid Mermaid diagram": "Неприпустима діаграма Mermaid",
|
|
||||||
"Double-click to edit Draw.io diagram": "Клацніть двічі для редагування діаграми Draw.io",
|
|
||||||
"Exit": "Вийти",
|
|
||||||
"Save & Exit": "Зберегти та вийти",
|
|
||||||
"Double-click to edit Excalidraw diagram": "Клацніть двічі для редагування діаграми Excalidraw",
|
|
||||||
"Paste link": "Вставити посилання",
|
|
||||||
"Edit link": "Редагувати посилання",
|
|
||||||
"Remove link": "Видалити посилання",
|
|
||||||
"Add link": "Додати посилання",
|
|
||||||
"Please enter a valid url": "Будь ласка, введіть коректний url",
|
|
||||||
"Empty equation": "Порожнє рівняння",
|
|
||||||
"Invalid equation": "Неприпустиме рівняння",
|
|
||||||
"Color": "Колір",
|
|
||||||
"Text color": "Колір тексту",
|
|
||||||
"Default": "За замовчуванням",
|
|
||||||
"Blue": "Синій",
|
|
||||||
"Green": "Зелений",
|
|
||||||
"Purple": "Фіолетовий",
|
|
||||||
"Red": "Червоний",
|
|
||||||
"Yellow": "Жовтий",
|
|
||||||
"Orange": "Помаранчевий",
|
|
||||||
"Pink": "Рожевий",
|
|
||||||
"Gray": "Сірий",
|
|
||||||
"Embed link": "Вбудоване посилання",
|
|
||||||
"Invalid {{provider}} embed link": "Невірне посилання для вбудовування {{provider}}",
|
|
||||||
"Embed {{provider}}": "Вбудувати {{provider}}",
|
|
||||||
"Enter {{provider}} link to embed": "Введіть посилання для вбудовування {{provider}}",
|
|
||||||
"Bold": "Жирний",
|
|
||||||
"Italic": "Курсив",
|
|
||||||
"Underline": "Підкреслений",
|
|
||||||
"Strike": "Закреслений",
|
|
||||||
"Code": "Код",
|
|
||||||
"Comment": "Коментар",
|
|
||||||
"Text": "Текст",
|
|
||||||
"Heading 1": "Заголовок 1",
|
|
||||||
"Heading 2": "Заголовок 2",
|
|
||||||
"Heading 3": "Заголовок 3",
|
|
||||||
"To-do List": "Список справ",
|
|
||||||
"Bullet List": "Маркований список",
|
|
||||||
"Numbered List": "Нумерований список",
|
|
||||||
"Blockquote": "Блок цитування",
|
|
||||||
"Just start typing with plain text.": "Просто почніть друкувати звичайний текст.",
|
|
||||||
"Track tasks with a to-do list.": "Відстежуйте завдання за допомогою списку справ.",
|
|
||||||
"Big section heading.": "Великий заголовок розділу.",
|
|
||||||
"Medium section heading.": "Середній заголовок розділу.",
|
|
||||||
"Small section heading.": "Малий заголовок розділу.",
|
|
||||||
"Create a simple bullet list.": "Створити простий маркований список.",
|
|
||||||
"Create a list with numbering.": "Створити нумерований список.",
|
|
||||||
"Create block quote.": "Створити блок цитування.",
|
|
||||||
"Insert code snippet.": "Вставити фрагмент коду.",
|
|
||||||
"Insert horizontal rule divider": "Вставити горизонтальний роздільник",
|
|
||||||
"Upload any image from your device.": "Завантажити будь-яке зображення з вашого пристрою.",
|
|
||||||
"Upload any video from your device.": "Завантажити будь-яке відео з вашого пристрою.",
|
|
||||||
"Upload any file from your device.": "Завантажити будь-який файл з вашого пристрою.",
|
|
||||||
"Table": "Таблиця",
|
|
||||||
"Insert a table.": "Вставити таблицю.",
|
|
||||||
"Insert collapsible block.": "Вставити блок, що згортається.",
|
|
||||||
"Video": "Відео",
|
|
||||||
"Divider": "Роздільник",
|
|
||||||
"Quote": "Цитата",
|
|
||||||
"Image": "Зображення",
|
|
||||||
"File attachment": "Прикріплений файл",
|
|
||||||
"Toggle block": "Блок, що згортається",
|
|
||||||
"Callout": "Виноска",
|
|
||||||
"Insert callout notice.": "Вставити виноску з повідомленням.",
|
|
||||||
"Math inline": "Формула",
|
|
||||||
"Insert inline math equation.": "Вставити математичне рівняння в рядок.",
|
|
||||||
"Math block": "Блок формул",
|
|
||||||
"Insert math equation": "Вставити математичне рівняння",
|
|
||||||
"Mermaid diagram": "Діаграма Mermaid",
|
|
||||||
"Insert mermaid diagram": "Вставити діаграму Mermaid",
|
|
||||||
"Insert and design Drawio diagrams": "Вставити та розробити діаграми Draw.io",
|
|
||||||
"Insert current date": "Вставити поточну дату",
|
|
||||||
"Draw and sketch excalidraw diagrams": "Вставити та малювати діаграми Excalidraw",
|
|
||||||
"Multiple": "Декілька",
|
|
||||||
"Heading {{level}}": "Заголовок {{level}}",
|
|
||||||
"Toggle title": "Перемкнути заголовок",
|
|
||||||
"Write anything. Enter \"/\" for commands": "Почніть писати. Введіть \"/\" для списку команд",
|
|
||||||
"Names do not match": "Назви не співпадають",
|
|
||||||
"Today, {{time}}": "Сьогодні, {{time}}",
|
|
||||||
"Yesterday, {{time}}": "Вчора, {{time}}",
|
|
||||||
"Space created successfully": "Простір успішно створено",
|
|
||||||
"Space updated successfully": "Простір успішно оновлено",
|
|
||||||
"Space deleted successfully": "Простір успішно видалено",
|
|
||||||
"Members added successfully": "Учасників успішно додано",
|
|
||||||
"Member removed successfully": "Учасника успішно видалено",
|
|
||||||
"Member role updated successfully": "Роль учасника успішно оновлено",
|
|
||||||
"Created by: <b>{{creatorName}}</b>": "Автор: <b>{{creatorName}}</b>",
|
|
||||||
"Created at: {{time}}": "Дата створення: {{time}}",
|
|
||||||
"Edited by {{name}} {{time}}": "Змінено {{name}} {{time}}",
|
|
||||||
"Word count: {{wordCount}}": "Кількість слів: {{wordCount}}",
|
|
||||||
"Character count: {{characterCount}}": "Кількість символів: {{characterCount}}",
|
|
||||||
"New update": "Нове оновлення",
|
|
||||||
"{{latestVersion}} is available": "Доступна нова версія {{latestVersion}}",
|
|
||||||
"Delete member": "Видалити учасника",
|
|
||||||
"Member deleted successfully": "Учасника успішно видалено",
|
|
||||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "Ви впевнені, що хочете видалити цього учасника робочої області? Ця дія незворотна.",
|
|
||||||
"Move": "Перемістити",
|
|
||||||
"Move page": "Перемістити сторінку",
|
|
||||||
"Move page to a different space.": "Перемістити сторінку в інший простір.",
|
|
||||||
"Real-time editor connection lost. Retrying...": "З'єднання з редактором у реальному часі втрачено. Повторна спроба...",
|
|
||||||
"Table of contents": "Зміст",
|
|
||||||
"Add headings (H1, H2, H3) to generate a table of contents.": "Додайте заголовки (H1, H2, H3), щоб створити зміст.",
|
|
||||||
"Share": "Поділитися",
|
|
||||||
"Public sharing": "Публічний доступ",
|
|
||||||
"Shared by": "Поділився",
|
|
||||||
"Shared at": "Поділився в",
|
|
||||||
"Inherits public sharing from": "Успадковує публічний доступ від",
|
|
||||||
"Share to web": "Поділитися в інтернеті",
|
|
||||||
"Shared to web": "Розміщено в інтернеті",
|
|
||||||
"Anyone with the link can view this page": "Будь-хто, хто має посилання, може переглянути цю сторінку",
|
|
||||||
"Make this page publicly accessible": "Зробити цю сторінку загальнодоступною",
|
|
||||||
"Include sub-pages": "Включити підсторінки",
|
|
||||||
"Make sub-pages public too": "Зробити підсторінки також загальнодоступними",
|
|
||||||
"Allow search engines to index page": "Дозволити пошуковим системам індексувати сторінку",
|
|
||||||
"Open page": "Відкрити сторінку",
|
|
||||||
"Page": "Сторінка",
|
|
||||||
"Delete public share link": "Видалити посилання на публічний доступ",
|
|
||||||
"Delete share": "Видалити спільний доступ",
|
|
||||||
"Are you sure you want to delete this shared link?": "Ви впевнені, що хочете видалити це посилання спільного доступу?",
|
|
||||||
"Publicly shared pages from spaces you are a member of will appear here": "Публічні сторінки з просторів, учасником яких ви є, з'являться тут",
|
|
||||||
"Share deleted successfully": "Спільний доступ успішно видалено",
|
|
||||||
"Share not found": "Спільний доступ не знайдено",
|
|
||||||
"Failed to share page": "Не вдалося поділитися сторінкою",
|
|
||||||
"Copy page": "Копіювати сторінки",
|
|
||||||
"Copy page to a different space.": "Скопіювати сторінку в інший простір.",
|
|
||||||
"Page copied successfully": "Сторінку успішно скопійовано"
|
|
||||||
}
|
|
||||||
@ -298,7 +298,7 @@
|
|||||||
"Heading 2": "2 级标题",
|
"Heading 2": "2 级标题",
|
||||||
"Heading 3": "3 级标题",
|
"Heading 3": "3 级标题",
|
||||||
"To-do List": "代办列表",
|
"To-do List": "代办列表",
|
||||||
"Bullet List": "无序列表",
|
"Bullet List": "无需列表",
|
||||||
"Numbered List": "有序列表",
|
"Numbered List": "有序列表",
|
||||||
"Blockquote": "引用块",
|
"Blockquote": "引用块",
|
||||||
"Just start typing with plain text.": "只需开始键入纯文本",
|
"Just start typing with plain text.": "只需开始键入纯文本",
|
||||||
@ -362,29 +362,5 @@
|
|||||||
"Move page to a different space.": "将页面移动到不同的空间。",
|
"Move page to a different space.": "将页面移动到不同的空间。",
|
||||||
"Real-time editor connection lost. Retrying...": "实时编辑器连接丢失。重试中……",
|
"Real-time editor connection lost. Retrying...": "实时编辑器连接丢失。重试中……",
|
||||||
"Table of contents": "目录",
|
"Table of contents": "目录",
|
||||||
"Add headings (H1, H2, H3) to generate a table of contents.": "添加标题(H1,H2,H3)以生成目录。",
|
"Add headings (H1, H2, H3) to generate a table of contents.": "添加标题(H1,H2,H3)以生成目录。"
|
||||||
"Share": "分享",
|
|
||||||
"Public sharing": "公开分享",
|
|
||||||
"Shared by": "分享者",
|
|
||||||
"Shared at": "分享时间",
|
|
||||||
"Inherits public sharing from": "继承自的公开分享",
|
|
||||||
"Share to web": "分享到网页",
|
|
||||||
"Shared to web": "已分享到网页",
|
|
||||||
"Anyone with the link can view this page": "任何有链接的人都可以查看此页面",
|
|
||||||
"Make this page publicly accessible": "使此页面可公开访问",
|
|
||||||
"Include sub-pages": "包括子页面",
|
|
||||||
"Make sub-pages public too": "将子页面也设为公开",
|
|
||||||
"Allow search engines to index page": "允许搜索引擎索引页面",
|
|
||||||
"Open page": "打开页面",
|
|
||||||
"Page": "页面",
|
|
||||||
"Delete public share link": "删除公开分享链接",
|
|
||||||
"Delete share": "删除分享",
|
|
||||||
"Are you sure you want to delete this shared link?": "您确定要删除此分享链接吗?",
|
|
||||||
"Publicly shared pages from spaces you are a member of will appear here": "您所在空间的公开共享页面会显示在此处",
|
|
||||||
"Share deleted successfully": "分享已成功删除",
|
|
||||||
"Share not found": "未找到分享",
|
|
||||||
"Failed to share page": "页面分享失败",
|
|
||||||
"Copy page": "复制页面",
|
|
||||||
"Copy page to a different space.": "将页面复制到不同的空间。",
|
|
||||||
"Page copied successfully": "页面复制成功"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,16 +26,10 @@ import { useTranslation } from "react-i18next";
|
|||||||
import Security from "@/ee/security/pages/security.tsx";
|
import Security from "@/ee/security/pages/security.tsx";
|
||||||
import License from "@/ee/licence/pages/license.tsx";
|
import License from "@/ee/licence/pages/license.tsx";
|
||||||
import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx";
|
import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx";
|
||||||
import SharedPage from "@/pages/share/shared-page.tsx";
|
|
||||||
import Shares from "@/pages/settings/shares/shares.tsx";
|
|
||||||
import ShareLayout from "@/features/share/components/share-layout.tsx";
|
|
||||||
import ShareRedirect from '@/pages/share/share-redirect.tsx';
|
|
||||||
import { useTrackOrigin } from "@/hooks/use-track-origin";
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
useRedirectToCloudSelect();
|
useRedirectToCloudSelect();
|
||||||
useTrackOrigin();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -57,12 +51,6 @@ export default function App() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Route element={<ShareLayout />}>
|
|
||||||
<Route path={"/share/:shareId/p/:pageSlug"} element={<SharedPage />} />
|
|
||||||
<Route path={"/share/p/:pageSlug"} element={<SharedPage />} />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path={"/share/:shareId"} element={<ShareRedirect />} />
|
|
||||||
<Route path={"/p/:pageSlug"} element={<PageRedirect />} />
|
<Route path={"/p/:pageSlug"} element={<PageRedirect />} />
|
||||||
|
|
||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
@ -90,7 +78,6 @@ export default function App() {
|
|||||||
<Route path={"groups"} element={<Groups />} />
|
<Route path={"groups"} element={<Groups />} />
|
||||||
<Route path={"groups/:groupId"} element={<GroupInfo />} />
|
<Route path={"groups/:groupId"} element={<GroupInfo />} />
|
||||||
<Route path={"spaces"} element={<Spaces />} />
|
<Route path={"spaces"} element={<Spaces />} />
|
||||||
<Route path={"sharing"} element={<Shares />} />
|
|
||||||
<Route path={"security"} element={<Security />} />
|
<Route path={"security"} element={<Security />} />
|
||||||
{!isCloud() && <Route path={"license"} element={<License />} />}
|
{!isCloud() && <Route path={"license"} element={<License />} />}
|
||||||
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
||||||
|
|||||||
@ -1,20 +0,0 @@
|
|||||||
import { rem } from "@mantine/core";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
size?: number | string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ConfluenceIcon({ size }: Props) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
style={{ width: rem(size), height: rem(size) }}
|
|
||||||
>
|
|
||||||
<path d="M.87 18.257c-.248.382-.53.875-.763 1.245a.764.764 0 0 0 .255 1.04l4.965 3.054a.764.764 0 0 0 1.058-.26c.199-.332.454-.763.733-1.221 1.967-3.247 3.945-2.853 7.508-1.146l4.957 2.337a.764.764 0 0 0 1.028-.382l2.364-5.346a.764.764 0 0 0-.382-1 599.851 599.851 0 0 1-4.965-2.361C10.911 10.97 5.224 11.185.87 18.257zM23.131 5.743c.249-.405.531-.875.764-1.25a.764.764 0 0 0-.256-1.034L18.675.404a.764.764 0 0 0-1.058.26c-.195.335-.451.763-.734 1.225-1.966 3.246-3.945 2.85-7.508 1.146L4.437.694a.764.764 0 0 0-1.027.382L1.046 6.422a.764.764 0 0 0 .382 1c1.039.49 3.105 1.467 4.965 2.361 6.698 3.246 12.392 3.029 16.738-4.04z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -14,7 +14,6 @@ import { AppHeader } from "@/components/layouts/global/app-header.tsx";
|
|||||||
import Aside from "@/components/layouts/global/aside.tsx";
|
import Aside from "@/components/layouts/global/aside.tsx";
|
||||||
import classes from "./app-shell.module.css";
|
import classes from "./app-shell.module.css";
|
||||||
import { useTrialEndAction } from "@/ee/hooks/use-trial-end-action.tsx";
|
import { useTrialEndAction } from "@/ee/hooks/use-trial-end-action.tsx";
|
||||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
|
||||||
|
|
||||||
export default function GlobalAppShell({
|
export default function GlobalAppShell({
|
||||||
children,
|
children,
|
||||||
@ -23,7 +22,6 @@ export default function GlobalAppShell({
|
|||||||
}) {
|
}) {
|
||||||
useTrialEndAction();
|
useTrialEndAction();
|
||||||
const [mobileOpened] = useAtom(mobileSidebarAtom);
|
const [mobileOpened] = useAtom(mobileSidebarAtom);
|
||||||
const toggleMobile = useToggleSidebar(mobileSidebarAtom);
|
|
||||||
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
||||||
const [{ isAsideOpen }] = useAtom(asideStateAtom);
|
const [{ isAsideOpen }] = useAtom(asideStateAtom);
|
||||||
const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom);
|
const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom);
|
||||||
@ -113,7 +111,7 @@ export default function GlobalAppShell({
|
|||||||
)}
|
)}
|
||||||
<AppShell.Main>
|
<AppShell.Main>
|
||||||
{isSettingsRoute ? (
|
{isSettingsRoute ? (
|
||||||
<Container size={850}>{children}</Container>
|
<Container size={800}>{children}</Container>
|
||||||
) : (
|
) : (
|
||||||
children
|
children
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,10 +0,0 @@
|
|||||||
import { atom, WritableAtom } from "jotai";
|
|
||||||
|
|
||||||
export const settingsOriginAtom: WritableAtom<string | null, [string | null], void> = atom(
|
|
||||||
null,
|
|
||||||
(get, set, newValue) => {
|
|
||||||
if (get(settingsOriginAtom) !== newValue) {
|
|
||||||
set(settingsOriginAtom, newValue);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
@ -8,8 +8,7 @@ import { getGroups } from "@/features/group/services/group-service.ts";
|
|||||||
import { QueryParams } from "@/lib/types.ts";
|
import { QueryParams } from "@/lib/types.ts";
|
||||||
import { getWorkspaceMembers } from "@/features/workspace/services/workspace-service.ts";
|
import { getWorkspaceMembers } from "@/features/workspace/services/workspace-service.ts";
|
||||||
import { getLicenseInfo } from "@/ee/licence/services/license-service.ts";
|
import { getLicenseInfo } from "@/ee/licence/services/license-service.ts";
|
||||||
import { getSsoProviders } from "@/ee/security/services/security-service.ts";
|
import { getSsoProviders } from '@/ee/security/services/security-service.ts';
|
||||||
import { getShares } from "@/features/share/services/share-service.ts";
|
|
||||||
|
|
||||||
export const prefetchWorkspaceMembers = () => {
|
export const prefetchWorkspaceMembers = () => {
|
||||||
const params = { limit: 100, page: 1, query: "" } as QueryParams;
|
const params = { limit: 100, page: 1, query: "" } as QueryParams;
|
||||||
@ -58,10 +57,3 @@ export const prefetchSsoProviders = () => {
|
|||||||
queryFn: () => getSsoProviders(),
|
queryFn: () => getSsoProviders(),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const prefetchShares = () => {
|
|
||||||
queryClient.prefetchQuery({
|
|
||||||
queryKey: ["share-list", { page: 1 }],
|
|
||||||
queryFn: () => getShares({ page: 1, limit: 100 }),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|||||||
@ -11,9 +11,8 @@ import {
|
|||||||
IconCoin,
|
IconCoin,
|
||||||
IconLock,
|
IconLock,
|
||||||
IconKey,
|
IconKey,
|
||||||
IconWorld,
|
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||||
import classes from "./settings.module.css";
|
import classes from "./settings.module.css";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { isCloud } from "@/lib/config.ts";
|
import { isCloud } from "@/lib/config.ts";
|
||||||
@ -24,15 +23,11 @@ import {
|
|||||||
prefetchBilling,
|
prefetchBilling,
|
||||||
prefetchGroups,
|
prefetchGroups,
|
||||||
prefetchLicense,
|
prefetchLicense,
|
||||||
prefetchShares,
|
|
||||||
prefetchSpaces,
|
prefetchSpaces,
|
||||||
prefetchSsoProviders,
|
prefetchSsoProviders,
|
||||||
prefetchWorkspaceMembers,
|
prefetchWorkspaceMembers,
|
||||||
} from "@/components/settings/settings-queries.tsx";
|
} from "@/components/settings/settings-queries.tsx";
|
||||||
import AppVersion from "@/components/settings/app-version.tsx";
|
import AppVersion from "@/components/settings/app-version.tsx";
|
||||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
|
||||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
|
||||||
import { useSettingsNavigation } from "@/hooks/use-settings-navigation";
|
|
||||||
|
|
||||||
interface DataItem {
|
interface DataItem {
|
||||||
label: string;
|
label: string;
|
||||||
@ -87,7 +82,6 @@ const groupedData: DataGroup[] = [
|
|||||||
},
|
},
|
||||||
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
|
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
|
||||||
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
|
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
|
||||||
{ label: "Public sharing", icon: IconWorld, path: "/settings/sharing" },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -106,11 +100,9 @@ export default function SettingsSidebar() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [active, setActive] = useState(location.pathname);
|
const [active, setActive] = useState(location.pathname);
|
||||||
const { goBack } = useSettingsNavigation();
|
const navigate = useNavigate();
|
||||||
const { isAdmin } = useUserRole();
|
const { isAdmin } = useUserRole();
|
||||||
const [workspace] = useAtom(workspaceAtom);
|
const [workspace] = useAtom(workspaceAtom);
|
||||||
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
|
|
||||||
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActive(location.pathname);
|
setActive(location.pathname);
|
||||||
@ -178,9 +170,6 @@ export default function SettingsSidebar() {
|
|||||||
case "Security & SSO":
|
case "Security & SSO":
|
||||||
prefetchHandler = prefetchSsoProviders;
|
prefetchHandler = prefetchSsoProviders;
|
||||||
break;
|
break;
|
||||||
case "Public sharing":
|
|
||||||
prefetchHandler = prefetchShares;
|
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -192,11 +181,6 @@ export default function SettingsSidebar() {
|
|||||||
data-active={active.startsWith(item.path) || undefined}
|
data-active={active.startsWith(item.path) || undefined}
|
||||||
key={item.label}
|
key={item.label}
|
||||||
to={item.path}
|
to={item.path}
|
||||||
onClick={() => {
|
|
||||||
if (mobileSidebarOpened) {
|
|
||||||
toggleMobileSidebar();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<item.icon className={classes.linkIcon} stroke={2} />
|
<item.icon className={classes.linkIcon} stroke={2} />
|
||||||
<span>{t(item.label)}</span>
|
<span>{t(item.label)}</span>
|
||||||
@ -211,12 +195,7 @@ export default function SettingsSidebar() {
|
|||||||
<div className={classes.navbar}>
|
<div className={classes.navbar}>
|
||||||
<Group className={classes.title} justify="flex-start">
|
<Group className={classes.title} justify="flex-start">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={() => {
|
onClick={() => navigate(-1)}
|
||||||
goBack();
|
|
||||||
if (mobileSidebarOpened) {
|
|
||||||
toggleMobileSidebar();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
variant="transparent"
|
variant="transparent"
|
||||||
c="gray"
|
c="gray"
|
||||||
aria-label="Back"
|
aria-label="Back"
|
||||||
|
|||||||
@ -1,19 +0,0 @@
|
|||||||
.dark {
|
|
||||||
@mixin dark {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin light {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.light {
|
|
||||||
@mixin light {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin dark {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,28 +1,13 @@
|
|||||||
import {
|
import { Button, Group, useMantineColorScheme } from '@mantine/core';
|
||||||
ActionIcon,
|
|
||||||
Tooltip,
|
|
||||||
useComputedColorScheme,
|
|
||||||
useMantineColorScheme,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { IconMoon, IconSun } from "@tabler/icons-react";
|
|
||||||
import classes from "./theme-toggle.module.css";
|
|
||||||
|
|
||||||
export function ThemeToggle() {
|
export function ThemeToggle() {
|
||||||
const { setColorScheme } = useMantineColorScheme();
|
const { setColorScheme } = useMantineColorScheme();
|
||||||
const computedColorScheme = useComputedColorScheme();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip label="Toggle Color Scheme">
|
<Group justify="center" mt="xl">
|
||||||
<ActionIcon
|
<Button onClick={() => setColorScheme('light')}>Light</Button>
|
||||||
variant="default"
|
<Button onClick={() => setColorScheme('dark')}>Dark</Button>
|
||||||
onClick={() => {
|
<Button onClick={() => setColorScheme('auto')}>Auto</Button>
|
||||||
setColorScheme(computedColorScheme === "light" ? "dark" : "light");
|
</Group>
|
||||||
}}
|
|
||||||
aria-label="Toggle color scheme"
|
|
||||||
>
|
|
||||||
<IconSun className={classes.light} size={18} stroke={1.5} />
|
|
||||||
<IconMoon className={classes.dark} size={18} stroke={1.5} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -112,21 +112,8 @@ export default function BillingDetails() {
|
|||||||
fz="xs"
|
fz="xs"
|
||||||
className={classes.label}
|
className={classes.label}
|
||||||
>
|
>
|
||||||
Cost
|
Total
|
||||||
</Text>
|
</Text>
|
||||||
{billing.billingScheme === "tiered" && (
|
|
||||||
<>
|
|
||||||
<Text fw={700} fz="lg">
|
|
||||||
${billing.amount / 100} {billing.currency.toUpperCase()}
|
|
||||||
</Text>
|
|
||||||
<Text c="dimmed" fz="sm">
|
|
||||||
per {billing.interval}
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{billing.billingScheme !== "tiered" && (
|
|
||||||
<>
|
|
||||||
<Text fw={700} fz="lg">
|
<Text fw={700} fz="lg">
|
||||||
{(billing.amount / 100) * billing.quantity}{" "}
|
{(billing.amount / 100) * billing.quantity}{" "}
|
||||||
{billing.currency.toUpperCase()}
|
{billing.currency.toUpperCase()}
|
||||||
@ -134,36 +121,9 @@ export default function BillingDetails() {
|
|||||||
<Text c="dimmed" fz="sm">
|
<Text c="dimmed" fz="sm">
|
||||||
${billing.amount / 100} /user/{billing.interval}
|
${billing.amount / 100} /user/{billing.interval}
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{billing.billingScheme === "tiered" && billing.tieredUpTo && (
|
|
||||||
<Paper p="md" radius="md">
|
|
||||||
<Group justify="apart">
|
|
||||||
<div>
|
|
||||||
<Text
|
|
||||||
c="dimmed"
|
|
||||||
tt="uppercase"
|
|
||||||
fw={700}
|
|
||||||
fz="xs"
|
|
||||||
className={classes.label}
|
|
||||||
>
|
|
||||||
Current Tier
|
|
||||||
</Text>
|
|
||||||
<Text fw={700} fz="lg">
|
|
||||||
For up to {billing.tieredUpTo} users
|
|
||||||
</Text>
|
|
||||||
{/*billing.tieredFlatAmount && (
|
|
||||||
<Text c="dimmed" fz="sm">
|
|
||||||
</Text>
|
|
||||||
)*/}
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,28 +2,24 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
List,
|
List,
|
||||||
|
SegmentedControl,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
Title,
|
Title,
|
||||||
Text,
|
Text,
|
||||||
Group,
|
Group,
|
||||||
Select,
|
|
||||||
Container,
|
|
||||||
Stack,
|
|
||||||
Badge,
|
|
||||||
Flex,
|
|
||||||
Switch,
|
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { IconCheck } from "@tabler/icons-react";
|
import { IconCheck } from "@tabler/icons-react";
|
||||||
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
|
|
||||||
import { useBillingPlans } from "@/ee/billing/queries/billing-query.ts";
|
import { useBillingPlans } from "@/ee/billing/queries/billing-query.ts";
|
||||||
|
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
|
||||||
|
|
||||||
export default function BillingPlans() {
|
export default function BillingPlans() {
|
||||||
const { data: plans } = useBillingPlans();
|
const { data: plans } = useBillingPlans();
|
||||||
const [isAnnual, setIsAnnual] = useState(true);
|
const [interval, setInterval] = useState("yearly");
|
||||||
const [selectedTierValue, setSelectedTierValue] = useState<string | null>(
|
|
||||||
null,
|
if (!plans) {
|
||||||
);
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const handleCheckout = async (priceId: string) => {
|
const handleCheckout = async (priceId: string) => {
|
||||||
try {
|
try {
|
||||||
@ -36,153 +32,84 @@ export default function BillingPlans() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!plans || plans.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstPlan = plans[0];
|
|
||||||
|
|
||||||
// Set initial tier value if not set
|
|
||||||
if (!selectedTierValue && firstPlan.pricingTiers.length > 0) {
|
|
||||||
setSelectedTierValue(firstPlan.pricingTiers[0].upTo.toString());
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedTierValue) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectData = firstPlan.pricingTiers
|
|
||||||
.filter((tier) => !tier.custom)
|
|
||||||
.map((tier, index) => {
|
|
||||||
const prevMaxUsers =
|
|
||||||
index > 0 ? firstPlan.pricingTiers[index - 1].upTo : 0;
|
|
||||||
return {
|
|
||||||
value: tier.upTo.toString(),
|
|
||||||
label: `${prevMaxUsers + 1}-${tier.upTo} users`,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="xl" py="xl">
|
<Group justify="center" p="xl">
|
||||||
{/* Controls Section */}
|
{plans.map((plan) => {
|
||||||
<Stack gap="xl" mb="md">
|
const price =
|
||||||
{/* Team Size and Billing Controls */}
|
interval === "monthly" ? plan.price.monthly : plan.price.yearly;
|
||||||
<Group justify="center" align="center" gap="sm">
|
const priceId = interval === "monthly" ? plan.monthlyId : plan.yearlyId;
|
||||||
<Select
|
const yearlyMonthPrice = parseInt(plan.price.yearly) / 12;
|
||||||
label="Team size"
|
|
||||||
description="Select the number of users"
|
|
||||||
value={selectedTierValue}
|
|
||||||
onChange={setSelectedTierValue}
|
|
||||||
data={selectData}
|
|
||||||
w={250}
|
|
||||||
size="md"
|
|
||||||
allowDeselect={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Group justify="center" align="start">
|
|
||||||
<Flex justify="center" gap="md" align="center">
|
|
||||||
<Text size="md">Monthly</Text>
|
|
||||||
<Switch
|
|
||||||
defaultChecked={isAnnual}
|
|
||||||
onChange={(event) => setIsAnnual(event.target.checked)}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
<Text size="md">
|
|
||||||
Annually
|
|
||||||
<Badge component="span" variant="light" color="blue">
|
|
||||||
15% OFF
|
|
||||||
</Badge>
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{/* Plans Grid */}
|
|
||||||
<Group justify="center" gap="lg" align="stretch">
|
|
||||||
{plans.map((plan, index) => {
|
|
||||||
const tieredPlan = plan;
|
|
||||||
const planSelectedTier =
|
|
||||||
tieredPlan.pricingTiers.find(
|
|
||||||
(tier) => tier.upTo.toString() === selectedTierValue,
|
|
||||||
) || tieredPlan.pricingTiers[0];
|
|
||||||
|
|
||||||
const price = isAnnual
|
|
||||||
? planSelectedTier.yearly
|
|
||||||
: planSelectedTier.monthly;
|
|
||||||
const priceId = isAnnual ? plan.yearlyId : plan.monthlyId;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={plan.name}
|
key={plan.name}
|
||||||
withBorder
|
withBorder
|
||||||
radius="lg"
|
radius="md"
|
||||||
shadow="sm"
|
shadow="sm"
|
||||||
p="xl"
|
p="xl"
|
||||||
w={350}
|
w={300}
|
||||||
miw={300}
|
|
||||||
style={{
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Stack gap="lg">
|
<SegmentedControl
|
||||||
{/* Plan Header */}
|
value={interval}
|
||||||
<Stack gap="xs">
|
onChange={setInterval}
|
||||||
<Title order={3} size="h4">
|
fullWidth
|
||||||
|
data={[
|
||||||
|
{ label: "Monthly", value: "monthly" },
|
||||||
|
{ label: "Yearly (25% OFF)", value: "yearly" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Title order={3} ta="center" mt="sm" mb="xs">
|
||||||
{plan.name}
|
{plan.name}
|
||||||
</Title>
|
</Title>
|
||||||
{plan.description && (
|
<Text ta="center" size="lg" fw={700}>
|
||||||
<Text size="sm" c="dimmed">
|
{interval === "monthly" && (
|
||||||
{plan.description}
|
<>
|
||||||
|
${price}{" "}
|
||||||
|
<Text span size="sm" fw={500} c="dimmed">
|
||||||
|
/user/month
|
||||||
</Text>
|
</Text>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
{interval === "yearly" && (
|
||||||
|
<>
|
||||||
{/* Pricing */}
|
${yearlyMonthPrice}{" "}
|
||||||
<Stack gap="xs">
|
<Text span size="sm" fw={500} c="dimmed">
|
||||||
<Group align="baseline" gap="xs">
|
/user/month
|
||||||
<Title order={1} size="h1">
|
|
||||||
${isAnnual ? (price / 12).toFixed(0) : price}
|
|
||||||
</Title>
|
|
||||||
<Text size="lg" c="dimmed">
|
|
||||||
per {isAnnual ? "month" : "month"}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
{isAnnual && (
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
Billed annually
|
|
||||||
</Text>
|
</Text>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<Text size="md" fw={500}>
|
<br/>
|
||||||
for up to {planSelectedTier.upTo} users
|
<Text span ta="center" size="md" fw={500} c="dimmed">
|
||||||
|
billed {interval}
|
||||||
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{/* CTA Button */}
|
<Card.Section mt="lg">
|
||||||
<Button onClick={() => handleCheckout(priceId)} fullWidth>
|
<Button onClick={() => handleCheckout(priceId)} fullWidth>
|
||||||
Upgrade
|
Subscribe
|
||||||
</Button>
|
</Button>
|
||||||
|
</Card.Section>
|
||||||
|
|
||||||
{/* Features */}
|
<Card.Section mt="md">
|
||||||
<List
|
<List
|
||||||
spacing="xs"
|
spacing="xs"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
center
|
||||||
icon={
|
icon={
|
||||||
<ThemeIcon size={20} radius="xl">
|
<ThemeIcon variant="light" size={24} radius="xl">
|
||||||
<IconCheck size={14} />
|
<IconCheck size={16} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{plan.features.map((feature, featureIndex) => (
|
{plan.features.map((feature, index) => (
|
||||||
<List.Item key={featureIndex}>{feature}</List.Item>
|
<List.Item key={index}>{feature}</List.Item>
|
||||||
))}
|
))}
|
||||||
</List>
|
</List>
|
||||||
</Stack>
|
</Card.Section>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Group>
|
</Group>
|
||||||
</Container>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { Alert } from "@mantine/core";
|
import { Alert } from "@mantine/core";
|
||||||
import { useBillingQuery } from "@/ee/billing/queries/billing-query.ts";
|
import { useBillingQuery } from "@/ee/billing/queries/billing-query.ts";
|
||||||
import useTrial from "@/ee/hooks/use-trial.tsx";
|
import useTrial from "@/ee/hooks/use-trial.tsx";
|
||||||
import { getBillingTrialDays } from '@/lib/config.ts';
|
|
||||||
|
|
||||||
export default function BillingTrial() {
|
export default function BillingTrial() {
|
||||||
const { data: billing, isLoading } = useBillingQuery();
|
const { data: billing, isLoading } = useBillingQuery();
|
||||||
@ -16,14 +15,14 @@ export default function BillingTrial() {
|
|||||||
{trialDaysLeft > 0 && !billing && (
|
{trialDaysLeft > 0 && !billing && (
|
||||||
<Alert title="Your Trial is Active 🎉" color="blue" radius="md">
|
<Alert title="Your Trial is Active 🎉" color="blue" radius="md">
|
||||||
You have {trialDaysLeft} {trialDaysLeft === 1 ? "day" : "days"} left
|
You have {trialDaysLeft} {trialDaysLeft === 1 ? "day" : "days"} left
|
||||||
in your {getBillingTrialDays()}-day free trial. Please subscribe to a paid plan before your trial
|
in your 7-day trial. Please subscribe to a plan before your trial
|
||||||
ends.
|
ends.
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{trialDaysLeft === 0 && (
|
{trialDaysLeft === 0 && (
|
||||||
<Alert title="Your Trial has ended" color="red" radius="md">
|
<Alert title="Your Trial has ended" color="red" radius="md">
|
||||||
Your {getBillingTrialDays()}-day free trial has come to an end. Please subscribe to a paid plan to
|
Your 7-day trial has come to an end. Please subscribe to a plan to
|
||||||
continue using this service.
|
continue using this service.
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
export enum BillingPlan {
|
export enum BillingPlan {
|
||||||
STANDARD = "standard",
|
STANDARD = "standard",
|
||||||
BUSINESS = "business",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IBilling {
|
export interface IBilling {
|
||||||
@ -25,11 +24,6 @@ export interface IBilling {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
deletedAt: Date;
|
deletedAt: Date;
|
||||||
billingScheme: string | null;
|
|
||||||
tieredUpTo: string | null;
|
|
||||||
tieredFlatAmount: number | null;
|
|
||||||
tieredUnitAmount: number | null;
|
|
||||||
planName: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ICheckoutLink {
|
export interface ICheckoutLink {
|
||||||
@ -47,18 +41,9 @@ export interface IBillingPlan {
|
|||||||
monthlyId: string;
|
monthlyId: string;
|
||||||
yearlyId: string;
|
yearlyId: string;
|
||||||
currency: string;
|
currency: string;
|
||||||
price?: {
|
price: {
|
||||||
monthly: string;
|
monthly: string;
|
||||||
yearly: string;
|
yearly: string;
|
||||||
};
|
};
|
||||||
features: string[];
|
features: string[];
|
||||||
billingScheme: string | null;
|
|
||||||
pricingTiers: PricingTier[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PricingTier {
|
|
||||||
upTo: number;
|
|
||||||
monthly?: number;
|
|
||||||
yearly?: number;
|
|
||||||
custom?: boolean;
|
|
||||||
}
|
}
|
||||||
@ -2,18 +2,14 @@ import { useAtom } from "jotai";
|
|||||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
import { BillingPlan } from "@/ee/billing/types/billing.types.ts";
|
import { BillingPlan } from "@/ee/billing/types/billing.types.ts";
|
||||||
|
|
||||||
const usePlan = () => {
|
export const usePlan = () => {
|
||||||
const [workspace] = useAtom(workspaceAtom);
|
const [workspace] = useAtom(workspaceAtom);
|
||||||
|
|
||||||
const isStandard =
|
const isStandard =
|
||||||
typeof workspace?.plan === "string" &&
|
typeof workspace?.plan === "string" &&
|
||||||
workspace?.plan.toLowerCase() === BillingPlan.STANDARD.toLowerCase();
|
workspace?.plan.toLowerCase() === BillingPlan.STANDARD.toLowerCase();
|
||||||
|
|
||||||
const isBusiness =
|
return { isStandard };
|
||||||
typeof workspace?.plan === "string" &&
|
|
||||||
workspace?.plan.toLowerCase() === BillingPlan.BUSINESS.toLowerCase();
|
|
||||||
|
|
||||||
return { isStandard, isBusiness };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default usePlan;
|
export default usePlan;
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { getBillingTrialDays, isCloud } from "@/lib/config.ts";
|
import { isCloud } from "@/lib/config.ts";
|
||||||
import APP_ROUTE from "@/lib/app-route.ts";
|
import APP_ROUTE from "@/lib/app-route.ts";
|
||||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
@ -18,7 +18,7 @@ export const useTrialEndAction = () => {
|
|||||||
notifications.show({
|
notifications.show({
|
||||||
position: "top-right",
|
position: "top-right",
|
||||||
color: "red",
|
color: "red",
|
||||||
title: `Your ${getBillingTrialDays()}-day trial has ended`,
|
title: "Your 7-day trial has ended",
|
||||||
message:
|
message:
|
||||||
"Please upgrade to a paid plan or contact your workspace admin.",
|
"Please upgrade to a paid plan or contact your workspace admin.",
|
||||||
autoClose: false,
|
autoClose: false,
|
||||||
|
|||||||
@ -15,7 +15,7 @@ export default function EnforceSso() {
|
|||||||
<Text size="md">{t("Enforce SSO")}</Text>
|
<Text size="md">{t("Enforce SSO")}</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{t(
|
{t(
|
||||||
"Once enforced, members will not be able to login with email and password.",
|
"Once enforced, members will not able able to login with email and password.",
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -10,13 +10,11 @@ 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 useLicense from "@/ee/hooks/use-license.tsx";
|
||||||
import usePlan from "@/ee/hooks/use-plan.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 { hasLicenseKey } = useLicense();
|
||||||
const { isBusiness } = usePlan();
|
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return null;
|
return null;
|
||||||
@ -37,7 +35,8 @@ export default function Security() {
|
|||||||
Single sign-on (SSO)
|
Single sign-on (SSO)
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
{(isCloud() && isBusiness) || (!isCloud() && hasLicenseKey) ? (
|
{/*TODO: revisit when we add a second plan */}
|
||||||
|
{!isCloud() && hasLicenseKey ? (
|
||||||
<>
|
<>
|
||||||
<EnforceSso />
|
<EnforceSso />
|
||||||
<Divider my="lg" />
|
<Divider my="lg" />
|
||||||
|
|||||||
@ -18,7 +18,6 @@ import classes from "@/features/auth/components/auth.module.css";
|
|||||||
import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-query.ts";
|
import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-query.ts";
|
||||||
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
|
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import SsoLogin from "@/ee/components/sso-login.tsx";
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
name: z.string().trim().min(1),
|
name: z.string().trim().min(1),
|
||||||
@ -72,9 +71,6 @@ export function InviteSignUpForm() {
|
|||||||
{t("Join the workspace")}
|
{t("Join the workspace")}
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
<SsoLogin />
|
|
||||||
|
|
||||||
{!invitation.enforceSso && (
|
|
||||||
<Stack align="stretch" justify="center" gap="xl">
|
<Stack align="stretch" justify="center" gap="xl">
|
||||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||||
<TextInput
|
<TextInput
|
||||||
@ -108,7 +104,6 @@ export function InviteSignUpForm() {
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -21,7 +21,7 @@ import { Link } from "react-router-dom";
|
|||||||
import APP_ROUTE from "@/lib/app-route.ts";
|
import APP_ROUTE from "@/lib/app-route.ts";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
workspaceName: z.string().trim().max(50).optional(),
|
workspaceName: z.string().trim().min(3).max(50),
|
||||||
name: z.string().min(1).max(50),
|
name: z.string().min(1).max(50),
|
||||||
email: z
|
email: z
|
||||||
.string()
|
.string()
|
||||||
@ -60,7 +60,6 @@ export function SetupWorkspaceForm() {
|
|||||||
{isCloud() && <SsoCloudSignup />}
|
{isCloud() && <SsoCloudSignup />}
|
||||||
|
|
||||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||||
{!isCloud() && (
|
|
||||||
<TextInput
|
<TextInput
|
||||||
id="workspaceName"
|
id="workspaceName"
|
||||||
type="text"
|
type="text"
|
||||||
@ -70,7 +69,6 @@ export function SetupWorkspaceForm() {
|
|||||||
mt="md"
|
mt="md"
|
||||||
{...form.getInputProps("workspaceName")}
|
{...form.getInputProps("workspaceName")}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
id="name"
|
id="name"
|
||||||
|
|||||||
@ -10,7 +10,7 @@ export interface IRegister {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ISetupWorkspace {
|
export interface ISetupWorkspace {
|
||||||
workspaceName?: string;
|
workspaceName: string;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
@ -36,5 +36,5 @@ export interface IVerifyUserToken {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ICollabToken {
|
export interface ICollabToken {
|
||||||
token?: string;
|
token: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Button, Group, Tooltip } from "@mantine/core";
|
import { Button, Group } from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
type CommentActionsProps = {
|
type CommentActionsProps = {
|
||||||
|
|||||||
@ -15,7 +15,6 @@ import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-
|
|||||||
import { useEditor } from "@tiptap/react";
|
import { useEditor } from "@tiptap/react";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
|
||||||
|
|
||||||
interface CommentDialogProps {
|
interface CommentDialogProps {
|
||||||
editor: ReturnType<typeof useEditor>;
|
editor: ReturnType<typeof useEditor>;
|
||||||
@ -36,8 +35,6 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
|||||||
const createCommentMutation = useCreateCommentMutation();
|
const createCommentMutation = useCreateCommentMutation();
|
||||||
const { isPending } = createCommentMutation;
|
const { isPending } = createCommentMutation;
|
||||||
|
|
||||||
const emit = useQueryEmit();
|
|
||||||
|
|
||||||
const handleDialogClose = () => {
|
const handleDialogClose = () => {
|
||||||
setShowCommentPopup(false);
|
setShowCommentPopup(false);
|
||||||
editor.chain().focus().unsetCommentDecoration().run();
|
editor.chain().focus().unsetCommentDecoration().run();
|
||||||
@ -66,23 +63,11 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
|||||||
.run();
|
.run();
|
||||||
setActiveCommentId(createdComment.id);
|
setActiveCommentId(createdComment.id);
|
||||||
|
|
||||||
//unselect text to close bubble menu
|
|
||||||
editor.commands.setTextSelection({ from: editor.view.state.selection.from, to: editor.view.state.selection.from });
|
|
||||||
|
|
||||||
setAsideState({ tab: "comments", isAsideOpen: true });
|
setAsideState({ tab: "comments", isAsideOpen: true });
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const selector = `div[data-comment-id="${createdComment.id}"]`;
|
const selector = `div[data-comment-id="${createdComment.id}"]`;
|
||||||
const commentElement = document.querySelector(selector);
|
const commentElement = document.querySelector(selector);
|
||||||
commentElement?.scrollIntoView({ behavior: "smooth", block: "center" });
|
commentElement?.scrollIntoView();
|
||||||
|
|
||||||
editor.view.dispatch(
|
|
||||||
editor.state.tr.scrollIntoView()
|
|
||||||
);
|
|
||||||
}, 400);
|
|
||||||
|
|
||||||
emit({
|
|
||||||
operation: "invalidateComment",
|
|
||||||
pageId: pageId,
|
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setShowCommentPopup(false);
|
setShowCommentPopup(false);
|
||||||
@ -124,7 +109,6 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
|||||||
|
|
||||||
<CommentEditor
|
<CommentEditor
|
||||||
onUpdate={handleCommentEditorChange}
|
onUpdate={handleCommentEditorChange}
|
||||||
onSave={handleAddComment}
|
|
||||||
placeholder={t("Write a comment")}
|
placeholder={t("Write a comment")}
|
||||||
editable={true}
|
editable={true}
|
||||||
autofocus={true}
|
autofocus={true}
|
||||||
|
|||||||
@ -8,12 +8,10 @@ import { useFocusWithin } from "@mantine/hooks";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { forwardRef, useEffect, useImperativeHandle } from "react";
|
import { forwardRef, useEffect, useImperativeHandle } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import EmojiCommand from "@/features/editor/extensions/emoji-command";
|
|
||||||
|
|
||||||
interface CommentEditorProps {
|
interface CommentEditorProps {
|
||||||
defaultContent?: any;
|
defaultContent?: any;
|
||||||
onUpdate?: any;
|
onUpdate?: any;
|
||||||
onSave?: any;
|
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
autofocus?: boolean;
|
autofocus?: boolean;
|
||||||
@ -24,7 +22,6 @@ const CommentEditor = forwardRef(
|
|||||||
{
|
{
|
||||||
defaultContent,
|
defaultContent,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onSave,
|
|
||||||
editable,
|
editable,
|
||||||
placeholder,
|
placeholder,
|
||||||
autofocus,
|
autofocus,
|
||||||
@ -45,35 +42,7 @@ const CommentEditor = forwardRef(
|
|||||||
}),
|
}),
|
||||||
Underline,
|
Underline,
|
||||||
Link,
|
Link,
|
||||||
EmojiCommand,
|
|
||||||
],
|
],
|
||||||
editorProps: {
|
|
||||||
handleDOMEvents: {
|
|
||||||
keydown: (_view, event) => {
|
|
||||||
if (
|
|
||||||
[
|
|
||||||
"ArrowUp",
|
|
||||||
"ArrowDown",
|
|
||||||
"ArrowLeft",
|
|
||||||
"ArrowRight",
|
|
||||||
"Enter",
|
|
||||||
].includes(event.key)
|
|
||||||
) {
|
|
||||||
const emojiCommand = document.querySelector("#emoji-command");
|
|
||||||
if (emojiCommand) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
|
|
||||||
event.preventDefault();
|
|
||||||
if (onSave) onSave();
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onUpdate({ editor }) {
|
onUpdate({ editor }) {
|
||||||
if (onUpdate) onUpdate(editor.getJSON());
|
if (onUpdate) onUpdate(editor.getJSON());
|
||||||
},
|
},
|
||||||
@ -84,10 +53,6 @@ const CommentEditor = forwardRef(
|
|||||||
autofocus: (autofocus && "end") || false,
|
autofocus: (autofocus && "end") || false,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
commentEditor.commands.setContent(defaultContent);
|
|
||||||
}, [defaultContent]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (autofocus) {
|
if (autofocus) {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Group, Text, Box } from "@mantine/core";
|
import { Group, Text, Box } from "@mantine/core";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { 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";
|
||||||
@ -15,14 +15,12 @@ import {
|
|||||||
import { IComment } from "@/features/comment/types/comment.types";
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
|
||||||
|
|
||||||
interface CommentListItemProps {
|
interface CommentListItemProps {
|
||||||
comment: IComment;
|
comment: IComment;
|
||||||
pageId: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommentListItem({ comment, pageId }: CommentListItemProps) {
|
function CommentListItem({ comment }: CommentListItemProps) {
|
||||||
const { hovered, ref } = useHover();
|
const { hovered, ref } = useHover();
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@ -31,11 +29,6 @@ function CommentListItem({ comment, pageId }: CommentListItemProps) {
|
|||||||
const updateCommentMutation = useUpdateCommentMutation();
|
const updateCommentMutation = useUpdateCommentMutation();
|
||||||
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
const emit = useQueryEmit();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setContent(comment.content)
|
|
||||||
}, [comment]);
|
|
||||||
|
|
||||||
async function handleUpdateComment() {
|
async function handleUpdateComment() {
|
||||||
try {
|
try {
|
||||||
@ -46,11 +39,6 @@ function CommentListItem({ comment, pageId }: CommentListItemProps) {
|
|||||||
};
|
};
|
||||||
await updateCommentMutation.mutateAsync(commentToUpdate);
|
await updateCommentMutation.mutateAsync(commentToUpdate);
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
|
|
||||||
emit({
|
|
||||||
operation: "invalidateComment",
|
|
||||||
pageId: pageId,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update comment:", error);
|
console.error("Failed to update comment:", error);
|
||||||
} finally {
|
} finally {
|
||||||
@ -62,27 +50,11 @@ function CommentListItem({ comment, pageId }: CommentListItemProps) {
|
|||||||
try {
|
try {
|
||||||
await deleteCommentMutation.mutateAsync(comment.id);
|
await deleteCommentMutation.mutateAsync(comment.id);
|
||||||
editor?.commands.unsetComment(comment.id);
|
editor?.commands.unsetComment(comment.id);
|
||||||
|
|
||||||
emit({
|
|
||||||
operation: "invalidateComment",
|
|
||||||
pageId: pageId,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to delete comment:", error);
|
console.error("Failed to delete comment:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCommentClick(comment: IComment) {
|
|
||||||
const el = document.querySelector(`.comment-mark[data-comment-id="${comment.id}"]`);
|
|
||||||
if (el) {
|
|
||||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
||||||
el.classList.add("comment-highlight");
|
|
||||||
setTimeout(() => {
|
|
||||||
el.classList.remove("comment-highlight");
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEditToggle() {
|
function handleEditToggle() {
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
}
|
}
|
||||||
@ -127,7 +99,7 @@ function CommentListItem({ comment, pageId }: CommentListItemProps) {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
{!comment.parentCommentId && comment?.selection && (
|
{!comment.parentCommentId && comment?.selection && (
|
||||||
<Box className={classes.textSelection} onClick={() => handleCommentClick(comment)}>
|
<Box className={classes.textSelection}>
|
||||||
<Text size="sm">{comment?.selection}</Text>
|
<Text size="sm">{comment?.selection}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@ -140,7 +112,6 @@ function CommentListItem({ comment, pageId }: CommentListItemProps) {
|
|||||||
defaultContent={content}
|
defaultContent={content}
|
||||||
editable={true}
|
editable={true}
|
||||||
onUpdate={(newContent: any) => setContent(newContent)}
|
onUpdate={(newContent: any) => setContent(newContent)}
|
||||||
onSave={handleUpdateComment}
|
|
||||||
autofocus={true}
|
autofocus={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -14,7 +14,6 @@ import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
|||||||
import { IPagination } from "@/lib/types.ts";
|
import { IPagination } from "@/lib/types.ts";
|
||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId } from "@/lib";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
|
||||||
|
|
||||||
function CommentList() {
|
function CommentList() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -27,7 +26,6 @@ function CommentList() {
|
|||||||
} = useCommentsQuery({ pageId: page?.id, limit: 100 });
|
} = useCommentsQuery({ pageId: page?.id, limit: 100 });
|
||||||
const createCommentMutation = useCreateCommentMutation();
|
const createCommentMutation = useCreateCommentMutation();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const emit = useQueryEmit();
|
|
||||||
|
|
||||||
const handleAddReply = useCallback(
|
const handleAddReply = useCallback(
|
||||||
async (commentId: string, content: string) => {
|
async (commentId: string, content: string) => {
|
||||||
@ -40,11 +38,6 @@ function CommentList() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await createCommentMutation.mutateAsync(commentData);
|
await createCommentMutation.mutateAsync(commentData);
|
||||||
|
|
||||||
emit({
|
|
||||||
operation: "invalidateComment",
|
|
||||||
pageId: page?.id,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to post comment:", error);
|
console.error("Failed to post comment:", error);
|
||||||
} finally {
|
} finally {
|
||||||
@ -66,8 +59,8 @@ function CommentList() {
|
|||||||
data-comment-id={comment.id}
|
data-comment-id={comment.id}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<CommentListItem comment={comment} pageId={page?.id} />
|
<CommentListItem comment={comment} />
|
||||||
<MemoizedChildComments comments={comments} parentId={comment.id} pageId={page?.id} />
|
<MemoizedChildComments comments={comments} parentId={comment.id} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Divider my={4} />
|
<Divider my={4} />
|
||||||
@ -106,9 +99,8 @@ function CommentList() {
|
|||||||
interface ChildCommentsProps {
|
interface ChildCommentsProps {
|
||||||
comments: IPagination<IComment>;
|
comments: IPagination<IComment>;
|
||||||
parentId: string;
|
parentId: string;
|
||||||
pageId: string;
|
|
||||||
}
|
}
|
||||||
const ChildComments = ({ comments, parentId, pageId }: ChildCommentsProps) => {
|
const ChildComments = ({ comments, parentId }: ChildCommentsProps) => {
|
||||||
const getChildComments = useCallback(
|
const getChildComments = useCallback(
|
||||||
(parentId: string) =>
|
(parentId: string) =>
|
||||||
comments.items.filter(
|
comments.items.filter(
|
||||||
@ -121,11 +113,10 @@ const ChildComments = ({ comments, parentId, pageId }: ChildCommentsProps) => {
|
|||||||
<div>
|
<div>
|
||||||
{getChildComments(parentId).map((childComment) => (
|
{getChildComments(parentId).map((childComment) => (
|
||||||
<div key={childComment.id}>
|
<div key={childComment.id}>
|
||||||
<CommentListItem comment={childComment} pageId={pageId} />
|
<CommentListItem comment={childComment} />
|
||||||
<MemoizedChildComments
|
<MemoizedChildComments
|
||||||
comments={comments}
|
comments={comments}
|
||||||
parentId={childComment.id}
|
parentId={childComment.id}
|
||||||
pageId={pageId}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -151,7 +142,6 @@ const CommentEditorWithActions = ({ commentId, onSave, isLoading }) => {
|
|||||||
<CommentEditor
|
<CommentEditor
|
||||||
ref={commentEditorRef}
|
ref={commentEditorRef}
|
||||||
onUpdate={setContent}
|
onUpdate={setContent}
|
||||||
onSave={handleSave}
|
|
||||||
editable={true}
|
editable={true}
|
||||||
/>
|
/>
|
||||||
{focused && <CommentActions onSave={handleSave} isLoading={isLoading} />}
|
{focused && <CommentActions onSave={handleSave} isLoading={isLoading} />}
|
||||||
|
|||||||
@ -11,25 +11,22 @@
|
|||||||
border-left: 2px solid var(--mantine-color-gray-6);
|
border-left: 2px solid var(--mantine-color-gray-6);
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
background: var(--mantine-color-gray-light);
|
background: var(--mantine-color-gray-light);
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.commentEditor {
|
.commentEditor {
|
||||||
|
|
||||||
.focused {
|
.focused {
|
||||||
border-radius: var(--mantine-radius-sm);
|
|
||||||
box-shadow: 0 0 0 2px var(--mantine-color-blue-3);
|
box-shadow: 0 0 0 2px var(--mantine-color-blue-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror :global(.ProseMirror){
|
.ProseMirror :global(.ProseMirror){
|
||||||
border-radius: var(--mantine-radius-sm);
|
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
max-height: 20vh;
|
max-height: 20vh;
|
||||||
padding-left: 6px;
|
padding-left: 6px;
|
||||||
padding-right: 6px;
|
padding-right: 6px;
|
||||||
margin-top: 10px;
|
margin-top: 2px;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
overflow: hidden auto;
|
overflow: hidden auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,4 @@ export const pageEditorAtom = atom<Editor | null>(null);
|
|||||||
|
|
||||||
export const titleEditorAtom = atom<Editor | null>(null);
|
export const titleEditorAtom = atom<Editor | null>(null);
|
||||||
|
|
||||||
export const readOnlyEditorAtom = atom<Editor | null>(null);
|
|
||||||
|
|
||||||
export const yjsConnectionStatusAtom = atom<string>("");
|
export const yjsConnectionStatusAtom = atom<string>("");
|
||||||
|
|||||||
@ -116,12 +116,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
},
|
},
|
||||||
tippyOptions: {
|
tippyOptions: {
|
||||||
moveTransition: "transform 0.15s ease-out",
|
moveTransition: "transform 0.15s ease-out",
|
||||||
onCreate: (instance) => {
|
|
||||||
instance.popper.firstChild?.addEventListener("blur", (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopImmediatePropagation();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onHide: () => {
|
onHide: () => {
|
||||||
setIsNodeSelectorOpen(false);
|
setIsNodeSelectorOpen(false);
|
||||||
setIsTextAlignmentOpen(false);
|
setIsTextAlignmentOpen(false);
|
||||||
@ -183,8 +177,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
<LinkSelector
|
<LinkSelector
|
||||||
editor={props.editor}
|
editor={props.editor}
|
||||||
isOpen={isLinkSelectorOpen}
|
isOpen={isLinkSelectorOpen}
|
||||||
setIsOpen={(value) => {
|
setIsOpen={() => {
|
||||||
setIsLinkSelectorOpen(value);
|
setIsLinkSelectorOpen(!isLinkSelectorOpen);
|
||||||
setIsNodeSelectorOpen(false);
|
setIsNodeSelectorOpen(false);
|
||||||
setIsTextAlignmentOpen(false);
|
setIsTextAlignmentOpen(false);
|
||||||
setIsColorSelectorOpen(false);
|
setIsColorSelectorOpen(false);
|
||||||
|
|||||||
@ -139,7 +139,7 @@ export default function DrawioView(props: NodeViewProps) {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{selected && editor.isEditable && (
|
{selected && (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={handleOpen}
|
onClick={handleOpen}
|
||||||
variant="default"
|
variant="default"
|
||||||
|
|||||||
@ -15,13 +15,13 @@ import {
|
|||||||
import { IconEdit } from "@tabler/icons-react";
|
import { IconEdit } from "@tabler/icons-react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { useForm, zodResolver } from "@mantine/form";
|
import { useForm, zodResolver } from "@mantine/form";
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import i18n from "i18next";
|
|
||||||
import {
|
import {
|
||||||
getEmbedProviderById,
|
getEmbedProviderById,
|
||||||
getEmbedUrlAndProvider,
|
getEmbedUrlAndProvider,
|
||||||
} from "@docmost/editor-ext";
|
} from "@/features/editor/components/embed/providers.ts";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import i18n from "i18next";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
url: z
|
url: z
|
||||||
@ -32,7 +32,7 @@ const schema = z.object({
|
|||||||
|
|
||||||
export default function EmbedView(props: NodeViewProps) {
|
export default function EmbedView(props: NodeViewProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { node, selected, updateAttributes, editor } = props;
|
const { node, selected, updateAttributes } = props;
|
||||||
const { src, provider } = node.attrs;
|
const { src, provider } = node.attrs;
|
||||||
|
|
||||||
const embedUrl = useMemo(() => {
|
const embedUrl = useMemo(() => {
|
||||||
@ -50,16 +50,8 @@ export default function EmbedView(props: NodeViewProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function onSubmit(data: { url: string }) {
|
async function onSubmit(data: { url: string }) {
|
||||||
if (!editor.isEditable) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (provider) {
|
if (provider) {
|
||||||
const embedProvider = getEmbedProviderById(provider);
|
const embedProvider = getEmbedProviderById(provider);
|
||||||
if (embedProvider.id === "iframe") {
|
|
||||||
updateAttributes({ src: data.url });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (embedProvider.regex.test(data.url)) {
|
if (embedProvider.regex.test(data.url)) {
|
||||||
updateAttributes({ src: data.url });
|
updateAttributes({ src: data.url });
|
||||||
} else {
|
} else {
|
||||||
@ -89,13 +81,7 @@ export default function EmbedView(props: NodeViewProps) {
|
|||||||
</AspectRatio>
|
</AspectRatio>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Popover
|
<Popover width={300} position="bottom" withArrow shadow="md">
|
||||||
width={300}
|
|
||||||
position="bottom"
|
|
||||||
withArrow
|
|
||||||
shadow="md"
|
|
||||||
disabled={!editor.isEditable}
|
|
||||||
>
|
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<Card
|
<Card
|
||||||
radius="md"
|
radius="md"
|
||||||
@ -115,7 +101,7 @@ export default function EmbedView(props: NodeViewProps) {
|
|||||||
|
|
||||||
<Text component="span" size="lg" c="dimmed">
|
<Text component="span" size="lg" c="dimmed">
|
||||||
{t("Embed {{provider}}", {
|
{t("Embed {{provider}}", {
|
||||||
provider: getEmbedProviderById(provider)?.name,
|
provider: getEmbedProviderById(provider).name,
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -7,117 +7,102 @@ export interface IEmbedProvider {
|
|||||||
|
|
||||||
export const embedProviders: IEmbedProvider[] = [
|
export const embedProviders: IEmbedProvider[] = [
|
||||||
{
|
{
|
||||||
id: "loom",
|
id: 'loom',
|
||||||
name: "Loom",
|
name: 'Loom',
|
||||||
regex: /^https?:\/\/(?:www\.)?loom\.com\/(?:share|embed)\/([\da-zA-Z]+)\/?/,
|
regex: /^https?:\/\/(?:www\.)?loom\.com\/(?:share|embed)\/([\da-zA-Z]+)\/?/,
|
||||||
getEmbedUrl: (match, url) => {
|
getEmbedUrl: (match, url) => {
|
||||||
if(url.includes("/embed/")){
|
if(url.includes("/embed/")){
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
return `https://loom.com/embed/${match[1]}`;
|
return `https://loom.com/embed/${match[1]}`;
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "airtable",
|
id: 'airtable',
|
||||||
name: "Airtable",
|
name: 'Airtable',
|
||||||
regex: /^https:\/\/(www.)?airtable.com\/([a-zA-Z0-9]{2,})\/.*/,
|
regex: /^https:\/\/(www.)?airtable.com\/([a-zA-Z0-9]{2,})\/.*/,
|
||||||
getEmbedUrl: (match, url: string) => {
|
getEmbedUrl: (match, url: string) => {
|
||||||
const path = url.split("airtable.com/");
|
const path = url.split('airtable.com/');
|
||||||
if(url.includes("/embed/")){
|
if(url.includes("/embed/")){
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
return `https://airtable.com/embed/${path[1]}`;
|
return `https://airtable.com/embed/${path[1]}`;
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "figma",
|
id: 'figma',
|
||||||
name: "Figma",
|
name: 'Figma',
|
||||||
regex:
|
regex: /^https:\/\/[\w\.-]+\.?figma.com\/(file|proto|board|design|slides|deck)\/([0-9a-zA-Z]{22,128})/,
|
||||||
/^https:\/\/[\w\.-]+\.?figma.com\/(file|proto|board|design|slides|deck)\/([0-9a-zA-Z]{22,128})/,
|
|
||||||
getEmbedUrl: (match, url: string) => {
|
getEmbedUrl: (match, url: string) => {
|
||||||
return `https://www.figma.com/embed?url=${url}&embed_host=docmost`;
|
return `https://www.figma.com/embed?url=${url}&embed_host=docmost`;
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "typeform",
|
'id': 'typeform',
|
||||||
name: "Typeform",
|
name: 'Typeform',
|
||||||
regex: /^(https?:)?(\/\/)?[\w\.]+\.typeform\.com\/to\/.+/,
|
regex: /^(https?:)?(\/\/)?[\w\.]+\.typeform\.com\/to\/.+/,
|
||||||
getEmbedUrl: (match, url: string) => {
|
getEmbedUrl: (match, url: string) => {
|
||||||
return url;
|
return url;
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "miro",
|
id: 'miro',
|
||||||
name: "Miro",
|
name: 'Miro',
|
||||||
regex: /^https:\/\/(www\.)?miro\.com\/app\/board\/([\w-]+=)/,
|
regex: /^https:\/\/(www\.)?miro\.com\/app\/board\/([\w-]+=)/,
|
||||||
getEmbedUrl: (match, url) => {
|
getEmbedUrl: (match, url) => {
|
||||||
if(url.includes("/live-embed/")){
|
if(url.includes("/live-embed/")){
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
return `https://miro.com/app/live-embed/${match[2]}?embedMode=view_only_without_ui&autoplay=true&embedSource=docmost`;
|
return `https://miro.com/app/live-embed/${match[2]}?embedMode=view_only_without_ui&autoplay=true&embedSource=docmost`;
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "youtube",
|
id: 'youtube',
|
||||||
name: "YouTube",
|
name: 'YouTube',
|
||||||
regex:
|
regex: /^((?:https?:)?\/\/)?((?:www|m|music)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$/,
|
||||||
/^((?:https?:)?\/\/)?((?:www|m|music)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$/,
|
|
||||||
getEmbedUrl: (match, url) => {
|
getEmbedUrl: (match, url) => {
|
||||||
if (url.includes("/embed/")){
|
if (url.includes("/embed/")){
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
return `https://www.youtube-nocookie.com/embed/${match[5]}`;
|
return `https://www.youtube-nocookie.com/embed/${match[5]}`;
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "vimeo",
|
id: 'vimeo',
|
||||||
name: "Vimeo",
|
name: 'Vimeo',
|
||||||
regex:
|
regex: /^(https:)?\/\/(?:www\.|player\.)?vimeo.com\/(?:channels\/(?:\w+\/)?|groups\/([^/]*)\/videos\/|album\/(\d+)\/video\/|video\/|)(\d+)/,
|
||||||
/^(https:)?\/\/(?:www\.|player\.)?vimeo.com\/(?:channels\/(?:\w+\/)?|groups\/([^/]*)\/videos\/|album\/(\d+)\/video\/|video\/|)(\d+)/,
|
|
||||||
getEmbedUrl: (match) => {
|
getEmbedUrl: (match) => {
|
||||||
return `https://player.vimeo.com/video/${match[4]}`;
|
return `https://player.vimeo.com/video/${match[4]}`;
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "framer",
|
id: 'framer',
|
||||||
name: "Framer",
|
name: 'Framer',
|
||||||
regex: /^https:\/\/(www\.)?framer\.com\/embed\/([\w-]+)/,
|
regex: /^https:\/\/(www\.)?framer\.com\/embed\/([\w-]+)/,
|
||||||
getEmbedUrl: (match, url: string) => {
|
getEmbedUrl: (match, url: string) => {
|
||||||
return url;
|
return url;
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "gdrive",
|
id: 'gdrive',
|
||||||
name: "Google Drive",
|
name: 'Google Drive',
|
||||||
regex:
|
regex: /^((?:https?:)?\/\/)?((?:www|m)\.)?(drive\.google\.com)\/file\/d\/([a-zA-Z0-9_-]+)\/.*$/,
|
||||||
/^((?:https?:)?\/\/)?((?:www|m)\.)?(drive\.google\.com)\/file\/d\/([a-zA-Z0-9_-]+)\/.*$/,
|
|
||||||
getEmbedUrl: (match) => {
|
getEmbedUrl: (match) => {
|
||||||
return `https://drive.google.com/file/d/${match[4]}/preview`;
|
return `https://drive.google.com/file/d/${match[4]}/preview`;
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "gsheets",
|
id: 'gsheets',
|
||||||
name: "Google Sheets",
|
name: 'Google Sheets',
|
||||||
regex:
|
regex: /^((?:https?:)?\/\/)?((?:www|m)\.)?(docs\.google\.com)\/spreadsheets\/d\/e\/([a-zA-Z0-9_-]+)\/.*$/,
|
||||||
/^((?:https?:)?\/\/)?((?:www|m)\.)?(docs\.google\.com)\/spreadsheets\/d\/e\/([a-zA-Z0-9_-]+)\/.*$/,
|
|
||||||
getEmbedUrl: (match, url: string) => {
|
getEmbedUrl: (match, url: string) => {
|
||||||
return url;
|
return url
|
||||||
},
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "iframe",
|
|
||||||
name: "Iframe",
|
|
||||||
regex: /any-iframe/,
|
|
||||||
getEmbedUrl: (match, url) => {
|
|
||||||
return url;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function getEmbedProviderById(id: string) {
|
export function getEmbedProviderById(id: string) {
|
||||||
return embedProviders.find(
|
return embedProviders.find(provider => provider.id.toLowerCase() === id.toLowerCase());
|
||||||
(provider) => provider.id.toLowerCase() === id.toLowerCase(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IEmbedResult {
|
export interface IEmbedResult {
|
||||||
@ -131,12 +116,14 @@ export function getEmbedUrlAndProvider(url: string): IEmbedResult {
|
|||||||
if (match) {
|
if (match) {
|
||||||
return {
|
return {
|
||||||
embedUrl: provider.getEmbedUrl(match, url),
|
embedUrl: provider.getEmbedUrl(match, url),
|
||||||
provider: provider.name.toLowerCase(),
|
provider: provider.name.toLowerCase()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
embedUrl: url,
|
embedUrl: url,
|
||||||
provider: "iframe",
|
provider: 'iframe',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -33,7 +33,7 @@ const renderEmojiItems = () => {
|
|||||||
showOnCreate: true,
|
showOnCreate: true,
|
||||||
interactive: true,
|
interactive: true,
|
||||||
trigger: "manual",
|
trigger: "manual",
|
||||||
placement: "bottom",
|
placement: "bottom-start",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onStart: (props: {
|
onStart: (props: {
|
||||||
|
|||||||
@ -1,42 +0,0 @@
|
|||||||
type LibraryItems = any;
|
|
||||||
|
|
||||||
type LibraryPersistedData = {
|
|
||||||
libraryItems: LibraryItems;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface LibraryPersistenceAdapter {
|
|
||||||
load(metadata: { source: "load" | "save" }):
|
|
||||||
| Promise<{ libraryItems: LibraryItems } | null>
|
|
||||||
| {
|
|
||||||
libraryItems: LibraryItems;
|
|
||||||
}
|
|
||||||
| null;
|
|
||||||
|
|
||||||
save(libraryData: LibraryPersistedData): Promise<void> | void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LOCAL_STORAGE_KEY = "excalidrawLibrary";
|
|
||||||
|
|
||||||
export const localStorageLibraryAdapter: LibraryPersistenceAdapter = {
|
|
||||||
async load() {
|
|
||||||
try {
|
|
||||||
const data = localStorage.getItem(LOCAL_STORAGE_KEY);
|
|
||||||
if (data) {
|
|
||||||
return JSON.parse(data);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Error downloading Excalidraw library from localStorage", e);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
async save(libraryData) {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(libraryData));
|
|
||||||
} catch (e) {
|
|
||||||
console.error(
|
|
||||||
"Error while saving library from Excalidraw to localStorage",
|
|
||||||
e,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -13,8 +13,7 @@ import { uploadFile } from "@/features/page/services/page-service.ts";
|
|||||||
import { svgStringToFile } from "@/lib";
|
import { svgStringToFile } from "@/lib";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { getFileUrl } from "@/lib/config.ts";
|
import { getFileUrl } from "@/lib/config.ts";
|
||||||
import "@excalidraw/excalidraw/index.css";
|
import { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types/types";
|
||||||
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
|
|
||||||
import { IAttachment } from "@/lib/types";
|
import { IAttachment } from "@/lib/types";
|
||||||
import ReactClearModal from "react-clear-modal";
|
import ReactClearModal from "react-clear-modal";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
@ -22,8 +21,6 @@ import { IconEdit } from "@tabler/icons-react";
|
|||||||
import { lazy } from "react";
|
import { lazy } from "react";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useHandleLibrary } from "@excalidraw/excalidraw";
|
|
||||||
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
|
|
||||||
|
|
||||||
const Excalidraw = lazy(() =>
|
const Excalidraw = lazy(() =>
|
||||||
import("@excalidraw/excalidraw").then((module) => ({
|
import("@excalidraw/excalidraw").then((module) => ({
|
||||||
@ -38,10 +35,6 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
|||||||
|
|
||||||
const [excalidrawAPI, setExcalidrawAPI] =
|
const [excalidrawAPI, setExcalidrawAPI] =
|
||||||
useState<ExcalidrawImperativeAPI>(null);
|
useState<ExcalidrawImperativeAPI>(null);
|
||||||
useHandleLibrary({
|
|
||||||
excalidrawAPI,
|
|
||||||
adapter: localStorageLibraryAdapter,
|
|
||||||
});
|
|
||||||
const [excalidrawData, setExcalidrawData] = useState<any>(null);
|
const [excalidrawData, setExcalidrawData] = useState<any>(null);
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
const computedColorScheme = useComputedColorScheme();
|
const computedColorScheme = useComputedColorScheme();
|
||||||
@ -177,7 +170,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{selected && editor.isEditable && (
|
{selected && (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={handleOpen}
|
onClick={handleOpen}
|
||||||
variant="default"
|
variant="default"
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import React, {
|
|||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
useMemo,
|
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
@ -19,7 +18,7 @@ import {
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import classes from "./mention.module.css";
|
import classes from "./mention.module.css";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { IconFileDescription, IconPlus } from "@tabler/icons-react";
|
import { IconFileDescription } from "@tabler/icons-react";
|
||||||
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
|
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { v7 as uuid7 } from "uuid";
|
import { v7 as uuid7 } from "uuid";
|
||||||
@ -29,28 +28,14 @@ import {
|
|||||||
MentionListProps,
|
MentionListProps,
|
||||||
MentionSuggestionItem,
|
MentionSuggestionItem,
|
||||||
} from "@/features/editor/components/mention/mention.type.ts";
|
} from "@/features/editor/components/mention/mention.type.ts";
|
||||||
import { IPage } from "@/features/page/types/page.types";
|
|
||||||
import { useCreatePageMutation, usePageQuery } from "@/features/page/queries/page-query";
|
|
||||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
|
|
||||||
import { SimpleTree } from "react-arborist";
|
|
||||||
import { SpaceTreeNode } from "@/features/page/tree/types";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
|
||||||
import { extractPageSlugId } from "@/lib";
|
|
||||||
|
|
||||||
const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||||
const [selectedIndex, setSelectedIndex] = useState(1);
|
const [selectedIndex, setSelectedIndex] = useState(1);
|
||||||
const viewportRef = useRef<HTMLDivElement>(null);
|
const viewportRef = useRef<HTMLDivElement>(null);
|
||||||
const { pageSlug, spaceSlug } = useParams();
|
const { spaceSlug } = useParams();
|
||||||
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
|
||||||
const { data: space } = useSpaceQuery(spaceSlug);
|
const { data: space } = useSpaceQuery(spaceSlug);
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
const [renderItems, setRenderItems] = useState<MentionSuggestionItem[]>([]);
|
const [renderItems, setRenderItems] = useState<MentionSuggestionItem[]>([]);
|
||||||
const { t } = useTranslation();
|
|
||||||
const [data, setData] = useAtom(treeDataAtom);
|
|
||||||
const tree = useMemo(() => new SimpleTree<SpaceTreeNode>(data), [data]);
|
|
||||||
const createPageMutation = useCreatePageMutation();
|
|
||||||
const emit = useQueryEmit();
|
|
||||||
|
|
||||||
const { data: suggestion, isLoading } = useSearchSuggestionsQuery({
|
const { data: suggestion, isLoading } = useSearchSuggestionsQuery({
|
||||||
query: props.query,
|
query: props.query,
|
||||||
@ -60,23 +45,12 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
limit: 10,
|
limit: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
const createPageItem = (label: string) : MentionSuggestionItem => {
|
|
||||||
return {
|
|
||||||
id: null,
|
|
||||||
label: label,
|
|
||||||
entityType: "page",
|
|
||||||
entityId: null,
|
|
||||||
slugId: null,
|
|
||||||
icon: null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (suggestion && !isLoading) {
|
if (suggestion && !isLoading) {
|
||||||
let items: MentionSuggestionItem[] = [];
|
let items: MentionSuggestionItem[] = [];
|
||||||
|
|
||||||
if (suggestion?.users?.length > 0) {
|
if (suggestion?.users?.length > 0) {
|
||||||
items.push({ entityType: "header", label: t("Users") });
|
items.push({ entityType: "header", label: "Users" });
|
||||||
|
|
||||||
items = items.concat(
|
items = items.concat(
|
||||||
suggestion.users.map((user) => ({
|
suggestion.users.map((user) => ({
|
||||||
@ -90,7 +64,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (suggestion?.pages?.length > 0) {
|
if (suggestion?.pages?.length > 0) {
|
||||||
items.push({ entityType: "header", label: t("Pages") });
|
items.push({ entityType: "header", label: "Pages" });
|
||||||
items = items.concat(
|
items = items.concat(
|
||||||
suggestion.pages.map((page) => ({
|
suggestion.pages.map((page) => ({
|
||||||
id: uuid7(),
|
id: uuid7(),
|
||||||
@ -102,7 +76,6 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
items.push(createPageItem(props.query));
|
|
||||||
|
|
||||||
setRenderItems(items);
|
setRenderItems(items);
|
||||||
// update editor storage
|
// update editor storage
|
||||||
@ -123,7 +96,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
creatorId: currentUser?.user.id,
|
creatorId: currentUser?.user.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (item.entityType === "page" && item.id!==null) {
|
if (item.entityType === "page") {
|
||||||
props.command({
|
props.command({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
label: item.label || "Untitled",
|
label: item.label || "Untitled",
|
||||||
@ -133,9 +106,6 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
creatorId: currentUser?.user.id,
|
creatorId: currentUser?.user.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (item.entityType === "page" && item.id===null) {
|
|
||||||
createPage(item.label);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[renderItems],
|
[renderItems],
|
||||||
@ -197,58 +167,6 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const createPage = async (title: string) => {
|
|
||||||
const payload: { spaceId: string; parentPageId?: string; title: string } = {
|
|
||||||
spaceId: space.id,
|
|
||||||
parentPageId: page.id || null,
|
|
||||||
title: title
|
|
||||||
};
|
|
||||||
|
|
||||||
let createdPage: IPage;
|
|
||||||
try {
|
|
||||||
createdPage = await createPageMutation.mutateAsync(payload);
|
|
||||||
const parentId = page.id || null;
|
|
||||||
const data = {
|
|
||||||
id: createdPage.id,
|
|
||||||
slugId: createdPage.slugId,
|
|
||||||
name: createdPage.title,
|
|
||||||
position: createdPage.position,
|
|
||||||
spaceId: createdPage.spaceId,
|
|
||||||
parentPageId: createdPage.parentPageId,
|
|
||||||
children: [],
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const lastIndex = tree.data.length;
|
|
||||||
|
|
||||||
tree.create({ parentId, index: lastIndex, data });
|
|
||||||
setData(tree.data);
|
|
||||||
|
|
||||||
props.command({
|
|
||||||
id: uuid7(),
|
|
||||||
label: createdPage.title || "Untitled",
|
|
||||||
entityType: "page",
|
|
||||||
entityId: createdPage.id,
|
|
||||||
slugId: createdPage.slugId,
|
|
||||||
creatorId: currentUser?.user.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
emit({
|
|
||||||
operation: "addTreeNode",
|
|
||||||
spaceId: space.id,
|
|
||||||
payload: {
|
|
||||||
parentId,
|
|
||||||
index: lastIndex,
|
|
||||||
data,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, 50);
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
throw new Error("Failed to create page");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// if no results and enter what to do?
|
// if no results and enter what to do?
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -260,7 +178,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
if (renderItems.length === 0) {
|
if (renderItems.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Paper shadow="md" p="xs" withBorder>
|
<Paper shadow="md" p="xs" withBorder>
|
||||||
{ t("No results") }
|
No results
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -330,14 +248,14 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
color="gray"
|
color="gray"
|
||||||
size={18}
|
size={18}
|
||||||
>
|
>
|
||||||
{ (item.id) ? <IconFileDescription size={18} /> : <IconPlus size={18} /> }
|
<IconFileDescription size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
)}
|
)}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={500}>
|
||||||
{ (item.id) ? item.label : t("Create page") + ': ' + item.label }
|
{item.label}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@ -1,34 +1,21 @@
|
|||||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
import { ActionIcon, Anchor, Text } from "@mantine/core";
|
import { ActionIcon, Anchor, Text } from "@mantine/core";
|
||||||
import { IconFileDescription } from "@tabler/icons-react";
|
import { IconFileDescription } from "@tabler/icons-react";
|
||||||
import { Link, useLocation, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||||
import {
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
buildPageUrl,
|
|
||||||
buildSharedPageUrl,
|
|
||||||
} from "@/features/page/page.utils.ts";
|
|
||||||
import classes from "./mention.module.css";
|
import classes from "./mention.module.css";
|
||||||
|
|
||||||
export default function MentionView(props: NodeViewProps) {
|
export default function MentionView(props: NodeViewProps) {
|
||||||
const { node } = props;
|
const { node } = props;
|
||||||
const { label, entityType, entityId, slugId } = node.attrs;
|
const { label, entityType, entityId, slugId } = node.attrs;
|
||||||
const { spaceSlug } = useParams();
|
const { spaceSlug } = useParams();
|
||||||
const { shareId } = useParams();
|
|
||||||
const {
|
const {
|
||||||
data: page,
|
data: page,
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
} = usePageQuery({ pageId: entityType === "page" ? slugId : null });
|
} = usePageQuery({ pageId: entityType === "page" ? slugId : null });
|
||||||
|
|
||||||
const location = useLocation();
|
|
||||||
const isShareRoute = location.pathname.startsWith("/share");
|
|
||||||
|
|
||||||
const shareSlugUrl = buildSharedPageUrl({
|
|
||||||
shareId,
|
|
||||||
pageSlugId: slugId,
|
|
||||||
pageTitle: label,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper style={{ display: "inline" }}>
|
<NodeViewWrapper style={{ display: "inline" }}>
|
||||||
{entityType === "user" && (
|
{entityType === "user" && (
|
||||||
@ -41,9 +28,7 @@ export default function MentionView(props: NodeViewProps) {
|
|||||||
<Anchor
|
<Anchor
|
||||||
component={Link}
|
component={Link}
|
||||||
fw={500}
|
fw={500}
|
||||||
to={
|
to={buildPageUrl(spaceSlug, slugId, label)}
|
||||||
isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label)
|
|
||||||
}
|
|
||||||
underline="never"
|
underline="never"
|
||||||
className={classes.pageMentionLink}
|
className={classes.pageMentionLink}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -17,8 +17,8 @@ import {
|
|||||||
IconTable,
|
IconTable,
|
||||||
IconTypography,
|
IconTypography,
|
||||||
IconMenu4,
|
IconMenu4,
|
||||||
IconCalendar, IconAppWindow,
|
IconCalendar,
|
||||||
} from '@tabler/icons-react';
|
} from "@tabler/icons-react";
|
||||||
import {
|
import {
|
||||||
CommandProps,
|
CommandProps,
|
||||||
SlashMenuGroupedItemsType,
|
SlashMenuGroupedItemsType,
|
||||||
@ -357,20 +357,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
.run();
|
.run();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "Iframe embed",
|
|
||||||
description: "Embed any Iframe",
|
|
||||||
searchTerms: ["iframe"],
|
|
||||||
icon: IconAppWindow,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
editor
|
|
||||||
.chain()
|
|
||||||
.focus()
|
|
||||||
.deleteRange(range)
|
|
||||||
.setEmbed({ provider: "iframe" })
|
|
||||||
.run();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "Airtable",
|
title: "Airtable",
|
||||||
description: "Embed Airtable",
|
description: "Embed Airtable",
|
||||||
|
|||||||
@ -52,8 +52,3 @@
|
|||||||
) !important;
|
) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.leftBorder {
|
|
||||||
border-left: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
|
||||||
}
|
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
type TableOfContentsProps = {
|
type TableOfContentsProps = {
|
||||||
editor: ReturnType<typeof useEditor>;
|
editor: ReturnType<typeof useEditor>;
|
||||||
isShare?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HeadingLink = {
|
export type HeadingLink = {
|
||||||
@ -74,7 +73,6 @@ export const TableOfContents: FC<TableOfContentsProps> = (props) => {
|
|||||||
|
|
||||||
const handleUpdate = () => {
|
const handleUpdate = () => {
|
||||||
const result = recalculateLinks(props.editor?.$nodes("heading"));
|
const result = recalculateLinks(props.editor?.$nodes("heading"));
|
||||||
|
|
||||||
setLinks(result.links);
|
setLinks(result.links);
|
||||||
setHeadingDOMNodes(result.nodes);
|
setHeadingDOMNodes(result.nodes);
|
||||||
};
|
};
|
||||||
@ -87,12 +85,9 @@ export const TableOfContents: FC<TableOfContentsProps> = (props) => {
|
|||||||
};
|
};
|
||||||
}, [props.editor]);
|
}, [props.editor]);
|
||||||
|
|
||||||
useEffect(
|
useEffect(() => {
|
||||||
() => {
|
|
||||||
handleUpdate();
|
handleUpdate();
|
||||||
},
|
}, []);
|
||||||
props.isShare ? [props.editor] : [],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
@ -138,29 +133,16 @@ export const TableOfContents: FC<TableOfContentsProps> = (props) => {
|
|||||||
if (!links.length) {
|
if (!links.length) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!props.isShare && (
|
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
{t("Add headings (H1, H2, H3) to generate a table of contents.")}
|
{t("Add headings (H1, H2, H3) to generate a table of contents.")}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
|
||||||
|
|
||||||
{props.isShare && (
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t("No table of contents.")}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{props.isShare && (
|
<div>
|
||||||
<Text mb="md" fw={500}>
|
|
||||||
{t("Table of contents")}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<div className={props.isShare ? classes.leftBorder : ""}>
|
|
||||||
{links.map((item, idx) => (
|
{links.map((item, idx) => (
|
||||||
<Box<"button">
|
<Box<"button">
|
||||||
component="button"
|
component="button"
|
||||||
|
|||||||
@ -17,9 +17,9 @@ import {
|
|||||||
IconColumnRemove,
|
IconColumnRemove,
|
||||||
IconRowInsertBottom,
|
IconRowInsertBottom,
|
||||||
IconRowInsertTop,
|
IconRowInsertTop,
|
||||||
IconRowRemove, IconTableColumn, IconTableRow,
|
IconRowRemove,
|
||||||
IconTrashX,
|
IconTrashX,
|
||||||
} from '@tabler/icons-react';
|
} from "@tabler/icons-react";
|
||||||
import { isCellSelection } from "@docmost/editor-ext";
|
import { isCellSelection } from "@docmost/editor-ext";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@ -50,14 +50,6 @@ export const TableMenu = React.memo(
|
|||||||
return posToDOMRect(editor.view, selection.from, selection.to);
|
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const toggleHeaderColumn = useCallback(() => {
|
|
||||||
editor.chain().focus().toggleHeaderColumn().run();
|
|
||||||
}, [editor]);
|
|
||||||
|
|
||||||
const toggleHeaderRow = useCallback(() => {
|
|
||||||
editor.chain().focus().toggleHeaderRow().run();
|
|
||||||
}, [editor]);
|
|
||||||
|
|
||||||
const addColumnLeft = useCallback(() => {
|
const addColumnLeft = useCallback(() => {
|
||||||
editor.chain().focus().addColumnBefore().run();
|
editor.chain().focus().addColumnBefore().run();
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
@ -188,30 +180,6 @@ export const TableMenu = React.memo(
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Toggle header row")}
|
|
||||||
>
|
|
||||||
<ActionIcon
|
|
||||||
onClick={toggleHeaderRow}
|
|
||||||
variant="default"
|
|
||||||
size="lg"
|
|
||||||
aria-label={t("Toggle header row")}
|
|
||||||
>
|
|
||||||
<IconTableRow size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Toggle header column")}
|
|
||||||
>
|
|
||||||
<ActionIcon
|
|
||||||
onClick={toggleHeaderColumn}
|
|
||||||
variant="default"
|
|
||||||
size="lg"
|
|
||||||
aria-label={t("Toggle header column")}
|
|
||||||
>
|
|
||||||
<IconTableColumn size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Delete table")}>
|
<Tooltip position="top" label={t("Delete table")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={deleteTable}
|
onClick={deleteTable}
|
||||||
|
|||||||
@ -58,7 +58,6 @@ import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-v
|
|||||||
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
||||||
import plaintext from "highlight.js/lib/languages/plaintext";
|
import plaintext from "highlight.js/lib/languages/plaintext";
|
||||||
import powershell from "highlight.js/lib/languages/powershell";
|
import powershell from "highlight.js/lib/languages/powershell";
|
||||||
import abap from "highlightjs-sap-abap";
|
|
||||||
import elixir from "highlight.js/lib/languages/elixir";
|
import elixir from "highlight.js/lib/languages/elixir";
|
||||||
import erlang from "highlight.js/lib/languages/erlang";
|
import erlang from "highlight.js/lib/languages/erlang";
|
||||||
import dockerfile from "highlight.js/lib/languages/dockerfile";
|
import dockerfile from "highlight.js/lib/languages/dockerfile";
|
||||||
@ -73,12 +72,11 @@ import i18n from "@/i18n.ts";
|
|||||||
import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts";
|
import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts";
|
||||||
import EmojiCommand from "./emoji-command";
|
import EmojiCommand from "./emoji-command";
|
||||||
import { CharacterCount } from "@tiptap/extension-character-count";
|
import { CharacterCount } from "@tiptap/extension-character-count";
|
||||||
import { countWords } from "alfaaz";
|
|
||||||
|
|
||||||
const lowlight = createLowlight(common);
|
const lowlight = createLowlight(common);
|
||||||
lowlight.register("mermaid", plaintext);
|
lowlight.register("mermaid", plaintext);
|
||||||
lowlight.register("powershell", powershell);
|
lowlight.register("powershell", powershell);
|
||||||
lowlight.register("abap", abap);
|
lowlight.register("powershell", powershell);
|
||||||
lowlight.register("erlang", erlang);
|
lowlight.register("erlang", erlang);
|
||||||
lowlight.register("elixir", elixir);
|
lowlight.register("elixir", elixir);
|
||||||
lowlight.register("dockerfile", dockerfile);
|
lowlight.register("dockerfile", dockerfile);
|
||||||
@ -214,9 +212,7 @@ export const mainExtensions = [
|
|||||||
MarkdownClipboard.configure({
|
MarkdownClipboard.configure({
|
||||||
transformPastedText: true,
|
transformPastedText: true,
|
||||||
}),
|
}),
|
||||||
CharacterCount.configure({
|
CharacterCount
|
||||||
wordCounter: (text) => countWords(text),
|
|
||||||
}),
|
|
||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
||||||
|
|||||||
@ -42,11 +42,7 @@ export function FullEditor({
|
|||||||
spaceSlug={spaceSlug}
|
spaceSlug={spaceSlug}
|
||||||
editable={editable}
|
editable={editable}
|
||||||
/>
|
/>
|
||||||
<MemoizedPageEditor
|
<MemoizedPageEditor pageId={pageId} editable={editable} content={content} />
|
||||||
pageId={pageId}
|
|
||||||
editable={editable}
|
|
||||||
content={content}
|
|
||||||
/>
|
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -52,7 +52,6 @@ import { IPage } from "@/features/page/types/page.types.ts";
|
|||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId } from "@/lib";
|
||||||
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
||||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
|
||||||
import { jwtDecode } from "jwt-decode";
|
import { jwtDecode } from "jwt-decode";
|
||||||
|
|
||||||
interface PageEditorProps {
|
interface PageEditorProps {
|
||||||
@ -86,8 +85,6 @@ export default function PageEditor({
|
|||||||
const [isCollabReady, setIsCollabReady] = useState(false);
|
const [isCollabReady, setIsCollabReady] = useState(false);
|
||||||
const { pageSlug } = useParams();
|
const { pageSlug } = useParams();
|
||||||
const slugId = extractPageSlugId(pageSlug);
|
const slugId = extractPageSlugId(pageSlug);
|
||||||
const userPageEditMode =
|
|
||||||
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
|
||||||
|
|
||||||
const localProvider = useMemo(() => {
|
const localProvider = useMemo(() => {
|
||||||
const provider = new IndexeddbPersistence(documentName, ydoc);
|
const provider = new IndexeddbPersistence(documentName, ydoc);
|
||||||
@ -222,12 +219,9 @@ export default function PageEditor({
|
|||||||
setActiveCommentId(commentId);
|
setActiveCommentId(commentId);
|
||||||
setAsideState({ tab: "comments", isAsideOpen: true });
|
setAsideState({ tab: "comments", isAsideOpen: true });
|
||||||
|
|
||||||
//wait if aside is closed
|
|
||||||
setTimeout(() => {
|
|
||||||
const selector = `div[data-comment-id="${commentId}"]`;
|
const selector = `div[data-comment-id="${commentId}"]`;
|
||||||
const commentElement = document.querySelector(selector);
|
const commentElement = document.querySelector(selector);
|
||||||
commentElement?.scrollIntoView({ behavior: "smooth", block: "center" });
|
commentElement?.scrollIntoView();
|
||||||
}, 400);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -293,17 +287,6 @@ export default function PageEditor({
|
|||||||
return () => clearTimeout(collabReadyTimeout);
|
return () => clearTimeout(collabReadyTimeout);
|
||||||
}, [isRemoteSynced, isLocalSynced, remoteProvider?.status]);
|
}, [isRemoteSynced, isLocalSynced, remoteProvider?.status]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// honor user default page edit mode preference
|
|
||||||
if (userPageEditMode && editor && editable && isSynced) {
|
|
||||||
if (userPageEditMode === PageEditMode.Edit) {
|
|
||||||
editor.setEditable(true);
|
|
||||||
} else if (userPageEditMode === PageEditMode.Read) {
|
|
||||||
editor.setEditable(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [userPageEditMode, editor, editable, isSynced]);
|
|
||||||
|
|
||||||
return isCollabReady ? (
|
return isCollabReady ? (
|
||||||
<div>
|
<div>
|
||||||
<div ref={menuContainerRef}>
|
<div ref={menuContainerRef}>
|
||||||
|
|||||||
@ -1,67 +0,0 @@
|
|||||||
import "@/features/editor/styles/index.css";
|
|
||||||
import React, { useMemo } from "react";
|
|
||||||
import { EditorProvider } from "@tiptap/react";
|
|
||||||
import { mainExtensions } from "@/features/editor/extensions/extensions";
|
|
||||||
import { Document } from "@tiptap/extension-document";
|
|
||||||
import { Heading } from "@tiptap/extension-heading";
|
|
||||||
import { Text } from "@tiptap/extension-text";
|
|
||||||
import { Placeholder } from "@tiptap/extension-placeholder";
|
|
||||||
import { useAtom } from "jotai/index";
|
|
||||||
import {
|
|
||||||
pageEditorAtom,
|
|
||||||
readOnlyEditorAtom,
|
|
||||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
|
||||||
import { Editor } from "@tiptap/core";
|
|
||||||
|
|
||||||
interface PageEditorProps {
|
|
||||||
title: string;
|
|
||||||
content: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ReadonlyPageEditor({
|
|
||||||
title,
|
|
||||||
content,
|
|
||||||
}: PageEditorProps) {
|
|
||||||
const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom);
|
|
||||||
|
|
||||||
const extensions = useMemo(() => {
|
|
||||||
return [...mainExtensions];
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const titleExtensions = [
|
|
||||||
Document.extend({
|
|
||||||
content: "heading",
|
|
||||||
}),
|
|
||||||
Heading,
|
|
||||||
Text,
|
|
||||||
Placeholder.configure({
|
|
||||||
placeholder: "Untitled",
|
|
||||||
showOnlyWhenEditable: false,
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<EditorProvider
|
|
||||||
editable={false}
|
|
||||||
immediatelyRender={true}
|
|
||||||
extensions={titleExtensions}
|
|
||||||
content={title}
|
|
||||||
></EditorProvider>
|
|
||||||
|
|
||||||
<EditorProvider
|
|
||||||
editable={false}
|
|
||||||
immediatelyRender={true}
|
|
||||||
extensions={extensions}
|
|
||||||
content={content}
|
|
||||||
onCreate={({ editor }) => {
|
|
||||||
if (editor) {
|
|
||||||
// @ts-ignore
|
|
||||||
setReadOnlyEditor(editor);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
></EditorProvider>
|
|
||||||
<div style={{ paddingBottom: "20vh" }}></div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -144,19 +144,6 @@
|
|||||||
border-bottom: 2px solid rgb(166, 158, 12);
|
border-bottom: 2px solid rgb(166, 158, 12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-highlight {
|
|
||||||
animation: flash-highlight 3s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes flash-highlight {
|
|
||||||
0% {
|
|
||||||
background-color: #ff4d4d;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background-color: rgba(255, 215, 0, 0.14);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.resize-cursor {
|
.resize-cursor {
|
||||||
cursor: ew-resize;
|
cursor: ew-resize;
|
||||||
cursor: col-resize;
|
cursor: col-resize;
|
||||||
|
|||||||
@ -47,7 +47,7 @@
|
|||||||
|
|
||||||
.column-resize-handle {
|
.column-resize-handle {
|
||||||
background-color: #adf;
|
background-color: #adf;
|
||||||
bottom: -1px;
|
bottom: -2px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: -2px;
|
right: -2px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import {
|
|||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
titleEditorAtom,
|
titleEditorAtom,
|
||||||
} from "@/features/editor/atoms/editor-atoms";
|
} from "@/features/editor/atoms/editor-atoms";
|
||||||
import { updatePageData, useUpdateTitlePageMutation } from "@/features/page/queries/page-query";
|
import { useUpdatePageMutation } from "@/features/page/queries/page-query";
|
||||||
import { useDebouncedCallback } from "@mantine/hooks";
|
import { useDebouncedCallback } from "@mantine/hooks";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
||||||
@ -21,8 +21,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
import EmojiCommand from "@/features/editor/extensions/emoji-command.ts";
|
import EmojiCommand from "@/features/editor/extensions/emoji-command.ts";
|
||||||
import { UpdateEvent } from "@/features/websocket/types";
|
import { UpdateEvent } from "@/features/websocket/types";
|
||||||
import localEmitter from "@/lib/local-emitter.ts";
|
import localEmitter from "@/lib/local-emitter.ts";
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
|
||||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
|
||||||
|
|
||||||
export interface TitleEditorProps {
|
export interface TitleEditorProps {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@ -40,15 +38,12 @@ export function TitleEditor({
|
|||||||
editable,
|
editable,
|
||||||
}: TitleEditorProps) {
|
}: TitleEditorProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { mutateAsync: updateTitlePageMutationAsync } = useUpdateTitlePageMutation();
|
const { mutateAsync: updatePageMutationAsync } = useUpdatePageMutation();
|
||||||
const pageEditor = useAtomValue(pageEditorAtom);
|
const pageEditor = useAtomValue(pageEditorAtom);
|
||||||
const [, setTitleEditor] = useAtom(titleEditorAtom);
|
const [, setTitleEditor] = useAtom(titleEditorAtom);
|
||||||
const emit = useQueryEmit();
|
const emit = useQueryEmit();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [activePageId, setActivePageId] = useState(pageId);
|
const [activePageId, setActivePageId] = useState(pageId);
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
|
||||||
const userPageEditMode =
|
|
||||||
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
|
||||||
|
|
||||||
const titleEditor = useEditor({
|
const titleEditor = useEditor({
|
||||||
extensions: [
|
extensions: [
|
||||||
@ -99,7 +94,7 @@ export function TitleEditor({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateTitlePageMutationAsync({
|
updatePageMutationAsync({
|
||||||
pageId: pageId,
|
pageId: pageId,
|
||||||
title: titleEditor.getText(),
|
title: titleEditor.getText(),
|
||||||
}).then((page) => {
|
}).then((page) => {
|
||||||
@ -108,13 +103,9 @@ export function TitleEditor({
|
|||||||
spaceId: page.spaceId,
|
spaceId: page.spaceId,
|
||||||
entity: ["pages"],
|
entity: ["pages"],
|
||||||
id: page.id,
|
id: page.id,
|
||||||
payload: { title: page.title, slugId: page.slugId, parentPageId: page.parentPageId, icon: page.icon },
|
payload: { title: page.title, slugId: page.slugId },
|
||||||
};
|
};
|
||||||
|
|
||||||
if (page.title !== titleEditor.getText()) return;
|
|
||||||
|
|
||||||
updatePageData(page);
|
|
||||||
|
|
||||||
localEmitter.emit("message", event);
|
localEmitter.emit("message", event);
|
||||||
emit(event);
|
emit(event);
|
||||||
});
|
});
|
||||||
@ -141,24 +132,9 @@ export function TitleEditor({
|
|||||||
};
|
};
|
||||||
}, [pageId]);
|
}, [pageId]);
|
||||||
|
|
||||||
useEffect(() => {
|
function handleTitleKeyDown(event) {
|
||||||
// honor user default page edit mode preference
|
|
||||||
if (userPageEditMode && titleEditor && editable) {
|
|
||||||
if (userPageEditMode === PageEditMode.Edit) {
|
|
||||||
titleEditor.setEditable(true);
|
|
||||||
} else if (userPageEditMode === PageEditMode.Read) {
|
|
||||||
titleEditor.setEditable(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [userPageEditMode, titleEditor, editable]);
|
|
||||||
|
|
||||||
function handleTitleKeyDown(event: any) {
|
|
||||||
if (!titleEditor || !pageEditor || event.shiftKey) return;
|
if (!titleEditor || !pageEditor || event.shiftKey) return;
|
||||||
|
|
||||||
// Prevent focus shift when IME composition is active
|
|
||||||
// `keyCode === 229` is added to support Safari where `isComposing` may not be reliable
|
|
||||||
if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return;
|
|
||||||
|
|
||||||
const { key } = event;
|
const { key } = event;
|
||||||
const { $head } = titleEditor.state.selection;
|
const { $head } = titleEditor.state.selection;
|
||||||
|
|
||||||
|
|||||||
@ -1,14 +0,0 @@
|
|||||||
import api from "@/lib/api-client";
|
|
||||||
import { IFileTask } from "@/features/file-task/types/file-task.types.ts";
|
|
||||||
|
|
||||||
export async function getFileTaskById(fileTaskId: string): Promise<IFileTask> {
|
|
||||||
const req = await api.post<IFileTask>("/file-tasks/info", {
|
|
||||||
fileTaskId: fileTaskId,
|
|
||||||
});
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getFileTasks(): Promise<IFileTask[]> {
|
|
||||||
const req = await api.post<IFileTask[]>("/file-tasks");
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
export interface IFileTask {
|
|
||||||
id: string;
|
|
||||||
type: "import" | "export";
|
|
||||||
source: string;
|
|
||||||
status: string;
|
|
||||||
fileName: string;
|
|
||||||
filePath: string;
|
|
||||||
fileSize: number;
|
|
||||||
fileExt: string;
|
|
||||||
errorMessage: string | null;
|
|
||||||
creatorId: string;
|
|
||||||
spaceId: string;
|
|
||||||
workspaceId: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
deletedAt: string | null;
|
|
||||||
}
|
|
||||||
@ -1,10 +1,9 @@
|
|||||||
import api from "@/lib/api-client";
|
import api from "@/lib/api-client";
|
||||||
import { IPageHistory } from "@/features/page-history/types/page.types";
|
import { IPageHistory } from "@/features/page-history/types/page.types";
|
||||||
import { IPagination } from "@/lib/types.ts";
|
|
||||||
|
|
||||||
export async function getPageHistoryList(
|
export async function getPageHistoryList(
|
||||||
pageId: string,
|
pageId: string,
|
||||||
): Promise<IPagination<IPageHistory>> {
|
): Promise<IPageHistory[]> {
|
||||||
const req = await api.post("/pages/history", {
|
const req = await api.post("/pages/history", {
|
||||||
pageId,
|
pageId,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex-wrap: nowrap;
|
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: var(--mantine-color-default-color);
|
color: var(--mantine-color-default-color);
|
||||||
@ -22,9 +21,3 @@
|
|||||||
max-width: 200px;
|
max-width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.breadcrumbDiv {
|
|
||||||
overflow: hidden;
|
|
||||||
@media (max-width: $mantine-breakpoint-sm) {
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { findBreadcrumbPath } from "@/features/page/tree/utils";
|
import { findBreadcrumbPath } from "@/features/page/tree/utils";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@ -9,16 +9,14 @@ import {
|
|||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Text,
|
Text,
|
||||||
Tooltip,
|
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconCornerDownRightDouble, IconDots } from "@tabler/icons-react";
|
import { IconDots } from "@tabler/icons-react";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import classes from "./breadcrumb.module.css";
|
import classes from "./breadcrumb.module.css";
|
||||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId } from "@/lib";
|
||||||
import { useMediaQuery } from "@mantine/hooks";
|
|
||||||
|
|
||||||
function getTitle(name: string, icon: string) {
|
function getTitle(name: string, icon: string) {
|
||||||
if (icon) {
|
if (icon) {
|
||||||
@ -36,7 +34,6 @@ export default function Breadcrumb() {
|
|||||||
const { data: currentPage } = usePageQuery({
|
const { data: currentPage } = usePageQuery({
|
||||||
pageId: extractPageSlugId(pageSlug),
|
pageId: extractPageSlugId(pageSlug),
|
||||||
});
|
});
|
||||||
const isMobile = useMediaQuery("(max-width: 48em)");
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (treeData?.length > 0 && currentPage) {
|
if (treeData?.length > 0 && currentPage) {
|
||||||
@ -46,7 +43,7 @@ export default function Breadcrumb() {
|
|||||||
}, [currentPage?.id, treeData]);
|
}, [currentPage?.id, treeData]);
|
||||||
|
|
||||||
const HiddenNodesTooltipContent = () =>
|
const HiddenNodesTooltipContent = () =>
|
||||||
breadcrumbNodes?.slice(1, -1).map((node) => (
|
breadcrumbNodes?.slice(1, -2).map((node) => (
|
||||||
<Button.Group orientation="vertical" key={node.id}>
|
<Button.Group orientation="vertical" key={node.id}>
|
||||||
<Button
|
<Button
|
||||||
justify="start"
|
justify="start"
|
||||||
@ -62,39 +59,17 @@ export default function Breadcrumb() {
|
|||||||
</Button.Group>
|
</Button.Group>
|
||||||
));
|
));
|
||||||
|
|
||||||
const MobileHiddenNodesTooltipContent = () =>
|
const renderAnchor = (node: SpaceTreeNode) => (
|
||||||
breadcrumbNodes?.map((node) => (
|
|
||||||
<Button.Group orientation="vertical" key={node.id}>
|
|
||||||
<Button
|
|
||||||
justify="start"
|
|
||||||
component={Link}
|
|
||||||
to={buildPageUrl(spaceSlug, node.slugId, node.name)}
|
|
||||||
variant="default"
|
|
||||||
style={{ border: "none" }}
|
|
||||||
>
|
|
||||||
<Text fz={"sm"} className={classes.truncatedText}>
|
|
||||||
{getTitle(node.name, node.icon)}
|
|
||||||
</Text>
|
|
||||||
</Button>
|
|
||||||
</Button.Group>
|
|
||||||
));
|
|
||||||
|
|
||||||
const renderAnchor = useCallback(
|
|
||||||
(node: SpaceTreeNode) => (
|
|
||||||
<Tooltip label={node.name} key={node.id}>
|
|
||||||
<Anchor
|
<Anchor
|
||||||
component={Link}
|
component={Link}
|
||||||
to={buildPageUrl(spaceSlug, node.slugId, node.name)}
|
to={buildPageUrl(spaceSlug, node.slugId, node.name)}
|
||||||
underline="never"
|
underline="never"
|
||||||
fz="sm"
|
fz={"sm"}
|
||||||
key={node.id}
|
key={node.id}
|
||||||
className={classes.truncatedText}
|
className={classes.truncatedText}
|
||||||
>
|
>
|
||||||
{getTitle(node.name, node.icon)}
|
{getTitle(node.name, node.icon)}
|
||||||
</Anchor>
|
</Anchor>
|
||||||
</Tooltip>
|
|
||||||
),
|
|
||||||
[spaceSlug],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const getBreadcrumbItems = () => {
|
const getBreadcrumbItems = () => {
|
||||||
@ -102,7 +77,7 @@ export default function Breadcrumb() {
|
|||||||
|
|
||||||
if (breadcrumbNodes.length > 3) {
|
if (breadcrumbNodes.length > 3) {
|
||||||
const firstNode = breadcrumbNodes[0];
|
const firstNode = breadcrumbNodes[0];
|
||||||
//const secondLastNode = breadcrumbNodes[breadcrumbNodes.length - 2];
|
const secondLastNode = breadcrumbNodes[breadcrumbNodes.length - 2];
|
||||||
const lastNode = breadcrumbNodes[breadcrumbNodes.length - 1];
|
const lastNode = breadcrumbNodes[breadcrumbNodes.length - 1];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@ -123,7 +98,7 @@ export default function Breadcrumb() {
|
|||||||
<HiddenNodesTooltipContent />
|
<HiddenNodesTooltipContent />
|
||||||
</Popover.Dropdown>
|
</Popover.Dropdown>
|
||||||
</Popover>,
|
</Popover>,
|
||||||
//renderAnchor(secondLastNode),
|
renderAnchor(secondLastNode),
|
||||||
renderAnchor(lastNode),
|
renderAnchor(lastNode),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -131,40 +106,11 @@ export default function Breadcrumb() {
|
|||||||
return breadcrumbNodes.map(renderAnchor);
|
return breadcrumbNodes.map(renderAnchor);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getMobileBreadcrumbItems = () => {
|
|
||||||
if (!breadcrumbNodes) return [];
|
|
||||||
|
|
||||||
if (breadcrumbNodes.length > 0) {
|
|
||||||
return [
|
|
||||||
<Popover
|
|
||||||
width={250}
|
|
||||||
position="bottom"
|
|
||||||
withArrow
|
|
||||||
shadow="xl"
|
|
||||||
key="mobile-hidden-nodes"
|
|
||||||
>
|
|
||||||
<Popover.Target>
|
|
||||||
<Tooltip label="Breadcrumbs">
|
|
||||||
<ActionIcon color="gray" variant="transparent">
|
|
||||||
<IconCornerDownRightDouble size={20} stroke={2} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</Popover.Target>
|
|
||||||
<Popover.Dropdown>
|
|
||||||
<MobileHiddenNodesTooltipContent />
|
|
||||||
</Popover.Dropdown>
|
|
||||||
</Popover>,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return breadcrumbNodes.map(renderAnchor);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.breadcrumbDiv}>
|
<div style={{ overflow: "hidden" }}>
|
||||||
{breadcrumbNodes && (
|
{breadcrumbNodes && (
|
||||||
<Breadcrumbs className={classes.breadcrumbs}>
|
<Breadcrumbs className={classes.breadcrumbs}>
|
||||||
{isMobile ? getMobileBreadcrumbItems() : getBreadcrumbItems()}
|
{getBreadcrumbItems()}
|
||||||
</Breadcrumbs>
|
</Breadcrumbs>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,105 +0,0 @@
|
|||||||
import { Modal, Button, Group, Text } from "@mantine/core";
|
|
||||||
import { copyPageToSpace } from "@/features/page/services/page-service.ts";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { ISpace } from "@/features/space/types/space.types.ts";
|
|
||||||
import { queryClient } from "@/main.tsx";
|
|
||||||
import { SpaceSelect } from "@/features/space/components/sidebar/space-select.tsx";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
|
||||||
|
|
||||||
interface CopyPageModalProps {
|
|
||||||
pageId: string;
|
|
||||||
currentSpaceSlug: string;
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CopyPageModal({
|
|
||||||
pageId,
|
|
||||||
currentSpaceSlug,
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
}: CopyPageModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [targetSpace, setTargetSpace] = useState<ISpace>(null);
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const handleCopy = async () => {
|
|
||||||
if (!targetSpace) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const copiedPage = await copyPageToSpace({
|
|
||||||
pageId,
|
|
||||||
spaceId: targetSpace.id,
|
|
||||||
});
|
|
||||||
queryClient.removeQueries({
|
|
||||||
predicate: (item) =>
|
|
||||||
["pages", "sidebar-pages", "root-sidebar-pages"].includes(
|
|
||||||
item.queryKey[0] as string,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const pageUrl = buildPageUrl(
|
|
||||||
copiedPage.space.slug,
|
|
||||||
copiedPage.slugId,
|
|
||||||
copiedPage.title,
|
|
||||||
);
|
|
||||||
navigate(pageUrl);
|
|
||||||
notifications.show({
|
|
||||||
message: t("Page copied successfully"),
|
|
||||||
});
|
|
||||||
onClose();
|
|
||||||
setTargetSpace(null);
|
|
||||||
} catch (err) {
|
|
||||||
notifications.show({
|
|
||||||
message: err.response?.data.message || "An error occurred",
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
console.log(err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChange = (space: ISpace) => {
|
|
||||||
setTargetSpace(space);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal.Root
|
|
||||||
opened={open}
|
|
||||||
onClose={onClose}
|
|
||||||
size={500}
|
|
||||||
padding="xl"
|
|
||||||
yOffset="10vh"
|
|
||||||
xOffset={0}
|
|
||||||
mah={400}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Modal.Overlay />
|
|
||||||
<Modal.Content style={{ overflow: "hidden" }}>
|
|
||||||
<Modal.Header py={0}>
|
|
||||||
<Modal.Title fw={500}>{t("Copy page")}</Modal.Title>
|
|
||||||
<Modal.CloseButton />
|
|
||||||
</Modal.Header>
|
|
||||||
<Modal.Body>
|
|
||||||
<Text mb="xs" c="dimmed" size="sm">
|
|
||||||
{t("Copy page to a different space.")}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<SpaceSelect
|
|
||||||
value={currentSpaceSlug}
|
|
||||||
clearable={false}
|
|
||||||
onChange={handleChange}
|
|
||||||
/>
|
|
||||||
<Group justify="end" mt="md">
|
|
||||||
<Button onClick={onClose} variant="default">
|
|
||||||
{t("Cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleCopy}>{t("Copy")}</Button>
|
|
||||||
</Group>
|
|
||||||
</Modal.Body>
|
|
||||||
</Modal.Content>
|
|
||||||
</Modal.Root>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -12,7 +12,7 @@ import {
|
|||||||
IconTrash,
|
IconTrash,
|
||||||
IconWifiOff,
|
IconWifiOff,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import React from "react";
|
import React, { useEffect } from "react";
|
||||||
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||||
@ -33,10 +33,8 @@ import {
|
|||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||||
import { formattedDate, timeAgo } from "@/lib/time.ts";
|
import { formattedDate, timeAgo } from "@/lib/time.ts";
|
||||||
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";
|
||||||
import ShareModal from "@/features/share/components/share-modal.tsx";
|
|
||||||
|
|
||||||
interface PageHeaderMenuProps {
|
interface PageHeaderMenuProps {
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
@ -60,10 +58,6 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!readOnly && <PageStateSegmentedControl size="xs" />}
|
|
||||||
|
|
||||||
<ShareModal readOnly={readOnly} />
|
|
||||||
|
|
||||||
<Tooltip label={t("Comments")} openDelay={250} withArrow>
|
<Tooltip label={t("Comments")} openDelay={250} withArrow>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="default"
|
variant="default"
|
||||||
@ -109,7 +103,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
{ open: openMovePageModal, close: closeMoveSpaceModal },
|
{ open: openMovePageModal, close: closeMoveSpaceModal },
|
||||||
] = useDisclosure(false);
|
] = useDisclosure(false);
|
||||||
const [pageEditor] = useAtom(pageEditorAtom);
|
const [pageEditor] = useAtom(pageEditorAtom);
|
||||||
const pageUpdatedAt = useTimeAgo(page?.updatedAt);
|
const pageUpdatedAt = useTimeAgo(page.updatedAt);
|
||||||
|
|
||||||
const handleCopyLink = () => {
|
const handleCopyLink = () => {
|
||||||
const pageUrl =
|
const pageUrl =
|
||||||
|
|||||||
@ -9,19 +9,7 @@
|
|||||||
inset-inline-start: var(--app-shell-navbar-offset, 0rem);
|
inset-inline-start: var(--app-shell-navbar-offset, 0rem);
|
||||||
inset-inline-end: var(--app-shell-aside-offset, 0rem);
|
inset-inline-end: var(--app-shell-aside-offset, 0rem);
|
||||||
|
|
||||||
@media (max-width: $mantine-breakpoint-sm) {
|
|
||||||
padding-left: var(--mantine-spacing-xs);
|
|
||||||
padding-right: var(--mantine-spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.group {
|
|
||||||
@media (max-width: $mantine-breakpoint-sm) {
|
|
||||||
gap: var(--mantine-spacing-sm);
|
|
||||||
padding-inline: 0 !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -9,10 +9,10 @@ interface Props {
|
|||||||
export default function PageHeader({ readOnly }: Props) {
|
export default function PageHeader({ readOnly }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className={classes.header}>
|
<div className={classes.header}>
|
||||||
<Group justify="space-between" h="100%" px="md" wrap="nowrap" className={classes.group}>
|
<Group justify="space-between" h="100%" px="md" wrap="nowrap">
|
||||||
<Breadcrumb />
|
<Breadcrumb />
|
||||||
|
|
||||||
<Group justify="flex-end" h="100%" px="md" wrap="nowrap" gap="var(--mantine-spacing-xs)">
|
<Group justify="flex-end" h="100%" px="md" wrap="nowrap">
|
||||||
<PageHeaderMenu readOnly={readOnly} />
|
<PageHeaderMenu readOnly={readOnly} />
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@ -46,7 +46,6 @@ export default function MovePageModal({
|
|||||||
message: t("Page moved successfully"),
|
message: t("Page moved successfully"),
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
setTargetSpace(null);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: err.response?.data.message || "An error occurred",
|
message: err.response?.data.message || "An error occurred",
|
||||||
@ -54,6 +53,7 @@ export default function MovePageModal({
|
|||||||
});
|
});
|
||||||
console.log(err);
|
console.log(err);
|
||||||
}
|
}
|
||||||
|
setTargetSpace(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChange = (space: ISpace) => {
|
const handleChange = (space: ISpace) => {
|
||||||
@ -69,7 +69,7 @@ export default function MovePageModal({
|
|||||||
yOffset="10vh"
|
yOffset="10vh"
|
||||||
xOffset={0}
|
xOffset={0}
|
||||||
mah={400}
|
mah={400}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<Modal.Overlay />
|
<Modal.Overlay />
|
||||||
<Modal.Content style={{ overflow: "hidden" }}>
|
<Modal.Content style={{ overflow: "hidden" }}>
|
||||||
@ -78,9 +78,7 @@ export default function MovePageModal({
|
|||||||
<Modal.CloseButton />
|
<Modal.CloseButton />
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
<Modal.Body>
|
<Modal.Body>
|
||||||
<Text mb="xs" c="dimmed" size="sm">
|
<Text mb="xs" c="dimmed" size="sm">{t("Move page to a different space.")}</Text>
|
||||||
{t("Move page to a different space.")}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<SpaceSelect
|
<SpaceSelect
|
||||||
value={currentSpaceSlug}
|
value={currentSpaceSlug}
|
||||||
|
|||||||
@ -1,38 +1,18 @@
|
|||||||
|
import { Modal, Button, SimpleGrid, FileButton } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
Modal,
|
|
||||||
Button,
|
|
||||||
SimpleGrid,
|
|
||||||
FileButton,
|
|
||||||
Group,
|
|
||||||
Text,
|
|
||||||
Tooltip,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import {
|
|
||||||
IconBrandNotion,
|
|
||||||
IconCheck,
|
IconCheck,
|
||||||
IconFileCode,
|
IconFileCode,
|
||||||
IconFileTypeZip,
|
|
||||||
IconMarkdown,
|
IconMarkdown,
|
||||||
IconX,
|
IconX,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import {
|
import { importPage } from "@/features/page/services/page-service.ts";
|
||||||
importPage,
|
|
||||||
importZip,
|
|
||||||
} from "@/features/page/services/page-service.ts";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { buildTree } from "@/features/page/tree/utils";
|
import { buildTree } from "@/features/page/tree/utils";
|
||||||
import { IPage } from "@/features/page/types/page.types.ts";
|
import { IPage } from "@/features/page/types/page.types.ts";
|
||||||
import React, { useEffect, useState } from "react";
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ConfluenceIcon } from "@/components/icons/confluence-icon.tsx";
|
|
||||||
import { getFileImportSizeLimit, isCloud } from "@/lib/config.ts";
|
|
||||||
import { formatBytes } from "@/lib";
|
|
||||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
|
||||||
import { getFileTaskById } from "@/features/file-task/services/file-task-service.ts";
|
|
||||||
import { queryClient } from "@/main.tsx";
|
|
||||||
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
|
||||||
|
|
||||||
interface PageImportModalProps {
|
interface PageImportModalProps {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
@ -56,7 +36,6 @@ export default function PageImportModal({
|
|||||||
yOffset="10vh"
|
yOffset="10vh"
|
||||||
xOffset={0}
|
xOffset={0}
|
||||||
mah={400}
|
mah={400}
|
||||||
keepMounted={true}
|
|
||||||
>
|
>
|
||||||
<Modal.Overlay />
|
<Modal.Overlay />
|
||||||
<Modal.Content style={{ overflow: "hidden" }}>
|
<Modal.Content style={{ overflow: "hidden" }}>
|
||||||
@ -80,133 +59,6 @@ interface ImportFormatSelection {
|
|||||||
function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
||||||
const [workspace] = useAtom(workspaceAtom);
|
|
||||||
const [fileTaskId, setFileTaskId] = useState<string | null>(null);
|
|
||||||
const emit = useQueryEmit();
|
|
||||||
|
|
||||||
const canUseConfluence = isCloud() || workspace?.hasLicenseKey;
|
|
||||||
|
|
||||||
const handleZipUpload = async (selectedFile: File, source: string) => {
|
|
||||||
if (!selectedFile) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
onClose();
|
|
||||||
|
|
||||||
notifications.show({
|
|
||||||
id: "import",
|
|
||||||
title: t("Uploading import file"),
|
|
||||||
message: t("Please don't close this tab."),
|
|
||||||
loading: true,
|
|
||||||
withCloseButton: false,
|
|
||||||
autoClose: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const importTask = await importZip(selectedFile, spaceId, source);
|
|
||||||
notifications.update({
|
|
||||||
id: "import",
|
|
||||||
title: t("Importing pages"),
|
|
||||||
message: t(
|
|
||||||
"Page import is in progress. You can check back later if this takes longer.",
|
|
||||||
),
|
|
||||||
loading: true,
|
|
||||||
withCloseButton: true,
|
|
||||||
autoClose: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
setFileTaskId(importTask.id);
|
|
||||||
} catch (err) {
|
|
||||||
console.log("Failed to upload import file", err);
|
|
||||||
notifications.update({
|
|
||||||
id: "import",
|
|
||||||
color: "red",
|
|
||||||
title: t("Failed to upload import file"),
|
|
||||||
message: err?.response.data.message,
|
|
||||||
icon: <IconX size={18} />,
|
|
||||||
loading: false,
|
|
||||||
withCloseButton: true,
|
|
||||||
autoClose: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!fileTaskId) return;
|
|
||||||
|
|
||||||
const intervalId = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const fileTask = await getFileTaskById(fileTaskId);
|
|
||||||
const status = fileTask.status;
|
|
||||||
|
|
||||||
if (status === "success") {
|
|
||||||
notifications.update({
|
|
||||||
id: "import",
|
|
||||||
color: "teal",
|
|
||||||
title: t("Import complete"),
|
|
||||||
message: t("Your pages were successfully imported."),
|
|
||||||
icon: <IconCheck size={18} />,
|
|
||||||
loading: false,
|
|
||||||
withCloseButton: true,
|
|
||||||
autoClose: false,
|
|
||||||
});
|
|
||||||
clearInterval(intervalId);
|
|
||||||
setFileTaskId(null);
|
|
||||||
|
|
||||||
await queryClient.refetchQueries({
|
|
||||||
queryKey: ["root-sidebar-pages", fileTask.spaceId],
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
emit({
|
|
||||||
operation: "refetchRootTreeNodeEvent",
|
|
||||||
spaceId: spaceId,
|
|
||||||
});
|
|
||||||
}, 50);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === "failed") {
|
|
||||||
notifications.update({
|
|
||||||
id: "import",
|
|
||||||
color: "red",
|
|
||||||
title: t("Page import failed"),
|
|
||||||
message: t(
|
|
||||||
"Something went wrong while importing pages: {{reason}}.",
|
|
||||||
{
|
|
||||||
reason: fileTask.errorMessage,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
icon: <IconX size={18} />,
|
|
||||||
loading: false,
|
|
||||||
withCloseButton: true,
|
|
||||||
autoClose: false,
|
|
||||||
});
|
|
||||||
clearInterval(intervalId);
|
|
||||||
setFileTaskId(null);
|
|
||||||
console.error(fileTask.errorMessage);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
notifications.update({
|
|
||||||
id: "import",
|
|
||||||
color: "red",
|
|
||||||
title: t("Import failed"),
|
|
||||||
message: t(
|
|
||||||
"Something went wrong while importing pages: {{reason}}.",
|
|
||||||
{
|
|
||||||
reason: err.response?.data.message,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
icon: <IconX size={18} />,
|
|
||||||
loading: false,
|
|
||||||
withCloseButton: true,
|
|
||||||
autoClose: false,
|
|
||||||
});
|
|
||||||
clearInterval(intervalId);
|
|
||||||
setFileTaskId(null);
|
|
||||||
console.error("Failed to fetch import status", err);
|
|
||||||
}
|
|
||||||
}, 3000);
|
|
||||||
}, [fileTaskId]);
|
|
||||||
|
|
||||||
const handleFileUpload = async (selectedFiles: File[]) => {
|
const handleFileUpload = async (selectedFiles: File[]) => {
|
||||||
if (!selectedFiles) {
|
if (!selectedFiles) {
|
||||||
@ -268,7 +120,6 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SimpleGrid cols={2}>
|
<SimpleGrid cols={2}>
|
||||||
@ -297,76 +148,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</FileButton>
|
</FileButton>
|
||||||
|
|
||||||
<FileButton
|
|
||||||
onChange={(file) => handleZipUpload(file, "notion")}
|
|
||||||
accept="application/zip"
|
|
||||||
>
|
|
||||||
{(props) => (
|
|
||||||
<Button
|
|
||||||
justify="start"
|
|
||||||
variant="default"
|
|
||||||
leftSection={<IconBrandNotion size={18} />}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
Notion
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</FileButton>
|
|
||||||
<FileButton
|
|
||||||
onChange={(file) => handleZipUpload(file, "confluence")}
|
|
||||||
accept="application/zip"
|
|
||||||
>
|
|
||||||
{(props) => (
|
|
||||||
<Tooltip
|
|
||||||
label="Available in enterprise edition"
|
|
||||||
disabled={canUseConfluence}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
disabled={!canUseConfluence}
|
|
||||||
justify="start"
|
|
||||||
variant="default"
|
|
||||||
leftSection={<ConfluenceIcon size={18} />}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
Confluence
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</FileButton>
|
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
<Group justify="center" gap="xl" mih={150}>
|
|
||||||
<div>
|
|
||||||
<Text ta="center" size="lg" inline>
|
|
||||||
Import zip file
|
|
||||||
</Text>
|
|
||||||
<Text ta="center" size="sm" c="dimmed" inline py="sm">
|
|
||||||
{t(
|
|
||||||
`Upload zip file containing Markdown and HTML files. Max: {{sizeLimit}}`,
|
|
||||||
{
|
|
||||||
sizeLimit: formatBytes(getFileImportSizeLimit()),
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
<FileButton
|
|
||||||
onChange={(file) => handleZipUpload(file, "generic")}
|
|
||||||
accept="application/zip"
|
|
||||||
>
|
|
||||||
{(props) => (
|
|
||||||
<Group justify="center">
|
|
||||||
<Button
|
|
||||||
justify="center"
|
|
||||||
leftSection={<IconFileTypeZip size={18} />}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{t("Upload file")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
</FileButton>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,7 @@ const buildPageSlug = (pageSlugId: string, pageTitle?: string): string => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
return `${titleSlug}-${pageSlugId}`;
|
return `p/${titleSlug}-${pageSlugId}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const buildPageUrl = (
|
export const buildPageUrl = (
|
||||||
@ -17,20 +17,7 @@ export const buildPageUrl = (
|
|||||||
pageTitle?: string,
|
pageTitle?: string,
|
||||||
): string => {
|
): string => {
|
||||||
if (spaceName === undefined) {
|
if (spaceName === undefined) {
|
||||||
return `/p/${buildPageSlug(pageSlugId, pageTitle)}`;
|
return `/${buildPageSlug(pageSlugId, pageTitle)}`;
|
||||||
}
|
}
|
||||||
return `/s/${spaceName}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
|
return `/s/${spaceName}/${buildPageSlug(pageSlugId, pageTitle)}`;
|
||||||
};
|
|
||||||
|
|
||||||
export const buildSharedPageUrl = (opts: {
|
|
||||||
shareId: string;
|
|
||||||
pageSlugId: string;
|
|
||||||
pageTitle?: string;
|
|
||||||
}): string => {
|
|
||||||
const { shareId, pageSlugId, pageTitle } = opts;
|
|
||||||
if (!shareId) {
|
|
||||||
return `/share/p/${buildPageSlug(pageSlugId, pageTitle)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `/share/${shareId}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,8 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
InfiniteData,
|
|
||||||
QueryKey,
|
|
||||||
useInfiniteQuery,
|
useInfiniteQuery,
|
||||||
UseInfiniteQueryResult,
|
|
||||||
useMutation,
|
useMutation,
|
||||||
useQuery,
|
useQuery,
|
||||||
useQueryClient,
|
useQueryClient,
|
||||||
@ -17,7 +14,6 @@ import {
|
|||||||
movePage,
|
movePage,
|
||||||
getPageBreadcrumbs,
|
getPageBreadcrumbs,
|
||||||
getRecentChanges,
|
getRecentChanges,
|
||||||
getAllSidebarPages,
|
|
||||||
} from "@/features/page/services/page-service";
|
} from "@/features/page/services/page-service";
|
||||||
import {
|
import {
|
||||||
IMovePage,
|
IMovePage,
|
||||||
@ -60,16 +56,19 @@ export function useCreatePageMutation() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return useMutation<IPage, Error, Partial<IPageInput>>({
|
return useMutation<IPage, Error, Partial<IPageInput>>({
|
||||||
mutationFn: (data) => createPage(data),
|
mutationFn: (data) => createPage(data),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {},
|
||||||
invalidateOnCreatePage(data);
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
notifications.show({ message: t("Failed to create page"), color: "red" });
|
notifications.show({ message: t("Failed to create page"), color: "red" });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updatePageData(data: IPage) {
|
export function useUpdatePageMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<IPage, Error, Partial<IPageInput>>({
|
||||||
|
mutationFn: (data) => updatePage(data),
|
||||||
|
onSuccess: (data) => {
|
||||||
const pageBySlug = queryClient.getQueryData<IPage>([
|
const pageBySlug = queryClient.getQueryData<IPage>([
|
||||||
"pages",
|
"pages",
|
||||||
data.slugId,
|
data.slugId,
|
||||||
@ -86,23 +85,6 @@ export function updatePageData(data: IPage) {
|
|||||||
if (pageById) {
|
if (pageById) {
|
||||||
queryClient.setQueryData(["pages", data.id], { ...pageById, ...data });
|
queryClient.setQueryData(["pages", data.id], { ...pageById, ...data });
|
||||||
}
|
}
|
||||||
|
|
||||||
invalidateOnUpdatePage(data.spaceId, data.parentPageId, data.id, data.title, data.icon);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateTitlePageMutation() {
|
|
||||||
return useMutation<IPage, Error, Partial<IPageInput>>({
|
|
||||||
mutationFn: (data) => updatePage(data),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdatePageMutation() {
|
|
||||||
return useMutation<IPage, Error, Partial<IPageInput>>({
|
|
||||||
mutationFn: (data) => updatePage(data),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
updatePage(data);
|
|
||||||
|
|
||||||
invalidateOnUpdatePage(data.spaceId, data.parentPageId, data.id, data.title, data.icon);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -111,9 +93,8 @@ export function useDeletePageMutation() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (pageId: string) => deletePage(pageId),
|
mutationFn: (pageId: string) => deletePage(pageId),
|
||||||
onSuccess: (data, pageId) => {
|
onSuccess: () => {
|
||||||
notifications.show({ message: t("Page deleted successfully") });
|
notifications.show({ message: t("Page deleted successfully") });
|
||||||
invalidateOnDeletePage(pageId);
|
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
notifications.show({ message: t("Failed to delete page"), color: "red" });
|
notifications.show({ message: t("Failed to delete page"), color: "red" });
|
||||||
@ -124,21 +105,15 @@ 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();
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useGetSidebarPagesQuery(data: SidebarPagesParams|null): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> {
|
export function useGetSidebarPagesQuery(
|
||||||
return useInfiniteQuery({
|
data: SidebarPagesParams,
|
||||||
|
): UseQueryResult<IPagination<IPage>, Error> {
|
||||||
|
return useQuery({
|
||||||
queryKey: ["sidebar-pages", data],
|
queryKey: ["sidebar-pages", data],
|
||||||
queryFn: ({ pageParam }) => getSidebarPages({ ...data, page: pageParam }),
|
queryFn: () => getSidebarPages(data),
|
||||||
initialPageParam: 1,
|
|
||||||
getPreviousPageParam: (firstPage) =>
|
|
||||||
firstPage.meta.hasPrevPage ? firstPage.meta.page - 1 : undefined,
|
|
||||||
getNextPageParam: (lastPage) =>
|
|
||||||
lastPage.meta.hasNextPage ? lastPage.meta.page + 1 : undefined,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -166,16 +141,14 @@ export function usePageBreadcrumbsQuery(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAllAncestorChildren(params: SidebarPagesParams) {
|
export async function fetchAncestorChildren(params: SidebarPagesParams) {
|
||||||
// not using a hook here, so we can call it inside a useEffect hook
|
// not using a hook here, so we can call it inside a useEffect hook
|
||||||
const response = await queryClient.fetchQuery({
|
const response = await queryClient.fetchQuery({
|
||||||
queryKey: ["sidebar-pages", params],
|
queryKey: ["sidebar-pages", params],
|
||||||
queryFn: () => getAllSidebarPages(params),
|
queryFn: () => getSidebarPages(params),
|
||||||
staleTime: 30 * 60 * 1000,
|
staleTime: 30 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
return buildTree(response.items);
|
||||||
const allItems = response.pages.flatMap((page) => page.items);
|
|
||||||
return buildTree(allItems);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useRecentChangesQuery(
|
export function useRecentChangesQuery(
|
||||||
@ -187,157 +160,3 @@ export function useRecentChangesQuery(
|
|||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function invalidateOnCreatePage(data: Partial<IPage>) {
|
|
||||||
const newPage: Partial<IPage> = {
|
|
||||||
creatorId: data.creatorId,
|
|
||||||
hasChildren: data.hasChildren,
|
|
||||||
icon: data.icon,
|
|
||||||
id: data.id,
|
|
||||||
parentPageId: data.parentPageId,
|
|
||||||
position: data.position,
|
|
||||||
slugId: data.slugId,
|
|
||||||
spaceId: data.spaceId,
|
|
||||||
title: data.title,
|
|
||||||
};
|
|
||||||
|
|
||||||
let queryKey: QueryKey = null;
|
|
||||||
if (data.parentPageId===null) {
|
|
||||||
queryKey = ['root-sidebar-pages', data.spaceId];
|
|
||||||
}else{
|
|
||||||
queryKey = ['sidebar-pages', {pageId: data.parentPageId, spaceId: data.spaceId}]
|
|
||||||
}
|
|
||||||
|
|
||||||
//update all sidebar pages
|
|
||||||
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(queryKey, (old) => {
|
|
||||||
if (!old) return old;
|
|
||||||
return {
|
|
||||||
...old,
|
|
||||||
pages: old.pages.map((page,index) => {
|
|
||||||
if (index === old.pages.length - 1) {
|
|
||||||
return {
|
|
||||||
...page,
|
|
||||||
items: [...page.items, newPage],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return page;
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
//update sidebar haschildren
|
|
||||||
if (data.parentPageId!==null){
|
|
||||||
//update sub sidebar pages haschildern
|
|
||||||
const subSideBarMatches = queryClient.getQueriesData({
|
|
||||||
queryKey: ['sidebar-pages'],
|
|
||||||
exact: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
subSideBarMatches.forEach(([key, d]) => {
|
|
||||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(key, (old) => {
|
|
||||||
if (!old) return old;
|
|
||||||
return {
|
|
||||||
...old,
|
|
||||||
pages: old.pages.map((page) => ({
|
|
||||||
...page,
|
|
||||||
items: page.items.map((sidebarPage: IPage) =>
|
|
||||||
sidebarPage.id === data.parentPageId ? { ...sidebarPage, hasChildren: true } : sidebarPage
|
|
||||||
)
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
//update root sidebar pages haschildern
|
|
||||||
const rootSideBarMatches = queryClient.getQueriesData({
|
|
||||||
queryKey: ['root-sidebar-pages', data.spaceId],
|
|
||||||
exact: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
rootSideBarMatches.forEach(([key, d]) => {
|
|
||||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(key, (old) => {
|
|
||||||
if (!old) return old;
|
|
||||||
return {
|
|
||||||
...old,
|
|
||||||
pages: old.pages.map((page) => ({
|
|
||||||
...page,
|
|
||||||
items: page.items.map((sidebarPage: IPage) =>
|
|
||||||
sidebarPage.id === data.parentPageId ? { ...sidebarPage, hasChildren: true } : sidebarPage
|
|
||||||
)
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
//update recent changes
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["recent-changes", data.spaceId],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function invalidateOnUpdatePage(spaceId: string, parentPageId: string, id: string, title: string, icon: string) {
|
|
||||||
let queryKey: QueryKey = null;
|
|
||||||
if(parentPageId===null){
|
|
||||||
queryKey = ['root-sidebar-pages', spaceId];
|
|
||||||
}else{
|
|
||||||
queryKey = ['sidebar-pages', {pageId: parentPageId, spaceId: spaceId}]
|
|
||||||
}
|
|
||||||
//update all sidebar pages
|
|
||||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(queryKey, (old) => {
|
|
||||||
if (!old) return old;
|
|
||||||
return {
|
|
||||||
...old,
|
|
||||||
pages: old.pages.map((page) => ({
|
|
||||||
...page,
|
|
||||||
items: page.items.map((sidebarPage: IPage) =>
|
|
||||||
sidebarPage.id === id ? { ...sidebarPage, title: title, icon: icon } : sidebarPage
|
|
||||||
)
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
//update recent changes
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["recent-changes", spaceId],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function invalidateOnMovePage() {
|
|
||||||
//for move invalidate all sidebars for now (how to do???)
|
|
||||||
//invalidate all root sidebar pages
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["root-sidebar-pages"],
|
|
||||||
});
|
|
||||||
//invalidate all sub sidebar pages
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ['sidebar-pages'],
|
|
||||||
});
|
|
||||||
// ---
|
|
||||||
}
|
|
||||||
|
|
||||||
export function invalidateOnDeletePage(pageId: string) {
|
|
||||||
//update all sidebar pages
|
|
||||||
const allSideBarMatches = queryClient.getQueriesData({
|
|
||||||
predicate: (query) =>
|
|
||||||
query.queryKey[0] === 'root-sidebar-pages' || query.queryKey[0] === 'sidebar-pages',
|
|
||||||
});
|
|
||||||
|
|
||||||
allSideBarMatches.forEach(([key, d]) => {
|
|
||||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(key, (old) => {
|
|
||||||
if (!old) return old;
|
|
||||||
return {
|
|
||||||
...old,
|
|
||||||
pages: old.pages.map((page) => ({
|
|
||||||
...page,
|
|
||||||
items: page.items.filter((sidebarPage: IPage) => sidebarPage.id !== pageId),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
//update recent changes
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["recent-changes"],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,17 +1,14 @@
|
|||||||
import api from "@/lib/api-client";
|
import api from "@/lib/api-client";
|
||||||
import {
|
import {
|
||||||
ICopyPageToSpace,
|
|
||||||
IExportPageParams,
|
IExportPageParams,
|
||||||
IMovePage,
|
IMovePage,
|
||||||
IMovePageToSpace,
|
IMovePageToSpace,
|
||||||
IPage,
|
IPage,
|
||||||
IPageInput,
|
IPageInput,
|
||||||
SidebarPagesParams,
|
SidebarPagesParams,
|
||||||
} from '@/features/page/types/page.types';
|
} from "@/features/page/types/page.types";
|
||||||
import { IAttachment, IPagination } from "@/lib/types.ts";
|
import { IAttachment, IPagination } from "@/lib/types.ts";
|
||||||
import { saveAs } from "file-saver";
|
import { saveAs } from "file-saver";
|
||||||
import { InfiniteData } from "@tanstack/react-query";
|
|
||||||
import { IFileTask } from '@/features/file-task/types/file-task.types.ts';
|
|
||||||
|
|
||||||
export async function createPage(data: Partial<IPage>): Promise<IPage> {
|
export async function createPage(data: Partial<IPage>): Promise<IPage> {
|
||||||
const req = await api.post<IPage>("/pages/create", data);
|
const req = await api.post<IPage>("/pages/create", data);
|
||||||
@ -42,11 +39,6 @@ export async function movePageToSpace(data: IMovePageToSpace): Promise<void> {
|
|||||||
await api.post<void>("/pages/move-to-space", data);
|
await api.post<void>("/pages/move-to-space", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function copyPageToSpace(data: ICopyPageToSpace): Promise<IPage> {
|
|
||||||
const req = await api.post<IPage>("/pages/copy-to-space", data);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSidebarPages(
|
export async function getSidebarPages(
|
||||||
params: SidebarPagesParams,
|
params: SidebarPagesParams,
|
||||||
): Promise<IPagination<IPage>> {
|
): Promise<IPagination<IPage>> {
|
||||||
@ -54,32 +46,6 @@ export async function getSidebarPages(
|
|||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAllSidebarPages(
|
|
||||||
params: SidebarPagesParams,
|
|
||||||
): Promise<InfiniteData<IPagination<IPage>, unknown>> {
|
|
||||||
let page = 1;
|
|
||||||
let hasNextPage = false;
|
|
||||||
const pages: IPagination<IPage>[] = [];
|
|
||||||
const pageParams: number[] = [];
|
|
||||||
|
|
||||||
do {
|
|
||||||
const req = await api.post("/pages/sidebar-pages", { ...params, page: page });
|
|
||||||
|
|
||||||
const data: IPagination<IPage> = req.data;
|
|
||||||
pages.push(data);
|
|
||||||
pageParams.push(page);
|
|
||||||
|
|
||||||
hasNextPage = data.meta.hasNextPage;
|
|
||||||
|
|
||||||
page += 1;
|
|
||||||
} while (hasNextPage);
|
|
||||||
|
|
||||||
return {
|
|
||||||
pageParams,
|
|
||||||
pages,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getPageBreadcrumbs(
|
export async function getPageBreadcrumbs(
|
||||||
pageId: string,
|
pageId: string,
|
||||||
): Promise<Partial<IPage[]>> {
|
): Promise<Partial<IPage[]>> {
|
||||||
@ -120,25 +86,6 @@ export async function importPage(file: File, spaceId: string) {
|
|||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function importZip(
|
|
||||||
file: File,
|
|
||||||
spaceId: string,
|
|
||||||
source?: string,
|
|
||||||
): Promise<IFileTask> {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("spaceId", spaceId);
|
|
||||||
formData.append("source", source);
|
|
||||||
formData.append("file", file);
|
|
||||||
|
|
||||||
const req = await api.post<any>("/pages/import-zip", formData, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "multipart/form-data",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function uploadFile(
|
export async function uploadFile(
|
||||||
file: File,
|
file: File,
|
||||||
pageId: string,
|
pageId: string,
|
||||||
|
|||||||
@ -1,19 +1,4 @@
|
|||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
import { SpaceTreeNode } from "@/features/page/tree/types";
|
import { SpaceTreeNode } from "@/features/page/tree/types";
|
||||||
import { appendNodeChildren } from "../utils";
|
|
||||||
|
|
||||||
export const treeDataAtom = atom<SpaceTreeNode[]>([]);
|
export const treeDataAtom = atom<SpaceTreeNode[]>([]);
|
||||||
|
|
||||||
// Atom
|
|
||||||
export const appendNodeChildrenAtom = atom(
|
|
||||||
null,
|
|
||||||
(
|
|
||||||
get,
|
|
||||||
set,
|
|
||||||
{ parentId, children }: { parentId: string; children: SpaceTreeNode[] }
|
|
||||||
) => {
|
|
||||||
const currentTree = get(treeDataAtom);
|
|
||||||
const updatedTree = appendNodeChildren(currentTree, parentId, children);
|
|
||||||
set(treeDataAtom, updatedTree);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|||||||
@ -2,20 +2,19 @@ import { NodeApi, NodeRendererProps, Tree, TreeApi } from "react-arborist";
|
|||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
||||||
import {
|
import {
|
||||||
fetchAllAncestorChildren,
|
fetchAncestorChildren,
|
||||||
useGetRootSidebarPagesQuery,
|
useGetRootSidebarPagesQuery,
|
||||||
usePageQuery,
|
usePageQuery,
|
||||||
useUpdatePageMutation,
|
useUpdatePageMutation,
|
||||||
} from "@/features/page/queries/page-query.ts";
|
} from "@/features/page/queries/page-query.ts";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import classes from "@/features/page/tree/styles/tree.module.css";
|
import classes from "@/features/page/tree/styles/tree.module.css";
|
||||||
import { ActionIcon, Box, Menu, rem } from "@mantine/core";
|
import { ActionIcon, Menu, rem } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
IconArrowRight,
|
IconArrowRight,
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
IconChevronRight,
|
IconChevronRight,
|
||||||
IconCopy,
|
|
||||||
IconDotsVertical,
|
IconDotsVertical,
|
||||||
IconFileDescription,
|
IconFileDescription,
|
||||||
IconFileExport,
|
IconFileExport,
|
||||||
@ -24,10 +23,7 @@ import {
|
|||||||
IconPointFilled,
|
IconPointFilled,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import {
|
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||||
appendNodeChildrenAtom,
|
|
||||||
treeDataAtom,
|
|
||||||
} from "@/features/page/tree/atoms/tree-data-atom.ts";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
|
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
|
||||||
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
||||||
@ -35,7 +31,6 @@ import {
|
|||||||
appendNodeChildren,
|
appendNodeChildren,
|
||||||
buildTree,
|
buildTree,
|
||||||
buildTreeWithChildren,
|
buildTreeWithChildren,
|
||||||
mergeRootTrees,
|
|
||||||
updateTreeNodeIcon,
|
updateTreeNodeIcon,
|
||||||
} from "@/features/page/tree/utils/utils.ts";
|
} from "@/features/page/tree/utils/utils.ts";
|
||||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||||
@ -63,9 +58,6 @@ import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import ExportModal from "@/components/common/export-modal";
|
import ExportModal from "@/components/common/export-modal";
|
||||||
import MovePageModal from "../../components/move-page-modal.tsx";
|
import MovePageModal from "../../components/move-page-modal.tsx";
|
||||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
|
||||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
|
||||||
import CopyPageModal from "../../components/copy-page-modal.tsx";
|
|
||||||
|
|
||||||
interface SpaceTreeProps {
|
interface SpaceTreeProps {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
@ -92,7 +84,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
|||||||
const rootElement = useRef<HTMLDivElement>();
|
const rootElement = useRef<HTMLDivElement>();
|
||||||
const { ref: sizeRef, width, height } = useElementSize();
|
const { ref: sizeRef, width, height } = useElementSize();
|
||||||
const mergedRef = useMergedRef(rootElement, sizeRef);
|
const mergedRef = useMergedRef(rootElement, sizeRef);
|
||||||
const [isDataLoaded, setIsDataLoaded] = useState(false);
|
const isDataLoaded = useRef(false);
|
||||||
const { data: currentPage } = usePageQuery({
|
const { data: currentPage } = usePageQuery({
|
||||||
pageId: extractPageSlugId(pageSlug),
|
pageId: extractPageSlugId(pageSlug),
|
||||||
});
|
});
|
||||||
@ -108,23 +100,23 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
|||||||
const allItems = pagesData.pages.flatMap((page) => page.items);
|
const allItems = pagesData.pages.flatMap((page) => page.items);
|
||||||
const treeData = buildTree(allItems);
|
const treeData = buildTree(allItems);
|
||||||
|
|
||||||
setData((prev) => {
|
if (data.length < 1 || data?.[0].spaceId !== spaceId) {
|
||||||
// fresh space; full reset
|
//Thoughts
|
||||||
if (prev.length === 0 || prev[0]?.spaceId !== spaceId) {
|
// don't reset if there is data in state
|
||||||
setIsDataLoaded(true);
|
// we only expect to call this once on initial load
|
||||||
|
// even if we decide to refetch, it should only update
|
||||||
|
// and append root pages instead of resetting the entire tree
|
||||||
|
// which looses async loaded children too
|
||||||
|
setData(treeData);
|
||||||
|
isDataLoaded.current = true;
|
||||||
setOpenTreeNodes({});
|
setOpenTreeNodes({});
|
||||||
return treeData;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// same space; append only missing roots
|
|
||||||
return mergeRootTrees(prev, treeData);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [pagesData, hasNextPage]);
|
}, [pagesData, hasNextPage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
if (isDataLoaded && currentPage) {
|
if (isDataLoaded.current && currentPage) {
|
||||||
// check if pageId node is present in the tree
|
// check if pageId node is present in the tree
|
||||||
const node = dfs(treeApiRef.current?.root, currentPage.id);
|
const node = dfs(treeApiRef.current?.root, currentPage.id);
|
||||||
if (node) {
|
if (node) {
|
||||||
@ -144,7 +136,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
|||||||
if (ancestor.id === currentPage.id) {
|
if (ancestor.id === currentPage.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const children = await fetchAllAncestorChildren({
|
const children = await fetchAncestorChildren({
|
||||||
pageId: ancestor.id,
|
pageId: ancestor.id,
|
||||||
spaceId: ancestor.spaceId,
|
spaceId: ancestor.spaceId,
|
||||||
});
|
});
|
||||||
@ -186,7 +178,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [isDataLoaded, currentPage?.id]);
|
}, [isDataLoaded.current, currentPage?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentPage?.id) {
|
if (currentPage?.id) {
|
||||||
@ -238,15 +230,13 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
||||||
const { t } = useTranslation();
|
const navigate = useNavigate();
|
||||||
const updatePageMutation = useUpdatePageMutation();
|
const updatePageMutation = useUpdatePageMutation();
|
||||||
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
||||||
const [, appendChildren] = useAtom(appendNodeChildrenAtom);
|
|
||||||
const emit = useQueryEmit();
|
const emit = useQueryEmit();
|
||||||
const { spaceSlug } = useParams();
|
const { spaceSlug } = useParams();
|
||||||
const timerRef = useRef(null);
|
const timerRef = useRef(null);
|
||||||
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
|
const { t } = useTranslation();
|
||||||
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
|
|
||||||
|
|
||||||
const prefetchPage = () => {
|
const prefetchPage = () => {
|
||||||
timerRef.current = setTimeout(() => {
|
timerRef.current = setTimeout(() => {
|
||||||
@ -267,10 +257,9 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
|||||||
|
|
||||||
async function handleLoadChildren(node: NodeApi<SpaceTreeNode>) {
|
async function handleLoadChildren(node: NodeApi<SpaceTreeNode>) {
|
||||||
if (!node.data.hasChildren) return;
|
if (!node.data.hasChildren) return;
|
||||||
// in conflict with use-query-subscription.ts => case "addTreeNode","moveTreeNode" etc with websocket
|
if (node.data.children && node.data.children.length > 0) {
|
||||||
// if (node.data.children && node.data.children.length > 0) {
|
return;
|
||||||
// return;
|
}
|
||||||
// }
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const params: SidebarPagesParams = {
|
const params: SidebarPagesParams = {
|
||||||
@ -278,17 +267,31 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
|||||||
spaceId: node.data.spaceId,
|
spaceId: node.data.spaceId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const childrenTree = await fetchAllAncestorChildren(params);
|
const newChildren = await queryClient.fetchQuery({
|
||||||
|
queryKey: ["sidebar-pages", params],
|
||||||
appendChildren({
|
queryFn: () => getSidebarPages(params),
|
||||||
parentId: node.data.id,
|
staleTime: 10 * 60 * 1000,
|
||||||
children: childrenTree,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const childrenTree = buildTree(newChildren.items);
|
||||||
|
|
||||||
|
const updatedTreeData = appendNodeChildren(
|
||||||
|
treeData,
|
||||||
|
node.data.id,
|
||||||
|
childrenTree,
|
||||||
|
);
|
||||||
|
|
||||||
|
setTreeData(updatedTreeData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch children:", error);
|
console.error("Failed to fetch children:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
const pageUrl = buildPageUrl(spaceSlug, node.data.slugId, node.data.name);
|
||||||
|
navigate(pageUrl);
|
||||||
|
};
|
||||||
|
|
||||||
const handleUpdateNodeIcon = (nodeId: string, newIcon: string) => {
|
const handleUpdateNodeIcon = (nodeId: string, newIcon: string) => {
|
||||||
const updatedTree = updateTreeNodeIcon(treeData, nodeId, newIcon);
|
const updatedTree = updateTreeNodeIcon(treeData, nodeId, newIcon);
|
||||||
setTreeData(updatedTree);
|
setTreeData(updatedTree);
|
||||||
@ -301,19 +304,17 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
|||||||
|
|
||||||
const handleEmojiSelect = (emoji: { native: string }) => {
|
const handleEmojiSelect = (emoji: { native: string }) => {
|
||||||
handleUpdateNodeIcon(node.id, emoji.native);
|
handleUpdateNodeIcon(node.id, emoji.native);
|
||||||
updatePageMutation
|
updatePageMutation.mutateAsync({ pageId: node.id, icon: emoji.native });
|
||||||
.mutateAsync({ pageId: node.id, icon: emoji.native })
|
|
||||||
.then((data) => {
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
emit({
|
emit({
|
||||||
operation: "updateOne",
|
operation: "updateOne",
|
||||||
spaceId: node.data.spaceId,
|
spaceId: node.data.spaceId,
|
||||||
entity: ["pages"],
|
entity: ["pages"],
|
||||||
id: node.id,
|
id: node.id,
|
||||||
payload: { icon: emoji.native, parentPageId: data.parentPageId },
|
payload: { icon: emoji.native },
|
||||||
});
|
});
|
||||||
}, 50);
|
}, 50);
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveEmoji = () => {
|
const handleRemoveEmoji = () => {
|
||||||
@ -344,22 +345,13 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
|||||||
}, 650);
|
}, 650);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageUrl = buildPageUrl(spaceSlug, node.data.slugId, node.data.name);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Box
|
<div
|
||||||
style={style}
|
style={style}
|
||||||
className={clsx(classes.node, node.state)}
|
className={clsx(classes.node, node.state)}
|
||||||
component={Link}
|
|
||||||
to={pageUrl}
|
|
||||||
// @ts-ignore
|
|
||||||
ref={dragHandle}
|
ref={dragHandle}
|
||||||
onClick={() => {
|
onClick={handleClick}
|
||||||
if (mobileSidebarOpened) {
|
|
||||||
toggleMobileSidebar();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseEnter={prefetchPage}
|
onMouseEnter={prefetchPage}
|
||||||
onMouseLeave={cancelPagePrefetch}
|
onMouseLeave={cancelPagePrefetch}
|
||||||
>
|
>
|
||||||
@ -393,7 +385,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Box>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -449,10 +441,6 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
|||||||
movePageModalOpened,
|
movePageModalOpened,
|
||||||
{ open: openMovePageModal, close: closeMoveSpaceModal },
|
{ open: openMovePageModal, close: closeMoveSpaceModal },
|
||||||
] = useDisclosure(false);
|
] = useDisclosure(false);
|
||||||
const [
|
|
||||||
copyPageModalOpened,
|
|
||||||
{ open: openCopyPageModal, close: closeCopySpaceModal },
|
|
||||||
] = useDisclosure(false);
|
|
||||||
|
|
||||||
const handleCopyLink = () => {
|
const handleCopyLink = () => {
|
||||||
const pageUrl =
|
const pageUrl =
|
||||||
@ -516,17 +504,6 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
|||||||
{t("Move")}
|
{t("Move")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Item
|
|
||||||
leftSection={<IconCopy size={16} />}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
openCopyPageModal();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("Copy")}
|
|
||||||
</Menu.Item>
|
|
||||||
|
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
c="red"
|
c="red"
|
||||||
@ -552,13 +529,6 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
|||||||
open={movePageModalOpened}
|
open={movePageModalOpened}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CopyPageModal
|
|
||||||
pageId={node.id}
|
|
||||||
currentSpaceSlug={spaceSlug}
|
|
||||||
onClose={closeCopySpaceModal}
|
|
||||||
open={copyPageModalOpened}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ExportModal
|
<ExportModal
|
||||||
type="page"
|
type="page"
|
||||||
id={node.id}
|
id={node.id}
|
||||||
@ -575,12 +545,6 @@ interface PageArrowProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PageArrow({ node, onExpandTree }: PageArrowProps) {
|
function PageArrow({ node, onExpandTree }: PageArrowProps) {
|
||||||
useEffect(() => {
|
|
||||||
if (node.isOpen) {
|
|
||||||
onExpandTree();
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
size={20}
|
size={20}
|
||||||
|
|||||||
@ -93,7 +93,7 @@ export function useTreeMutation<T>(spaceId: string) {
|
|||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onMove: MoveHandler<T> = async (args: {
|
const onMove: MoveHandler<T> = (args: {
|
||||||
dragIds: string[];
|
dragIds: string[];
|
||||||
dragNodes: NodeApi<T>[];
|
dragNodes: NodeApi<T>[];
|
||||||
parentId: string | null;
|
parentId: string | null;
|
||||||
@ -176,7 +176,7 @@ export function useTreeMutation<T>(spaceId: string) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await movePageMutation.mutateAsync(payload);
|
movePageMutation.mutateAsync(payload);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
emit({
|
emit({
|
||||||
@ -206,23 +206,6 @@ export function useTreeMutation<T>(spaceId: string) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isPageInNode = (
|
|
||||||
node: { data: SpaceTreeNode; children?: any[] },
|
|
||||||
pageSlug: string
|
|
||||||
): boolean => {
|
|
||||||
if (node.data.slugId === pageSlug) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
for (const item of node.children) {
|
|
||||||
if (item.data.slugId === pageSlug) {
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
return isPageInNode(item, pageSlug);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDelete: DeleteHandler<T> = async (args: { ids: string[] }) => {
|
const onDelete: DeleteHandler<T> = async (args: { ids: string[] }) => {
|
||||||
try {
|
try {
|
||||||
await deletePageMutation.mutateAsync(args.ids[0]);
|
await deletePageMutation.mutateAsync(args.ids[0]);
|
||||||
@ -235,7 +218,8 @@ export function useTreeMutation<T>(spaceId: string) {
|
|||||||
tree.drop({ id: args.ids[0] });
|
tree.drop({ id: args.ids[0] });
|
||||||
setData(tree.data);
|
setData(tree.data);
|
||||||
|
|
||||||
if (pageSlug && isPageInNode(node, pageSlug.split("-")[1])) {
|
// navigate only if the current url is same as the deleted page
|
||||||
|
if (pageSlug && node.data.slugId === pageSlug.split("-")[1]) {
|
||||||
navigate(getSpaceUrl(spaceSlug));
|
navigate(getSpaceUrl(spaceSlug));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,7 +18,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 93%; /* not to overlap with scroll bar */
|
width: 93%; /* not to overlap with scroll bar */
|
||||||
text-decoration: none;
|
|
||||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@ -70,10 +70,6 @@
|
|||||||
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
|
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
|
||||||
}
|
}
|
||||||
|
|
||||||
.row:focus .node:global(.isFocused) {
|
|
||||||
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
|
|
||||||
}
|
|
||||||
|
|
||||||
.row {
|
.row {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { IPage } from "@/features/page/types/page.types.ts";
|
import { IPage } from "@/features/page/types/page.types.ts";
|
||||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||||
|
|
||||||
export function sortPositionKeys(keys: any[]) {
|
function sortPositionKeys(keys: any[]) {
|
||||||
return keys.sort((a, b) => {
|
return keys.sort((a, b) => {
|
||||||
if (a.position < b.position) return -1;
|
if (a.position < b.position) return -1;
|
||||||
if (a.position > b.position) return 1;
|
if (a.position > b.position) return 1;
|
||||||
@ -121,6 +121,7 @@ export const deleteTreeNode = (
|
|||||||
.filter((node) => node !== null);
|
.filter((node) => node !== null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export function buildTreeWithChildren(items: SpaceTreeNode[]): SpaceTreeNode[] {
|
export function buildTreeWithChildren(items: SpaceTreeNode[]): SpaceTreeNode[] {
|
||||||
const nodeMap = {};
|
const nodeMap = {};
|
||||||
let result: SpaceTreeNode[] = [];
|
let result: SpaceTreeNode[] = [];
|
||||||
@ -163,55 +164,16 @@ export function appendNodeChildren(
|
|||||||
nodeId: string,
|
nodeId: string,
|
||||||
children: SpaceTreeNode[],
|
children: SpaceTreeNode[],
|
||||||
) {
|
) {
|
||||||
// Preserve deeper children if they exist and remove node if deleted
|
return treeItems.map((nodeItem) => {
|
||||||
return treeItems.map((node) => {
|
if (nodeItem.id === nodeId) {
|
||||||
if (node.id === nodeId) {
|
return { ...nodeItem, children };
|
||||||
const newIds = new Set(children.map((c) => c.id));
|
}
|
||||||
|
if (nodeItem.children) {
|
||||||
const existingMap = new Map(
|
|
||||||
(node.children ?? [])
|
|
||||||
.filter((c) => newIds.has(c.id))
|
|
||||||
.map((c) => [c.id, c]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const merged = children.map((newChild) => {
|
|
||||||
const existing = existingMap.get(newChild.id);
|
|
||||||
return existing && existing.children
|
|
||||||
? { ...newChild, children: existing.children }
|
|
||||||
: newChild;
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...node,
|
...nodeItem,
|
||||||
children: merged,
|
children: appendNodeChildren(nodeItem.children, nodeId, children),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
return nodeItem;
|
||||||
if (node.children) {
|
|
||||||
return {
|
|
||||||
...node,
|
|
||||||
children: appendNodeChildren(node.children, nodeId, children),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return node;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Merge root nodes; keep existing ones intact, append new ones,
|
|
||||||
*/
|
|
||||||
export function mergeRootTrees(
|
|
||||||
prevRoots: SpaceTreeNode[],
|
|
||||||
incomingRoots: SpaceTreeNode[],
|
|
||||||
): SpaceTreeNode[] {
|
|
||||||
const seen = new Set(prevRoots.map((r) => r.id));
|
|
||||||
|
|
||||||
// add new roots that were not present before
|
|
||||||
const merged = [...prevRoots];
|
|
||||||
incomingRoots.forEach((node) => {
|
|
||||||
if (!seen.has(node.id)) merged.push(node);
|
|
||||||
});
|
|
||||||
|
|
||||||
return sortPositionKeys(merged);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -12,7 +12,7 @@ export interface IPage {
|
|||||||
spaceId: string;
|
spaceId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
isLocked: boolean;
|
isLocked: boolean;
|
||||||
lastUpdatedById: string;
|
lastUpdatedById: Date;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
deletedAt: Date;
|
deletedAt: Date;
|
||||||
@ -47,11 +47,6 @@ export interface IMovePageToSpace {
|
|||||||
spaceId: string;
|
spaceId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ICopyPageToSpace {
|
|
||||||
pageId: string;
|
|
||||||
spaceId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SidebarPagesParams {
|
export interface SidebarPagesParams {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
pageId?: string;
|
pageId?: string;
|
||||||
@ -65,7 +60,6 @@ export interface IPageInput {
|
|||||||
icon: string;
|
icon: string;
|
||||||
coverPhoto: string;
|
coverPhoto: string;
|
||||||
position: string;
|
position: string;
|
||||||
isLocked: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IExportPageParams {
|
export interface IExportPageParams {
|
||||||
|
|||||||
@ -1,44 +0,0 @@
|
|||||||
.root {
|
|
||||||
height: 34px;
|
|
||||||
padding-left: var(--mantine-spacing-sm);
|
|
||||||
padding-right: 4px;
|
|
||||||
border-radius: var(--mantine-radius-md);
|
|
||||||
color: var(--mantine-color-placeholder);
|
|
||||||
border: 1px solid;
|
|
||||||
|
|
||||||
@mixin light {
|
|
||||||
border-color: var(--mantine-color-gray-3);
|
|
||||||
background-color: var(--mantine-color-white);
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin dark {
|
|
||||||
border-color: var(--mantine-color-dark-4);
|
|
||||||
background-color: var(--mantine-color-dark-6);
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin rtl {
|
|
||||||
padding-left: 4px;
|
|
||||||
padding-right: var(--mantine-spacing-sm);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.shortcut {
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 1;
|
|
||||||
padding: 4px 7px;
|
|
||||||
border-radius: var(--mantine-radius-sm);
|
|
||||||
border: 1px solid;
|
|
||||||
font-weight: bold;
|
|
||||||
|
|
||||||
@mixin light {
|
|
||||||
color: var(--mantine-color-gray-7);
|
|
||||||
border-color: var(--mantine-color-gray-2);
|
|
||||||
background-color: var(--mantine-color-gray-0);
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin dark {
|
|
||||||
color: var(--mantine-color-dark-0);
|
|
||||||
border-color: var(--mantine-color-dark-7);
|
|
||||||
background-color: var(--mantine-color-dark-7);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
import { IconSearch } from "@tabler/icons-react";
|
|
||||||
import cx from "clsx";
|
|
||||||
import {
|
|
||||||
ActionIcon,
|
|
||||||
BoxProps,
|
|
||||||
ElementProps,
|
|
||||||
Group,
|
|
||||||
rem,
|
|
||||||
Text,
|
|
||||||
Tooltip,
|
|
||||||
UnstyledButton,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import classes from "./search-control.module.css";
|
|
||||||
import React from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
interface SearchControlProps extends BoxProps, ElementProps<"button"> {}
|
|
||||||
|
|
||||||
export function SearchControl({ className, ...others }: SearchControlProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<UnstyledButton {...others} className={cx(classes.root, className)}>
|
|
||||||
<Group gap="xs" wrap="nowrap">
|
|
||||||
<IconSearch style={{ width: rem(15), height: rem(15) }} stroke={1.5} />
|
|
||||||
<Text fz="sm" c="dimmed" pr={80}>
|
|
||||||
{t("Search")}
|
|
||||||
</Text>
|
|
||||||
<Text fw={700} className={classes.shortcut}>
|
|
||||||
Ctrl + K
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</UnstyledButton>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SearchMobileControlProps {
|
|
||||||
onSearch: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SearchMobileControl({ onSearch }: SearchMobileControlProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip label={t("Search")} withArrow>
|
|
||||||
<ActionIcon
|
|
||||||
variant="default"
|
|
||||||
style={{ border: "none" }}
|
|
||||||
onClick={onSearch}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<IconSearch size={20} stroke={2} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
import { createSpotlight } from '@mantine/spotlight';
|
|
||||||
|
|
||||||
export const [searchSpotlightStore, searchSpotlight] = createSpotlight();
|
|
||||||
|
|
||||||
export const [shareSearchSpotlightStore, shareSearchSpotlight] =
|
|
||||||
createSpotlight();
|
|
||||||
|
|
||||||
@ -1,7 +1,6 @@
|
|||||||
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
searchPage,
|
searchPage,
|
||||||
searchShare,
|
|
||||||
searchSuggestions,
|
searchSuggestions,
|
||||||
} from "@/features/search/services/search-service";
|
} from "@/features/search/services/search-service";
|
||||||
import {
|
import {
|
||||||
@ -31,13 +30,3 @@ export function useSearchSuggestionsQuery(
|
|||||||
enabled: !!params.query,
|
enabled: !!params.query,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useShareSearchQuery(
|
|
||||||
params: IPageSearchParams,
|
|
||||||
): UseQueryResult<IPageSearch[], Error> {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["share-search", params],
|
|
||||||
queryFn: () => searchShare(params),
|
|
||||||
enabled: !!params.query,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@ -2,36 +2,36 @@ import { Group, Center, Text } from "@mantine/core";
|
|||||||
import { Spotlight } from "@mantine/spotlight";
|
import { Spotlight } from "@mantine/spotlight";
|
||||||
import { IconSearch } from "@tabler/icons-react";
|
import { IconSearch } from "@tabler/icons-react";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
import { usePageSearchQuery } from "@/features/search/queries/search-query";
|
import { usePageSearchQuery } from "@/features/search/queries/search-query";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
import { getPageIcon } from "@/lib";
|
import { getPageIcon } from "@/lib";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { searchSpotlightStore } from "./constants";
|
|
||||||
|
|
||||||
interface SearchSpotlightProps {
|
interface SearchSpotlightProps {
|
||||||
spaceId?: string;
|
spaceId?: string;
|
||||||
}
|
}
|
||||||
export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [debouncedSearchQuery] = useDebouncedValue(query, 300);
|
const [debouncedSearchQuery] = useDebouncedValue(query, 300);
|
||||||
|
|
||||||
const { data: searchResults } = usePageSearchQuery({
|
const {
|
||||||
query: debouncedSearchQuery,
|
data: searchResults,
|
||||||
spaceId,
|
isLoading,
|
||||||
});
|
error,
|
||||||
|
} = usePageSearchQuery({ query: debouncedSearchQuery, spaceId });
|
||||||
|
|
||||||
const pages = (
|
const pages = (
|
||||||
searchResults && searchResults.length > 0 ? searchResults : []
|
searchResults && searchResults.length > 0 ? searchResults : []
|
||||||
).map((page) => (
|
).map((page) => (
|
||||||
<Spotlight.Action
|
<Spotlight.Action
|
||||||
key={page.id}
|
key={page.id}
|
||||||
component={Link}
|
onClick={() =>
|
||||||
//@ts-ignore
|
navigate(buildPageUrl(page.space.slug, page.slugId, page.title))
|
||||||
to={buildPageUrl(page.space.slug, page.slugId, page.title)}
|
}
|
||||||
style={{ userSelect: "none" }}
|
|
||||||
>
|
>
|
||||||
<Group wrap="nowrap" w="100%">
|
<Group wrap="nowrap" w="100%">
|
||||||
<Center>{getPageIcon(page?.icon)}</Center>
|
<Center>{getPageIcon(page?.icon)}</Center>
|
||||||
@ -54,7 +54,6 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Spotlight.Root
|
<Spotlight.Root
|
||||||
store={searchSpotlightStore}
|
|
||||||
query={query}
|
query={query}
|
||||||
onQueryChange={setQuery}
|
onQueryChange={setQuery}
|
||||||
scrollable
|
scrollable
|
||||||
|
|||||||
@ -19,10 +19,3 @@ export async function searchSuggestions(
|
|||||||
const req = await api.post<ISuggestionResult>("/search/suggest", params);
|
const req = await api.post<ISuggestionResult>("/search/suggest", params);
|
||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchShare(
|
|
||||||
params: IPageSearchParams,
|
|
||||||
): Promise<IPageSearch[]> {
|
|
||||||
const req = await api.post<IPageSearch[]>("/search/share-search", params);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,87 +0,0 @@
|
|||||||
import { Group, Center, Text } from "@mantine/core";
|
|
||||||
import { Spotlight } from "@mantine/spotlight";
|
|
||||||
import { IconSearch } from "@tabler/icons-react";
|
|
||||||
import React, { useState } from "react";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
|
||||||
import { useShareSearchQuery } from "@/features/search/queries/search-query";
|
|
||||||
import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
|
|
||||||
import { getPageIcon } from "@/lib";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { shareSearchSpotlightStore } from "@/features/search/constants.ts";
|
|
||||||
|
|
||||||
interface ShareSearchSpotlightProps {
|
|
||||||
shareId?: string;
|
|
||||||
}
|
|
||||||
export function ShareSearchSpotlight({ shareId }: ShareSearchSpotlightProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const [debouncedSearchQuery] = useDebouncedValue(query, 300);
|
|
||||||
|
|
||||||
const { data: searchResults } = useShareSearchQuery({
|
|
||||||
query: debouncedSearchQuery,
|
|
||||||
shareId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const pages = (
|
|
||||||
searchResults && searchResults.length > 0 ? searchResults : []
|
|
||||||
).map((page) => (
|
|
||||||
<Spotlight.Action
|
|
||||||
key={page.id}
|
|
||||||
component={Link}
|
|
||||||
//@ts-ignore
|
|
||||||
to={buildSharedPageUrl({
|
|
||||||
shareId: shareId,
|
|
||||||
pageTitle: page.title,
|
|
||||||
pageSlugId: page.slugId,
|
|
||||||
})}
|
|
||||||
style={{ userSelect: "none" }}
|
|
||||||
>
|
|
||||||
<Group wrap="nowrap" w="100%">
|
|
||||||
<Center>{getPageIcon(page?.icon)}</Center>
|
|
||||||
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<Text>{page.title}</Text>
|
|
||||||
|
|
||||||
{page?.highlight && (
|
|
||||||
<Text
|
|
||||||
opacity={0.6}
|
|
||||||
size="xs"
|
|
||||||
dangerouslySetInnerHTML={{ __html: page.highlight }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
</Spotlight.Action>
|
|
||||||
));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Spotlight.Root
|
|
||||||
store={shareSearchSpotlightStore}
|
|
||||||
query={query}
|
|
||||||
onQueryChange={setQuery}
|
|
||||||
scrollable
|
|
||||||
overlayProps={{
|
|
||||||
backgroundOpacity: 0.55,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Spotlight.Search
|
|
||||||
placeholder={t("Search...")}
|
|
||||||
leftSection={<IconSearch size={20} stroke={1.5} />}
|
|
||||||
/>
|
|
||||||
<Spotlight.ActionsList>
|
|
||||||
{query.length === 0 && pages.length === 0 && (
|
|
||||||
<Spotlight.Empty>{t("Start typing to search...")}</Spotlight.Empty>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{query.length > 0 && pages.length === 0 && (
|
|
||||||
<Spotlight.Empty>{t("No results found...")}</Spotlight.Empty>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{pages.length > 0 && pages}
|
|
||||||
</Spotlight.ActionsList>
|
|
||||||
</Spotlight.Root>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -35,5 +35,4 @@ export interface ISuggestionResult {
|
|||||||
export interface IPageSearchParams {
|
export interface IPageSearchParams {
|
||||||
query: string;
|
query: string;
|
||||||
spaceId?: string;
|
spaceId?: string;
|
||||||
shareId?: string;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +0,0 @@
|
|||||||
import { atomWithWebStorage } from "@/lib/jotai-helper.ts";
|
|
||||||
import { atom } from 'jotai';
|
|
||||||
|
|
||||||
export const tableOfContentAsideAtom = atomWithWebStorage<boolean>(
|
|
||||||
"showTOC",
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
export const mobileTableOfContentAsideAtom = atom<boolean>(false);
|
|
||||||
@ -1,106 +0,0 @@
|
|||||||
import { Menu, ActionIcon, Text } from "@mantine/core";
|
|
||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
IconCopy,
|
|
||||||
IconDots,
|
|
||||||
IconFileDescription,
|
|
||||||
IconTrash,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import { modals } from "@mantine/modals";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { ISharedItem } from "@/features/share/types/share.types.ts";
|
|
||||||
import {
|
|
||||||
buildPageUrl,
|
|
||||||
buildSharedPageUrl,
|
|
||||||
} from "@/features/page/page.utils.ts";
|
|
||||||
import { useClipboard } from "@mantine/hooks";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { useDeleteShareMutation } from "@/features/share/queries/share-query.ts";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
share: ISharedItem;
|
|
||||||
}
|
|
||||||
export default function ShareActionMenu({ share }: Props) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const clipboard = useClipboard();
|
|
||||||
const deleteShareMutation = useDeleteShareMutation();
|
|
||||||
|
|
||||||
const openPage = () => {
|
|
||||||
const pageLink = buildPageUrl(
|
|
||||||
share.space.slug,
|
|
||||||
share.page.slugId,
|
|
||||||
share.page.title,
|
|
||||||
);
|
|
||||||
navigate(pageLink);
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyLink = () => {
|
|
||||||
const shareLink = buildSharedPageUrl({
|
|
||||||
shareId: share.key,
|
|
||||||
pageTitle: share.page.title,
|
|
||||||
pageSlugId: share.page.slugId,
|
|
||||||
});
|
|
||||||
|
|
||||||
clipboard.copy(shareLink);
|
|
||||||
notifications.show({ message: t("Link copied") });
|
|
||||||
};
|
|
||||||
const onDelete = async () => {
|
|
||||||
deleteShareMutation.mutateAsync(share.key);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openDeleteModal = () =>
|
|
||||||
modals.openConfirmModal({
|
|
||||||
title: t("Delete public share link"),
|
|
||||||
children: (
|
|
||||||
<Text size="sm">
|
|
||||||
{t("Are you sure you want to delete this shared link?")}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
centered: true,
|
|
||||||
labels: { confirm: t("Delete"), cancel: t("Don't") },
|
|
||||||
confirmProps: { color: "red" },
|
|
||||||
onConfirm: onDelete,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Menu
|
|
||||||
shadow="xl"
|
|
||||||
position="bottom-end"
|
|
||||||
offset={20}
|
|
||||||
width={200}
|
|
||||||
withArrow
|
|
||||||
arrowPosition="center"
|
|
||||||
>
|
|
||||||
<Menu.Target>
|
|
||||||
<ActionIcon variant="subtle" c="gray">
|
|
||||||
<IconDots size={20} stroke={2} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Menu.Target>
|
|
||||||
|
|
||||||
<Menu.Dropdown>
|
|
||||||
<Menu.Item onClick={copyLink} leftSection={<IconCopy size={16} />}>
|
|
||||||
{t("Copy link")}
|
|
||||||
</Menu.Item>
|
|
||||||
|
|
||||||
<Menu.Item
|
|
||||||
onClick={openPage}
|
|
||||||
leftSection={<IconFileDescription size={16} />}
|
|
||||||
>
|
|
||||||
{t("Open page")}
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Item
|
|
||||||
c="red"
|
|
||||||
onClick={openDeleteModal}
|
|
||||||
leftSection={<IconTrash size={16} />}
|
|
||||||
disabled={share.space?.userRole === "reader"}
|
|
||||||
>
|
|
||||||
{t("Delete share")}
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu.Dropdown>
|
|
||||||
</Menu>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
import { Affix, Button } from "@mantine/core";
|
|
||||||
|
|
||||||
export default function ShareBranding() {
|
|
||||||
return (
|
|
||||||
<Affix position={{ bottom: 20, right: 20 }}>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
component="a"
|
|
||||||
target="_blank"
|
|
||||||
href="https://docmost.com?ref=public-share"
|
|
||||||
>
|
|
||||||
Powered by Docmost
|
|
||||||
</Button>
|
|
||||||
</Affix>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
import { Outlet } from "react-router-dom";
|
|
||||||
import ShareShell from "@/features/share/components/share-shell.tsx";
|
|
||||||
|
|
||||||
export default function ShareLayout() {
|
|
||||||
return (
|
|
||||||
<ShareShell>
|
|
||||||
<Outlet />
|
|
||||||
</ShareShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,97 +0,0 @@
|
|||||||
import { Table, Group, Text, Anchor } from "@mantine/core";
|
|
||||||
import React, { useState } from "react";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import Paginate from "@/components/common/paginate.tsx";
|
|
||||||
import { useGetSharesQuery } from "@/features/share/queries/share-query.ts";
|
|
||||||
import { ISharedItem } from "@/features/share/types/share.types.ts";
|
|
||||||
import { format } from "date-fns";
|
|
||||||
import ShareActionMenu from "@/features/share/components/share-action-menu.tsx";
|
|
||||||
import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
|
|
||||||
import { getPageIcon } from "@/lib";
|
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
|
||||||
import classes from "./share.module.css";
|
|
||||||
|
|
||||||
export default function ShareList() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const { data, isLoading } = useGetSharesQuery({ page });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Table.ScrollContainer minWidth={500}>
|
|
||||||
<Table verticalSpacing="xs">
|
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th>{t("Page")}</Table.Th>
|
|
||||||
<Table.Th>{t("Shared by")}</Table.Th>
|
|
||||||
<Table.Th>{t("Shared at")}</Table.Th>
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
|
|
||||||
<Table.Tbody>
|
|
||||||
{data?.items.map((share: ISharedItem, index: number) => (
|
|
||||||
<Table.Tr key={index}>
|
|
||||||
<Table.Td>
|
|
||||||
<Anchor
|
|
||||||
size="sm"
|
|
||||||
underline="never"
|
|
||||||
style={{
|
|
||||||
cursor: "pointer",
|
|
||||||
color: "var(--mantine-color-text)",
|
|
||||||
}}
|
|
||||||
component={Link}
|
|
||||||
target="_blank"
|
|
||||||
to={buildSharedPageUrl({
|
|
||||||
shareId: share.key,
|
|
||||||
pageTitle: share.page.title,
|
|
||||||
pageSlugId: share.page.slugId,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Group gap="4" wrap="nowrap">
|
|
||||||
{getPageIcon(share.page.icon)}
|
|
||||||
<div className={classes.shareLinkText}>
|
|
||||||
<Text fz="sm" fw={500} lineClamp={1}>
|
|
||||||
{share.page.title || t("untitled")}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
</Anchor>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Group gap="4" wrap="nowrap">
|
|
||||||
<CustomAvatar
|
|
||||||
avatarUrl={share.creator?.avatarUrl}
|
|
||||||
name={share.creator.name}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
<Text fz="sm" lineClamp={1}>
|
|
||||||
{share.creator.name}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
|
|
||||||
{format(new Date(share.createdAt), "MMM dd, yyyy")}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<ShareActionMenu share={share} />
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</Table.ScrollContainer>
|
|
||||||
|
|
||||||
{data?.items.length > 0 && (
|
|
||||||
<Paginate
|
|
||||||
currentPage={page}
|
|
||||||
hasPrevPage={data?.meta.hasPrevPage}
|
|
||||||
hasNextPage={data?.meta.hasNextPage}
|
|
||||||
onPageChange={setPage}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,227 +0,0 @@
|
|||||||
import {
|
|
||||||
ActionIcon,
|
|
||||||
Anchor,
|
|
||||||
Button,
|
|
||||||
Group,
|
|
||||||
Indicator,
|
|
||||||
Popover,
|
|
||||||
Switch,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
Tooltip,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { IconExternalLink, IconWorld } from "@tabler/icons-react";
|
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
useCreateShareMutation,
|
|
||||||
useDeleteShareMutation,
|
|
||||||
useShareForPageQuery,
|
|
||||||
useUpdateShareMutation,
|
|
||||||
} from "@/features/share/queries/share-query.ts";
|
|
||||||
import { Link, useParams } from "react-router-dom";
|
|
||||||
import { extractPageSlugId, getPageIcon } from "@/lib";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import CopyTextButton from "@/components/common/copy.tsx";
|
|
||||||
import { getAppUrl } from "@/lib/config.ts";
|
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
|
||||||
import classes from "@/features/share/components/share.module.css";
|
|
||||||
|
|
||||||
interface ShareModalProps {
|
|
||||||
readOnly: boolean;
|
|
||||||
}
|
|
||||||
export default function ShareModal({ readOnly }: ShareModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { pageSlug } = useParams();
|
|
||||||
const pageId = extractPageSlugId(pageSlug);
|
|
||||||
const { data: share } = useShareForPageQuery(pageId);
|
|
||||||
const { spaceSlug } = useParams();
|
|
||||||
const createShareMutation = useCreateShareMutation();
|
|
||||||
const updateShareMutation = useUpdateShareMutation();
|
|
||||||
const deleteShareMutation = useDeleteShareMutation();
|
|
||||||
// pageIsShared means that the share exists and its level equals zero.
|
|
||||||
const pageIsShared = share && share.level === 0;
|
|
||||||
// if level is greater than zero, then it is a descendant page from a shared page
|
|
||||||
const isDescendantShared = share && share.level > 0;
|
|
||||||
|
|
||||||
const publicLink = `${getAppUrl()}/share/${share?.key}/p/${pageSlug}`;
|
|
||||||
|
|
||||||
const [isPagePublic, setIsPagePublic] = useState<boolean>(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (share) {
|
|
||||||
setIsPagePublic(true);
|
|
||||||
} else {
|
|
||||||
setIsPagePublic(false);
|
|
||||||
}
|
|
||||||
}, [share, pageId]);
|
|
||||||
|
|
||||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const value = event.currentTarget.checked;
|
|
||||||
|
|
||||||
if (value) {
|
|
||||||
createShareMutation.mutateAsync({
|
|
||||||
pageId: pageId,
|
|
||||||
includeSubPages: true,
|
|
||||||
searchIndexing: true,
|
|
||||||
});
|
|
||||||
setIsPagePublic(value);
|
|
||||||
} else {
|
|
||||||
if (share && share.id) {
|
|
||||||
deleteShareMutation.mutateAsync(share.id);
|
|
||||||
setIsPagePublic(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubPagesChange = async (
|
|
||||||
event: React.ChangeEvent<HTMLInputElement>,
|
|
||||||
) => {
|
|
||||||
const value = event.currentTarget.checked;
|
|
||||||
updateShareMutation.mutateAsync({
|
|
||||||
shareId: share.id,
|
|
||||||
includeSubPages: value,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleIndexSearchChange = async (
|
|
||||||
event: React.ChangeEvent<HTMLInputElement>,
|
|
||||||
) => {
|
|
||||||
const value = event.currentTarget.checked;
|
|
||||||
updateShareMutation.mutateAsync({
|
|
||||||
shareId: share.id,
|
|
||||||
searchIndexing: value,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const shareLink = useMemo(() => (
|
|
||||||
<Group my="sm" gap={4} wrap="nowrap">
|
|
||||||
<TextInput
|
|
||||||
variant="filled"
|
|
||||||
value={publicLink}
|
|
||||||
readOnly
|
|
||||||
rightSection={<CopyTextButton text={publicLink} />}
|
|
||||||
style={{ width: "100%" }}
|
|
||||||
/>
|
|
||||||
<ActionIcon
|
|
||||||
component="a"
|
|
||||||
variant="default"
|
|
||||||
target="_blank"
|
|
||||||
href={publicLink}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<IconExternalLink size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Group>
|
|
||||||
), [publicLink]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover width={350} position="bottom" withArrow shadow="md">
|
|
||||||
<Popover.Target>
|
|
||||||
<Button
|
|
||||||
style={{ border: "none" }}
|
|
||||||
size="compact-sm"
|
|
||||||
leftSection={
|
|
||||||
<Indicator
|
|
||||||
color="green"
|
|
||||||
offset={5}
|
|
||||||
disabled={!isPagePublic}
|
|
||||||
withBorder
|
|
||||||
>
|
|
||||||
<IconWorld size={20} stroke={1.5} />
|
|
||||||
</Indicator>
|
|
||||||
}
|
|
||||||
variant="default"
|
|
||||||
>
|
|
||||||
{t("Share")}
|
|
||||||
</Button>
|
|
||||||
</Popover.Target>
|
|
||||||
<Popover.Dropdown style={{ userSelect: "none" }}>
|
|
||||||
{isDescendantShared ? (
|
|
||||||
<>
|
|
||||||
<Text size="sm">{t("Inherits public sharing from")}</Text>
|
|
||||||
<Anchor
|
|
||||||
size="sm"
|
|
||||||
underline="never"
|
|
||||||
style={{
|
|
||||||
cursor: "pointer",
|
|
||||||
color: "var(--mantine-color-text)",
|
|
||||||
}}
|
|
||||||
component={Link}
|
|
||||||
to={buildPageUrl(
|
|
||||||
spaceSlug,
|
|
||||||
share.sharedPage.slugId,
|
|
||||||
share.sharedPage.title,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Group gap="4" wrap="nowrap" my="sm">
|
|
||||||
{getPageIcon(share.sharedPage.icon)}
|
|
||||||
<div className={classes.shareLinkText}>
|
|
||||||
<Text fz="sm" fw={500} lineClamp={1}>
|
|
||||||
{share.sharedPage.title || t("untitled")}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
</Anchor>
|
|
||||||
|
|
||||||
{shareLink}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
|
||||||
<div>
|
|
||||||
<Text size="sm">
|
|
||||||
{isPagePublic ? t("Shared to web") : t("Share to web")}
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{isPagePublic
|
|
||||||
? t("Anyone with the link can view this page")
|
|
||||||
: t("Make this page publicly accessible")}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
onChange={handleChange}
|
|
||||||
defaultChecked={isPagePublic}
|
|
||||||
disabled={readOnly}
|
|
||||||
size="xs"
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{pageIsShared && (
|
|
||||||
<>
|
|
||||||
{shareLink}
|
|
||||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
|
||||||
<div>
|
|
||||||
<Text size="sm">{t("Include sub-pages")}</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{t("Make sub-pages public too")}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Switch
|
|
||||||
onChange={handleSubPagesChange}
|
|
||||||
checked={share.includeSubPages}
|
|
||||||
size="xs"
|
|
||||||
disabled={readOnly}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
<Group justify="space-between" wrap="nowrap" gap="xl" mt="sm">
|
|
||||||
<div>
|
|
||||||
<Text size="sm">{t("Search engine indexing")}</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{t("Allow search engines to index page")}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
onChange={handleIndexSearchChange}
|
|
||||||
checked={share.searchIndexing}
|
|
||||||
size="xs"
|
|
||||||
disabled={readOnly}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Popover.Dropdown>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,187 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
ActionIcon,
|
|
||||||
Affix,
|
|
||||||
AppShell,
|
|
||||||
Button,
|
|
||||||
Group,
|
|
||||||
ScrollArea,
|
|
||||||
Tooltip,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { useGetSharedPageTreeQuery } from "@/features/share/queries/share-query.ts";
|
|
||||||
import { useParams } from "react-router-dom";
|
|
||||||
import SharedTree from "@/features/share/components/shared-tree.tsx";
|
|
||||||
import { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx";
|
|
||||||
import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
|
||||||
import { ThemeToggle } from "@/components/theme-toggle.tsx";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import {
|
|
||||||
desktopSidebarAtom,
|
|
||||||
mobileSidebarAtom,
|
|
||||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
|
||||||
import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
|
||||||
import {
|
|
||||||
mobileTableOfContentAsideAtom,
|
|
||||||
tableOfContentAsideAtom,
|
|
||||||
} from "@/features/share/atoms/sidebar-atom.ts";
|
|
||||||
import { IconList } from "@tabler/icons-react";
|
|
||||||
import { useToggleToc } from "@/features/share/hooks/use-toggle-toc.ts";
|
|
||||||
import classes from "./share.module.css";
|
|
||||||
import {
|
|
||||||
SearchControl,
|
|
||||||
SearchMobileControl,
|
|
||||||
} from "@/features/search/components/search-control.tsx";
|
|
||||||
import { ShareSearchSpotlight } from "@/features/search/share-search-spotlight";
|
|
||||||
import { shareSearchSpotlight } from "@/features/search/constants";
|
|
||||||
import ShareBranding from '@/features/share/components/share-branding.tsx';
|
|
||||||
|
|
||||||
const MemoizedSharedTree = React.memo(SharedTree);
|
|
||||||
|
|
||||||
export default function ShareShell({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [mobileOpened] = useAtom(mobileSidebarAtom);
|
|
||||||
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
|
||||||
const toggleMobile = useToggleSidebar(mobileSidebarAtom);
|
|
||||||
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
|
|
||||||
|
|
||||||
const [tocOpened] = useAtom(tableOfContentAsideAtom);
|
|
||||||
const [mobileTocOpened] = useAtom(mobileTableOfContentAsideAtom);
|
|
||||||
const toggleTocMobile = useToggleToc(mobileTableOfContentAsideAtom);
|
|
||||||
const toggleToc = useToggleToc(tableOfContentAsideAtom);
|
|
||||||
|
|
||||||
const { shareId } = useParams();
|
|
||||||
const { data } = useGetSharedPageTreeQuery(shareId);
|
|
||||||
const readOnlyEditor = useAtomValue(readOnlyEditorAtom);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AppShell
|
|
||||||
header={{ height: 50 }}
|
|
||||||
{...(data?.pageTree?.length > 1 && {
|
|
||||||
navbar: {
|
|
||||||
width: 300,
|
|
||||||
breakpoint: "sm",
|
|
||||||
collapsed: {
|
|
||||||
mobile: !mobileOpened,
|
|
||||||
desktop: !desktopOpened,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
aside={{
|
|
||||||
width: 300,
|
|
||||||
breakpoint: "sm",
|
|
||||||
collapsed: {
|
|
||||||
mobile: !mobileTocOpened,
|
|
||||||
desktop: !tocOpened,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
padding="md"
|
|
||||||
>
|
|
||||||
<AppShell.Header>
|
|
||||||
<Group wrap="nowrap" justify="space-between" py="sm" px="xl">
|
|
||||||
<Group wrap="nowrap">
|
|
||||||
{data?.pageTree?.length > 1 && (
|
|
||||||
<>
|
|
||||||
<Tooltip label={t("Sidebar toggle")}>
|
|
||||||
<SidebarToggle
|
|
||||||
aria-label={t("Sidebar toggle")}
|
|
||||||
opened={mobileOpened}
|
|
||||||
onClick={toggleMobile}
|
|
||||||
hiddenFrom="sm"
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip label={t("Sidebar toggle")}>
|
|
||||||
<SidebarToggle
|
|
||||||
aria-label={t("Sidebar toggle")}
|
|
||||||
opened={desktopOpened}
|
|
||||||
onClick={toggleDesktop}
|
|
||||||
visibleFrom="sm"
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{shareId && (
|
|
||||||
<Group visibleFrom="sm">
|
|
||||||
<SearchControl onClick={shareSearchSpotlight.open} />
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Group>
|
|
||||||
<>
|
|
||||||
{shareId && (
|
|
||||||
<Group hiddenFrom="sm">
|
|
||||||
<SearchMobileControl onSearch={shareSearchSpotlight.open} />
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Tooltip label={t("Table of contents")} withArrow>
|
|
||||||
<ActionIcon
|
|
||||||
variant="default"
|
|
||||||
style={{ border: "none" }}
|
|
||||||
onClick={toggleTocMobile}
|
|
||||||
hiddenFrom="sm"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<IconList size={20} stroke={2} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip label={t("Table of contents")} withArrow>
|
|
||||||
<ActionIcon
|
|
||||||
variant="default"
|
|
||||||
style={{ border: "none" }}
|
|
||||||
onClick={toggleToc}
|
|
||||||
visibleFrom="sm"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<IconList size={20} stroke={2} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</>
|
|
||||||
|
|
||||||
<ThemeToggle />
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
</AppShell.Header>
|
|
||||||
|
|
||||||
{data?.pageTree?.length > 1 && (
|
|
||||||
<AppShell.Navbar p="md" className={classes.navbar}>
|
|
||||||
<MemoizedSharedTree sharedPageTree={data} />
|
|
||||||
</AppShell.Navbar>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<AppShell.Main>
|
|
||||||
{children}
|
|
||||||
|
|
||||||
{data && shareId && !data.hasLicenseKey && <ShareBranding />}
|
|
||||||
</AppShell.Main>
|
|
||||||
|
|
||||||
<AppShell.Aside
|
|
||||||
p="md"
|
|
||||||
withBorder={mobileTocOpened}
|
|
||||||
className={classes.aside}
|
|
||||||
>
|
|
||||||
<ScrollArea style={{ height: "80vh" }} scrollbarSize={5} type="scroll">
|
|
||||||
<div style={{ paddingBottom: "50px" }}>
|
|
||||||
{readOnlyEditor && (
|
|
||||||
<TableOfContents isShare={true} editor={readOnlyEditor} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</AppShell.Aside>
|
|
||||||
|
|
||||||
<ShareSearchSpotlight shareId={shareId} />
|
|
||||||
</AppShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
.shareLinkText {
|
|
||||||
@mixin light {
|
|
||||||
border-bottom: 0.05em solid var(--mantine-color-dark-0);
|
|
||||||
}
|
|
||||||
@mixin dark {
|
|
||||||
border-bottom: 0.05em solid var(--mantine-color-dark-2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.treeNode {
|
|
||||||
text-decoration: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar,
|
|
||||||
.aside {
|
|
||||||
@media (max-width: $mantine-breakpoint-sm) {
|
|
||||||
width: 350px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,195 +0,0 @@
|
|||||||
import { ISharedPageTree } from "@/features/share/types/share.types.ts";
|
|
||||||
import { NodeApi, NodeRendererProps, Tree, TreeApi } from "react-arborist";
|
|
||||||
import {
|
|
||||||
buildSharedPageTree,
|
|
||||||
SharedPageTreeNode,
|
|
||||||
} from "@/features/share/utils.ts";
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { useElementSize, useMergedRef } from "@mantine/hooks";
|
|
||||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
|
||||||
import { Link, useParams } from "react-router-dom";
|
|
||||||
import { atom, useAtom } from "jotai/index";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import {
|
|
||||||
IconChevronDown,
|
|
||||||
IconChevronRight,
|
|
||||||
IconFileDescription,
|
|
||||||
IconPointFilled,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import { ActionIcon, Box } from "@mantine/core";
|
|
||||||
import { extractPageSlugId } from "@/lib";
|
|
||||||
import { OpenMap } from "react-arborist/dist/main/state/open-slice";
|
|
||||||
import classes from "@/features/page/tree/styles/tree.module.css";
|
|
||||||
import styles from "./share.module.css";
|
|
||||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
|
||||||
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
|
|
||||||
|
|
||||||
interface SharedTree {
|
|
||||||
sharedPageTree: ISharedPageTree;
|
|
||||||
}
|
|
||||||
|
|
||||||
const openSharedTreeNodesAtom = atom<OpenMap>({});
|
|
||||||
|
|
||||||
export default function SharedTree({ sharedPageTree }: SharedTree) {
|
|
||||||
const [tree, setTree] = useState<
|
|
||||||
TreeApi<SharedPageTreeNode> | null | undefined
|
|
||||||
>(null);
|
|
||||||
const rootElement = useRef<HTMLDivElement>();
|
|
||||||
const { ref: sizeRef, width, height } = useElementSize();
|
|
||||||
const mergedRef = useMergedRef(rootElement, sizeRef);
|
|
||||||
const { pageSlug } = useParams();
|
|
||||||
const [openTreeNodes, setOpenTreeNodes] = useAtom<OpenMap>(
|
|
||||||
openSharedTreeNodesAtom,
|
|
||||||
);
|
|
||||||
|
|
||||||
const currentNodeId = extractPageSlugId(pageSlug);
|
|
||||||
|
|
||||||
const treeData: SharedPageTreeNode[] = useMemo(() => {
|
|
||||||
if (!sharedPageTree?.pageTree) return;
|
|
||||||
return buildSharedPageTree(sharedPageTree.pageTree);
|
|
||||||
}, [sharedPageTree?.pageTree]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const parentNodeId = treeData?.[0]?.slugId;
|
|
||||||
|
|
||||||
if (parentNodeId && tree) {
|
|
||||||
const parentNode = tree.get(parentNodeId);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
if (parentNode) {
|
|
||||||
tree.openSiblings(parentNode);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// open direct children of parent node
|
|
||||||
parentNode?.children.forEach((node) => {
|
|
||||||
tree.openSiblings(node);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [treeData, tree]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentNodeId && tree) {
|
|
||||||
setTimeout(() => {
|
|
||||||
// focus on node and open all parents
|
|
||||||
tree?.select(currentNodeId, { align: "auto" });
|
|
||||||
}, 200);
|
|
||||||
} else {
|
|
||||||
tree?.deselectAll();
|
|
||||||
}
|
|
||||||
}, [currentNodeId, tree]);
|
|
||||||
|
|
||||||
if (!sharedPageTree || !sharedPageTree?.pageTree) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={mergedRef} className={classes.treeContainer}>
|
|
||||||
{rootElement.current && (
|
|
||||||
<Tree
|
|
||||||
data={treeData}
|
|
||||||
disableDrag={true}
|
|
||||||
disableDrop={true}
|
|
||||||
disableEdit={true}
|
|
||||||
width={width}
|
|
||||||
height={rootElement.current.clientHeight}
|
|
||||||
ref={(t) => setTree(t)}
|
|
||||||
openByDefault={false}
|
|
||||||
disableMultiSelection={true}
|
|
||||||
className={classes.tree}
|
|
||||||
rowClassName={classes.row}
|
|
||||||
rowHeight={30}
|
|
||||||
overscanCount={10}
|
|
||||||
dndRootElement={rootElement.current}
|
|
||||||
onToggle={() => {
|
|
||||||
setOpenTreeNodes(tree?.openState);
|
|
||||||
}}
|
|
||||||
initialOpenState={openTreeNodes}
|
|
||||||
onClick={(e) => {
|
|
||||||
if (tree && tree.focusedNode) {
|
|
||||||
tree.select(tree.focusedNode);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Node}
|
|
||||||
</Tree>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Node({ node, style, tree }: NodeRendererProps<any>) {
|
|
||||||
const { shareId } = useParams();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [, setMobileSidebarState] = useAtom(mobileSidebarAtom);
|
|
||||||
|
|
||||||
const pageUrl = buildSharedPageUrl({
|
|
||||||
shareId: shareId,
|
|
||||||
pageSlugId: node.data.slugId,
|
|
||||||
pageTitle: node.data.name,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Box
|
|
||||||
style={style}
|
|
||||||
className={clsx(classes.node, node.state, styles.treeNode)}
|
|
||||||
component={Link}
|
|
||||||
to={pageUrl}
|
|
||||||
onClick={() => {
|
|
||||||
setMobileSidebarState(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PageArrow node={node} />
|
|
||||||
<div style={{ marginRight: "4px" }}>
|
|
||||||
<EmojiPicker
|
|
||||||
onEmojiSelect={() => {}}
|
|
||||||
icon={
|
|
||||||
node.data.icon ? (
|
|
||||||
node.data.icon
|
|
||||||
) : (
|
|
||||||
<IconFileDescription size="18" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
readOnly={true}
|
|
||||||
removeEmojiAction={() => {}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className={classes.text}>{node.data.name || t("untitled")}</span>
|
|
||||||
</Box>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PageArrowProps {
|
|
||||||
node: NodeApi<SpaceTreeNode>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function PageArrow({ node }: PageArrowProps) {
|
|
||||||
return (
|
|
||||||
<ActionIcon
|
|
||||||
size={20}
|
|
||||||
variant="subtle"
|
|
||||||
c="gray"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
node.toggle();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{node.isInternal ? (
|
|
||||||
node.children && (node.children.length > 0 || node.data.hasChildren) ? (
|
|
||||||
node.isOpen ? (
|
|
||||||
<IconChevronDown stroke={2} size={16} />
|
|
||||||
) : (
|
|
||||||
<IconChevronRight stroke={2} size={16} />
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<IconPointFilled size={4} />
|
|
||||||
)
|
|
||||||
) : null}
|
|
||||||
</ActionIcon>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
import { useAtom } from "jotai";
|
|
||||||
|
|
||||||
export function useToggleToc(tocAtom: any) {
|
|
||||||
const [tocState, setTocState] = useAtom(tocAtom);
|
|
||||||
return () => {
|
|
||||||
setTocState(!tocState);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,179 +0,0 @@
|
|||||||
import {
|
|
||||||
keepPreviousData,
|
|
||||||
useMutation,
|
|
||||||
useQuery,
|
|
||||||
useQueryClient,
|
|
||||||
UseQueryResult,
|
|
||||||
} from "@tanstack/react-query";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
ICreateShare,
|
|
||||||
IShare,
|
|
||||||
ISharedItem,
|
|
||||||
ISharedPage,
|
|
||||||
ISharedPageTree,
|
|
||||||
IShareForPage,
|
|
||||||
IShareInfoInput,
|
|
||||||
IUpdateShare,
|
|
||||||
} from "@/features/share/types/share.types.ts";
|
|
||||||
import {
|
|
||||||
createShare,
|
|
||||||
deleteShare,
|
|
||||||
getSharedPageTree,
|
|
||||||
getShareForPage,
|
|
||||||
getShareInfo,
|
|
||||||
getSharePageInfo,
|
|
||||||
getShares,
|
|
||||||
updateShare,
|
|
||||||
} from "@/features/share/services/share-service.ts";
|
|
||||||
import { IPage } from "@/features/page/types/page.types.ts";
|
|
||||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
|
|
||||||
export function useGetSharesQuery(
|
|
||||||
params?: QueryParams,
|
|
||||||
): UseQueryResult<IPagination<ISharedItem>, Error> {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["share-list"],
|
|
||||||
queryFn: () => getShares(params),
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useGetShareByIdQuery(
|
|
||||||
shareId: string,
|
|
||||||
): UseQueryResult<IShare, Error> {
|
|
||||||
const query = useQuery({
|
|
||||||
queryKey: ["share-by-id", shareId],
|
|
||||||
queryFn: () => getShareInfo(shareId),
|
|
||||||
enabled: !!shareId,
|
|
||||||
});
|
|
||||||
|
|
||||||
return query;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSharePageQuery(
|
|
||||||
shareInput: Partial<IShareInfoInput>,
|
|
||||||
): UseQueryResult<ISharedPage, Error> {
|
|
||||||
const query = useQuery({
|
|
||||||
queryKey: ["shares", shareInput],
|
|
||||||
queryFn: () => getSharePageInfo(shareInput),
|
|
||||||
enabled: !!shareInput.pageId,
|
|
||||||
});
|
|
||||||
|
|
||||||
return query;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useShareForPageQuery(
|
|
||||||
pageId: string,
|
|
||||||
): UseQueryResult<IShareForPage, Error> {
|
|
||||||
const query = useQuery({
|
|
||||||
queryKey: ["share-for-page", pageId],
|
|
||||||
queryFn: () => getShareForPage(pageId),
|
|
||||||
enabled: !!pageId,
|
|
||||||
staleTime: 0,
|
|
||||||
retry: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
return query;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreateShareMutation() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation<any, Error, ICreateShare>({
|
|
||||||
mutationFn: (data) => createShare(data),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
predicate: (item) =>
|
|
||||||
["share-for-page", "share-list"].includes(item.queryKey[0] as string),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
notifications.show({ message: t("Failed to share page"), color: "red" });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateShareMutation() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation<any, Error, IUpdateShare>({
|
|
||||||
mutationFn: (data) => updateShare(data),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
predicate: (item) =>
|
|
||||||
["share-for-page", "share-list"].includes(item.queryKey[0] as string),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (error, params) => {
|
|
||||||
if (error?.["status"] === 404) {
|
|
||||||
queryClient.removeQueries({
|
|
||||||
predicate: (item) =>
|
|
||||||
["share-for-page"].includes(item.queryKey[0] as string),
|
|
||||||
});
|
|
||||||
|
|
||||||
notifications.show({
|
|
||||||
message: t("Share not found"),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
notifications.show({
|
|
||||||
message: error?.["response"]?.data?.message || "Share not found",
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteShareMutation() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (shareId: string) => deleteShare(shareId),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
queryClient.removeQueries({
|
|
||||||
predicate: (item) =>
|
|
||||||
["share-for-page"].includes(item.queryKey[0] as string),
|
|
||||||
});
|
|
||||||
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
predicate: (item) =>
|
|
||||||
["share-list"].includes(item.queryKey[0] as string),
|
|
||||||
});
|
|
||||||
|
|
||||||
notifications.show({ message: t("Share deleted successfully") });
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
if (error?.["status"] === 404) {
|
|
||||||
queryClient.removeQueries({
|
|
||||||
predicate: (item) =>
|
|
||||||
["share-for-page"].includes(item.queryKey[0] as string),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
notifications.show({
|
|
||||||
message: error?.["response"]?.data?.message || "Failed to delete share",
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useGetSharedPageTreeQuery(
|
|
||||||
shareId: string,
|
|
||||||
): UseQueryResult<ISharedPageTree, Error> {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["shared-page-tree", shareId],
|
|
||||||
queryFn: () => getSharedPageTree(shareId),
|
|
||||||
enabled: !!shareId,
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
staleTime: 60 * 60 * 1000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user