Compare commits

...

26 Commits

Author SHA1 Message Date
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
117 changed files with 4802 additions and 1278 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.20.4",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@ -25,7 +25,7 @@
"@tabler/icons-react": "^3.22.0",
"@tanstack/react-query": "^5.61.4",
"@tiptap/extension-character-count": "^2.11.5",
"axios": "^1.7.9",
"axios": "^1.8.4",
"clsx": "^2.1.1",
"emoji-mart": "^5.6.0",
"file-saver": "^2.0.5",
@ -63,7 +63,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 +76,6 @@
"prettier": "^3.4.1",
"typescript": "^5.7.2",
"typescript-eslint": "^8.17.0",
"vite": "^6.1.0"
"vite": "^6.3.2"
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -362,5 +362,26 @@
"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": "ページの共有に失敗しました"
}

View File

@ -362,5 +362,26 @@
"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": "페이지 공유에 실패했습니다"
}

View File

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

View File

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

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,26 @@
"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": "Не удалось поделиться страницей"
}

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,26 @@
"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": "页面分享失败"
}

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

@ -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

@ -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

@ -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,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 able to login with email and password.",
)}
</Text>
</div>

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

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

View File

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

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

@ -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

@ -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

@ -2,6 +2,7 @@
display: flex;
align-items: center;
overflow: hidden;
flex-wrap: nowrap;
a {
color: var(--mantine-color-default-color);

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" }}>
{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";
@ -35,6 +35,7 @@ import {
import { formattedDate, timeAgo } from "@/lib/time.ts";
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 +59,8 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
</Tooltip>
)}
<ShareModal readOnly={readOnly} />
<Tooltip label={t("Comments")} openDelay={250} withArrow>
<ActionIcon
variant="default"
@ -103,7 +106,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

@ -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

@ -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,6 @@
import api from "@/lib/api-client";
import {
ICopyPageToSpace,
IExportPageParams,
IMovePage,
IMovePageToSpace,
@ -39,6 +40,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>> {

View File

@ -7,14 +7,15 @@ import {
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,
@ -58,6 +59,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 +88,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),
});
@ -108,7 +112,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
// and append root pages instead of resetting the entire tree
// which looses async loaded children too
setData(treeData);
isDataLoaded.current = true;
setIsDataLoaded(true);
setOpenTreeNodes({});
}
}
@ -116,7 +120,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
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) {
@ -178,7 +182,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
};
fetchData();
}, [isDataLoaded.current, currentPage?.id]);
}, [isDataLoaded, currentPage?.id]);
useEffect(() => {
if (currentPage?.id) {
@ -230,13 +234,14 @@ 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 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(() => {
@ -287,11 +292,6 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
}
}
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);
@ -345,13 +345,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 +394,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
/>
)}
</div>
</div>
</Box>
</>
);
}
@ -441,6 +450,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 +517,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 +553,13 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
open={movePageModalOpened}
/>
<CopyPageModal
pageId={node.id}
currentSpaceSlug={spaceSlug}
onClose={closeCopySpaceModal}
open={copyPageModalOpened}
/>
<ExportModal
type="page"
id={node.id}

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;

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;

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,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,195 @@
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";
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}
<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>
</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>
);
}

View File

@ -0,0 +1,8 @@
import { useAtom } from "jotai";
export function useToggleToc(tocAtom: any) {
const [tocState, setTocState] = useAtom(tocAtom);
return () => {
setTocState(!tocState);
}
}

View File

@ -0,0 +1,179 @@
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import {
ICreateShare,
IShare,
ISharedItem,
ISharedPage,
ISharedPageTree,
IShareForPage,
IShareInfoInput,
IUpdateShare,
} from "@/features/share/types/share.types.ts";
import {
createShare,
deleteShare,
getSharedPageTree,
getShareForPage,
getShareInfo,
getSharePageInfo,
getShares,
updateShare,
} from "@/features/share/services/share-service.ts";
import { IPage } from "@/features/page/types/page.types.ts";
import { IPagination, QueryParams } from "@/lib/types.ts";
import { useEffect } from "react";
export function useGetSharesQuery(
params?: QueryParams,
): UseQueryResult<IPagination<ISharedItem>, Error> {
return useQuery({
queryKey: ["share-list"],
queryFn: () => getShares(params),
placeholderData: keepPreviousData,
});
}
export function useGetShareByIdQuery(
shareId: string,
): UseQueryResult<IShare, Error> {
const query = useQuery({
queryKey: ["share-by-id", shareId],
queryFn: () => getShareInfo(shareId),
enabled: !!shareId,
});
return query;
}
export function useSharePageQuery(
shareInput: Partial<IShareInfoInput>,
): UseQueryResult<ISharedPage, Error> {
const query = useQuery({
queryKey: ["shares", shareInput],
queryFn: () => getSharePageInfo(shareInput),
enabled: !!shareInput.pageId,
});
return query;
}
export function useShareForPageQuery(
pageId: string,
): UseQueryResult<IShareForPage, Error> {
const query = useQuery({
queryKey: ["share-for-page", pageId],
queryFn: () => getShareForPage(pageId),
enabled: !!pageId,
staleTime: 0,
retry: false,
});
return query;
}
export function useCreateShareMutation() {
const { t } = useTranslation();
const queryClient = useQueryClient();
return useMutation<any, Error, ICreateShare>({
mutationFn: (data) => createShare(data),
onSuccess: (data) => {
queryClient.invalidateQueries({
predicate: (item) =>
["share-for-page", "share-list"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
notifications.show({ message: t("Failed to share page"), color: "red" });
},
});
}
export function useUpdateShareMutation() {
const { t } = useTranslation();
const queryClient = useQueryClient();
return useMutation<any, Error, IUpdateShare>({
mutationFn: (data) => updateShare(data),
onSuccess: (data) => {
queryClient.invalidateQueries({
predicate: (item) =>
["share-for-page", "share-list"].includes(item.queryKey[0] as string),
});
},
onError: (error, params) => {
if (error?.["status"] === 404) {
queryClient.removeQueries({
predicate: (item) =>
["share-for-page"].includes(item.queryKey[0] as string),
});
notifications.show({
message: t("Share not found"),
color: "red",
});
return;
}
notifications.show({
message: error?.["response"]?.data?.message || "Share not found",
color: "red",
});
},
});
}
export function useDeleteShareMutation() {
const { t } = useTranslation();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (shareId: string) => deleteShare(shareId),
onSuccess: (data) => {
queryClient.removeQueries({
predicate: (item) =>
["share-for-page"].includes(item.queryKey[0] as string),
});
queryClient.invalidateQueries({
predicate: (item) =>
["share-list"].includes(item.queryKey[0] as string),
});
notifications.show({ message: t("Share deleted successfully") });
},
onError: (error) => {
if (error?.["status"] === 404) {
queryClient.removeQueries({
predicate: (item) =>
["share-for-page"].includes(item.queryKey[0] as string),
});
}
notifications.show({
message: error?.["response"]?.data?.message || "Failed to delete share",
color: "red",
});
},
});
}
export function useGetSharedPageTreeQuery(
shareId: string,
): UseQueryResult<ISharedPageTree, Error> {
return useQuery({
queryKey: ["shared-page-tree", shareId],
queryFn: () => getSharedPageTree(shareId),
enabled: !!shareId,
placeholderData: keepPreviousData,
staleTime: 60 * 60 * 1000,
});
}

View File

@ -0,0 +1,59 @@
import api from "@/lib/api-client";
import { IPage } from "@/features/page/types/page.types";
import {
ICreateShare,
IShare,
ISharedItem,
ISharedPage,
ISharedPageTree,
IShareForPage,
IShareInfoInput,
IUpdateShare,
} from "@/features/share/types/share.types.ts";
import { IPagination, QueryParams } from "@/lib/types.ts";
export async function getShares(
params?: QueryParams,
): Promise<IPagination<ISharedItem>> {
const req = await api.post("/shares", params);
return req.data;
}
export async function createShare(data: ICreateShare): Promise<any> {
const req = await api.post<any>("/shares/create", data);
return req.data;
}
export async function getShareInfo(shareId: string): Promise<IShare> {
const req = await api.post<IShare>("/shares/info", { shareId });
return req.data;
}
export async function updateShare(data: IUpdateShare): Promise<any> {
const req = await api.post<any>("/shares/update", data);
return req.data;
}
export async function getShareForPage(pageId: string): Promise<IShareForPage> {
const req = await api.post<any>("/shares/for-page", { pageId });
return req.data;
}
export async function getSharePageInfo(
shareInput: Partial<IShareInfoInput>,
): Promise<ISharedPage> {
const req = await api.post<ISharedPage>("/shares/page-info", shareInput);
return req.data;
}
export async function deleteShare(shareId: string): Promise<void> {
await api.post("/shares/delete", { shareId });
}
export async function getSharedPageTree(
shareId: string,
): Promise<ISharedPageTree> {
const req = await api.post<ISharedPageTree>("/shares/tree", { shareId });
return req.data;
}

View File

@ -0,0 +1,73 @@
import { IPage } from "@/features/page/types/page.types.ts";
export interface IShare {
id: string;
key: string;
pageId: string;
includeSubPages: boolean;
searchIndexing: boolean;
creatorId: string;
spaceId: string;
workspaceId: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
sharedPage?: ISharePage;
}
export interface ISharedItem extends IShare {
page: {
id: string;
title: string;
slugId: string;
icon: string | null;
};
space: {
id: string;
name: string;
slug: string;
userRole: string;
};
creator: {
id: string;
name: string;
avatarUrl: string | null;
};
}
export interface ISharedPage extends IShare {
page: IPage;
share: IShare & {
level: number;
sharedPage: { id: string; slugId: string; title: string; icon: string };
};
}
export interface IShareForPage extends IShare {
level: number;
sharedPage: ISharePage;
}
interface ISharePage {
id: string;
slugId: string;
title: string;
icon: string;
}
export interface ICreateShare {
pageId?: string;
includeSubPages?: boolean;
searchIndexing?: boolean;
}
export type IUpdateShare = ICreateShare & { shareId: string; pageId?: string };
export interface IShareInfoInput {
pageId: string;
}
export interface ISharedPageTree {
share: IShare;
pageTree: Partial<IPage[]>;
}

View File

@ -0,0 +1,68 @@
import { IPage } from "@/features/page/types/page.types.ts";
import { sortPositionKeys } from "@/features/page/tree/utils";
export type SharedPageTreeNode = {
id: string;
slugId: string;
name: string;
icon?: string;
position: string;
spaceId: string;
parentPageId: string;
hasChildren: boolean;
children: SharedPageTreeNode[];
label: string;
value: string;
};
export function buildSharedPageTree(
pages: Partial<IPage[]>,
): SharedPageTreeNode[] {
const pageMap: Record<string, SharedPageTreeNode> = {};
// Initialize each page as a tree node and store it in a map.
pages.forEach((page) => {
pageMap[page.id] = {
id: page.slugId,
slugId: page.slugId,
name: page.title,
icon: page.icon,
position: page.position,
// Initially assume a page has no children.
hasChildren: false,
spaceId: page.spaceId,
parentPageId: page.parentPageId,
label: page.title || "untitled",
value: page.id,
children: [],
};
});
// Build the tree structure.
const tree: SharedPageTreeNode[] = [];
pages.forEach((page) => {
if (page.parentPageId) {
// If the page has a parent, add it as a child of the parent node.
const parentNode = pageMap[page.parentPageId];
if (parentNode) {
parentNode.children.push(pageMap[page.id]);
parentNode.hasChildren = true;
} else {
// Parent not found treat this page as a top-level node.
tree.push(pageMap[page.id]);
}
} else {
// No parentPageId indicates a top-level page.
tree.push(pageMap[page.id]);
}
});
function sortTree(nodes: SharedPageTreeNode[]): SharedPageTreeNode[] {
return sortPositionKeys(nodes).map((node: SharedPageTreeNode) => ({
...node,
children: sortTree(node.children),
}));
}
return sortTree(tree);
}

View File

@ -6,7 +6,6 @@ import {
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { spotlight } from "@mantine/spotlight";
import {
IconArrowDown,
IconDots,
@ -16,9 +15,8 @@ import {
IconSearch,
IconSettings,
} from "@tabler/icons-react";
import classes from "./space-sidebar.module.css";
import React, { useMemo } from "react";
import React from "react";
import { useAtom } from "jotai";
import { SearchSpotlight } from "@/features/search/search-spotlight.tsx";
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
@ -38,6 +36,9 @@ import PageImportModal from "@/features/page/components/page-import-modal.tsx";
import { useTranslation } from "react-i18next";
import { SwitchSpace } from "./switch-space";
import ExportModal from "@/components/common/export-modal";
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import { searchSpotlight } from "@/features/search/constants";
export function SpaceSidebar() {
const { t } = useTranslation();
@ -45,8 +46,11 @@ export function SpaceSidebar() {
const location = useLocation();
const [opened, { open: openSettings, close: closeSettings }] =
useDisclosure(false);
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
const { spaceSlug } = useParams();
const { data: space, isLoading, isError } = useGetSpaceBySlugQuery(spaceSlug);
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
const spaceRules = space?.membership?.permissions;
const spaceAbility = useSpaceAbility(spaceRules);
@ -95,7 +99,10 @@ export function SpaceSidebar() {
</div>
</UnstyledButton>
<UnstyledButton className={classes.menu} onClick={spotlight.open}>
<UnstyledButton
className={classes.menu}
onClick={searchSpotlight.open}
>
<div className={classes.menuItemInner}>
<IconSearch
size={18}
@ -123,7 +130,12 @@ export function SpaceSidebar() {
) && (
<UnstyledButton
className={classes.menu}
onClick={handleCreatePage}
onClick={() => {
handleCreatePage();
if (mobileSidebarOpened) {
toggleMobileSidebar();
}
}}
>
<div className={classes.menuItemInner}>
<IconPlus

View File

@ -0,0 +1,14 @@
import { settingsOriginAtom } from "@/components/settings/atoms/settings-origin-atom";
import { useAtomValue } from "jotai";
import { useNavigate } from "react-router-dom";
export function useSettingsNavigation() {
const navigate = useNavigate();
const origin = useAtomValue(settingsOriginAtom);
const goBack = () => {
navigate(origin ?? "/home", { replace: true });
};
return { goBack };
}

View File

@ -0,0 +1,16 @@
import { settingsOriginAtom } from "@/components/settings/atoms/settings-origin-atom";
import { useAtomValue, useSetAtom } from "jotai";
import { useEffect } from "react";
import { useLocation } from "react-router-dom";
export function useTrackOrigin() {
const location = useLocation();
const setOrigin = useSetAtom(settingsOriginAtom);
useEffect(() => {
const isInSettings = location.pathname.startsWith("/settings");
if (!isInSettings) {
setOrigin(location.pathname);
}
}, [location.pathname, setOrigin]);
}

View File

@ -26,6 +26,7 @@ api.interceptors.response.use(
case 401: {
const url = new URL(error.request.responseURL)?.pathname;
if (url === "/api/auth/collab-token") return;
if (window.location.pathname.startsWith("/share/")) return;
// Handle unauthorized error
redirectToLogin();

View File

@ -74,6 +74,10 @@ export function getDrawioUrl() {
return getConfigValue("DRAWIO_URL", "https://embed.diagrams.net");
}
export function getBillingTrialDays() {
return getConfigValue("BILLING_TRIAL_DAYS");
}
function getConfigValue(key: string, defaultValue: string = undefined): string {
const rawValue = import.meta.env.DEV
? process?.env?.[key]

View File

@ -0,0 +1,31 @@
import SettingsTitle from "@/components/settings/settings-title.tsx";
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
import ShareList from "@/features/share/components/share-list.tsx";
import { Alert, Text } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import React from "react";
export default function Shares() {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>
{t("Public sharing")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Public sharing")} />
<Alert variant="light" color="blue" icon={<IconInfoCircle />}>
{t(
"Publicly shared pages from spaces you are a member of will appear here",
)}
</Alert>
<ShareList />
</>
);
}

View File

@ -0,0 +1,35 @@
import { useNavigate, useParams } from "react-router-dom";
import { useEffect } from "react";
import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
import { Error404 } from "@/components/ui/error-404.tsx";
import { useGetShareByIdQuery } from "@/features/share/queries/share-query.ts";
export default function ShareRedirect() {
const { shareId } = useParams();
const navigate = useNavigate();
const { data: share, isLoading, isError } = useGetShareByIdQuery(shareId);
useEffect(() => {
if (share) {
navigate(
buildSharedPageUrl({
shareId: share.key,
pageSlugId: share?.sharedPage.slugId,
pageTitle: share?.sharedPage.title,
}),
{ replace: true },
);
}
}, [isLoading, share]);
if (isError) {
return <Error404 />;
}
if (isLoading) {
return <></>;
}
return null;
}

View File

@ -0,0 +1,58 @@
import { useNavigate, useParams } from "react-router-dom";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { useSharePageQuery } from "@/features/share/queries/share-query.ts";
import { Container } from "@mantine/core";
import React, { useEffect } from "react";
import ReadonlyPageEditor from "@/features/editor/readonly-page-editor.tsx";
import { extractPageSlugId } from "@/lib";
import { Error404 } from "@/components/ui/error-404.tsx";
export default function SingleSharedPage() {
const { t } = useTranslation();
const { pageSlug } = useParams();
const { shareId } = useParams();
const navigate = useNavigate();
const { data, isLoading, isError, error } = useSharePageQuery({
pageId: extractPageSlugId(pageSlug),
});
useEffect(() => {
if (shareId && data) {
if (data.share.key !== shareId) {
navigate(`/share/${data.share.key}/p/${pageSlug}`, { replace: true });
}
}
}, [shareId, data]);
if (isLoading) {
return <></>;
}
if (isError || !data) {
if ([401, 403, 404].includes(error?.["status"])) {
return <Error404 />;
}
return <div>{t("Error fetching page data.")}</div>;
}
return (
<div>
<Helmet>
<title>{`${data?.page?.title || t("untitled")}`}</title>
{!data?.share.searchIndexing && (
<meta name="robots" content="noindex" />
)}
</Helmet>
<Container size={900} p={0}>
<ReadonlyPageEditor
key={data.page.id}
title={data.page.title}
content={data.page.content}
/>
</Container>
</div>
);
}

View File

@ -12,6 +12,7 @@ export default defineConfig(({ mode }) => {
CLOUD,
SUBDOMAIN_HOST,
COLLAB_URL,
BILLING_TRIAL_DAYS,
} = loadEnv(mode, envPath, "");
return {
@ -23,6 +24,7 @@ export default defineConfig(({ mode }) => {
CLOUD,
SUBDOMAIN_HOST,
COLLAB_URL,
BILLING_TRIAL_DAYS,
},
APP_VERSION: JSON.stringify(process.env.npm_package_version),
},

View File

@ -1,6 +1,6 @@
{
"name": "server",
"version": "0.10.2",
"version": "0.20.4",
"description": "",
"author": "",
"private": true,
@ -37,18 +37,18 @@
"@fastify/multipart": "^9.0.3",
"@fastify/static": "^8.1.1",
"@nestjs/bullmq": "^11.0.2",
"@nestjs/common": "^11.0.10",
"@nestjs/config": "^4.0.0",
"@nestjs/core": "^11.0.10",
"@nestjs/common": "^11.0.20",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.20",
"@nestjs/event-emitter": "^3.0.0",
"@nestjs/jwt": "^11.0.0",
"@nestjs/mapped-types": "^2.1.0",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-fastify": "^11.0.10",
"@nestjs/platform-socket.io": "^11.0.10",
"@nestjs/platform-fastify": "^11.0.20",
"@nestjs/platform-socket.io": "^11.0.20",
"@nestjs/schedule": "^5.0.1",
"@nestjs/terminus": "^11.0.0",
"@nestjs/websockets": "^11.0.10",
"@nestjs/websockets": "^11.0.20",
"@node-saml/passport-saml": "^5.0.1",
"@react-email/components": "0.0.28",
"@react-email/render": "1.0.2",
@ -59,14 +59,13 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"cookie": "^1.0.2",
"fix-esm": "^1.0.1",
"fs-extra": "^11.3.0",
"happy-dom": "^15.11.6",
"jsonwebtoken": "^9.0.2",
"kysely": "^0.27.5",
"kysely-migration-cli": "^0.4.2",
"mime-types": "^2.1.35",
"nanoid": "^5.1.0",
"nanoid": "3.3.11",
"nestjs-kysely": "^1.1.0",
"nodemailer": "^6.10.0",
"openid-client": "^5.7.1",

View File

@ -1,4 +1,4 @@
import { Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Logger, Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { AuthenticationExtension } from './extensions/authentication.extension';
import { PersistenceExtension } from './extensions/persistence.extension';
import { CollaborationGateway } from './collaboration.gateway';
@ -22,6 +22,7 @@ import { LoggerExtension } from './extensions/logger.extension';
imports: [TokenModule],
})
export class CollaborationModule implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(CollaborationModule.name);
private collabWsAdapter: CollabWsAdapter;
private path = '/collab';
@ -38,7 +39,15 @@ export class CollaborationModule implements OnModuleInit, OnModuleDestroy {
wss.on('connection', (client: WebSocket, request: IncomingMessage) => {
this.collaborationGateway.handleConnection(client, request);
client.on('error', (error) => {
this.logger.error('WebSocket client error:', error);
});
});
wss.on('error', (error) =>
this.logger.log('WebSocket server error:', error),
);
}
async onModuleDestroy(): Promise<void> {

View File

@ -130,7 +130,7 @@ export class PersistenceExtension implements Extension {
);
this.contributors.delete(documentName);
} catch (err) {
this.logger.log('Contributors error:' + err?.['message']);
this.logger.debug('Contributors error:' + err?.['message']);
}
await this.pageRepo.updatePage(

View File

@ -1,9 +1,8 @@
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { customAlphabet } = require('fix-esm').require('nanoid');
import { customAlphabet } from 'nanoid';
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
export const nanoIdGen = customAlphabet(alphabet, 10);
const slugIdAlphabet =
'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
export const generateSlugId = customAlphabet(slugIdAlphabet, 10);
export const generateSlugId = customAlphabet(slugIdAlphabet, 10);

View File

@ -1,5 +1,12 @@
import { Node } from '@tiptap/pm/model';
import { jsonToNode } from '../../../collaboration/collaboration.util';
import {
jsonToNode,
tiptapExtensions,
} from '../../../collaboration/collaboration.util';
import { validate as isValidUUID } from 'uuid';
import { Transform } from '@tiptap/pm/transform';
import { TiptapTransformer } from '@hocuspocus/transformer';
import * as Y from 'yjs';
export interface MentionNode {
id: string;
@ -56,3 +63,67 @@ export function extractPageMentions(mentionList: MentionNode[]): MentionNode[] {
}
return pageMentionList as MentionNode[];
}
export function getProsemirrorContent(content: any) {
return (
content ?? {
type: 'doc',
content: [{ type: 'paragraph', attrs: { textAlign: 'left' } }],
}
);
}
export function isAttachmentNode(nodeType: string) {
const attachmentNodeTypes = [
'attachment',
'image',
'video',
'excalidraw',
'drawio',
];
return attachmentNodeTypes.includes(nodeType);
}
export function getAttachmentIds(prosemirrorJson: any) {
const doc = jsonToNode(prosemirrorJson);
const attachmentIds = [];
doc?.descendants((node: Node) => {
if (isAttachmentNode(node.type.name)) {
if (node.attrs.attachmentId && isValidUUID(node.attrs.attachmentId)) {
if (!attachmentIds.includes(node.attrs.attachmentId)) {
attachmentIds.push(node.attrs.attachmentId);
}
}
}
});
return attachmentIds;
}
export function removeMarkTypeFromDoc(doc: Node, markName: string): Node {
const { schema } = doc.type;
const markType = schema.marks[markName];
if (!markType) {
return doc;
}
const tr = new Transform(doc).removeMark(0, doc.content.size, markType);
return tr.doc;
}
export function createYdocFromJson(prosemirrorJson: any): Buffer | null {
if (prosemirrorJson) {
const ydoc = TiptapTransformer.toYdoc(
prosemirrorJson,
'default',
tiptapExtensions,
);
Y.encodeStateAsUpdate(ydoc);
return Buffer.from(Y.encodeStateAsUpdate(ydoc));
}
return null;
}

View File

@ -1,310 +1,373 @@
import {
BadRequestException,
Controller,
ForbiddenException,
Get,
HttpCode,
HttpStatus,
Logger,
NotFoundException,
Param,
Post,
Req,
Res,
UseGuards,
UseInterceptors,
BadRequestException,
Controller,
ForbiddenException,
Get,
HttpCode,
HttpStatus,
Logger,
NotFoundException,
Param,
Post,
Query,
Req,
Res,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import {AttachmentService} from './services/attachment.service';
import {FastifyReply} from 'fastify';
import {FileInterceptor} from '../../common/interceptors/file.interceptor';
import { AttachmentService } from './services/attachment.service';
import { FastifyReply } from 'fastify';
import { FileInterceptor } from '../../common/interceptors/file.interceptor';
import * as bytes from 'bytes';
import {AuthUser} from '../../common/decorators/auth-user.decorator';
import {AuthWorkspace} from '../../common/decorators/auth-workspace.decorator';
import {JwtAuthGuard} from '../../common/guards/jwt-auth.guard';
import {User, Workspace} from '@docmost/db/types/entity.types';
import {StorageService} from '../../integrations/storage/storage.service';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { StorageService } from '../../integrations/storage/storage.service';
import {
getAttachmentFolderPath,
validAttachmentTypes,
getAttachmentFolderPath,
validAttachmentTypes,
} from './attachment.utils';
import {getMimeType} from '../../common/helpers';
import { getMimeType } from '../../common/helpers';
import {
AttachmentType,
inlineFileExtensions,
MAX_AVATAR_SIZE,
AttachmentType,
inlineFileExtensions,
MAX_AVATAR_SIZE,
} from './attachment.constants';
import {
SpaceCaslAction,
SpaceCaslSubject,
SpaceCaslAction,
SpaceCaslSubject,
} from '../casl/interfaces/space-ability.type';
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
import {
WorkspaceCaslAction,
WorkspaceCaslSubject,
WorkspaceCaslAction,
WorkspaceCaslSubject,
} from '../casl/interfaces/workspace-ability.type';
import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory';
import {PageRepo} from '@docmost/db/repos/page/page.repo';
import {AttachmentRepo} from '@docmost/db/repos/attachment/attachment.repo';
import {validate as isValidUUID} from 'uuid';
import {EnvironmentService} from "../../integrations/environment/environment.service";
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
import { validate as isValidUUID } from 'uuid';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { TokenService } from '../auth/services/token.service';
import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload';
@Controller()
export class AttachmentController {
private readonly logger = new Logger(AttachmentController.name);
private readonly logger = new Logger(AttachmentController.name);
constructor(
private readonly attachmentService: AttachmentService,
private readonly storageService: StorageService,
private readonly workspaceAbility: WorkspaceAbilityFactory,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly pageRepo: PageRepo,
private readonly attachmentRepo: AttachmentRepo,
private readonly environmentService: EnvironmentService,
) {
}
constructor(
private readonly attachmentService: AttachmentService,
private readonly storageService: StorageService,
private readonly workspaceAbility: WorkspaceAbilityFactory,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly pageRepo: PageRepo,
private readonly attachmentRepo: AttachmentRepo,
private readonly environmentService: EnvironmentService,
private readonly tokenService: TokenService,
) {}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('files/upload')
@UseInterceptors(FileInterceptor)
async uploadFile(
@Req() req: any,
@Res() res: FastifyReply,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const maxFileSize = bytes(this.environmentService.getFileUploadSizeLimit());
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('files/upload')
@UseInterceptors(FileInterceptor)
async uploadFile(
@Req() req: any,
@Res() res: FastifyReply,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const maxFileSize = bytes(this.environmentService.getFileUploadSizeLimit());
let file = null;
try {
file = await req.file({
limits: {fileSize: maxFileSize, fields: 3, files: 1},
});
} catch (err: any) {
this.logger.error(err.message);
if (err?.statusCode === 413) {
throw new BadRequestException(
`File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`,
);
}
}
if (!file) {
throw new BadRequestException('Failed to upload file');
}
const pageId = file.fields?.pageId?.value;
if (!pageId) {
throw new BadRequestException('PageId is required');
}
const page = await this.pageRepo.findById(pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
const spaceAbility = await this.spaceAbility.createForUser(
user,
page.spaceId,
let file = null;
try {
file = await req.file({
limits: { fileSize: maxFileSize, fields: 3, files: 1 },
});
} catch (err: any) {
this.logger.error(err.message);
if (err?.statusCode === 413) {
throw new BadRequestException(
`File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`,
);
if (spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const spaceId = page.spaceId;
const attachmentId = file.fields?.attachmentId?.value;
if (attachmentId && !isValidUUID(attachmentId)) {
throw new BadRequestException('Invalid attachment id');
}
try {
const fileResponse = await this.attachmentService.uploadFile({
filePromise: file,
pageId: pageId,
spaceId: spaceId,
userId: user.id,
workspaceId: workspace.id,
attachmentId: attachmentId,
});
return res.send(fileResponse);
} catch (err: any) {
if (err?.statusCode === 413) {
const errMessage = `File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`;
this.logger.error(errMessage);
throw new BadRequestException(errMessage);
}
this.logger.error(err);
throw new BadRequestException('Error processing file upload.');
}
}
}
@UseGuards(JwtAuthGuard)
@Get('/files/:fileId/:fileName')
async getFile(
@Res() res: FastifyReply,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
@Param('fileId') fileId: string,
@Param('fileName') fileName?: string,
if (!file) {
throw new BadRequestException('Failed to upload file');
}
const pageId = file.fields?.pageId?.value;
if (!pageId) {
throw new BadRequestException('PageId is required');
}
const page = await this.pageRepo.findById(pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
const spaceAbility = await this.spaceAbility.createForUser(
user,
page.spaceId,
);
if (spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const spaceId = page.spaceId;
const attachmentId = file.fields?.attachmentId?.value;
if (attachmentId && !isValidUUID(attachmentId)) {
throw new BadRequestException('Invalid attachment id');
}
try {
const fileResponse = await this.attachmentService.uploadFile({
filePromise: file,
pageId: pageId,
spaceId: spaceId,
userId: user.id,
workspaceId: workspace.id,
attachmentId: attachmentId,
});
return res.send(fileResponse);
} catch (err: any) {
if (err?.statusCode === 413) {
const errMessage = `File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`;
this.logger.error(errMessage);
throw new BadRequestException(errMessage);
}
this.logger.error(err);
throw new BadRequestException('Error processing file upload.');
}
}
@UseGuards(JwtAuthGuard)
@Get('/files/:fileId/:fileName')
async getFile(
@Res() res: FastifyReply,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
@Param('fileId') fileId: string,
@Param('fileName') fileName?: string,
) {
if (!isValidUUID(fileId)) {
throw new NotFoundException('Invalid file id');
}
const attachment = await this.attachmentRepo.findById(fileId);
if (
!attachment ||
attachment.workspaceId !== workspace.id ||
!attachment.pageId ||
!attachment.spaceId
) {
if (!isValidUUID(fileId)) {
throw new NotFoundException('Invalid file id');
}
throw new NotFoundException();
}
const attachment = await this.attachmentRepo.findById(fileId);
if (
!attachment ||
attachment.workspaceId !== workspace.id ||
!attachment.pageId ||
!attachment.spaceId
) {
throw new NotFoundException();
}
const spaceAbility = await this.spaceAbility.createForUser(
user,
attachment.spaceId,
);
const spaceAbility = await this.spaceAbility.createForUser(
user,
attachment.spaceId,
if (spaceAbility.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
try {
const fileStream = await this.storageService.read(attachment.filePath);
res.headers({
'Content-Type': attachment.mimeType,
'Cache-Control': 'private, max-age=3600',
});
if (!inlineFileExtensions.includes(attachment.fileExt)) {
res.header(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(attachment.fileName)}"`,
);
}
if (spaceAbility.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
return res.send(fileStream);
} catch (err) {
this.logger.error(err);
throw new NotFoundException('File not found');
}
}
try {
const fileStream = await this.storageService.read(attachment.filePath);
res.headers({
'Content-Type': attachment.mimeType,
'Cache-Control': 'private, max-age=3600',
});
if (!inlineFileExtensions.includes(attachment.fileExt)) {
res.header(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(attachment.fileName)}"`,
);
}
return res.send(fileStream);
} catch (err) {
this.logger.error(err);
throw new NotFoundException('File not found');
}
@Get('/files/public/:fileId/:fileName')
async getPublicFile(
@Res() res: FastifyReply,
@AuthWorkspace() workspace: Workspace,
@Param('fileId') fileId: string,
@Param('fileName') fileName?: string,
@Query('jwt') jwtToken?: string,
) {
let jwtPayload: JwtAttachmentPayload = null;
try {
jwtPayload = await this.tokenService.verifyJwt(
jwtToken,
JwtType.ATTACHMENT,
);
} catch (err) {
throw new BadRequestException(
'Expired or invalid attachment access token',
);
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('attachments/upload-image')
@UseInterceptors(FileInterceptor)
async uploadAvatarOrLogo(
@Req() req: any,
@Res() res: FastifyReply,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
if (
!isValidUUID(fileId) ||
fileId !== jwtPayload.attachmentId ||
jwtPayload.workspaceId !== workspace.id
) {
const maxFileSize = bytes(MAX_AVATAR_SIZE);
let file = null;
try {
file = await req.file({
limits: {fileSize: maxFileSize, fields: 3, files: 1},
});
} catch (err: any) {
if (err?.statusCode === 413) {
throw new BadRequestException(
`File too large. Exceeds the ${MAX_AVATAR_SIZE} limit`,
);
}
}
if (!file) {
throw new BadRequestException('Invalid file upload');
}
const attachmentType = file.fields?.type?.value;
const spaceId = file.fields?.spaceId?.value;
if (!attachmentType) {
throw new BadRequestException('attachment type is required');
}
if (
!validAttachmentTypes.includes(attachmentType) ||
attachmentType === AttachmentType.File
) {
throw new BadRequestException('Invalid image attachment type');
}
if (attachmentType === AttachmentType.WorkspaceLogo) {
const ability = this.workspaceAbility.createForUser(user, workspace);
if (
ability.cannot(
WorkspaceCaslAction.Manage,
WorkspaceCaslSubject.Settings,
)
) {
throw new ForbiddenException();
}
}
if (attachmentType === AttachmentType.SpaceLogo) {
if (!spaceId) {
throw new BadRequestException('spaceId is required');
}
const spaceAbility = await this.spaceAbility.createForUser(user, spaceId);
if (
spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)
) {
throw new ForbiddenException();
}
}
try {
const fileResponse = await this.attachmentService.uploadImage(
file,
attachmentType,
user.id,
workspace.id,
spaceId,
);
return res.send(fileResponse);
} catch (err: any) {
this.logger.error(err);
throw new BadRequestException('Error processing file upload.');
}
throw new NotFoundException('File not found');
}
@Get('attachments/img/:attachmentType/:fileName')
async getLogoOrAvatar(
@Res() res: FastifyReply,
@AuthWorkspace() workspace: Workspace,
@Param('attachmentType') attachmentType: AttachmentType,
@Param('fileName') fileName?: string,
const attachment = await this.attachmentRepo.findById(fileId);
if (
!attachment ||
attachment.workspaceId !== workspace.id ||
!attachment.pageId ||
!attachment.spaceId ||
jwtPayload.pageId !== attachment.pageId
) {
if (
!validAttachmentTypes.includes(attachmentType) ||
attachmentType === AttachmentType.File
) {
throw new BadRequestException('Invalid image attachment type');
}
const filePath = `${getAttachmentFolderPath(attachmentType, workspace.id)}/${fileName}`;
try {
const fileStream = await this.storageService.read(filePath);
res.headers({
'Content-Type': getMimeType(filePath),
'Cache-Control': 'private, max-age=86400',
});
return res.send(fileStream);
} catch (err) {
this.logger.error(err);
throw new NotFoundException('File not found');
}
throw new NotFoundException('File not found');
}
try {
const fileStream = await this.storageService.read(attachment.filePath);
res.headers({
'Content-Type': attachment.mimeType,
'Cache-Control': 'public, max-age=3600',
});
if (!inlineFileExtensions.includes(attachment.fileExt)) {
res.header(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(attachment.fileName)}"`,
);
}
return res.send(fileStream);
} catch (err) {
this.logger.error(err);
throw new NotFoundException('File not found');
}
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('attachments/upload-image')
@UseInterceptors(FileInterceptor)
async uploadAvatarOrLogo(
@Req() req: any,
@Res() res: FastifyReply,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const maxFileSize = bytes(MAX_AVATAR_SIZE);
let file = null;
try {
file = await req.file({
limits: { fileSize: maxFileSize, fields: 3, files: 1 },
});
} catch (err: any) {
if (err?.statusCode === 413) {
throw new BadRequestException(
`File too large. Exceeds the ${MAX_AVATAR_SIZE} limit`,
);
}
}
if (!file) {
throw new BadRequestException('Invalid file upload');
}
const attachmentType = file.fields?.type?.value;
const spaceId = file.fields?.spaceId?.value;
if (!attachmentType) {
throw new BadRequestException('attachment type is required');
}
if (
!validAttachmentTypes.includes(attachmentType) ||
attachmentType === AttachmentType.File
) {
throw new BadRequestException('Invalid image attachment type');
}
if (attachmentType === AttachmentType.WorkspaceLogo) {
const ability = this.workspaceAbility.createForUser(user, workspace);
if (
ability.cannot(
WorkspaceCaslAction.Manage,
WorkspaceCaslSubject.Settings,
)
) {
throw new ForbiddenException();
}
}
if (attachmentType === AttachmentType.SpaceLogo) {
if (!spaceId) {
throw new BadRequestException('spaceId is required');
}
const spaceAbility = await this.spaceAbility.createForUser(user, spaceId);
if (
spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)
) {
throw new ForbiddenException();
}
}
try {
const fileResponse = await this.attachmentService.uploadImage(
file,
attachmentType,
user.id,
workspace.id,
spaceId,
);
return res.send(fileResponse);
} catch (err: any) {
this.logger.error(err);
throw new BadRequestException('Error processing file upload.');
}
}
@Get('attachments/img/:attachmentType/:fileName')
async getLogoOrAvatar(
@Res() res: FastifyReply,
@AuthWorkspace() workspace: Workspace,
@Param('attachmentType') attachmentType: AttachmentType,
@Param('fileName') fileName?: string,
) {
if (
!validAttachmentTypes.includes(attachmentType) ||
attachmentType === AttachmentType.File
) {
throw new BadRequestException('Invalid image attachment type');
}
const filePath = `${getAttachmentFolderPath(attachmentType, workspace.id)}/${fileName}`;
try {
const fileStream = await this.storageService.read(filePath);
res.headers({
'Content-Type': getMimeType(filePath),
'Cache-Control': 'private, max-age=86400',
});
return res.send(fileStream);
} catch (err) {
this.logger.error(err);
throw new NotFoundException('File not found');
}
}
}

View File

@ -5,9 +5,10 @@ import { StorageModule } from '../../integrations/storage/storage.module';
import { UserModule } from '../user/user.module';
import { WorkspaceModule } from '../workspace/workspace.module';
import { AttachmentProcessor } from './processors/attachment.processor';
import { TokenModule } from '../auth/token.module';
@Module({
imports: [StorageModule, UserModule, WorkspaceModule],
imports: [StorageModule, UserModule, WorkspaceModule, TokenModule],
controllers: [AttachmentController],
providers: [AttachmentService, AttachmentProcessor],
})

View File

@ -2,6 +2,7 @@ export enum JwtType {
ACCESS = 'access',
COLLAB = 'collab',
EXCHANGE = 'exchange',
ATTACHMENT = 'attachment',
}
export type JwtPayload = {
sub: string;
@ -21,3 +22,11 @@ export type JwtExchangePayload = {
workspaceId: string;
type: 'exchange';
};
export type JwtAttachmentPayload = {
attachmentId: string;
pageId: string;
workspaceId: string;
type: 'attachment';
};

View File

@ -6,6 +6,7 @@ import {
import { JwtService } from '@nestjs/jwt';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
import {
JwtAttachmentPayload,
JwtCollabPayload,
JwtExchangePayload,
JwtPayload,
@ -59,6 +60,21 @@ export class TokenService {
return this.jwtService.sign(payload, { expiresIn: '10s' });
}
async generateAttachmentToken(opts: {
attachmentId: string;
pageId: string;
workspaceId: string;
}): Promise<string> {
const { attachmentId, pageId, workspaceId } = opts;
const payload: JwtAttachmentPayload = {
attachmentId: attachmentId,
pageId: pageId,
workspaceId: workspaceId,
type: JwtType.ATTACHMENT,
};
return this.jwtService.sign(payload, { expiresIn: '1h' });
}
async verifyJwt(token: string, tokenType: string) {
const payload = await this.jwtService.verifyAsync(token, {
secret: this.environmentService.getAppSecret(),

View File

@ -45,6 +45,7 @@ function buildSpaceAdminAbility() {
can(SpaceCaslAction.Manage, SpaceCaslSubject.Settings);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Member);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Page);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Share);
return build();
}
@ -55,6 +56,7 @@ function buildSpaceWriterAbility() {
can(SpaceCaslAction.Read, SpaceCaslSubject.Settings);
can(SpaceCaslAction.Read, SpaceCaslSubject.Member);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Page);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Share);
return build();
}
@ -65,5 +67,6 @@ function buildSpaceReaderAbility() {
can(SpaceCaslAction.Read, SpaceCaslSubject.Settings);
can(SpaceCaslAction.Read, SpaceCaslSubject.Member);
can(SpaceCaslAction.Read, SpaceCaslSubject.Page);
can(SpaceCaslAction.Read, SpaceCaslSubject.Share);
return build();
}

View File

@ -9,9 +9,11 @@ export enum SpaceCaslSubject {
Settings = 'settings',
Member = 'member',
Page = 'page',
Share = 'share',
}
export type ISpaceAbility =
| [SpaceCaslAction, SpaceCaslSubject.Settings]
| [SpaceCaslAction, SpaceCaslSubject.Member]
| [SpaceCaslAction, SpaceCaslSubject.Page];
| [SpaceCaslAction, SpaceCaslSubject.Page]
| [SpaceCaslAction, SpaceCaslSubject.Share];

View File

@ -15,6 +15,7 @@ import { SpaceModule } from './space/space.module';
import { GroupModule } from './group/group.module';
import { CaslModule } from './casl/casl.module';
import { DomainMiddleware } from '../common/middlewares/domain.middleware';
import { ShareModule } from './share/share.module';
@Module({
imports: [
@ -28,6 +29,7 @@ import { DomainMiddleware } from '../common/middlewares/domain.middleware';
SpaceModule,
GroupModule,
CaslModule,
ShareModule,
],
})
export class CoreModule implements NestModule {

View File

@ -0,0 +1,24 @@
import { IsString, IsNotEmpty } from 'class-validator';
export class CopyPageToSpaceDto {
@IsNotEmpty()
@IsString()
pageId: string;
@IsNotEmpty()
@IsString()
spaceId: string;
}
export type CopyPageMapEntry = {
newPageId: string;
newSlugId: string;
oldSlugId: string;
};
export type ICopyPageAttachment = {
newPageId: string,
oldPageId: string,
oldAttachmentId: string,
newAttachmentId: string,
};

View File

@ -1,4 +1,10 @@
import { IsString, IsOptional, MinLength, MaxLength } from 'class-validator';
import {
IsString,
IsOptional,
MinLength,
MaxLength,
IsNotEmpty,
} from 'class-validator';
export class MovePageDto {
@IsString()
@ -15,9 +21,11 @@ export class MovePageDto {
}
export class MovePageToSpaceDto {
@IsNotEmpty()
@IsString()
pageId: string;
@IsNotEmpty()
@IsString()
spaceId: string;
}

View File

@ -28,6 +28,7 @@ import {
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { RecentPageDto } from './dto/recent-page.dto';
import { CopyPageToSpaceDto } from './dto/copy-page.dto';
@UseGuards(JwtAuthGuard)
@Controller('pages')
@ -237,6 +238,36 @@ export class PageController {
return this.pageService.movePageToSpace(movedPage, dto.spaceId);
}
@HttpCode(HttpStatus.OK)
@Post('copy-to-space')
async copyPageToSpace(
@Body() dto: CopyPageToSpaceDto,
@AuthUser() user: User,
) {
const copiedPage = await this.pageRepo.findById(dto.pageId);
if (!copiedPage) {
throw new NotFoundException('Page to copy not found');
}
if (copiedPage.spaceId === dto.spaceId) {
throw new BadRequestException('Page is already in this space');
}
const abilities = await Promise.all([
this.spaceAbility.createForUser(user, copiedPage.spaceId),
this.spaceAbility.createForUser(user, dto.spaceId),
]);
if (
abilities.some((ability) =>
ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page),
)
) {
throw new ForbiddenException();
}
return this.pageService.copyPageToSpace(copiedPage, dto.spaceId, user);
}
@HttpCode(HttpStatus.OK)
@Post('move')
async movePage(@Body() dto: MovePageDto, @AuthUser() user: User) {

View File

@ -2,10 +2,12 @@ import { Module } from '@nestjs/common';
import { PageService } from './services/page.service';
import { PageController } from './page.controller';
import { PageHistoryService } from './services/page-history.service';
import { StorageModule } from '../../integrations/storage/storage.module';
@Module({
controllers: [PageController],
providers: [PageService, PageHistoryService],
exports: [PageService, PageHistoryService],
imports: [StorageModule]
})
export class PageModule {}

View File

@ -1,12 +1,13 @@
import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { CreatePageDto } from '../dto/create-page.dto';
import { UpdatePageDto } from '../dto/update-page.dto';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { Page } from '@docmost/db/types/entity.types';
import { InsertablePage, Page, User } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import {
executeWithPagination,
@ -21,13 +22,28 @@ import { DB } from '@docmost/db/types/db';
import { generateSlugId } from '../../../common/helpers';
import { executeTx } from '@docmost/db/utils';
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
import { v7 as uuid7 } from 'uuid';
import {
createYdocFromJson,
getAttachmentIds,
getProsemirrorContent,
isAttachmentNode,
removeMarkTypeFromDoc,
} from '../../../common/helpers/prosemirror/utils';
import { jsonToNode, jsonToText } from 'src/collaboration/collaboration.util';
import { CopyPageMapEntry, ICopyPageAttachment } from '../dto/copy-page.dto';
import { Node as PMNode } from '@tiptap/pm/model';
import { StorageService } from '../../../integrations/storage/storage.service';
@Injectable()
export class PageService {
private readonly logger = new Logger(PageService.name);
constructor(
private pageRepo: PageRepo,
private attachmentRepo: AttachmentRepo,
@InjectKysely() private readonly db: KyselyDB,
private readonly storageService: StorageService,
) {}
async findById(
@ -212,7 +228,7 @@ export class PageService {
trx,
);
const pageIds = await this.pageRepo
.getPageAndDescendants(rootPage.id)
.getPageAndDescendants(rootPage.id, { includeContent: false })
.then((pages) => pages.map((page) => page.id));
// The first id is the root page id
if (pageIds.length > 1) {
@ -223,6 +239,16 @@ export class PageService {
trx,
);
}
// update spaceId in shares
if (pageIds.length > 0) {
await trx
.updateTable('shares')
.set({ spaceId: spaceId })
.where('pageId', 'in', pageIds)
.execute();
}
// Update attachments
await this.attachmentRepo.updateAttachmentsByPageId(
{ spaceId },
@ -232,6 +258,154 @@ export class PageService {
});
}
async copyPageToSpace(rootPage: Page, spaceId: string, authUser: User) {
//TODO:
// i. maintain internal links within copied pages
const nextPosition = await this.nextPagePosition(spaceId);
const pages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
includeContent: true,
});
const pageMap = new Map<string, CopyPageMapEntry>();
pages.forEach((page) => {
pageMap.set(page.id, {
newPageId: uuid7(),
newSlugId: generateSlugId(),
oldSlugId: page.slugId,
});
});
const attachmentMap = new Map<string, ICopyPageAttachment>();
const insertablePages: InsertablePage[] = await Promise.all(
pages.map(async (page) => {
const pageContent = getProsemirrorContent(page.content);
const pageFromMap = pageMap.get(page.id);
const doc = jsonToNode(pageContent);
const prosemirrorDoc = removeMarkTypeFromDoc(doc, 'comment');
const attachmentIds = getAttachmentIds(prosemirrorDoc.toJSON());
if (attachmentIds.length > 0) {
attachmentIds.forEach((attachmentId: string) => {
const newPageId = pageFromMap.newPageId;
const newAttachmentId = uuid7();
attachmentMap.set(attachmentId, {
newPageId: newPageId,
oldPageId: page.id,
oldAttachmentId: attachmentId,
newAttachmentId: newAttachmentId,
});
prosemirrorDoc.descendants((node: PMNode) => {
if (isAttachmentNode(node.type.name)) {
if (node.attrs.attachmentId === attachmentId) {
//@ts-ignore
node.attrs.attachmentId = newAttachmentId;
if (node.attrs.src) {
//@ts-ignore
node.attrs.src = node.attrs.src.replace(
attachmentId,
newAttachmentId,
);
}
if (node.attrs.src) {
//@ts-ignore
node.attrs.src = node.attrs.src.replace(
attachmentId,
newAttachmentId,
);
}
}
}
});
});
}
const prosemirrorJson = prosemirrorDoc.toJSON();
return {
id: pageFromMap.newPageId,
slugId: pageFromMap.newSlugId,
title: page.title,
icon: page.icon,
content: prosemirrorJson,
textContent: jsonToText(prosemirrorJson),
ydoc: createYdocFromJson(prosemirrorJson),
position: page.id === rootPage.id ? nextPosition : page.position,
spaceId: spaceId,
workspaceId: page.workspaceId,
creatorId: authUser.id,
lastUpdatedById: authUser.id,
parentPageId: page.parentPageId
? pageMap.get(page.parentPageId)?.newPageId
: null,
};
}),
);
await this.db.insertInto('pages').values(insertablePages).execute();
//TODO: best to handle this in a queue
const attachmentsIds = Array.from(attachmentMap.keys());
if (attachmentsIds.length > 0) {
const attachments = await this.db
.selectFrom('attachments')
.selectAll()
.where('id', 'in', attachmentsIds)
.where('workspaceId', '=', rootPage.workspaceId)
.execute();
for (const attachment of attachments) {
try {
const pageAttachment = attachmentMap.get(attachment.id);
// make sure the copied attachment belongs to the page it was copied from
if (attachment.pageId !== pageAttachment.oldPageId) {
continue;
}
const newAttachmentId = pageAttachment.newAttachmentId;
const newPageId = pageAttachment.newPageId;
const newPathFile = attachment.filePath.replace(
attachment.id,
newAttachmentId,
);
await this.storageService.copy(attachment.filePath, newPathFile);
await this.db
.insertInto('attachments')
.values({
id: newAttachmentId,
type: attachment.type,
filePath: newPathFile,
fileName: attachment.fileName,
fileSize: attachment.fileSize,
mimeType: attachment.mimeType,
fileExt: attachment.fileExt,
creatorId: attachment.creatorId,
workspaceId: attachment.workspaceId,
pageId: newPageId,
spaceId: spaceId,
})
.execute();
} catch (err) {
this.logger.log(err);
}
}
}
const newPageId = pageMap.get(rootPage.id).newPageId;
return await this.pageRepo.findById(newPageId, {
includeSpace: true,
});
}
async movePage(dto: MovePageDto, movedPage: Page) {
// validate position value by attempting to generate a key
try {

View File

@ -5,8 +5,11 @@ import {
IsOptional,
IsString,
} from 'class-validator';
import { PartialType } from '@nestjs/mapped-types';
import { CreateWorkspaceDto } from '../../workspace/dto/create-workspace.dto';
export class SearchDTO {
@IsNotEmpty()
@IsString()
query: string;
@ -14,6 +17,10 @@ export class SearchDTO {
@IsString()
spaceId: string;
@IsOptional()
@IsString()
shareId?: string;
@IsOptional()
@IsString()
creatorId?: string;
@ -27,6 +34,16 @@ export class SearchDTO {
offset?: number;
}
export class SearchShareDTO extends SearchDTO {
@IsNotEmpty()
@IsString()
shareId: string;
@IsOptional()
@IsString()
spaceId: string;
}
export class SearchSuggestionDTO {
@IsString()
query: string;

View File

@ -1,15 +1,19 @@
import {
BadRequestException,
Body,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
NotImplementedException,
Post,
UseGuards,
} from '@nestjs/common';
import { SearchService } from './search.service';
import { SearchDTO, SearchSuggestionDTO } from './dto/search.dto';
import {
SearchDTO,
SearchShareDTO,
SearchSuggestionDTO,
} from './dto/search.dto';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { User, Workspace } from '@docmost/db/types/entity.types';
@ -19,6 +23,7 @@ import {
SpaceCaslSubject,
} from '../casl/interfaces/space-ability.type';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { Public } from 'src/common/decorators/public.decorator';
@UseGuards(JwtAuthGuard)
@Controller('search')
@ -30,7 +35,13 @@ export class SearchController {
@HttpCode(HttpStatus.OK)
@Post()
async pageSearch(@Body() searchDto: SearchDTO, @AuthUser() user: User) {
async pageSearch(
@Body() searchDto: SearchDTO,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
delete searchDto.shareId;
if (searchDto.spaceId) {
const ability = await this.spaceAbility.createForUser(
user,
@ -40,12 +51,12 @@ export class SearchController {
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
return this.searchService.searchPage(searchDto.query, searchDto);
}
// TODO: search all spaces user is a member of if no spaceId provided
throw new NotImplementedException();
return this.searchService.searchPage(searchDto.query, searchDto, {
userId: user.id,
workspaceId: workspace.id,
});
}
@HttpCode(HttpStatus.OK)
@ -57,4 +68,21 @@ export class SearchController {
) {
return this.searchService.searchSuggestions(dto, user.id, workspace.id);
}
@Public()
@HttpCode(HttpStatus.OK)
@Post('share-search')
async searchShare(
@Body() searchDto: SearchShareDTO,
@AuthWorkspace() workspace: Workspace,
) {
delete searchDto.spaceId;
if (!searchDto.shareId) {
throw new BadRequestException('shareId is required');
}
return this.searchService.searchPage(searchDto.query, searchDto, {
workspaceId: workspace.id,
});
}
}

View File

@ -6,6 +6,7 @@ import { KyselyDB } from '@docmost/db/types/kysely.types';
import { sql } from 'kysely';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const tsquery = require('pg-tsquery')();
@ -15,19 +16,24 @@ export class SearchService {
constructor(
@InjectKysely() private readonly db: KyselyDB,
private pageRepo: PageRepo,
private shareRepo: ShareRepo,
private spaceMemberRepo: SpaceMemberRepo,
) {}
async searchPage(
query: string,
searchParams: SearchDTO,
opts: {
userId?: string;
workspaceId: string;
},
): Promise<SearchResponseDto[]> {
if (query.length < 1) {
return;
}
const searchQuery = tsquery(query.trim() + '*');
const queryResults = await this.db
let queryResults = this.db
.selectFrom('pages')
.select([
'id',
@ -43,18 +49,71 @@ export class SearchService {
'highlight',
),
])
.select((eb) => this.pageRepo.withSpace(eb))
.where('spaceId', '=', searchParams.spaceId)
.where('tsv', '@@', sql<string>`to_tsquery(${searchQuery})`)
.$if(Boolean(searchParams.creatorId), (qb) =>
qb.where('creatorId', '=', searchParams.creatorId),
)
.orderBy('rank', 'desc')
.limit(searchParams.limit | 20)
.offset(searchParams.offset || 0)
.execute();
.offset(searchParams.offset || 0);
const searchResults = queryResults.map((result) => {
if (!searchParams.shareId) {
queryResults = queryResults.select((eb) => this.pageRepo.withSpace(eb));
}
if (searchParams.spaceId) {
// search by spaceId
queryResults = queryResults.where('spaceId', '=', searchParams.spaceId);
} else if (opts.userId && !searchParams.spaceId) {
// only search spaces the user is a member of
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(
opts.userId,
);
if (userSpaceIds.length > 0) {
queryResults = queryResults
.where('spaceId', 'in', userSpaceIds)
.where('workspaceId', '=', opts.workspaceId);
} else {
return [];
}
} else if (searchParams.shareId && !searchParams.spaceId && !opts.userId) {
// search in shares
const shareId = searchParams.shareId;
const share = await this.shareRepo.findById(shareId);
if (!share || share.workspaceId !== opts.workspaceId) {
return [];
}
const pageIdsToSearch = [];
if (share.includeSubPages) {
const pageList = await this.pageRepo.getPageAndDescendants(
share.pageId,
{
includeContent: false,
},
);
pageIdsToSearch.push(...pageList.map((page) => page.id));
} else {
pageIdsToSearch.push(share.pageId);
}
if (pageIdsToSearch.length > 0) {
queryResults = queryResults
.where('id', 'in', pageIdsToSearch)
.where('workspaceId', '=', opts.workspaceId);
} else {
return [];
}
} else {
return [];
}
//@ts-ignore
queryResults = await queryResults.execute();
//@ts-ignore
const searchResults = queryResults.map((result: SearchResponseDto) => {
if (result.highlight) {
result.highlight = result.highlight
.replace(/\r\n|\r|\n/g, ' ')

View File

@ -0,0 +1,58 @@
import {
IsBoolean,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
export class CreateShareDto {
@IsString()
@IsNotEmpty()
pageId: string;
@IsBoolean()
@IsOptional()
includeSubPages: boolean;
@IsOptional()
@IsBoolean()
searchIndexing: boolean;
}
export class UpdateShareDto extends CreateShareDto {
@IsString()
@IsNotEmpty()
shareId: string;
@IsString()
@IsOptional()
pageId: string;
}
export class ShareIdDto {
@IsString()
@IsNotEmpty()
shareId: string;
}
export class SpaceIdDto {
@IsUUID()
spaceId: string;
}
export class ShareInfoDto {
@IsString()
@IsOptional()
shareId?: string;
@IsString()
@IsOptional()
pageId: string;
}
export class SharePageIdDto {
@IsString()
@IsNotEmpty()
pageId: string;
}

View File

@ -0,0 +1,109 @@
import { Controller, Get, Param, Req, Res } from '@nestjs/common';
import { ShareService } from './share.service';
import { FastifyReply, FastifyRequest } from 'fastify';
import { join } from 'path';
import * as fs from 'node:fs';
import { validate as isValidUUID } from 'uuid';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { Workspace } from '@docmost/db/types/entity.types';
@Controller('share')
export class ShareSeoController {
constructor(
private readonly shareService: ShareService,
private workspaceRepo: WorkspaceRepo,
private environmentService: EnvironmentService,
) {}
/*
* add meta tags to publicly shared pages
*/
@Get([':shareId/p/:pageSlug', 'p/:pageSlug'])
async getShare(
@Res({ passthrough: false }) res: FastifyReply,
@Req() req: FastifyRequest,
@Param('shareId') shareId: string,
@Param('pageSlug') pageSlug: string,
) {
// Nestjs does not to apply middlewares to paths excluded from the global /api prefix
// https://github.com/nestjs/nest/issues/9124
// https://github.com/nestjs/nest/issues/11572
// https://github.com/nestjs/nest/issues/13401
// we have to duplicate the DomainMiddleware code here as a workaround
let workspace: Workspace = null;
if (this.environmentService.isSelfHosted()) {
workspace = await this.workspaceRepo.findFirst();
} else {
const header = req.raw.headers.host;
const subdomain = header.split('.')[0];
workspace = await this.workspaceRepo.findByHostname(subdomain);
}
const clientDistPath = join(
__dirname,
'..',
'..',
'..',
'..',
'client/dist',
);
if (fs.existsSync(clientDistPath)) {
const indexFilePath = join(clientDistPath, 'index.html');
if (!workspace) {
return this.sendIndex(indexFilePath, res);
}
const pageId = this.extractPageSlugId(pageSlug);
const share = await this.shareService.getShareForPage(
pageId,
workspace.id,
);
if (!share) {
return this.sendIndex(indexFilePath, res);
}
const rawTitle = share.sharedPage.title ?? 'untitled';
const metaTitle =
rawTitle.length > 80 ? `${rawTitle.slice(0, 77)}` : rawTitle;
const metaTagVar = '<!--meta-tags-->';
const metaTags = [
`<meta property="og:title" content="${metaTitle}" />`,
`<meta property="twitter:title" content="${metaTitle}" />`,
!share.searchIndexing ? `<meta name="robots" content="noindex" />` : '',
]
.filter(Boolean)
.join('\n ');
const html = fs.readFileSync(indexFilePath, 'utf8');
const transformedHtml = html
.replace(/<title>[\s\S]*?<\/title>/i, `<title>${metaTitle}</title>`)
.replace(metaTagVar, metaTags);
res.type('text/html').send(transformedHtml);
}
}
sendIndex(indexFilePath: string, res: FastifyReply) {
const stream = fs.createReadStream(indexFilePath);
res.type('text/html').send(stream);
}
extractPageSlugId(slug: string): string {
if (!slug) {
return undefined;
}
if (isValidUUID(slug)) {
return slug;
}
const parts = slug.split('-');
return parts.length > 1 ? parts[parts.length - 1] : slug;
}
}

View File

@ -0,0 +1,171 @@
import {
BadRequestException,
Body,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
NotFoundException,
Post,
UseGuards,
} from '@nestjs/common';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../casl/interfaces/space-ability.type';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
import { ShareService } from './share.service';
import {
CreateShareDto,
ShareIdDto,
ShareInfoDto,
SharePageIdDto,
UpdateShareDto,
} from './dto/share.dto';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { Public } from '../../common/decorators/public.decorator';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
@UseGuards(JwtAuthGuard)
@Controller('shares')
export class ShareController {
constructor(
private readonly shareService: ShareService,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly shareRepo: ShareRepo,
private readonly pageRepo: PageRepo,
) {}
@HttpCode(HttpStatus.OK)
@Post('/')
async getShares(
@AuthUser() user: User,
@Body() pagination: PaginationOptions,
) {
return this.shareRepo.getShares(user.id, pagination);
}
@Public()
@HttpCode(HttpStatus.OK)
@Post('/page-info')
async getSharedPageInfo(
@Body() dto: ShareInfoDto,
@AuthWorkspace() workspace: Workspace,
) {
if (!dto.pageId && !dto.shareId) {
throw new BadRequestException();
}
return this.shareService.getSharedPage(dto, workspace.id);
}
@Public()
@HttpCode(HttpStatus.OK)
@Post('/info')
async getShare(@Body() dto: ShareIdDto) {
const share = await this.shareRepo.findById(dto.shareId, {
includeSharedPage: true,
});
if (!share) {
throw new NotFoundException('Share not found');
}
return share;
}
@HttpCode(HttpStatus.OK)
@Post('/for-page')
async getShareForPage(
@Body() dto: SharePageIdDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const page = await this.pageRepo.findById(dto.pageId);
if (!page) {
throw new NotFoundException('Shared page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Share)) {
throw new ForbiddenException();
}
return this.shareService.getShareForPage(page.id, workspace.id);
}
@HttpCode(HttpStatus.OK)
@Post('create')
async create(
@Body() createShareDto: CreateShareDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const page = await this.pageRepo.findById(createShareDto.pageId);
if (!page || workspace.id !== page.workspaceId) {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Share)) {
throw new ForbiddenException();
}
return this.shareService.createShare({
page,
authUserId: user.id,
workspaceId: workspace.id,
createShareDto,
});
}
@HttpCode(HttpStatus.OK)
@Post('update')
async update(@Body() updateShareDto: UpdateShareDto, @AuthUser() user: User) {
const share = await this.shareRepo.findById(updateShareDto.shareId);
if (!share) {
throw new NotFoundException('Share not found');
}
const ability = await this.spaceAbility.createForUser(user, share.spaceId);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Share)) {
throw new ForbiddenException();
}
return this.shareService.updateShare(share.id, updateShareDto);
}
@HttpCode(HttpStatus.OK)
@Post('delete')
async delete(@Body() shareIdDto: ShareIdDto, @AuthUser() user: User) {
const share = await this.shareRepo.findById(shareIdDto.shareId);
if (!share) {
throw new NotFoundException('Share not found');
}
const ability = await this.spaceAbility.createForUser(user, share.spaceId);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Share)) {
throw new ForbiddenException();
}
await this.shareRepo.deleteShare(share.id);
}
@Public()
@HttpCode(HttpStatus.OK)
@Post('/tree')
async getSharePageTree(
@Body() dto: ShareIdDto,
@AuthWorkspace() workspace: Workspace,
) {
return this.shareService.getShareTree(dto.shareId, workspace.id);
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { ShareController } from './share.controller';
import { ShareService } from './share.service';
import { TokenModule } from '../auth/token.module';
import { ShareSeoController } from './share-seo.controller';
@Module({
imports: [TokenModule],
controllers: [ShareController, ShareSeoController],
providers: [ShareService],
exports: [ShareService],
})
export class ShareModule {}

View File

@ -0,0 +1,295 @@
import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { CreateShareDto, ShareInfoDto, UpdateShareDto } from './dto/share.dto';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { nanoIdGen } from '../../common/helpers';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { TokenService } from '../auth/services/token.service';
import { jsonToNode } from '../../collaboration/collaboration.util';
import {
getAttachmentIds,
getProsemirrorContent,
isAttachmentNode,
removeMarkTypeFromDoc,
} from '../../common/helpers/prosemirror/utils';
import { Node } from '@tiptap/pm/model';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { updateAttachmentAttr } from './share.util';
import { Page } from '@docmost/db/types/entity.types';
import { validate as isValidUUID } from 'uuid';
import { sql } from 'kysely';
@Injectable()
export class ShareService {
private readonly logger = new Logger(ShareService.name);
constructor(
private readonly shareRepo: ShareRepo,
private readonly pageRepo: PageRepo,
@InjectKysely() private readonly db: KyselyDB,
private readonly tokenService: TokenService,
) {}
async getShareTree(shareId: string, workspaceId: string) {
const share = await this.shareRepo.findById(shareId);
if (!share || share.workspaceId !== workspaceId) {
throw new NotFoundException('Share not found');
}
if (share.includeSubPages) {
const pageList = await this.pageRepo.getPageAndDescendants(share.pageId, {
includeContent: false,
});
return { share, pageTree: pageList };
} else {
return { share, pageTree: [] };
}
}
async createShare(opts: {
authUserId: string;
workspaceId: string;
page: Page;
createShareDto: CreateShareDto;
}) {
const { authUserId, workspaceId, page, createShareDto } = opts;
try {
const shares = await this.shareRepo.findByPageId(page.id);
if (shares) {
return shares;
}
return await this.shareRepo.insertShare({
key: nanoIdGen().toLowerCase(),
pageId: page.id,
includeSubPages: createShareDto.includeSubPages || true,
searchIndexing: createShareDto.searchIndexing || true,
creatorId: authUserId,
spaceId: page.spaceId,
workspaceId,
});
} catch (err) {
this.logger.error(err);
throw new BadRequestException('Failed to share page');
}
}
async updateShare(shareId: string, updateShareDto: UpdateShareDto) {
try {
return this.shareRepo.updateShare(
{
includeSubPages: updateShareDto.includeSubPages,
searchIndexing: updateShareDto.searchIndexing,
},
shareId,
);
} catch (err) {
this.logger.error(err);
throw new BadRequestException('Failed to update share');
}
}
async getSharedPage(dto: ShareInfoDto, workspaceId: string) {
const share = await this.getShareForPage(dto.pageId, workspaceId);
if (!share) {
throw new NotFoundException('Shared page not found');
}
const page = await this.pageRepo.findById(dto.pageId, {
includeContent: true,
includeCreator: true,
});
page.content = await this.updatePublicAttachments(page);
if (!page) {
throw new NotFoundException('Shared page not found');
}
return { page, share };
}
async getShareForPage(pageId: string, workspaceId: string) {
// here we try to check if a page was shared directly or if it inherits the share from its closest shared ancestor
const share = await this.db
.withRecursive('page_hierarchy', (cte) =>
cte
.selectFrom('pages')
.select([
'id',
'slugId',
'pages.title',
'pages.icon',
'parentPageId',
sql`0`.as('level'),
])
.where(isValidUUID(pageId) ? 'id' : 'slugId', '=', pageId)
.unionAll((union) =>
union
.selectFrom('pages as p')
.select([
'p.id',
'p.slugId',
'p.title',
'p.icon',
'p.parentPageId',
// Increase the level by 1 for each ancestor.
sql`ph.level + 1`.as('level'),
])
.innerJoin('page_hierarchy as ph', 'ph.parentPageId', 'p.id'),
),
)
.selectFrom('page_hierarchy')
.leftJoin('shares', 'shares.pageId', 'page_hierarchy.id')
.select([
'page_hierarchy.id as sharedPageId',
'page_hierarchy.slugId as sharedPageSlugId',
'page_hierarchy.title as sharedPageTitle',
'page_hierarchy.icon as sharedPageIcon',
'page_hierarchy.level as level',
'shares.id',
'shares.key',
'shares.pageId',
'shares.includeSubPages',
'shares.searchIndexing',
'shares.creatorId',
'shares.spaceId',
'shares.workspaceId',
'shares.createdAt',
'shares.updatedAt',
])
.where('shares.id', 'is not', null)
.orderBy('page_hierarchy.level', 'asc')
.executeTakeFirst();
if (!share || share.workspaceId != workspaceId) {
return undefined;
}
if (share.level === 1 && !share.includeSubPages) {
// we can only show a page if its shared ancestor permits it
return undefined;
}
return {
id: share.id,
key: share.key,
includeSubPages: share.includeSubPages,
searchIndexing: share.searchIndexing,
pageId: share.pageId,
creatorId: share.creatorId,
spaceId: share.spaceId,
workspaceId: share.workspaceId,
createdAt: share.createdAt,
level: share.level,
sharedPage: {
id: share.sharedPageId,
slugId: share.sharedPageSlugId,
title: share.sharedPageTitle,
icon: share.sharedPageIcon,
},
};
}
async getShareAncestorPage(
ancestorPageId: string,
childPageId: string,
): Promise<any> {
let ancestor = null;
try {
ancestor = await this.db
.withRecursive('page_ancestors', (db) =>
db
.selectFrom('pages')
.select([
'id',
'slugId',
'title',
'parentPageId',
'spaceId',
(eb) =>
eb
.case()
.when(eb.ref('id'), '=', ancestorPageId)
.then(true)
.else(false)
.end()
.as('found'),
])
.where(isValidUUID(childPageId) ? 'id' : 'slugId', '=', childPageId)
.unionAll((exp) =>
exp
.selectFrom('pages as p')
.select([
'p.id',
'p.slugId',
'p.title',
'p.parentPageId',
'p.spaceId',
(eb) =>
eb
.case()
.when(eb.ref('p.id'), '=', ancestorPageId)
.then(true)
.else(false)
.end()
.as('found'),
])
.innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id')
// Continue recursing only when the target ancestor hasn't been found on that branch.
.where('pa.found', '=', false),
),
)
.selectFrom('page_ancestors')
.selectAll()
.where('found', '=', true)
.limit(1)
.executeTakeFirst();
} catch (err) {
// empty
}
return ancestor;
}
async updatePublicAttachments(page: Page): Promise<any> {
const prosemirrorJson = getProsemirrorContent(page.content);
const attachmentIds = getAttachmentIds(prosemirrorJson);
const attachmentMap = new Map<string, string>();
await Promise.all(
attachmentIds.map(async (attachmentId: string) => {
const token = await this.tokenService.generateAttachmentToken({
attachmentId,
pageId: page.id,
workspaceId: page.workspaceId,
});
attachmentMap.set(attachmentId, token);
}),
);
const doc = jsonToNode(prosemirrorJson);
doc?.descendants((node: Node) => {
if (!isAttachmentNode(node.type.name)) return;
const attachmentId = node.attrs.attachmentId;
const token = attachmentMap.get(attachmentId);
if (!token) return;
updateAttachmentAttr(node, 'src', token);
updateAttachmentAttr(node, 'url', token);
});
const removeCommentMarks = removeMarkTypeFromDoc(doc, 'comment');
return removeCommentMarks.toJSON();
}
}

View File

@ -0,0 +1,22 @@
import { Node } from '@tiptap/pm/model';
export function updateAttachmentAttr(
node: Node,
attr: 'src' | 'url',
token: string,
) {
const attrVal = node.attrs[attr];
if (
attrVal &&
(attrVal.startsWith('/files') || attrVal.startsWith('/api/files'))
) {
// @ts-ignore
node.attrs[attr] = updateAttachmentUrl(attrVal, token);
}
}
function updateAttachmentUrl(src: string, jwtToken: string) {
const updatedSrc = src.replace('/files/', '/files/public/');
const separator = updatedSrc.includes('?') ? '&' : '?';
return `${updatedSrc}${separator}jwt=${jwtToken}`;
}

View File

@ -24,6 +24,7 @@ import * as process from 'node:process';
import { MigrationService } from '@docmost/db/services/migration.service';
import { UserTokenRepo } from './repos/user-token/user-token.repo';
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
// https://github.com/brianc/node-postgres/issues/811
types.setTypeParser(types.builtins.INT8, (val) => Number(val));
@ -74,6 +75,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
AttachmentRepo,
UserTokenRepo,
BacklinkRepo,
ShareRepo
],
exports: [
WorkspaceRepo,
@ -88,6 +90,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
AttachmentRepo,
UserTokenRepo,
BacklinkRepo,
ShareRepo
],
})
export class DatabaseModule

View File

@ -0,0 +1,38 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('shares')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('key', 'varchar', (col) => col.notNull())
.addColumn('page_id', 'uuid', (col) =>
col.references('pages.id').onDelete('cascade'),
)
.addColumn('include_sub_pages', 'boolean', (col) => col.defaultTo(false))
.addColumn('search_indexing', 'boolean', (col) => col.defaultTo(false))
.addColumn('creator_id', 'uuid', (col) => col.references('users.id'))
.addColumn('space_id', 'uuid', (col) =>
col.references('spaces.id').onDelete('cascade').notNull(),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade').notNull(),
)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('deleted_at', 'timestamptz', (col) => col)
.addUniqueConstraint('shares_key_workspace_id_unique', [
'key',
'workspace_id',
])
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('shares').execute();
}

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