Compare commits

...

73 Commits

Author SHA1 Message Date
62a2eb61ea custom domain support (cloud) 2025-06-28 19:05:03 -07:00
232cea8cc9 sync 2025-06-27 03:20:01 -07:00
b9643d3584 sync 2025-06-27 03:07:51 -07:00
9f144d35fb posthog integration (cloud) (#1304) 2025-06-27 10:58:36 +01:00
e44c170873 fix editor flickers on collab reconnection (#1295)
* fix editor flickers on reconnection

* cleanup

* adjust copy
2025-06-27 10:58:18 +01:00
1be39d4353 sync 2025-06-27 02:22:11 -07:00
36d028ef4d sync 2025-06-24 05:53:59 -07:00
f5a36c60e8 feat: tiered billing (cloud) (#1294)
* feat: tiered billing (cloud)

* custom tier
2025-06-24 13:22:38 +01:00
d5b84ae0b8 Only allow changing the email if the correct password is provided (#1288)
* fix

* fix overwriting password

* finalize

* BadRequestException

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2025-06-24 09:02:55 +01:00
e775e4dd8c fix(editor): prevent text color removal from other list items when setting color in lists (#1289)
Only unset color when 'Default' is selected. This ensures setting color on one list item does not remove it from others.
2025-06-23 19:31:30 +01:00
65b01038d7 v0.21.0 2025-06-18 14:28:14 -07:00
e07cb57b01 sync 2025-06-18 14:25:40 -07:00
2b53e0a455 fix: add import size limit to static window config 2025-06-18 13:58:41 -07:00
b9b3406b28 Fix: Prevent premature focus change in TitleEditor when pressing Enter during IME composition (#730)
* fix: Prevents key events during text composition

Stops handling title key events when composing text,
ensuring proper input behavior during IME use.

* Refines IME composition event checks

Separates IME composition control from shift key logic and adds a Safari-specific keyCode check to prevent premature focus shifts during IME input.
2025-06-18 21:33:35 +01:00
728cac0a34 fix word counter (#1269) 2025-06-18 21:32:11 +01:00
d35e16010b handle empty invitation 2025-06-18 13:10:32 -07:00
15791d4e59 sync 2025-06-18 12:50:43 -07:00
3318e13225 fix: use JWT expiry time for cookie duration (#1268)
* Set default jwt expiry to 90 days.
2025-06-18 20:50:11 +01:00
080900610d cleanup 2025-06-17 16:14:06 -07:00
d1dc6977ab feat: edit mode preference (#666)
* lock/unlock pages

* remove using isLocked column - add default page edit state preference

* * Move state management to editors (avoids flickers on edit mode switch)
* Rename variables
* Add strings to translation file
* Memoize components in page component
* Fix title editor sending update request on editable state change

* fixed errors merging main

* Fix embed view in read-only mode

* remove unused line

* sync

* fix responsiveness on mobile

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2025-06-18 00:11:47 +01:00
5f62448894 less create workspace form fields in cloud (#1265)
* sync

* less signup form fields in cloud

* min length
2025-06-17 23:56:07 +01:00
44445fbf46 fix: enforce SSO in invitation signups (#1258) 2025-06-15 20:25:15 +01:00
1c674efddd fix: revert tiptap version (#1255) 2025-06-13 21:38:49 +01:00
ccf7e34e99 feat: ukrainian language support (#1250) 2025-06-11 23:31:45 +01:00
f39d48d6ee New Crowdin updates (#1063)
* New translations translation.json
2025-06-11 23:21:01 +01:00
f584ea84b0 chore: upgrade packages (#1242)
* upgrade tiptap editor extensions

* upgrade packages

* fix type issue
2025-06-11 23:18:39 +01:00
bc0c4d6258 fix: make link popup work on safari (#1243)
* fix: make link popup work on safari

* fix: second iteration

* chore: cleanup

* chore: format

* chore: undo unused stuff
2025-06-11 23:09:59 +01:00
d8da307a61 feat: enhance excalidraw (#1240)
* WIP

* use next excalidraw version

* support local persistence for excalidraw library.

Co-authored-by: Drauggy <n.fomenko@safe-tech.ru>

---------

Co-authored-by: Drauggy <n.fomenko@safe-tech.ru>
2025-06-09 23:25:36 +01:00
50b3f9ddd9 generic iframe embed (#1234) 2025-06-09 22:32:23 +01:00
0029f84d50 feat: toggle table header row and column (#1203)
* feat: toggle table header row and column
* switch position
2025-06-09 05:39:43 +01:00
6d024fc3de feat: bulk page imports (#1219)
* refactor imports - WIP

* Add readstream

* WIP

* fix attachmentId render

* fix attachmentId render

* turndown video tag

* feat: add stream upload support and improve file handling

- Add stream upload functionality to storage drivers\n- Improve ZIP file extraction with better encoding handling\n- Fix attachment ID rendering issues\n- Add AWS S3 upload stream support\n- Update dependencies for better compatibility

* WIP

* notion formatter

* move embed parser to editor-ext package

* import embeds

* utility files

* cleanup

* Switch from happy-dom to cheerio
* Refine code

* WIP

* bug fixes and UI

* sync

* WIP

* sync

* keep import modal mounted

* Show modal during upload

* WIP

* WIP
2025-06-09 04:29:27 +01:00
ce1503af85 fix: sidebar list when changing workspace (#1150)
* init

* navigate in overview if current page is in deleted node

* fix: implement pagination in sidebar-pages queries

* fix: appendNodeChildren()

Preserve deeper children if they exist and remove node if deleted
2025-06-08 03:27:09 +01:00
69447fc375 Merge branch 'main' of https://github.com/docmost/docmost 2025-05-21 08:43:56 -07:00
858ff9da06 sync 2025-05-20 09:27:30 -07:00
343b2976c2 #1186/chore: add support language abap syntax highlight (#1188) 2025-05-19 20:05:31 +01:00
7491224d0f hide shared page branding in EE (#1193)
* hide shared page branding in EE

* Hide branding in business plan
2025-05-17 19:17:34 +01:00
4a0b4040ed Add second plan (#1187) 2025-05-17 19:03:01 +01:00
e3ba817723 feat: comment editor emoji picker and ctrl+enter action (#1121)
* commenteditor-emoji-picker

* capture Mac command key
* remove tooltip

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2025-05-16 20:01:27 +01:00
b0491d5da4 feat: create new page from mention (#1153)
* init

* create page in relative parent root
2025-05-16 19:15:11 +01:00
1c200dbd0f fix(table-hover): adjust row height to prevent unexpected scrollbar on hover (#1124)
fix: Hover table style height error causing scrollbar to appear #1108
2025-05-16 16:26:05 +01:00
fb7e4a7956 fix: copy/move select (#1174) 2025-05-16 16:24:31 +01:00
1413033568 feat: realtime comments (#1144)
* init

* fix: close bubblemenu after comment and wait before scroll

* scroll to comment when click

* highlight comment animation
2025-05-16 16:18:23 +01:00
00f4588c21 fix title update (#1154) 2025-05-16 16:11:29 +01:00
3a75251e75 fix alignment in shared page (#1123) 2025-05-16 16:00:47 +01:00
c6bca6a602 fix deprecated kysely usage 2025-05-09 16:44:33 +01:00
55d1a2c932 Fix typo in enforce-sso.tsx (#1145) 2025-05-09 11:11:02 +01:00
bc3cb2d63f fix: increase random subdomain suffix 2025-05-07 15:10:58 +01:00
7adbf85030 v0.20.4 2025-04-30 14:44:58 +01:00
de7982fe30 feat: copy page to different space (#1118)
* Add copy page to space endpoint
* copy storage function
* copy function
* feat: copy attachments too
* Copy page - WIP
* fix type
* sync
* cleanup
2025-04-30 14:43:16 +01:00
0402f7efb5 sync 2025-04-30 14:33:01 +01:00
8327251ab6 fix typo 2025-04-29 23:30:12 +01:00
e8847bd9cd fix: handle unhandled exceptions (#1116)
* Handle unhandled exceptions
* cleanup
2025-04-29 23:29:00 +01:00
9bbd62e0f0 v0.20.3 2025-04-24 23:22:53 +01:00
0289c5cb09 Reduce markdown checkbox space 2025-04-24 23:19:39 +01:00
7993532111 fix page export (#1081) 2025-04-24 23:18:54 +01:00
31e5c0c660 v0.20.2 2025-04-24 17:57:14 +01:00
33c314d4e8 remove clickoutside hook 2025-04-24 17:56:54 +01:00
08f223899a cloud trial refactor 2025-04-23 16:07:58 +01:00
c528f7e858 v0.20.1 2025-04-23 14:34:28 +01:00
c26a851d52 feat: enhance public sharing (#1057)
* fix tree nodes sort

* remove comment mark in shares

* remove clickoutside hook for now

* feat: search in shared pages

* fix user-select

* use Link

* render page icons
2025-04-23 14:32:35 +01:00
de5f90309c v0.20.0 2025-04-22 22:49:45 +01:00
0ec3ff2965 Add empty placeholder text 2025-04-22 22:48:12 +01:00
acffeacdbc fix TOC 2025-04-22 22:47:34 +01:00
00d92a3690 New Crowdin updates (#1008)
* New translations translation.json (Russian)

* New translations translation.json (Russian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (Spanish)

* New translations translation.json (French)

* New translations translation.json (Spanish)

* New translations translation.json (German)

* New translations translation.json (Italian)

* New translations translation.json (Japanese)

* New translations translation.json (Korean)

* New translations translation.json (Dutch)

* New translations translation.json (Russian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (English)

* New translations translation.json (Portuguese, Brazilian)
2025-04-22 20:57:07 +01:00
3430f715ec feat: remember and restore previous route when exiting settings (#1046)
Improves user experience by allowing users to return to the previous
page after visiting the Settings section.

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2025-04-22 20:47:57 +01:00
6c422011ac feat: public page sharing (#1012)
* Share - WIP

* - public attachment links
- WIP

* WIP

* WIP

* Share - WIP

* WIP

* WIP

* include userRole in space object

* WIP

* Server render shared page meta tags

* disable user select

* Close Navbar on outside click on mobile

* update shared page spaceId

* WIP

* fix

* close sidebar on click

* close sidebar

* defaults

* update copy

* Store share key in lowercase

* refactor page breadcrumbs

* Change copy

* add link ref

* open link button

* add meta og:title

* add twitter tags

* WIP

* make shares/info endpoint public

* fix

* * add /p/ segment to share urls
* minore fixes

* change mobile breadcrumb icon
2025-04-22 20:37:32 +01:00
3e8824435d update vite and axios 2025-04-22 20:28:27 +01:00
37a1804db9 Revert "switch to vite rolldown (#1048)" (#1050)
This reverts commit 1a1b2c8682.
2025-04-22 20:00:36 +01:00
882f3093bd search space members by email (#1049) 2025-04-22 19:37:06 +01:00
1a1b2c8682 switch to vite rolldown (#1048)
* switch to vite rolldown

* update
2025-04-22 15:52:44 +01:00
10b67929ea Update README.md 2025-04-21 21:50:21 +01:00
5c957fda8d fix: nested tree open state 2025-04-21 19:24:25 +01:00
862f6d4820 use non-esm nanoid version (#1040) 2025-04-19 19:45:09 +01:00
211 changed files with 11118 additions and 2483 deletions

View File

@ -4,14 +4,15 @@
Open-source collaborative wiki and documentation software.
<br />
<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>
</div>
<br />
## Getting started
To get started with Docmost, please refer to our [documentation](https://docmost.com/docs).
To get started with Docmost, please refer to our [documentation](https://docmost.com/docs) or try our [cloud version](https://docmost.com/pricing) .
## Features
@ -46,3 +47,16 @@ All files in the following directories are licensed under the Docmost Enterprise
### Contributing
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.

View File

@ -6,6 +6,7 @@
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Docmost</title>
<!--meta-tags-->
</head>
<body>
<div id="root"></div>

View File

@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.10.2",
"version": "0.21.0",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@ -15,44 +15,47 @@
"@docmost/editor-ext": "workspace:*",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@excalidraw/excalidraw": "^0.17.6",
"@excalidraw/excalidraw": "0.18.0-864353b",
"@mantine/core": "^7.17.0",
"@mantine/form": "^7.17.0",
"@mantine/hooks": "^7.17.0",
"@mantine/modals": "^7.17.0",
"@mantine/notifications": "^7.17.0",
"@mantine/spotlight": "^7.17.0",
"@tabler/icons-react": "^3.22.0",
"@tanstack/react-query": "^5.61.4",
"@tiptap/extension-character-count": "^2.11.5",
"axios": "^1.7.9",
"@tabler/icons-react": "^3.34.0",
"@tanstack/react-query": "^5.80.6",
"@tiptap/extension-character-count": "^2.10.3",
"alfaaz": "^1.1.0",
"axios": "^1.9.0",
"clsx": "^2.1.1",
"emoji-mart": "^5.6.0",
"file-saver": "^2.0.5",
"highlightjs-sap-abap": "^0.3.0",
"i18next": "^23.14.0",
"i18next-http-backend": "^2.6.1",
"jotai": "^2.12.1",
"jotai": "^2.12.5",
"jotai-optics": "^0.4.0",
"js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0",
"katex": "0.16.21",
"lowlight": "^3.2.0",
"mermaid": "^11.4.1",
"katex": "0.16.22",
"lowlight": "^3.3.0",
"mermaid": "^11.6.0",
"mitt": "^3.0.1",
"posthog-js": "^1.255.1",
"react": "^18.3.1",
"react-arborist": "3.4.0",
"react-clear-modal": "^2.0.11",
"react-clear-modal": "^2.0.15",
"react-dom": "^18.3.1",
"react-drawio": "^1.0.1",
"react-error-boundary": "^4.1.2",
"react-helmet-async": "^2.0.5",
"react-i18next": "^15.0.1",
"react-router-dom": "^7.0.1",
"semver": "^7.7.1",
"semver": "^7.7.2",
"socket.io-client": "^4.8.1",
"tippy.js": "^6.3.7",
"tiptap-extension-global-drag-handle": "^0.1.18",
"zod": "^3.23.8"
"zod": "^3.25.56"
},
"devDependencies": {
"@eslint/js": "^9.16.0",
@ -63,7 +66,7 @@
"@types/node": "22.10.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"@vitejs/plugin-react": "^4.4.1",
"eslint": "^9.15.0",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.1.0",
@ -76,6 +79,6 @@
"prettier": "^3.4.1",
"typescript": "^5.7.2",
"typescript-eslint": "^8.17.0",
"vite": "^6.1.0"
"vite": "^6.3.5"
}
}

View File

@ -362,5 +362,29 @@
"Move page to a different space.": "Seite in einen anderen Bereich verschieben.",
"Real-time editor connection lost. Retrying...": "Echtzeit-Editor-Verbindung verloren. Wiederholen...",
"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"
}

View File

@ -354,6 +354,9 @@
"Character count: {{characterCount}}": "Character count: {{characterCount}}",
"New update": "New update",
"{{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",
"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.",
@ -362,5 +365,29 @@
"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.": "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"
}

View File

@ -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",
"Join the workspace": "Unirse al espacio de trabajo",
"Language": "Idioma",
"Light": "Ligero",
"Light": "Claro",
"Link copied": "Enlace copiado",
"Login": "Iniciar sesión",
"Logout": "Cerrar sesión",
@ -362,5 +362,29 @@
"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...",
"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"
}

View File

@ -362,5 +362,29 @@
"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...",
"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"
}

View File

@ -362,5 +362,29 @@
"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...",
"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"
}

View File

@ -347,7 +347,7 @@
"Members added successfully": "メンバーを追加しました",
"Member removed 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}}",
"Edited by {{name}} {{time}}": "最終編集: {{name}} {{time}}",
"Word count: {{wordCount}}": "ワード数: {{wordCount}}",
@ -362,5 +362,29 @@
"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を追加して目次を生成します。"
"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": "ページのコピーに成功しました"
}

View File

@ -58,7 +58,7 @@
"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 current password": "기존 비밀번호를 입력하세요",
"enter your full name": "전체 이름을 입력하세요",
"Enter your new password": "새 비밀번호를 입력하세요",
"Enter your new preferred email": "새로운 이메일을 입력하세요",
@ -170,7 +170,7 @@
"Successfully restored": "복원 완료",
"System settings": "시스템 설정",
"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": "전체 페이지 너비 전환",
"Unable to import pages. Please try again.": "페이지를 가져올 수 없습니다. 다시 시도해주세요.",
"untitled": "제목 없음",
@ -362,5 +362,29 @@
"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)을 추가하세요."
"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"
}

View File

@ -362,5 +362,29 @@
"Move page to a different space.": "Verplaats pagina naar een andere ruimte.",
"Real-time editor connection lost. Retrying...": "Realtime editorverbinding verloren. Opnieuw proberen...",
"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"
}

View File

@ -362,5 +362,29 @@
"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...",
"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"
}

View File

@ -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 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 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 manage workspace": "Может управлять рабочей областью",
"Can manage workspace but cannot delete it": "Может управлять рабочей областью, но не может ее удалить",
"Can view": "Может просматривать",
"Can view pages in space but not edit.": "Может просматривать страницы в пространстве, но не может их редактировать.",
"Cancel": "Отменить",
@ -34,7 +34,7 @@
"Create group": "Создать группу",
"Create page": "Создать страницу",
"Create space": "Создать пространство",
"Create workspace": "Создать рабочее пространство",
"Create workspace": "Создать рабочую область",
"Current password": "Текущий пароль",
"Dark": "Темная",
"Date": "Дата",
@ -92,7 +92,7 @@
"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": "Присоединиться к рабочему пространству",
"Join the workspace": "Присоединиться к рабочей области",
"Language": "Язык",
"Light": "Светлая",
"Link copied": "Ссылка скопирована",
@ -150,7 +150,7 @@
"Send invitation": "Отправить приглашение",
"Invitation sent": "Приглашение отправлено",
"Settings": "Настройки",
"Setup workspace": "Настроить рабочее пространство",
"Setup workspace": "Настроить рабочую область",
"Sign In": "Вход",
"Sign Up": "Регистрация",
"Slug": "Slug",
@ -177,9 +177,9 @@
"Untitled": "Без названия",
"Updated successfully": "Обновлено успешно",
"User": "Пользователь",
"Workspace": "Рабочее пространство",
"Workspace Name": "Имя рабочего пространства",
"Workspace settings": "Настройки рабочего пространства",
"Workspace": "Рабочая область",
"Workspace Name": "Имя рабочей области",
"Workspace settings": "Настройки рабочей области",
"You can change your password here.": "Вы можете изменить свой пароль здесь.",
"Your Email": "Ваш адрес электронной почты",
"Your import is complete.": "Ваш импорт завершен.",
@ -217,9 +217,9 @@
"Revoke invitation": "Отозвать приглашение",
"Revoke": "Отозвать",
"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": "Отправить приглашение повторно",
"Anyone with this link can join this workspace.": "Любой, у кого есть эта ссылка, может присоединиться к этому рабочему пространству.",
"Anyone with this link can join this workspace.": "Любой, у кого есть данная ссылка, может присоединиться к этой рабочей области.",
"Invite link": "Ссылка для приглашения",
"Copy": "Копировать",
"Copied": "Скопировано",
@ -362,5 +362,29 @@
"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), чтобы создать оглавление."
"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": "Страница успешно скопирована"
}

View File

@ -0,0 +1,390 @@
{
"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": "Сторінку успішно скопійовано"
}

View File

@ -298,7 +298,7 @@
"Heading 2": "2 级标题",
"Heading 3": "3 级标题",
"To-do List": "代办列表",
"Bullet List": "无列表",
"Bullet List": "无列表",
"Numbered List": "有序列表",
"Blockquote": "引用块",
"Just start typing with plain text.": "只需开始键入纯文本",
@ -362,5 +362,29 @@
"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.": "添加标题H1H2H3以生成目录。"
"Add headings (H1, H2, H3) to generate a table of contents.": "添加标题H1H2H3以生成目录。",
"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": "页面复制成功"
}

View File

@ -26,10 +26,16 @@ import { useTranslation } from "react-i18next";
import Security from "@/ee/security/pages/security.tsx";
import License from "@/ee/licence/pages/license.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() {
const { t } = useTranslation();
useRedirectToCloudSelect();
useTrackOrigin();
return (
<>
@ -51,6 +57,12 @@ 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 element={<Layout />}>
@ -78,6 +90,7 @@ export default function App() {
<Route path={"groups"} element={<Groups />} />
<Route path={"groups/:groupId"} element={<GroupInfo />} />
<Route path={"spaces"} element={<Spaces />} />
<Route path={"sharing"} element={<Shares />} />
<Route path={"security"} element={<Security />} />
{!isCloud() && <Route path={"license"} element={<License />} />}
{isCloud() && <Route path={"billing"} element={<Billing />} />}

View File

@ -0,0 +1,20 @@
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>
);
}

View File

@ -14,6 +14,7 @@ import { AppHeader } from "@/components/layouts/global/app-header.tsx";
import Aside from "@/components/layouts/global/aside.tsx";
import classes from "./app-shell.module.css";
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({
children,
@ -22,6 +23,7 @@ export default function GlobalAppShell({
}) {
useTrialEndAction();
const [mobileOpened] = useAtom(mobileSidebarAtom);
const toggleMobile = useToggleSidebar(mobileSidebarAtom);
const [desktopOpened] = useAtom(desktopSidebarAtom);
const [{ isAsideOpen }] = useAtom(asideStateAtom);
const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom);
@ -111,7 +113,7 @@ export default function GlobalAppShell({
)}
<AppShell.Main>
{isSettingsRoute ? (
<Container size={800}>{children}</Container>
<Container size={850}>{children}</Container>
) : (
children
)}

View File

@ -1,6 +1,8 @@
import { UserProvider } from "@/features/user/user-provider.tsx";
import { Outlet } from "react-router-dom";
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
import { PosthogUser } from "@/ee/components/posthog-user.tsx";
import { isCloud } from "@/lib/config.ts";
export default function Layout() {
return (
@ -8,6 +10,7 @@ export default function Layout() {
<GlobalAppShell>
<Outlet />
</GlobalAppShell>
{isCloud() && <PosthogUser />}
</UserProvider>
);
}

View File

@ -0,0 +1,10 @@
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);
}
}
);

View File

@ -8,7 +8,8 @@ import { getGroups } from "@/features/group/services/group-service.ts";
import { QueryParams } from "@/lib/types.ts";
import { getWorkspaceMembers } from "@/features/workspace/services/workspace-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 = () => {
const params = { limit: 100, page: 1, query: "" } as QueryParams;
@ -56,4 +57,11 @@ export const prefetchSsoProviders = () => {
queryKey: ["sso-providers"],
queryFn: () => getSsoProviders(),
});
};
};
export const prefetchShares = () => {
queryClient.prefetchQuery({
queryKey: ["share-list", { page: 1 }],
queryFn: () => getShares({ page: 1, limit: 100 }),
});
};

View File

@ -11,8 +11,9 @@ import {
IconCoin,
IconLock,
IconKey,
IconWorld,
} from "@tabler/icons-react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { Link, useLocation } from "react-router-dom";
import classes from "./settings.module.css";
import { useTranslation } from "react-i18next";
import { isCloud } from "@/lib/config.ts";
@ -23,11 +24,15 @@ import {
prefetchBilling,
prefetchGroups,
prefetchLicense,
prefetchShares,
prefetchSpaces,
prefetchSsoProviders,
prefetchWorkspaceMembers,
} from "@/components/settings/settings-queries.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 {
label: string;
@ -82,6 +87,7 @@ const groupedData: DataGroup[] = [
},
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
{ label: "Public sharing", icon: IconWorld, path: "/settings/sharing" },
],
},
{
@ -100,9 +106,11 @@ export default function SettingsSidebar() {
const { t } = useTranslation();
const location = useLocation();
const [active, setActive] = useState(location.pathname);
const navigate = useNavigate();
const { goBack } = useSettingsNavigation();
const { isAdmin } = useUserRole();
const [workspace] = useAtom(workspaceAtom);
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
useEffect(() => {
setActive(location.pathname);
@ -170,6 +178,9 @@ export default function SettingsSidebar() {
case "Security & SSO":
prefetchHandler = prefetchSsoProviders;
break;
case "Public sharing":
prefetchHandler = prefetchShares;
break;
default:
break;
}
@ -181,6 +192,11 @@ export default function SettingsSidebar() {
data-active={active.startsWith(item.path) || undefined}
key={item.label}
to={item.path}
onClick={() => {
if (mobileSidebarOpened) {
toggleMobileSidebar();
}
}}
>
<item.icon className={classes.linkIcon} stroke={2} />
<span>{t(item.label)}</span>
@ -195,7 +211,12 @@ export default function SettingsSidebar() {
<div className={classes.navbar}>
<Group className={classes.title} justify="flex-start">
<ActionIcon
onClick={() => navigate(-1)}
onClick={() => {
goBack();
if (mobileSidebarOpened) {
toggleMobileSidebar();
}
}}
variant="transparent"
c="gray"
aria-label="Back"

View File

@ -0,0 +1,19 @@
.dark {
@mixin dark {
display: none;
}
@mixin light {
display: block;
}
}
.light {
@mixin light {
display: none;
}
@mixin dark {
display: block;
}
}

View File

@ -1,13 +1,28 @@
import { Button, Group, useMantineColorScheme } from '@mantine/core';
import {
ActionIcon,
Tooltip,
useComputedColorScheme,
useMantineColorScheme,
} from "@mantine/core";
import { IconMoon, IconSun } from "@tabler/icons-react";
import classes from "./theme-toggle.module.css";
export function ThemeToggle() {
const { setColorScheme } = useMantineColorScheme();
const { setColorScheme } = useMantineColorScheme();
const computedColorScheme = useComputedColorScheme();
return (
<Group justify="center" mt="xl">
<Button onClick={() => setColorScheme('light')}>Light</Button>
<Button onClick={() => setColorScheme('dark')}>Dark</Button>
<Button onClick={() => setColorScheme('auto')}>Auto</Button>
</Group>
);
return (
<Tooltip label="Toggle Color Scheme">
<ActionIcon
variant="default"
onClick={() => {
setColorScheme(computedColorScheme === "light" ? "dark" : "light");
}}
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>
);
}

View File

@ -30,12 +30,12 @@ export default function BillingDetails() {
>
Plan
</Text>
<Text fw={700} fz="lg">
{
plans.find(
(plan) => plan.productId === billing.stripeProductId,
)?.name
}
<Text fw={700} fz="lg" tt="capitalize">
{plans.find(
(plan) => plan.productId === billing.stripeProductId,
)?.name ||
billing.planName ||
"Standard"}
</Text>
</div>
</Group>
@ -112,18 +112,58 @@ export default function BillingDetails() {
fz="xs"
className={classes.label}
>
Total
</Text>
<Text fw={700} fz="lg">
{(billing.amount / 100) * billing.quantity}{" "}
{billing.currency.toUpperCase()}
</Text>
<Text c="dimmed" fz="sm">
${billing.amount / 100} /user/{billing.interval}
Cost
</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">
{(billing.amount / 100) * billing.quantity}{" "}
{billing.currency.toUpperCase()}
</Text>
<Text c="dimmed" fz="sm">
${billing.amount / 100} /user/{billing.interval}
</Text>
</>
)}
</div>
</Group>
</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 {billing.tieredUpTo} users
</Text>
{/*billing.tieredFlatAmount && (
<Text c="dimmed" fz="sm">
</Text>
)*/}
</div>
</Group>
</Paper>
)}
</SimpleGrid>
</div>
);

View File

@ -2,24 +2,28 @@ import {
Button,
Card,
List,
SegmentedControl,
ThemeIcon,
Title,
Text,
Group,
Select,
Container,
Stack,
Badge,
Flex,
Switch,
} from "@mantine/core";
import { useState } from "react";
import { IconCheck } from "@tabler/icons-react";
import { useBillingPlans } from "@/ee/billing/queries/billing-query.ts";
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
import { useBillingPlans } from "@/ee/billing/queries/billing-query.ts";
export default function BillingPlans() {
const { data: plans } = useBillingPlans();
const [interval, setInterval] = useState("yearly");
if (!plans) {
return null;
}
const [isAnnual, setIsAnnual] = useState(true);
const [selectedTierValue, setSelectedTierValue] = useState<string | null>(
null,
);
const handleCheckout = async (priceId: string) => {
try {
@ -32,84 +36,153 @@ 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 (
<Group justify="center" p="xl">
{plans.map((plan) => {
const price =
interval === "monthly" ? plan.price.monthly : plan.price.yearly;
const priceId = interval === "monthly" ? plan.monthlyId : plan.yearlyId;
const yearlyMonthPrice = parseInt(plan.price.yearly) / 12;
<Container size="xl" py="xl">
{/* Controls Section */}
<Stack gap="xl" mb="md">
{/* Team Size and Billing Controls */}
<Group justify="center" align="center" gap="sm">
<Select
label="Team size"
description="Select the number of users"
value={selectedTierValue}
onChange={setSelectedTierValue}
data={selectData}
w={250}
size="md"
allowDeselect={false}
/>
return (
<Card
key={plan.name}
withBorder
radius="md"
shadow="sm"
p="xl"
w={300}
>
<SegmentedControl
value={interval}
onChange={setInterval}
fullWidth
data={[
{ label: "Monthly", value: "monthly" },
{ label: "Yearly (25% OFF)", value: "yearly" },
]}
/>
<Title order={3} ta="center" mt="sm" mb="xs">
{plan.name}
</Title>
<Text ta="center" size="lg" fw={700}>
{interval === "monthly" && (
<>
${price}{" "}
<Text span size="sm" fw={500} c="dimmed">
/user/month
</Text>
</>
)}
{interval === "yearly" && (
<>
${yearlyMonthPrice}{" "}
<Text span size="sm" fw={500} c="dimmed">
/user/month
</Text>
</>
)}
<br/>
<Text span ta="center" size="md" fw={500} c="dimmed">
billed {interval}
</Text>
</Text>
<Card.Section mt="lg">
<Button onClick={() => handleCheckout(priceId)} fullWidth>
Subscribe
</Button>
</Card.Section>
<Card.Section mt="md">
<List
spacing="xs"
<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"
center
icon={
<ThemeIcon variant="light" size={24} radius="xl">
<IconCheck size={16} />
</ThemeIcon>
}
>
{plan.features.map((feature, index) => (
<List.Item key={index}>{feature}</List.Item>
))}
</List>
</Card.Section>
</Card>
);
})}
</Group>
/>
<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 (
<Card
key={plan.name}
withBorder
radius="lg"
shadow="sm"
p="xl"
w={350}
miw={300}
style={{
position: "relative",
}}
>
<Stack gap="lg">
{/* Plan Header */}
<Stack gap="xs">
<Title order={3} size="h4">
{plan.name}
</Title>
{plan.description && (
<Text size="sm" c="dimmed">
{plan.description}
</Text>
)}
</Stack>
{/* Pricing */}
<Stack gap="xs">
<Group align="baseline" gap="xs">
<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 size="md" fw={500}>
For {planSelectedTier.upTo} users
</Text>
</Stack>
{/* CTA Button */}
<Button onClick={() => handleCheckout(priceId)} fullWidth>
Upgrade
</Button>
{/* Features */}
<List
spacing="xs"
size="sm"
icon={
<ThemeIcon size={20} radius="xl">
<IconCheck size={14} />
</ThemeIcon>
}
>
{plan.features.map((feature, featureIndex) => (
<List.Item key={featureIndex}>{feature}</List.Item>
))}
</List>
</Stack>
</Card>
);
})}
</Group>
</Container>
);
}

View File

@ -1,6 +1,7 @@
import { Alert } from "@mantine/core";
import { useBillingQuery } from "@/ee/billing/queries/billing-query.ts";
import useTrial from "@/ee/hooks/use-trial.tsx";
import { getBillingTrialDays } from '@/lib/config.ts';
export default function BillingTrial() {
const { data: billing, isLoading } = useBillingQuery();
@ -15,14 +16,14 @@ export default function BillingTrial() {
{trialDaysLeft > 0 && !billing && (
<Alert title="Your Trial is Active 🎉" color="blue" radius="md">
You have {trialDaysLeft} {trialDaysLeft === 1 ? "day" : "days"} left
in your 7-day trial. Please subscribe to a plan before your trial
in your {getBillingTrialDays()}-day free trial. Please subscribe to a paid plan before your trial
ends.
</Alert>
)}
{trialDaysLeft === 0 && (
<Alert title="Your Trial has ended" color="red" radius="md">
Your 7-day trial has come to an end. Please subscribe to a plan to
Your {getBillingTrialDays()}-day free trial has come to an end. Please subscribe to a paid plan to
continue using this service.
</Alert>
)}

View File

@ -1,5 +1,6 @@
export enum BillingPlan {
STANDARD = "standard",
BUSINESS = "business",
}
export interface IBilling {
@ -24,6 +25,11 @@ export interface IBilling {
createdAt: Date;
updatedAt: Date;
deletedAt: Date;
billingScheme: string | null;
tieredUpTo: string | null;
tieredFlatAmount: number | null;
tieredUnitAmount: number | null;
planName: string | null;
}
export interface ICheckoutLink {
@ -41,9 +47,18 @@ export interface IBillingPlan {
monthlyId: string;
yearlyId: string;
currency: string;
price: {
price?: {
monthly: string;
yearly: string;
};
features: string[];
billingScheme: string | null;
pricingTiers: PricingTier[];
}
interface PricingTier {
upTo: number;
monthly?: number;
yearly?: number;
custom?: boolean;
}

View File

@ -0,0 +1,41 @@
import { usePostHog } from "posthog-js/react";
import { useEffect } from "react";
import { useAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
export function PosthogUser() {
const posthog = usePostHog();
const [currentUser] = useAtom(currentUserAtom);
useEffect(() => {
if (currentUser) {
const user = currentUser?.user;
const workspace = currentUser?.workspace;
if (!user || !workspace) return;
posthog?.identify(user.id, {
name: user.name,
email: user.email,
workspaceId: user.workspaceId,
workspaceHostname: workspace.hostname,
lastActiveAt: new Date().toISOString(),
createdAt: user.createdAt,
source: "docmost-app",
});
posthog?.group("workspace", workspace.id, {
name: workspace.name,
hostname: workspace.hostname,
plan: workspace?.plan,
status: workspace.status,
isOnTrial: !!workspace.trialEndAt,
hasStripeCustomerId: !!workspace.stripeCustomerId,
memberCount: workspace.memberCount,
lastActiveAt: new Date().toISOString(),
createdAt: workspace.createdAt,
source: "docmost-app",
});
}
}, [posthog, currentUser]);
return null;
}

View File

@ -2,14 +2,18 @@ import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { BillingPlan } from "@/ee/billing/types/billing.types.ts";
export const usePlan = () => {
const usePlan = () => {
const [workspace] = useAtom(workspaceAtom);
const isStandard =
typeof workspace?.plan === "string" &&
workspace?.plan.toLowerCase() === BillingPlan.STANDARD.toLowerCase();
return { isStandard };
const isBusiness =
typeof workspace?.plan === "string" &&
workspace?.plan.toLowerCase() === BillingPlan.BUSINESS.toLowerCase();
return { isStandard, isBusiness };
};
export default usePlan;

View File

@ -1,6 +1,6 @@
import { useEffect } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { isCloud } from "@/lib/config.ts";
import { getBillingTrialDays, isCloud } from "@/lib/config.ts";
import APP_ROUTE from "@/lib/app-route.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
import { notifications } from "@mantine/notifications";
@ -18,7 +18,7 @@ export const useTrialEndAction = () => {
notifications.show({
position: "top-right",
color: "red",
title: "Your 7-day trial has ended",
title: `Your ${getBillingTrialDays()}-day trial has ended`,
message:
"Please upgrade to a paid plan or contact your workspace admin.",
autoClose: false,

View File

@ -15,7 +15,7 @@ export default function EnforceSso() {
<Text size="md">{t("Enforce SSO")}</Text>
<Text size="sm" c="dimmed">
{t(
"Once enforced, members will not able able to login with email and password.",
"Once enforced, members will not be able to login with email and password.",
)}
</Text>
</div>

View File

@ -10,11 +10,13 @@ import EnforceSso from "@/ee/security/components/enforce-sso.tsx";
import AllowedDomains from "@/ee/security/components/allowed-domains.tsx";
import { useTranslation } from "react-i18next";
import useLicense from "@/ee/hooks/use-license.tsx";
import usePlan from "@/ee/hooks/use-plan.tsx";
export default function Security() {
const { t } = useTranslation();
const { isAdmin } = useUserRole();
const { hasLicenseKey } = useLicense();
const { isBusiness } = usePlan();
if (!isAdmin) {
return null;
@ -35,8 +37,7 @@ export default function Security() {
Single sign-on (SSO)
</Title>
{/*TODO: revisit when we add a second plan */}
{!isCloud() && hasLicenseKey ? (
{(isCloud() && isBusiness) || (!isCloud() && hasLicenseKey) ? (
<>
<EnforceSso />
<Divider my="lg" />

View File

@ -18,6 +18,7 @@ import classes from "@/features/auth/components/auth.module.css";
import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-query.ts";
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
import { useTranslation } from "react-i18next";
import SsoLogin from "@/ee/components/sso-login.tsx";
const formSchema = z.object({
name: z.string().trim().min(1),
@ -71,39 +72,43 @@ export function InviteSignUpForm() {
{t("Join the workspace")}
</Title>
<Stack align="stretch" justify="center" gap="xl">
<form onSubmit={form.onSubmit(onSubmit)}>
<TextInput
id="name"
type="text"
label={t("Name")}
placeholder={t("enter your full name")}
variant="filled"
{...form.getInputProps("name")}
/>
<SsoLogin />
<TextInput
id="email"
type="email"
label={t("Email")}
value={invitation.email}
disabled
variant="filled"
mt="md"
/>
{!invitation.enforceSso && (
<Stack align="stretch" justify="center" gap="xl">
<form onSubmit={form.onSubmit(onSubmit)}>
<TextInput
id="name"
type="text"
label={t("Name")}
placeholder={t("enter your full name")}
variant="filled"
{...form.getInputProps("name")}
/>
<PasswordInput
label={t("Password")}
placeholder={t("Your password")}
variant="filled"
mt="md"
{...form.getInputProps("password")}
/>
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
{t("Sign Up")}
</Button>
</form>
</Stack>
<TextInput
id="email"
type="email"
label={t("Email")}
value={invitation.email}
disabled
variant="filled"
mt="md"
/>
<PasswordInput
label={t("Password")}
placeholder={t("Your password")}
variant="filled"
mt="md"
{...form.getInputProps("password")}
/>
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
{t("Sign Up")}
</Button>
</form>
</Stack>
)}
</Box>
</Container>
);

View File

@ -21,7 +21,7 @@ import { Link } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route.ts";
const formSchema = z.object({
workspaceName: z.string().trim().min(3).max(50),
workspaceName: z.string().trim().max(50).optional(),
name: z.string().min(1).max(50),
email: z
.string()
@ -60,15 +60,17 @@ export function SetupWorkspaceForm() {
{isCloud() && <SsoCloudSignup />}
<form onSubmit={form.onSubmit(onSubmit)}>
<TextInput
id="workspaceName"
type="text"
label={t("Workspace Name")}
placeholder={t("e.g ACME Inc")}
variant="filled"
mt="md"
{...form.getInputProps("workspaceName")}
/>
{!isCloud() && (
<TextInput
id="workspaceName"
type="text"
label={t("Workspace Name")}
placeholder={t("e.g ACME Inc")}
variant="filled"
mt="md"
{...form.getInputProps("workspaceName")}
/>
)}
<TextInput
id="name"

View File

@ -10,7 +10,7 @@ export interface IRegister {
}
export interface ISetupWorkspace {
workspaceName: string;
workspaceName?: string;
name: string;
email: string;
password: string;
@ -36,5 +36,5 @@ export interface IVerifyUserToken {
}
export interface ICollabToken {
token: string;
token?: string;
}

View File

@ -1,4 +1,4 @@
import { Button, Group } from "@mantine/core";
import { Button, Group, Tooltip } from "@mantine/core";
import { useTranslation } from "react-i18next";
type CommentActionsProps = {
@ -15,7 +15,7 @@ function CommentActions({
isCommentEditor,
}: CommentActionsProps) {
const { t } = useTranslation();
return (
<Group justify="flex-end" pt="sm" wrap="nowrap">
{isCommentEditor && (

View File

@ -15,6 +15,7 @@ import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-
import { useEditor } from "@tiptap/react";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { useTranslation } from "react-i18next";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
interface CommentDialogProps {
editor: ReturnType<typeof useEditor>;
@ -35,6 +36,8 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
const createCommentMutation = useCreateCommentMutation();
const { isPending } = createCommentMutation;
const emit = useQueryEmit();
const handleDialogClose = () => {
setShowCommentPopup(false);
editor.chain().focus().unsetCommentDecoration().run();
@ -63,11 +66,23 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
.run();
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 });
setTimeout(() => {
const selector = `div[data-comment-id="${createdComment.id}"]`;
const commentElement = document.querySelector(selector);
commentElement?.scrollIntoView();
commentElement?.scrollIntoView({ behavior: "smooth", block: "center" });
editor.view.dispatch(
editor.state.tr.scrollIntoView()
);
}, 400);
emit({
operation: "invalidateComment",
pageId: pageId,
});
} finally {
setShowCommentPopup(false);
@ -109,6 +124,7 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
<CommentEditor
onUpdate={handleCommentEditorChange}
onSave={handleAddComment}
placeholder={t("Write a comment")}
editable={true}
autofocus={true}

View File

@ -8,10 +8,12 @@ import { useFocusWithin } from "@mantine/hooks";
import clsx from "clsx";
import { forwardRef, useEffect, useImperativeHandle } from "react";
import { useTranslation } from "react-i18next";
import EmojiCommand from "@/features/editor/extensions/emoji-command";
interface CommentEditorProps {
defaultContent?: any;
onUpdate?: any;
onSave?: any;
editable: boolean;
placeholder?: string;
autofocus?: boolean;
@ -22,6 +24,7 @@ const CommentEditor = forwardRef(
{
defaultContent,
onUpdate,
onSave,
editable,
placeholder,
autofocus,
@ -42,7 +45,35 @@ const CommentEditor = forwardRef(
}),
Underline,
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 }) {
if (onUpdate) onUpdate(editor.getJSON());
},
@ -53,6 +84,10 @@ const CommentEditor = forwardRef(
autofocus: (autofocus && "end") || false,
});
useEffect(() => {
commentEditor.commands.setContent(defaultContent);
}, [defaultContent]);
useEffect(() => {
setTimeout(() => {
if (autofocus) {

View File

@ -1,5 +1,5 @@
import { Group, Text, Box } from "@mantine/core";
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import classes from "./comment.module.css";
import { useAtom, useAtomValue } from "jotai";
import { timeAgo } from "@/lib/time";
@ -15,12 +15,14 @@ import {
import { IComment } from "@/features/comment/types/comment.types";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
interface CommentListItemProps {
comment: IComment;
pageId: string;
}
function CommentListItem({ comment }: CommentListItemProps) {
function CommentListItem({ comment, pageId }: CommentListItemProps) {
const { hovered, ref } = useHover();
const [isEditing, setIsEditing] = useState(false);
const [isLoading, setIsLoading] = useState(false);
@ -29,6 +31,11 @@ function CommentListItem({ comment }: CommentListItemProps) {
const updateCommentMutation = useUpdateCommentMutation();
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
const [currentUser] = useAtom(currentUserAtom);
const emit = useQueryEmit();
useEffect(() => {
setContent(comment.content)
}, [comment]);
async function handleUpdateComment() {
try {
@ -39,6 +46,11 @@ function CommentListItem({ comment }: CommentListItemProps) {
};
await updateCommentMutation.mutateAsync(commentToUpdate);
setIsEditing(false);
emit({
operation: "invalidateComment",
pageId: pageId,
});
} catch (error) {
console.error("Failed to update comment:", error);
} finally {
@ -50,11 +62,27 @@ function CommentListItem({ comment }: CommentListItemProps) {
try {
await deleteCommentMutation.mutateAsync(comment.id);
editor?.commands.unsetComment(comment.id);
emit({
operation: "invalidateComment",
pageId: pageId,
});
} catch (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() {
setIsEditing(true);
}
@ -99,7 +127,7 @@ function CommentListItem({ comment }: CommentListItemProps) {
<div>
{!comment.parentCommentId && comment?.selection && (
<Box className={classes.textSelection}>
<Box className={classes.textSelection} onClick={() => handleCommentClick(comment)}>
<Text size="sm">{comment?.selection}</Text>
</Box>
)}
@ -112,6 +140,7 @@ function CommentListItem({ comment }: CommentListItemProps) {
defaultContent={content}
editable={true}
onUpdate={(newContent: any) => setContent(newContent)}
onSave={handleUpdateComment}
autofocus={true}
/>

View File

@ -14,6 +14,7 @@ import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { IPagination } from "@/lib/types.ts";
import { extractPageSlugId } from "@/lib";
import { useTranslation } from "react-i18next";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
function CommentList() {
const { t } = useTranslation();
@ -26,6 +27,7 @@ function CommentList() {
} = useCommentsQuery({ pageId: page?.id, limit: 100 });
const createCommentMutation = useCreateCommentMutation();
const [isLoading, setIsLoading] = useState(false);
const emit = useQueryEmit();
const handleAddReply = useCallback(
async (commentId: string, content: string) => {
@ -38,6 +40,11 @@ function CommentList() {
};
await createCommentMutation.mutateAsync(commentData);
emit({
operation: "invalidateComment",
pageId: page?.id,
});
} catch (error) {
console.error("Failed to post comment:", error);
} finally {
@ -59,8 +66,8 @@ function CommentList() {
data-comment-id={comment.id}
>
<div>
<CommentListItem comment={comment} />
<MemoizedChildComments comments={comments} parentId={comment.id} />
<CommentListItem comment={comment} pageId={page?.id} />
<MemoizedChildComments comments={comments} parentId={comment.id} pageId={page?.id} />
</div>
<Divider my={4} />
@ -99,8 +106,9 @@ function CommentList() {
interface ChildCommentsProps {
comments: IPagination<IComment>;
parentId: string;
pageId: string;
}
const ChildComments = ({ comments, parentId }: ChildCommentsProps) => {
const ChildComments = ({ comments, parentId, pageId }: ChildCommentsProps) => {
const getChildComments = useCallback(
(parentId: string) =>
comments.items.filter(
@ -113,10 +121,11 @@ const ChildComments = ({ comments, parentId }: ChildCommentsProps) => {
<div>
{getChildComments(parentId).map((childComment) => (
<div key={childComment.id}>
<CommentListItem comment={childComment} />
<CommentListItem comment={childComment} pageId={pageId} />
<MemoizedChildComments
comments={comments}
parentId={childComment.id}
pageId={pageId}
/>
</div>
))}
@ -142,6 +151,7 @@ const CommentEditorWithActions = ({ commentId, onSave, isLoading }) => {
<CommentEditor
ref={commentEditorRef}
onUpdate={setContent}
onSave={handleSave}
editable={true}
/>
{focused && <CommentActions onSave={handleSave} isLoading={isLoading} />}

View File

@ -11,22 +11,25 @@
border-left: 2px solid var(--mantine-color-gray-6);
padding: 8px;
background: var(--mantine-color-gray-light);
cursor: pointer;
}
.commentEditor {
.focused {
border-radius: var(--mantine-radius-sm);
box-shadow: 0 0 0 2px var(--mantine-color-blue-3);
}
.ProseMirror :global(.ProseMirror){
border-radius: var(--mantine-radius-sm);
max-width: 100%;
white-space: pre-wrap;
word-break: break-word;
max-height: 20vh;
padding-left: 6px;
padding-right: 6px;
margin-top: 2px;
margin-top: 10px;
margin-bottom: 2px;
overflow: hidden auto;
}

View File

@ -5,4 +5,6 @@ export const pageEditorAtom = atom<Editor | null>(null);
export const titleEditorAtom = atom<Editor | null>(null);
export const readOnlyEditorAtom = atom<Editor | null>(null);
export const yjsConnectionStatusAtom = atom<string>("");

View File

@ -116,6 +116,12 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
},
tippyOptions: {
moveTransition: "transform 0.15s ease-out",
onCreate: (instance) => {
instance.popper.firstChild?.addEventListener("blur", (event) => {
event.preventDefault();
event.stopImmediatePropagation();
});
},
onHide: () => {
setIsNodeSelectorOpen(false);
setIsTextAlignmentOpen(false);
@ -177,8 +183,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
<LinkSelector
editor={props.editor}
isOpen={isLinkSelectorOpen}
setIsOpen={() => {
setIsLinkSelectorOpen(!isLinkSelectorOpen);
setIsOpen={(value) => {
setIsLinkSelectorOpen(value);
setIsNodeSelectorOpen(false);
setIsTextAlignmentOpen(false);
setIsColorSelectorOpen(false);

View File

@ -156,13 +156,11 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
)
}
onClick={() => {
editor.commands.unsetColor();
name !== "Default" &&
editor
.chain()
.focus()
.setColor(color || "")
.run();
if (name === "Default") {
editor.commands.unsetColor();
} else {
editor.chain().focus().setColor(color || "").run();
}
setIsOpen(false);
}}
style={{ border: "none" }}

View File

@ -139,7 +139,7 @@ export default function DrawioView(props: NodeViewProps) {
)}
/>
{selected && (
{selected && editor.isEditable && (
<ActionIcon
onClick={handleOpen}
variant="default"

View File

@ -15,13 +15,13 @@ import {
import { IconEdit } from "@tabler/icons-react";
import { z } from "zod";
import { useForm, zodResolver } from "@mantine/form";
import {
getEmbedProviderById,
getEmbedUrlAndProvider,
} from "@/features/editor/components/embed/providers.ts";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import i18n from "i18next";
import {
getEmbedProviderById,
getEmbedUrlAndProvider,
} from "@docmost/editor-ext";
const schema = z.object({
url: z
@ -32,7 +32,7 @@ const schema = z.object({
export default function EmbedView(props: NodeViewProps) {
const { t } = useTranslation();
const { node, selected, updateAttributes } = props;
const { node, selected, updateAttributes, editor } = props;
const { src, provider } = node.attrs;
const embedUrl = useMemo(() => {
@ -50,8 +50,16 @@ export default function EmbedView(props: NodeViewProps) {
});
async function onSubmit(data: { url: string }) {
if (!editor.isEditable) {
return;
}
if (provider) {
const embedProvider = getEmbedProviderById(provider);
if (embedProvider.id === "iframe") {
updateAttributes({ src: data.url });
return;
}
if (embedProvider.regex.test(data.url)) {
updateAttributes({ src: data.url });
} else {
@ -81,7 +89,13 @@ export default function EmbedView(props: NodeViewProps) {
</AspectRatio>
</>
) : (
<Popover width={300} position="bottom" withArrow shadow="md">
<Popover
width={300}
position="bottom"
withArrow
shadow="md"
disabled={!editor.isEditable}
>
<Popover.Target>
<Card
radius="md"
@ -101,7 +115,7 @@ export default function EmbedView(props: NodeViewProps) {
<Text component="span" size="lg" c="dimmed">
{t("Embed {{provider}}", {
provider: getEmbedProviderById(provider).name,
provider: getEmbedProviderById(provider)?.name,
})}
</Text>
</div>

View File

@ -33,7 +33,7 @@ const renderEmojiItems = () => {
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
placement: "bottom",
});
},
onStart: (props: {

View File

@ -0,0 +1,42 @@
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,
);
}
},
};

View File

@ -13,7 +13,8 @@ import { uploadFile } from "@/features/page/services/page-service.ts";
import { svgStringToFile } from "@/lib";
import { useDisclosure } from "@mantine/hooks";
import { getFileUrl } from "@/lib/config.ts";
import { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types/types";
import "@excalidraw/excalidraw/index.css";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
import { IAttachment } from "@/lib/types";
import ReactClearModal from "react-clear-modal";
import clsx from "clsx";
@ -21,6 +22,8 @@ import { IconEdit } from "@tabler/icons-react";
import { lazy } from "react";
import { Suspense } from "react";
import { useTranslation } from "react-i18next";
import { useHandleLibrary } from "@excalidraw/excalidraw";
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
const Excalidraw = lazy(() =>
import("@excalidraw/excalidraw").then((module) => ({
@ -35,6 +38,10 @@ export default function ExcalidrawView(props: NodeViewProps) {
const [excalidrawAPI, setExcalidrawAPI] =
useState<ExcalidrawImperativeAPI>(null);
useHandleLibrary({
excalidrawAPI,
adapter: localStorageLibraryAdapter,
});
const [excalidrawData, setExcalidrawData] = useState<any>(null);
const [opened, { open, close }] = useDisclosure(false);
const computedColorScheme = useComputedColorScheme();
@ -170,7 +177,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
)}
/>
{selected && (
{selected && editor.isEditable && (
<ActionIcon
onClick={handleOpen}
variant="default"

View File

@ -3,6 +3,7 @@ import React, {
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
@ -18,7 +19,7 @@ import {
import clsx from "clsx";
import classes from "./mention.module.css";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { IconFileDescription } from "@tabler/icons-react";
import { IconFileDescription, IconPlus } from "@tabler/icons-react";
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
import { useParams } from "react-router-dom";
import { v7 as uuid7 } from "uuid";
@ -28,14 +29,28 @@ import {
MentionListProps,
MentionSuggestionItem,
} 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 [selectedIndex, setSelectedIndex] = useState(1);
const viewportRef = useRef<HTMLDivElement>(null);
const { spaceSlug } = useParams();
const { pageSlug, spaceSlug } = useParams();
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
const { data: space } = useSpaceQuery(spaceSlug);
const [currentUser] = useAtom(currentUserAtom);
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({
query: props.query,
@ -45,12 +60,23 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
limit: 10,
});
const createPageItem = (label: string) : MentionSuggestionItem => {
return {
id: null,
label: label,
entityType: "page",
entityId: null,
slugId: null,
icon: null,
}
}
useEffect(() => {
if (suggestion && !isLoading) {
let items: MentionSuggestionItem[] = [];
if (suggestion?.users?.length > 0) {
items.push({ entityType: "header", label: "Users" });
items.push({ entityType: "header", label: t("Users") });
items = items.concat(
suggestion.users.map((user) => ({
@ -64,7 +90,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
}
if (suggestion?.pages?.length > 0) {
items.push({ entityType: "header", label: "Pages" });
items.push({ entityType: "header", label: t("Pages") });
items = items.concat(
suggestion.pages.map((page) => ({
id: uuid7(),
@ -76,6 +102,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
})),
);
}
items.push(createPageItem(props.query));
setRenderItems(items);
// update editor storage
@ -96,7 +123,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
creatorId: currentUser?.user.id,
});
}
if (item.entityType === "page") {
if (item.entityType === "page" && item.id!==null) {
props.command({
id: item.id,
label: item.label || "Untitled",
@ -106,6 +133,9 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
creatorId: currentUser?.user.id,
});
}
if (item.entityType === "page" && item.id===null) {
createPage(item.label);
}
}
},
[renderItems],
@ -167,6 +197,58 @@ 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?
useEffect(() => {
@ -178,7 +260,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
if (renderItems.length === 0) {
return (
<Paper shadow="md" p="xs" withBorder>
No results
{ t("No results") }
</Paper>
);
}
@ -248,14 +330,14 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
color="gray"
size={18}
>
<IconFileDescription size={18} />
{ (item.id) ? <IconFileDescription size={18} /> : <IconPlus size={18} /> }
</ActionIcon>
)}
</ActionIcon>
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{item.label}
{ (item.id) ? item.label : t("Create page") + ': ' + item.label }
</Text>
</div>
</Group>

View File

@ -1,21 +1,34 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { ActionIcon, Anchor, Text } from "@mantine/core";
import { IconFileDescription } from "@tabler/icons-react";
import { Link, useParams } from "react-router-dom";
import { Link, useLocation, useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import {
buildPageUrl,
buildSharedPageUrl,
} from "@/features/page/page.utils.ts";
import classes from "./mention.module.css";
export default function MentionView(props: NodeViewProps) {
const { node } = props;
const { label, entityType, entityId, slugId } = node.attrs;
const { spaceSlug } = useParams();
const { shareId } = useParams();
const {
data: page,
isLoading,
isError,
} = usePageQuery({ pageId: entityType === "page" ? slugId : null });
const location = useLocation();
const isShareRoute = location.pathname.startsWith("/share");
const shareSlugUrl = buildSharedPageUrl({
shareId,
pageSlugId: slugId,
pageTitle: label,
});
return (
<NodeViewWrapper style={{ display: "inline" }}>
{entityType === "user" && (
@ -28,7 +41,9 @@ export default function MentionView(props: NodeViewProps) {
<Anchor
component={Link}
fw={500}
to={buildPageUrl(spaceSlug, slugId, label)}
to={
isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label)
}
underline="never"
className={classes.pageMentionLink}
>

View File

@ -17,8 +17,8 @@ import {
IconTable,
IconTypography,
IconMenu4,
IconCalendar,
} from "@tabler/icons-react";
IconCalendar, IconAppWindow,
} from '@tabler/icons-react';
import {
CommandProps,
SlashMenuGroupedItemsType,
@ -357,6 +357,20 @@ const CommandGroups: SlashMenuGroupedItemsType = {
.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",
description: "Embed Airtable",

View File

@ -52,3 +52,8 @@
) !important;
}
}
.leftBorder {
border-left: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}

View File

@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
type TableOfContentsProps = {
editor: ReturnType<typeof useEditor>;
isShare?: boolean;
};
export type HeadingLink = {
@ -73,6 +74,7 @@ export const TableOfContents: FC<TableOfContentsProps> = (props) => {
const handleUpdate = () => {
const result = recalculateLinks(props.editor?.$nodes("heading"));
setLinks(result.links);
setHeadingDOMNodes(result.nodes);
};
@ -85,9 +87,12 @@ export const TableOfContents: FC<TableOfContentsProps> = (props) => {
};
}, [props.editor]);
useEffect(() => {
handleUpdate();
}, []);
useEffect(
() => {
handleUpdate();
},
props.isShare ? [props.editor] : [],
);
useEffect(() => {
try {
@ -133,16 +138,29 @@ export const TableOfContents: FC<TableOfContentsProps> = (props) => {
if (!links.length) {
return (
<>
<Text size="sm">
{t("Add headings (H1, H2, H3) to generate a table of contents.")}
</Text>
{!props.isShare && (
<Text size="sm">
{t("Add headings (H1, H2, H3) to generate a table of contents.")}
</Text>
)}
{props.isShare && (
<Text size="sm" c="dimmed">
{t("No table of contents.")}
</Text>
)}
</>
);
}
return (
<>
<div>
{props.isShare && (
<Text mb="md" fw={500}>
{t("Table of contents")}
</Text>
)}
<div className={props.isShare ? classes.leftBorder : ""}>
{links.map((item, idx) => (
<Box<"button">
component="button"

View File

@ -17,9 +17,9 @@ import {
IconColumnRemove,
IconRowInsertBottom,
IconRowInsertTop,
IconRowRemove,
IconRowRemove, IconTableColumn, IconTableRow,
IconTrashX,
} from "@tabler/icons-react";
} from '@tabler/icons-react';
import { isCellSelection } from "@docmost/editor-ext";
import { useTranslation } from "react-i18next";
@ -50,6 +50,14 @@ export const TableMenu = React.memo(
return posToDOMRect(editor.view, selection.from, selection.to);
}, [editor]);
const toggleHeaderColumn = useCallback(() => {
editor.chain().focus().toggleHeaderColumn().run();
}, [editor]);
const toggleHeaderRow = useCallback(() => {
editor.chain().focus().toggleHeaderRow().run();
}, [editor]);
const addColumnLeft = useCallback(() => {
editor.chain().focus().addColumnBefore().run();
}, [editor]);
@ -180,6 +188,30 @@ export const TableMenu = React.memo(
</ActionIcon>
</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")}>
<ActionIcon
onClick={deleteTable}

View File

@ -58,6 +58,7 @@ import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-v
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
import plaintext from "highlight.js/lib/languages/plaintext";
import powershell from "highlight.js/lib/languages/powershell";
import abap from "highlightjs-sap-abap";
import elixir from "highlight.js/lib/languages/elixir";
import erlang from "highlight.js/lib/languages/erlang";
import dockerfile from "highlight.js/lib/languages/dockerfile";
@ -72,11 +73,12 @@ import i18n from "@/i18n.ts";
import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts";
import EmojiCommand from "./emoji-command";
import { CharacterCount } from "@tiptap/extension-character-count";
import { countWords } from "alfaaz";
const lowlight = createLowlight(common);
lowlight.register("mermaid", plaintext);
lowlight.register("powershell", powershell);
lowlight.register("powershell", powershell);
lowlight.register("abap", abap);
lowlight.register("erlang", erlang);
lowlight.register("elixir", elixir);
lowlight.register("dockerfile", dockerfile);
@ -212,7 +214,9 @@ export const mainExtensions = [
MarkdownClipboard.configure({
transformPastedText: true,
}),
CharacterCount
CharacterCount.configure({
wordCounter: (text) => countWords(text),
}),
] as any;
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
@ -228,4 +232,4 @@ export const collabExtensions: CollabExtensions = (provider, user) => [
color: randomElement(userColors),
},
}),
];
];

View File

@ -42,7 +42,11 @@ export function FullEditor({
spaceSlug={spaceSlug}
editable={editable}
/>
<MemoizedPageEditor pageId={pageId} editable={editable} content={content} />
<MemoizedPageEditor
pageId={pageId}
editable={editable}
content={content}
/>
</Container>
);
}

View File

@ -1,7 +1,6 @@
import "@/features/editor/styles/index.css";
import React, {
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
@ -52,6 +51,7 @@ import { IPage } from "@/features/page/types/page.types.ts";
import { useParams } from "react-router-dom";
import { extractPageSlugId } from "@/lib";
import { FIVE_MINUTES } from "@/lib/constants.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import { jwtDecode } from "jwt-decode";
interface PageEditorProps {
@ -71,7 +71,11 @@ export default function PageEditor({
const [, setAsideState] = useAtom(asideStateAtom);
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const ydoc = useMemo(() => new Y.Doc(), [pageId]);
const ydocRef = useRef<Y.Doc | null>(null);
if (!ydocRef.current) {
ydocRef.current = new Y.Doc();
}
const ydoc = ydocRef.current;
const [isLocalSynced, setLocalSynced] = useState(false);
const [isRemoteSynced, setRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
@ -85,67 +89,103 @@ export default function PageEditor({
const [isCollabReady, setIsCollabReady] = useState(false);
const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug);
const userPageEditMode =
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const localProvider = useMemo(() => {
const provider = new IndexeddbPersistence(documentName, ydoc);
// Providers only created once per pageId
const providersRef = useRef<{
local: IndexeddbPersistence;
remote: HocuspocusProvider;
} | null>(null);
const [providersReady, setProvidersReady] = useState(false);
provider.on("synced", () => {
setLocalSynced(true);
});
const localProvider = providersRef.current?.local;
const remoteProvider = providersRef.current?.remote;
return provider;
}, [pageId, ydoc]);
// Track when collaborative provider is ready and synced
const [collabReady, setCollabReady] = useState(false);
useEffect(() => {
if (
remoteProvider?.status === WebSocketStatus.Connected &&
isLocalSynced &&
isRemoteSynced
) {
setCollabReady(true);
}
}, [remoteProvider?.status, isLocalSynced, isRemoteSynced]);
const remoteProvider = useMemo(() => {
const provider = new HocuspocusProvider({
name: documentName,
url: collaborationURL,
document: ydoc,
token: collabQuery?.token,
connect: false,
preserveConnection: false,
onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => {
const payload = jwtDecode(collabQuery?.token);
const now = Date.now().valueOf() / 1000;
const isTokenExpired = now >= payload.exp;
if (isTokenExpired) {
refetchCollabToken();
}
},
onStatus: (status) => {
if (status.status === "connected") {
setYjsConnectionStatus(status.status);
}
},
});
provider.on("synced", () => {
setRemoteSynced(true);
});
provider.on("disconnect", () => {
setYjsConnectionStatus(WebSocketStatus.Disconnected);
});
return provider;
}, [ydoc, pageId, collabQuery?.token]);
useLayoutEffect(() => {
remoteProvider.connect();
useEffect(() => {
if (!providersRef.current) {
const local = new IndexeddbPersistence(documentName, ydoc);
local.on("synced", () => setLocalSynced(true));
const remote = new HocuspocusProvider({
name: documentName,
url: collaborationURL,
document: ydoc,
token: collabQuery?.token,
connect: true,
preserveConnection: false,
onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => {
const payload = jwtDecode(collabQuery?.token);
const now = Date.now().valueOf() / 1000;
const isTokenExpired = now >= payload.exp;
if (isTokenExpired) {
refetchCollabToken();
}
},
onStatus: (status) => {
if (status.status === "connected") {
setYjsConnectionStatus(status.status);
}
},
});
remote.on("synced", () => setRemoteSynced(true));
remote.on("disconnect", () => {
setYjsConnectionStatus(WebSocketStatus.Disconnected);
});
providersRef.current = { local, remote };
setProvidersReady(true);
} else {
setProvidersReady(true);
}
// Only destroy on final unmount
return () => {
setRemoteSynced(false);
setLocalSynced(false);
remoteProvider.destroy();
localProvider.destroy();
providersRef.current?.remote.destroy();
providersRef.current?.local.destroy();
providersRef.current = null;
};
}, [remoteProvider, localProvider]);
}, [pageId]);
// Only connect/disconnect on tab/idle, not destroy
useEffect(() => {
if (!providersReady || !providersRef.current) return;
const remoteProvider = providersRef.current.remote;
if (
isIdle &&
documentState === "hidden" &&
remoteProvider.status === WebSocketStatus.Connected
) {
remoteProvider.disconnect();
setIsCollabReady(false);
return;
}
if (
documentState === "visible" &&
remoteProvider.status === WebSocketStatus.Disconnected
) {
resetIdle();
remoteProvider.connect();
setTimeout(() => setIsCollabReady(true), 500);
}
}, [isIdle, documentState, providersReady, resetIdle]);
const extensions = useMemo(() => {
if (!remoteProvider || !currentUser?.user) return mainExtensions;
return [
...mainExtensions,
...collabExtensions(remoteProvider, currentUser?.user),
];
}, [ydoc, pageId, remoteProvider, currentUser?.user]);
}, [remoteProvider, currentUser?.user]);
const editor = useEditor(
{
@ -199,7 +239,7 @@ export default function PageEditor({
debouncedUpdateContent(editorJson);
},
},
[pageId, editable, remoteProvider?.status],
[pageId, editable, remoteProvider],
);
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
@ -219,9 +259,12 @@ export default function PageEditor({
setActiveCommentId(commentId);
setAsideState({ tab: "comments", isAsideOpen: true });
const selector = `div[data-comment-id="${commentId}"]`;
const commentElement = document.querySelector(selector);
commentElement?.scrollIntoView();
//wait if aside is closed
setTimeout(() => {
const selector = `div[data-comment-id="${commentId}"]`;
const commentElement = document.querySelector(selector);
commentElement?.scrollIntoView({ behavior: "smooth", block: "center" });
}, 400);
};
useEffect(() => {
@ -249,29 +292,6 @@ export default function PageEditor({
}
}, [remoteProvider?.status]);
useEffect(() => {
if (
isIdle &&
documentState === "hidden" &&
remoteProvider?.status === WebSocketStatus.Connected
) {
remoteProvider.disconnect();
setIsCollabReady(false);
return;
}
if (
documentState === "visible" &&
remoteProvider?.status === WebSocketStatus.Disconnected
) {
resetIdle();
remoteProvider.connect();
setTimeout(() => {
setIsCollabReady(true);
}, 600);
}
}, [isIdle, documentState, remoteProvider]);
const isSynced = isLocalSynced && isRemoteSynced;
useEffect(() => {
@ -287,11 +307,49 @@ export default function PageEditor({
return () => clearTimeout(collabReadyTimeout);
}, [isRemoteSynced, isLocalSynced, remoteProvider?.status]);
return isCollabReady ? (
<div>
useEffect(() => {
// Only honor user default page edit mode preference and permissions
if (editor) {
if (userPageEditMode && editable) {
if (userPageEditMode === PageEditMode.Edit) {
editor.setEditable(true);
} else if (userPageEditMode === PageEditMode.Read) {
editor.setEditable(false);
}
} else {
editor.setEditable(false);
}
}
}, [userPageEditMode, editor, editable]);
const hasConnectedOnceRef = useRef(false);
const [showStatic, setShowStatic] = useState(true);
useEffect(() => {
if (
!hasConnectedOnceRef.current &&
remoteProvider?.status === WebSocketStatus.Connected
) {
hasConnectedOnceRef.current = true;
setShowStatic(false);
}
}, [remoteProvider?.status]);
if (showStatic) {
return (
<EditorProvider
editable={false}
immediatelyRender={true}
extensions={mainExtensions}
content={content}
/>
);
}
return (
<div style={{ position: "relative" }}>
<div ref={menuContainerRef}>
<EditorContent editor={editor} />
{editor && editor.isEditable && (
<div>
<EditorBubbleMenu editor={editor} />
@ -305,21 +363,12 @@ export default function PageEditor({
<LinkMenu editor={editor} appendTo={menuContainerRef} />
</div>
)}
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
</div>
<div
onClick={() => editor.commands.focus("end")}
style={{ paddingBottom: "20vh" }}
></div>
</div>
) : (
<EditorProvider
editable={false}
immediatelyRender={true}
extensions={mainExtensions}
content={content}
></EditorProvider>
);
}

View File

@ -0,0 +1,67 @@
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>
</>
);
}

View File

@ -144,6 +144,19 @@
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 {
cursor: ew-resize;
cursor: col-resize;

View File

@ -47,7 +47,7 @@
.column-resize-handle {
background-color: #adf;
bottom: -2px;
bottom: -1px;
position: absolute;
right: -2px;
pointer-events: none;

View File

@ -10,7 +10,7 @@ import {
pageEditorAtom,
titleEditorAtom,
} from "@/features/editor/atoms/editor-atoms";
import { useUpdatePageMutation } from "@/features/page/queries/page-query";
import { updatePageData, useUpdateTitlePageMutation } from "@/features/page/queries/page-query";
import { useDebouncedCallback } from "@mantine/hooks";
import { useAtom } from "jotai";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
@ -21,6 +21,8 @@ import { useTranslation } from "react-i18next";
import EmojiCommand from "@/features/editor/extensions/emoji-command.ts";
import { UpdateEvent } from "@/features/websocket/types";
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 {
pageId: string;
@ -38,12 +40,15 @@ export function TitleEditor({
editable,
}: TitleEditorProps) {
const { t } = useTranslation();
const { mutateAsync: updatePageMutationAsync } = useUpdatePageMutation();
const { mutateAsync: updateTitlePageMutationAsync } = useUpdateTitlePageMutation();
const pageEditor = useAtomValue(pageEditorAtom);
const [, setTitleEditor] = useAtom(titleEditorAtom);
const emit = useQueryEmit();
const navigate = useNavigate();
const [activePageId, setActivePageId] = useState(pageId);
const [currentUser] = useAtom(currentUserAtom);
const userPageEditMode =
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const titleEditor = useEditor({
extensions: [
@ -94,7 +99,7 @@ export function TitleEditor({
return;
}
updatePageMutationAsync({
updateTitlePageMutationAsync({
pageId: pageId,
title: titleEditor.getText(),
}).then((page) => {
@ -103,9 +108,13 @@ export function TitleEditor({
spaceId: page.spaceId,
entity: ["pages"],
id: page.id,
payload: { title: page.title, slugId: page.slugId },
payload: { title: page.title, slugId: page.slugId, parentPageId: page.parentPageId, icon: page.icon },
};
if (page.title !== titleEditor.getText()) return;
updatePageData(page);
localEmitter.emit("message", event);
emit(event);
});
@ -132,9 +141,24 @@ export function TitleEditor({
};
}, [pageId]);
function handleTitleKeyDown(event) {
if (!titleEditor || !pageEditor || event.shiftKey) return;
useEffect(() => {
// 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;
// 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 { $head } = titleEditor.state.selection;

View File

@ -0,0 +1,14 @@
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;
}

View File

@ -0,0 +1,17 @@
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;
}

View File

@ -1,9 +1,10 @@
import api from "@/lib/api-client";
import { IPageHistory } from "@/features/page-history/types/page.types";
import { IPagination } from "@/lib/types.ts";
export async function getPageHistoryList(
pageId: string,
): Promise<IPageHistory[]> {
): Promise<IPagination<IPageHistory>> {
const req = await api.post("/pages/history", {
pageId,
});

View File

@ -1,23 +1,30 @@
.breadcrumbs {
display: flex;
align-items: center;
display: flex;
align-items: center;
overflow: hidden;
flex-wrap: nowrap;
a {
color: var(--mantine-color-default-color);
line-height: inherit;
}
.mantine-Breadcrumbs-breadcrumb {
min-width: 1px;
overflow: hidden;
a {
color: var(--mantine-color-default-color);
line-height: inherit;
}
.mantine-Breadcrumbs-breadcrumb {
min-width: 1px;
overflow: hidden;
}
}
}
.truncatedText {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
.breadcrumbDiv {
overflow: hidden;
@media (max-width: $mantine-breakpoint-sm) {
overflow: visible;
}
}

View File

@ -1,6 +1,6 @@
import { useAtomValue } from "jotai";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import React, { useEffect, useState } from "react";
import React, { useCallback, useEffect, useState } from "react";
import { findBreadcrumbPath } from "@/features/page/tree/utils";
import {
Button,
@ -9,14 +9,16 @@ import {
Breadcrumbs,
ActionIcon,
Text,
Tooltip,
} from "@mantine/core";
import { IconDots } from "@tabler/icons-react";
import { IconCornerDownRightDouble, IconDots } from "@tabler/icons-react";
import { Link, useParams } from "react-router-dom";
import classes from "./breadcrumb.module.css";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { extractPageSlugId } from "@/lib";
import { useMediaQuery } from "@mantine/hooks";
function getTitle(name: string, icon: string) {
if (icon) {
@ -34,6 +36,7 @@ export default function Breadcrumb() {
const { data: currentPage } = usePageQuery({
pageId: extractPageSlugId(pageSlug),
});
const isMobile = useMediaQuery("(max-width: 48em)");
useEffect(() => {
if (treeData?.length > 0 && currentPage) {
@ -43,7 +46,7 @@ export default function Breadcrumb() {
}, [currentPage?.id, treeData]);
const HiddenNodesTooltipContent = () =>
breadcrumbNodes?.slice(1, -2).map((node) => (
breadcrumbNodes?.slice(1, -1).map((node) => (
<Button.Group orientation="vertical" key={node.id}>
<Button
justify="start"
@ -59,17 +62,39 @@ export default function Breadcrumb() {
</Button.Group>
));
const renderAnchor = (node: SpaceTreeNode) => (
<Anchor
component={Link}
to={buildPageUrl(spaceSlug, node.slugId, node.name)}
underline="never"
fz={"sm"}
key={node.id}
className={classes.truncatedText}
>
{getTitle(node.name, node.icon)}
</Anchor>
const MobileHiddenNodesTooltipContent = () =>
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
component={Link}
to={buildPageUrl(spaceSlug, node.slugId, node.name)}
underline="never"
fz="sm"
key={node.id}
className={classes.truncatedText}
>
{getTitle(node.name, node.icon)}
</Anchor>
</Tooltip>
),
[spaceSlug],
);
const getBreadcrumbItems = () => {
@ -77,7 +102,7 @@ export default function Breadcrumb() {
if (breadcrumbNodes.length > 3) {
const firstNode = breadcrumbNodes[0];
const secondLastNode = breadcrumbNodes[breadcrumbNodes.length - 2];
//const secondLastNode = breadcrumbNodes[breadcrumbNodes.length - 2];
const lastNode = breadcrumbNodes[breadcrumbNodes.length - 1];
return [
@ -98,7 +123,7 @@ export default function Breadcrumb() {
<HiddenNodesTooltipContent />
</Popover.Dropdown>
</Popover>,
renderAnchor(secondLastNode),
//renderAnchor(secondLastNode),
renderAnchor(lastNode),
];
}
@ -106,11 +131,40 @@ export default function Breadcrumb() {
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 (
<div style={{ overflow: "hidden" }}>
<div className={classes.breadcrumbDiv}>
{breadcrumbNodes && (
<Breadcrumbs className={classes.breadcrumbs}>
{getBreadcrumbItems()}
{isMobile ? getMobileBreadcrumbItems() : getBreadcrumbItems()}
</Breadcrumbs>
)}
</div>

View File

@ -0,0 +1,105 @@
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>
);
}

View File

@ -12,7 +12,7 @@ import {
IconTrash,
IconWifiOff,
} from "@tabler/icons-react";
import React, { useEffect } from "react";
import React from "react";
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
import { useAtom } from "jotai";
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
@ -33,8 +33,10 @@ import {
yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms.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 { useTimeAgo } from "@/hooks/use-time-ago.tsx";
import ShareModal from "@/features/share/components/share-modal.tsx";
interface PageHeaderMenuProps {
readOnly?: boolean;
@ -58,6 +60,10 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
</Tooltip>
)}
{!readOnly && <PageStateSegmentedControl size="xs" />}
<ShareModal readOnly={readOnly} />
<Tooltip label={t("Comments")} openDelay={250} withArrow>
<ActionIcon
variant="default"
@ -103,7 +109,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
{ open: openMovePageModal, close: closeMoveSpaceModal },
] = useDisclosure(false);
const [pageEditor] = useAtom(pageEditorAtom);
const pageUpdatedAt = useTimeAgo(page.updatedAt);
const pageUpdatedAt = useTimeAgo(page?.updatedAt);
const handleCopyLink = () => {
const pageUrl =

View File

@ -1,15 +1,27 @@
.header {
height: 45px;
background-color: var(--mantine-color-body);
padding-left: var(--mantine-spacing-md);
padding-right: var(--mantine-spacing-md);
position: fixed;
z-index: 99;
top: var(--app-shell-header-offset, 0rem);
inset-inline-start: var(--app-shell-navbar-offset, 0rem);
inset-inline-end: var(--app-shell-aside-offset, 0rem);
height: 45px;
background-color: var(--mantine-color-body);
padding-left: var(--mantine-spacing-md);
padding-right: var(--mantine-spacing-md);
position: fixed;
z-index: 99;
top: var(--app-shell-header-offset, 0rem);
inset-inline-start: var(--app-shell-navbar-offset, 0rem);
inset-inline-end: var(--app-shell-aside-offset, 0rem);
@media print {
display: none;
}
@media (max-width: $mantine-breakpoint-sm) {
padding-left: var(--mantine-spacing-xs);
padding-right: var(--mantine-spacing-xs);
}
@media print {
display: none;
}
}
.group {
@media (max-width: $mantine-breakpoint-sm) {
gap: var(--mantine-spacing-sm);
padding-inline: 0 !important;
}
}

View File

@ -9,10 +9,10 @@ interface Props {
export default function PageHeader({ readOnly }: Props) {
return (
<div className={classes.header}>
<Group justify="space-between" h="100%" px="md" wrap="nowrap">
<Group justify="space-between" h="100%" px="md" wrap="nowrap" className={classes.group}>
<Breadcrumb />
<Group justify="flex-end" h="100%" px="md" wrap="nowrap">
<Group justify="flex-end" h="100%" px="md" wrap="nowrap" gap="var(--mantine-spacing-xs)">
<PageHeaderMenu readOnly={readOnly} />
</Group>
</Group>

View File

@ -46,6 +46,7 @@ export default function MovePageModal({
message: t("Page moved successfully"),
});
onClose();
setTargetSpace(null);
} catch (err) {
notifications.show({
message: err.response?.data.message || "An error occurred",
@ -53,7 +54,6 @@ export default function MovePageModal({
});
console.log(err);
}
setTargetSpace(null);
};
const handleChange = (space: ISpace) => {
@ -69,7 +69,7 @@ export default function MovePageModal({
yOffset="10vh"
xOffset={0}
mah={400}
onClick={e => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
@ -78,7 +78,9 @@ export default function MovePageModal({
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>
<Text mb="xs" c="dimmed" size="sm">{t("Move page to a different space.")}</Text>
<Text mb="xs" c="dimmed" size="sm">
{t("Move page to a different space.")}
</Text>
<SpaceSelect
value={currentSpaceSlug}

View File

@ -1,18 +1,38 @@
import { Modal, Button, SimpleGrid, FileButton } from "@mantine/core";
import {
Modal,
Button,
SimpleGrid,
FileButton,
Group,
Text,
Tooltip,
} from "@mantine/core";
import {
IconBrandNotion,
IconCheck,
IconFileCode,
IconFileTypeZip,
IconMarkdown,
IconX,
} from "@tabler/icons-react";
import { importPage } from "@/features/page/services/page-service.ts";
import {
importPage,
importZip,
} from "@/features/page/services/page-service.ts";
import { notifications } from "@mantine/notifications";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { useAtom } from "jotai";
import { buildTree } from "@/features/page/tree/utils";
import { IPage } from "@/features/page/types/page.types.ts";
import React from "react";
import React, { useEffect, useState } from "react";
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 {
spaceId: string;
@ -36,6 +56,7 @@ export default function PageImportModal({
yOffset="10vh"
xOffset={0}
mah={400}
keepMounted={true}
>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
@ -59,6 +80,133 @@ interface ImportFormatSelection {
function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
const { t } = useTranslation();
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[]) => {
if (!selectedFiles) {
@ -120,6 +268,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
}
};
// @ts-ignore
return (
<>
<SimpleGrid cols={2}>
@ -148,7 +297,76 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
</Button>
)}
</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>
<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>
</>
);
}

View File

@ -8,7 +8,7 @@ const buildPageSlug = (pageSlugId: string, pageTitle?: string): string => {
],
});
return `p/${titleSlug}-${pageSlugId}`;
return `${titleSlug}-${pageSlugId}`;
};
export const buildPageUrl = (
@ -17,7 +17,20 @@ export const buildPageUrl = (
pageTitle?: string,
): string => {
if (spaceName === undefined) {
return `/${buildPageSlug(pageSlugId, pageTitle)}`;
return `/p/${buildPageSlug(pageSlugId, pageTitle)}`;
}
return `/s/${spaceName}/${buildPageSlug(pageSlugId, pageTitle)}`;
return `/s/${spaceName}/p/${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)}`;
};

View File

@ -1,5 +1,8 @@
import {
InfiniteData,
QueryKey,
useInfiniteQuery,
UseInfiniteQueryResult,
useMutation,
useQuery,
useQueryClient,
@ -14,6 +17,7 @@ import {
movePage,
getPageBreadcrumbs,
getRecentChanges,
getAllSidebarPages,
} from "@/features/page/services/page-service";
import {
IMovePage,
@ -56,35 +60,49 @@ export function useCreatePageMutation() {
const { t } = useTranslation();
return useMutation<IPage, Error, Partial<IPageInput>>({
mutationFn: (data) => createPage(data),
onSuccess: (data) => {},
onSuccess: (data) => {
invalidateOnCreatePage(data);
},
onError: (error) => {
notifications.show({ message: t("Failed to create page"), color: "red" });
},
});
}
export function useUpdatePageMutation() {
const queryClient = useQueryClient();
export function updatePageData(data: IPage) {
const pageBySlug = queryClient.getQueryData<IPage>([
"pages",
data.slugId,
]);
const pageById = queryClient.getQueryData<IPage>(["pages", data.id]);
if (pageBySlug) {
queryClient.setQueryData(["pages", data.slugId], {
...pageBySlug,
...data,
});
}
if (pageById) {
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) => {
const pageBySlug = queryClient.getQueryData<IPage>([
"pages",
data.slugId,
]);
const pageById = queryClient.getQueryData<IPage>(["pages", data.id]);
updatePage(data);
if (pageBySlug) {
queryClient.setQueryData(["pages", data.slugId], {
...pageBySlug,
...data,
});
}
if (pageById) {
queryClient.setQueryData(["pages", data.id], { ...pageById, ...data });
}
invalidateOnUpdatePage(data.spaceId, data.parentPageId, data.id, data.title, data.icon);
},
});
}
@ -93,8 +111,9 @@ export function useDeletePageMutation() {
const { t } = useTranslation();
return useMutation({
mutationFn: (pageId: string) => deletePage(pageId),
onSuccess: () => {
onSuccess: (data, pageId) => {
notifications.show({ message: t("Page deleted successfully") });
invalidateOnDeletePage(pageId);
},
onError: (error) => {
notifications.show({ message: t("Failed to delete page"), color: "red" });
@ -105,15 +124,21 @@ export function useDeletePageMutation() {
export function useMovePageMutation() {
return useMutation<void, Error, IMovePage>({
mutationFn: (data) => movePage(data),
onSuccess: () => {
invalidateOnMovePage();
},
});
}
export function useGetSidebarPagesQuery(
data: SidebarPagesParams,
): UseQueryResult<IPagination<IPage>, Error> {
return useQuery({
export function useGetSidebarPagesQuery(data: SidebarPagesParams|null): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> {
return useInfiniteQuery({
queryKey: ["sidebar-pages", data],
queryFn: () => getSidebarPages(data),
queryFn: ({ pageParam }) => getSidebarPages({ ...data, page: pageParam }),
initialPageParam: 1,
getPreviousPageParam: (firstPage) =>
firstPage.meta.hasPrevPage ? firstPage.meta.page - 1 : undefined,
getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage ? lastPage.meta.page + 1 : undefined,
});
}
@ -141,14 +166,16 @@ export function usePageBreadcrumbsQuery(
});
}
export async function fetchAncestorChildren(params: SidebarPagesParams) {
export async function fetchAllAncestorChildren(params: SidebarPagesParams) {
// not using a hook here, so we can call it inside a useEffect hook
const response = await queryClient.fetchQuery({
queryKey: ["sidebar-pages", params],
queryFn: () => getSidebarPages(params),
queryFn: () => getAllSidebarPages(params),
staleTime: 30 * 60 * 1000,
});
return buildTree(response.items);
const allItems = response.pages.flatMap((page) => page.items);
return buildTree(allItems);
}
export function useRecentChangesQuery(
@ -160,3 +187,157 @@ export function useRecentChangesQuery(
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"],
});
}

View File

@ -1,14 +1,17 @@
import api from "@/lib/api-client";
import {
ICopyPageToSpace,
IExportPageParams,
IMovePage,
IMovePageToSpace,
IPage,
IPageInput,
SidebarPagesParams,
} from "@/features/page/types/page.types";
} from '@/features/page/types/page.types';
import { IAttachment, IPagination } from "@/lib/types.ts";
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> {
const req = await api.post<IPage>("/pages/create", data);
@ -39,6 +42,11 @@ export async function movePageToSpace(data: IMovePageToSpace): Promise<void> {
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(
params: SidebarPagesParams,
): Promise<IPagination<IPage>> {
@ -46,6 +54,32 @@ export async function getSidebarPages(
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(
pageId: string,
): Promise<Partial<IPage[]>> {
@ -86,6 +120,25 @@ export async function importPage(file: File, spaceId: string) {
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(
file: File,
pageId: string,

View File

@ -1,4 +1,19 @@
import { atom } from "jotai";
import { SpaceTreeNode } from "@/features/page/tree/types";
import { appendNodeChildren } from "../utils";
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);
}
);

View File

@ -2,19 +2,20 @@ import { NodeApi, NodeRendererProps, Tree, TreeApi } from "react-arborist";
import { atom, useAtom } from "jotai";
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
import {
fetchAncestorChildren,
fetchAllAncestorChildren,
useGetRootSidebarPagesQuery,
usePageQuery,
useUpdatePageMutation,
} from "@/features/page/queries/page-query.ts";
import { useEffect, useRef } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useEffect, useRef, useState } from "react";
import { Link, useParams } from "react-router-dom";
import classes from "@/features/page/tree/styles/tree.module.css";
import { ActionIcon, Menu, rem } from "@mantine/core";
import { ActionIcon, Box, Menu, rem } from "@mantine/core";
import {
IconArrowRight,
IconChevronDown,
IconChevronRight,
IconCopy,
IconDotsVertical,
IconFileDescription,
IconFileExport,
@ -23,7 +24,10 @@ import {
IconPointFilled,
IconTrash,
} from "@tabler/icons-react";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import {
appendNodeChildrenAtom,
treeDataAtom,
} from "@/features/page/tree/atoms/tree-data-atom.ts";
import clsx from "clsx";
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
@ -31,6 +35,7 @@ import {
appendNodeChildren,
buildTree,
buildTreeWithChildren,
mergeRootTrees,
updateTreeNodeIcon,
} from "@/features/page/tree/utils/utils.ts";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
@ -58,6 +63,9 @@ import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.
import { useTranslation } from "react-i18next";
import ExportModal from "@/components/common/export-modal";
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 {
spaceId: string;
@ -84,7 +92,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
const rootElement = useRef<HTMLDivElement>();
const { ref: sizeRef, width, height } = useElementSize();
const mergedRef = useMergedRef(rootElement, sizeRef);
const isDataLoaded = useRef(false);
const [isDataLoaded, setIsDataLoaded] = useState(false);
const { data: currentPage } = usePageQuery({
pageId: extractPageSlugId(pageSlug),
});
@ -100,23 +108,23 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
const allItems = pagesData.pages.flatMap((page) => page.items);
const treeData = buildTree(allItems);
if (data.length < 1 || data?.[0].spaceId !== spaceId) {
//Thoughts
// don't reset if there is data in state
// 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({});
}
setData((prev) => {
// fresh space; full reset
if (prev.length === 0 || prev[0]?.spaceId !== spaceId) {
setIsDataLoaded(true);
setOpenTreeNodes({});
return treeData;
}
// same space; append only missing roots
return mergeRootTrees(prev, treeData);
});
}
}, [pagesData, hasNextPage]);
useEffect(() => {
const fetchData = async () => {
if (isDataLoaded.current && currentPage) {
if (isDataLoaded && currentPage) {
// check if pageId node is present in the tree
const node = dfs(treeApiRef.current?.root, currentPage.id);
if (node) {
@ -136,7 +144,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
if (ancestor.id === currentPage.id) {
return;
}
const children = await fetchAncestorChildren({
const children = await fetchAllAncestorChildren({
pageId: ancestor.id,
spaceId: ancestor.spaceId,
});
@ -178,7 +186,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
};
fetchData();
}, [isDataLoaded.current, currentPage?.id]);
}, [isDataLoaded, currentPage?.id]);
useEffect(() => {
if (currentPage?.id) {
@ -230,13 +238,15 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
}
function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
const navigate = useNavigate();
const { t } = useTranslation();
const updatePageMutation = useUpdatePageMutation();
const [treeData, setTreeData] = useAtom(treeDataAtom);
const [, appendChildren] = useAtom(appendNodeChildrenAtom);
const emit = useQueryEmit();
const { spaceSlug } = useParams();
const timerRef = useRef(null);
const { t } = useTranslation();
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
const prefetchPage = () => {
timerRef.current = setTimeout(() => {
@ -257,9 +267,10 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
async function handleLoadChildren(node: NodeApi<SpaceTreeNode>) {
if (!node.data.hasChildren) return;
if (node.data.children && node.data.children.length > 0) {
return;
}
// in conflict with use-query-subscription.ts => case "addTreeNode","moveTreeNode" etc with websocket
// if (node.data.children && node.data.children.length > 0) {
// return;
// }
try {
const params: SidebarPagesParams = {
@ -267,31 +278,17 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
spaceId: node.data.spaceId,
};
const newChildren = await queryClient.fetchQuery({
queryKey: ["sidebar-pages", params],
queryFn: () => getSidebarPages(params),
staleTime: 10 * 60 * 1000,
const childrenTree = await fetchAllAncestorChildren(params);
appendChildren({
parentId: node.data.id,
children: childrenTree,
});
const childrenTree = buildTree(newChildren.items);
const updatedTreeData = appendNodeChildren(
treeData,
node.data.id,
childrenTree,
);
setTreeData(updatedTreeData);
} catch (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 updatedTree = updateTreeNodeIcon(treeData, nodeId, newIcon);
setTreeData(updatedTree);
@ -304,17 +301,19 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
const handleEmojiSelect = (emoji: { native: string }) => {
handleUpdateNodeIcon(node.id, emoji.native);
updatePageMutation.mutateAsync({ pageId: node.id, icon: emoji.native });
setTimeout(() => {
emit({
operation: "updateOne",
spaceId: node.data.spaceId,
entity: ["pages"],
id: node.id,
payload: { icon: emoji.native },
updatePageMutation
.mutateAsync({ pageId: node.id, icon: emoji.native })
.then((data) => {
setTimeout(() => {
emit({
operation: "updateOne",
spaceId: node.data.spaceId,
entity: ["pages"],
id: node.id,
payload: { icon: emoji.native, parentPageId: data.parentPageId },
});
}, 50);
});
}, 50);
};
const handleRemoveEmoji = () => {
@ -345,13 +344,22 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
}, 650);
}
const pageUrl = buildPageUrl(spaceSlug, node.data.slugId, node.data.name);
return (
<>
<div
<Box
style={style}
className={clsx(classes.node, node.state)}
component={Link}
to={pageUrl}
// @ts-ignore
ref={dragHandle}
onClick={handleClick}
onClick={() => {
if (mobileSidebarOpened) {
toggleMobileSidebar();
}
}}
onMouseEnter={prefetchPage}
onMouseLeave={cancelPagePrefetch}
>
@ -385,7 +393,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
/>
)}
</div>
</div>
</Box>
</>
);
}
@ -441,6 +449,10 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
movePageModalOpened,
{ open: openMovePageModal, close: closeMoveSpaceModal },
] = useDisclosure(false);
const [
copyPageModalOpened,
{ open: openCopyPageModal, close: closeCopySpaceModal },
] = useDisclosure(false);
const handleCopyLink = () => {
const pageUrl =
@ -504,6 +516,17 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
{t("Move")}
</Menu.Item>
<Menu.Item
leftSection={<IconCopy size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openCopyPageModal();
}}
>
{t("Copy")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
c="red"
@ -529,6 +552,13 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
open={movePageModalOpened}
/>
<CopyPageModal
pageId={node.id}
currentSpaceSlug={spaceSlug}
onClose={closeCopySpaceModal}
open={copyPageModalOpened}
/>
<ExportModal
type="page"
id={node.id}
@ -545,6 +575,12 @@ interface PageArrowProps {
}
function PageArrow({ node, onExpandTree }: PageArrowProps) {
useEffect(() => {
if (node.isOpen) {
onExpandTree();
}
}, []);
return (
<ActionIcon
size={20}

View File

@ -93,7 +93,7 @@ export function useTreeMutation<T>(spaceId: string) {
return data;
};
const onMove: MoveHandler<T> = (args: {
const onMove: MoveHandler<T> = async (args: {
dragIds: string[];
dragNodes: NodeApi<T>[];
parentId: string | null;
@ -176,7 +176,7 @@ export function useTreeMutation<T>(spaceId: string) {
};
try {
movePageMutation.mutateAsync(payload);
await movePageMutation.mutateAsync(payload);
setTimeout(() => {
emit({
@ -206,6 +206,23 @@ 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[] }) => {
try {
await deletePageMutation.mutateAsync(args.ids[0]);
@ -218,8 +235,7 @@ export function useTreeMutation<T>(spaceId: string) {
tree.drop({ id: args.ids[0] });
setData(tree.data);
// navigate only if the current url is same as the deleted page
if (pageSlug && node.data.slugId === pageSlug.split("-")[1]) {
if (pageSlug && isPageInNode(node, pageSlug.split("-")[1])) {
navigate(getSpaceUrl(spaceSlug));
}

View File

@ -18,7 +18,7 @@
align-items: center;
height: 100%;
width: 93%; /* not to overlap with scroll bar */
text-decoration: none;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
&:hover {
@ -70,6 +70,10 @@
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 {
white-space: nowrap;
cursor: pointer;

View File

@ -1,7 +1,7 @@
import { IPage } from "@/features/page/types/page.types.ts";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
function sortPositionKeys(keys: any[]) {
export function sortPositionKeys(keys: any[]) {
return keys.sort((a, b) => {
if (a.position < b.position) return -1;
if (a.position > b.position) return 1;
@ -121,7 +121,6 @@ export const deleteTreeNode = (
.filter((node) => node !== null);
};
export function buildTreeWithChildren(items: SpaceTreeNode[]): SpaceTreeNode[] {
const nodeMap = {};
let result: SpaceTreeNode[] = [];
@ -164,16 +163,55 @@ export function appendNodeChildren(
nodeId: string,
children: SpaceTreeNode[],
) {
return treeItems.map((nodeItem) => {
if (nodeItem.id === nodeId) {
return { ...nodeItem, children };
}
if (nodeItem.children) {
// Preserve deeper children if they exist and remove node if deleted
return treeItems.map((node) => {
if (node.id === nodeId) {
const newIds = new Set(children.map((c) => c.id));
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 {
...nodeItem,
children: appendNodeChildren(nodeItem.children, nodeId, children),
...node,
children: merged,
};
}
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);
}

View File

@ -12,7 +12,7 @@ export interface IPage {
spaceId: string;
workspaceId: string;
isLocked: boolean;
lastUpdatedById: Date;
lastUpdatedById: string;
createdAt: Date;
updatedAt: Date;
deletedAt: Date;
@ -47,6 +47,11 @@ export interface IMovePageToSpace {
spaceId: string;
}
export interface ICopyPageToSpace {
pageId: string;
spaceId: string;
}
export interface SidebarPagesParams {
spaceId: string;
pageId?: string;
@ -60,6 +65,7 @@ export interface IPageInput {
icon: string;
coverPhoto: string;
position: string;
isLocked: boolean;
}
export interface IExportPageParams {

View File

@ -0,0 +1,44 @@
.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);
}
}

View File

@ -0,0 +1,56 @@
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>
);
}

View File

@ -0,0 +1,7 @@
import { createSpotlight } from '@mantine/spotlight';
export const [searchSpotlightStore, searchSpotlight] = createSpotlight();
export const [shareSearchSpotlightStore, shareSearchSpotlight] =
createSpotlight();

View File

@ -1,6 +1,7 @@
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import {
searchPage,
searchShare,
searchSuggestions,
} from "@/features/search/services/search-service";
import {
@ -30,3 +31,13 @@ export function useSearchSuggestionsQuery(
enabled: !!params.query,
});
}
export function useShareSearchQuery(
params: IPageSearchParams,
): UseQueryResult<IPageSearch[], Error> {
return useQuery({
queryKey: ["share-search", params],
queryFn: () => searchShare(params),
enabled: !!params.query,
});
}

View File

@ -2,36 +2,36 @@ import { Group, Center, Text } from "@mantine/core";
import { Spotlight } from "@mantine/spotlight";
import { IconSearch } from "@tabler/icons-react";
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Link } from "react-router-dom";
import { useDebouncedValue } from "@mantine/hooks";
import { usePageSearchQuery } from "@/features/search/queries/search-query";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { getPageIcon } from "@/lib";
import { useTranslation } from "react-i18next";
import { searchSpotlightStore } from "./constants";
interface SearchSpotlightProps {
spaceId?: string;
}
export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const [query, setQuery] = useState("");
const [debouncedSearchQuery] = useDebouncedValue(query, 300);
const {
data: searchResults,
isLoading,
error,
} = usePageSearchQuery({ query: debouncedSearchQuery, spaceId });
const { data: searchResults } = usePageSearchQuery({
query: debouncedSearchQuery,
spaceId,
});
const pages = (
searchResults && searchResults.length > 0 ? searchResults : []
).map((page) => (
<Spotlight.Action
key={page.id}
onClick={() =>
navigate(buildPageUrl(page.space.slug, page.slugId, page.title))
}
component={Link}
//@ts-ignore
to={buildPageUrl(page.space.slug, page.slugId, page.title)}
style={{ userSelect: "none" }}
>
<Group wrap="nowrap" w="100%">
<Center>{getPageIcon(page?.icon)}</Center>
@ -54,6 +54,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
return (
<>
<Spotlight.Root
store={searchSpotlightStore}
query={query}
onQueryChange={setQuery}
scrollable

View File

@ -19,3 +19,10 @@ export async function searchSuggestions(
const req = await api.post<ISuggestionResult>("/search/suggest", params);
return req.data;
}
export async function searchShare(
params: IPageSearchParams,
): Promise<IPageSearch[]> {
const req = await api.post<IPageSearch[]>("/search/share-search", params);
return req.data;
}

View File

@ -0,0 +1,87 @@
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>
</>
);
}

View File

@ -35,4 +35,5 @@ export interface ISuggestionResult {
export interface IPageSearchParams {
query: string;
spaceId?: string;
shareId?: string;
}

View File

@ -0,0 +1,9 @@
import { atomWithWebStorage } from "@/lib/jotai-helper.ts";
import { atom } from 'jotai';
export const tableOfContentAsideAtom = atomWithWebStorage<boolean>(
"showTOC",
true,
);
export const mobileTableOfContentAsideAtom = atom<boolean>(false);

View File

@ -0,0 +1,106 @@
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>
</>
);
}

View File

@ -0,0 +1,16 @@
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>
);
}

View File

@ -0,0 +1,10 @@
import { Outlet } from "react-router-dom";
import ShareShell from "@/features/share/components/share-shell.tsx";
export default function ShareLayout() {
return (
<ShareShell>
<Outlet />
</ShareShell>
);
}

View File

@ -0,0 +1,97 @@
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}
/>
)}
</>
);
}

View File

@ -0,0 +1,227 @@
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>
);
}

View File

@ -0,0 +1,187 @@
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>
);
}

View File

@ -0,0 +1,20 @@
.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;
}
}

View File

@ -0,0 +1,195 @@
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>
);
}

Some files were not shown because too many files have changed in this diff Show More