Compare commits

..

1 Commits

Author SHA1 Message Date
b52dd5245b switch from ref to useState 2025-04-20 20:24:43 +01:00
133 changed files with 1037 additions and 4878 deletions

View File

@ -4,15 +4,14 @@
Open-source collaborative wiki and documentation software. Open-source collaborative wiki and documentation software.
<br /> <br />
<a href="https://docmost.com"><strong>Website</strong></a> | <a href="https://docmost.com"><strong>Website</strong></a> |
<a href="https://docmost.com/docs"><strong>Documentation</strong></a> | <a href="https://docmost.com/docs"><strong>Documentation</strong></a>
<a href="https://twitter.com/DocmostHQ"><strong>Twitter / X</strong></a>
</p> </p>
</div> </div>
<br /> <br />
## Getting started ## Getting started
To get started with Docmost, please refer to our [documentation](https://docmost.com/docs) or try our [cloud version](https://docmost.com/pricing) . To get started with Docmost, please refer to our [documentation](https://docmost.com/docs).
## Features ## Features
@ -47,16 +46,3 @@ All files in the following directories are licensed under the Docmost Enterprise
### Contributing ### Contributing
See the [development documentation](https://docmost.com/docs/self-hosting/development) See the [development documentation](https://docmost.com/docs/self-hosting/development)
## Thanks
Special thanks to;
<img width="100" alt="Crowdin" src="https://github.com/user-attachments/assets/a6c3d352-e41b-448d-b6cd-3fbca3109f07" />
[Crowdin](https://crowdin.com/) for providing access to their localization platform.
<img width="48" alt="Algolia-mark-square-white" src="https://github.com/user-attachments/assets/6ccad04a-9589-4965-b6a1-d5cb1f4f9e94" />
[Algolia](https://www.algolia.com/) for providing full-text search to the docs.

View File

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

View File

@ -1,7 +1,7 @@
{ {
"name": "client", "name": "client",
"private": true, "private": true,
"version": "0.20.4", "version": "0.10.2",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
@ -25,7 +25,7 @@
"@tabler/icons-react": "^3.22.0", "@tabler/icons-react": "^3.22.0",
"@tanstack/react-query": "^5.61.4", "@tanstack/react-query": "^5.61.4",
"@tiptap/extension-character-count": "^2.11.5", "@tiptap/extension-character-count": "^2.11.5",
"axios": "^1.8.4", "axios": "^1.7.9",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"emoji-mart": "^5.6.0", "emoji-mart": "^5.6.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
@ -63,7 +63,7 @@
"@types/node": "22.10.0", "@types/node": "22.10.0",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.4.1", "@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.15.0", "eslint": "^9.15.0",
"eslint-plugin-react": "^7.37.2", "eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-hooks": "^5.1.0",
@ -76,6 +76,6 @@
"prettier": "^3.4.1", "prettier": "^3.4.1",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"typescript-eslint": "^8.17.0", "typescript-eslint": "^8.17.0",
"vite": "^6.3.2" "vite": "^6.1.0"
} }
} }

View File

@ -362,26 +362,5 @@
"Move page to a different space.": "Seite in einen anderen Bereich verschieben.", "Move page to a different space.": "Seite in einen anderen Bereich verschieben.",
"Real-time editor connection lost. Retrying...": "Echtzeit-Editor-Verbindung verloren. Wiederholen...", "Real-time editor connection lost. Retrying...": "Echtzeit-Editor-Verbindung verloren. Wiederholen...",
"Table of contents": "Inhaltsverzeichnis", "Table of contents": "Inhaltsverzeichnis",
"Add headings (H1, H2, H3) to generate a table of contents.": "Fügen Sie Überschriften (H1, H2, H3) hinzu, um ein Inhaltsverzeichnis zu erstellen.", "Add headings (H1, H2, H3) to generate a table of contents.": "Fügen Sie Überschriften (H1, H2, H3) hinzu, um ein Inhaltsverzeichnis zu erstellen."
"Share": "Teilen",
"Public sharing": "Öffentliches Teilen",
"Shared by": "Geteilt von",
"Shared at": "Geteilt am",
"Inherits public sharing from": "Erbt das öffentliche Teilen von",
"Share to web": "Im Web teilen",
"Shared to web": "Im Web geteilt",
"Anyone with the link can view this page": "Jeder mit dem Link kann diese Seite ansehen",
"Make this page publicly accessible": "Diese Seite öffentlich zugänglich machen",
"Include sub-pages": "Unterseiten einbeziehen",
"Make sub-pages public too": "Unterseiten auch öffentlich machen",
"Allow search engines to index page": "Suchmaschinen erlauben, die Seite zu indexieren",
"Open page": "Seite öffnen",
"Page": "Seite",
"Delete public share link": "Öffentlichen Freigabelink löschen",
"Delete share": "Freigabe löschen",
"Are you sure you want to delete this shared link?": "Möchten Sie diesen Freigabelink wirklich löschen?",
"Publicly shared pages from spaces you are a member of will appear here": "Öffentlich geteilte Seiten aus Bereichen, in denen Sie Mitglied sind, erscheinen hier",
"Share deleted successfully": "Freigabe erfolgreich gelöscht",
"Share not found": "Freigabe nicht gefunden",
"Failed to share page": "Fehler beim Teilen der Seite"
} }

View File

@ -362,29 +362,5 @@
"Move page to a different space.": "Move page to a different space.", "Move page to a different space.": "Move page to a different space.",
"Real-time editor connection lost. Retrying...": "Real-time editor connection lost. Retrying...", "Real-time editor connection lost. Retrying...": "Real-time editor connection lost. Retrying...",
"Table of contents": "Table of contents", "Table of contents": "Table of contents",
"Add headings (H1, H2, H3) to generate a table of contents.": "Add headings (H1, H2, H3) to generate a table of contents.", "Add headings (H1, H2, H3) to generate a table of contents.": "Add headings (H1, H2, H3) to generate a table of contents."
"Share": "Share",
"Public sharing": "Public sharing",
"Shared by": "Shared by",
"Shared at": "Shared at",
"Inherits public sharing from": "Inherits public sharing from",
"Share to web": "Share to web",
"Shared to web": "Shared to web",
"Anyone with the link can view this page": "Anyone with the link can view this page",
"Make this page publicly accessible": "Make this page publicly accessible",
"Include sub-pages": "Include sub-pages",
"Make sub-pages public too": "Make sub-pages public too",
"Allow search engines to index page": "Allow search engines to index page",
"Open page": "Open page",
"Page": "Page",
"Delete public share link": "Delete public share link",
"Delete share": "Delete share",
"Are you sure you want to delete this shared link?": "Are you sure you want to delete this shared link?",
"Publicly shared pages from spaces you are a member of will appear here": "Publicly shared pages from spaces you are a member of will appear here",
"Share deleted successfully": "Share deleted successfully",
"Share not found": "Share not found",
"Failed to share page": "Failed to share page",
"Copy page": "Copy page",
"Copy page to a different space.": "Copy page to a different space.",
"Page copied successfully": "Page copied successfully"
} }

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", "Invited members will be granted access to spaces the groups can access": "Los miembros invitados recibirán acceso a los espacios a los que los grupos pueden acceder",
"Join the workspace": "Unirse al espacio de trabajo", "Join the workspace": "Unirse al espacio de trabajo",
"Language": "Idioma", "Language": "Idioma",
"Light": "Claro", "Light": "Ligero",
"Link copied": "Enlace copiado", "Link copied": "Enlace copiado",
"Login": "Iniciar sesión", "Login": "Iniciar sesión",
"Logout": "Cerrar sesión", "Logout": "Cerrar sesión",
@ -362,26 +362,5 @@
"Move page to a different space.": "Mover página a un espacio diferente.", "Move page to a different space.": "Mover página a un espacio diferente.",
"Real-time editor connection lost. Retrying...": "Conexión del editor en tiempo real perdida. Reintentando...", "Real-time editor connection lost. Retrying...": "Conexión del editor en tiempo real perdida. Reintentando...",
"Table of contents": "Índice de contenidos", "Table of contents": "Índice de contenidos",
"Add headings (H1, H2, H3) to generate a table of contents.": "Añadir encabezados (H1, H2, H3) para generar un índice de contenidos.", "Add headings (H1, H2, H3) to generate a table of contents.": "Añadir encabezados (H1, H2, H3) para generar un índice de contenidos."
"Share": "Compartir",
"Public sharing": "Compartición pública",
"Shared by": "Compartido por",
"Shared at": "Compartido en",
"Inherits public sharing from": "Hereda la compartición pública de",
"Share to web": "Compartir en la web",
"Shared to web": "Compartido en la web",
"Anyone with the link can view this page": "Cualquiera con el enlace puede ver esta página",
"Make this page publicly accessible": "Hacer esta página accesible públicamente",
"Include sub-pages": "Incluir subpáginas",
"Make sub-pages public too": "Hacer públicas también las subpáginas",
"Allow search engines to index page": "Permitir a los motores de búsqueda indexar la página",
"Open page": "Abrir página",
"Page": "Página",
"Delete public share link": "Eliminar enlace de compartición pública",
"Delete share": "Eliminar compartición",
"Are you sure you want to delete this shared link?": "¿Está seguro de que desea eliminar este enlace compartido?",
"Publicly shared pages from spaces you are a member of will appear here": "Las páginas compartidas públicamente de los espacios a los que pertenece aparecerán aquí",
"Share deleted successfully": "Compartición eliminada con éxito",
"Share not found": "Compartición no encontrada",
"Failed to share page": "Error al compartir la página"
} }

View File

@ -362,26 +362,5 @@
"Move page to a different space.": "Déplacer la page vers un autre espace.", "Move page to a different space.": "Déplacer la page vers un autre espace.",
"Real-time editor connection lost. Retrying...": "Connexion avec l'éditeur en temps réel perdue. Nouvelle tentative...", "Real-time editor connection lost. Retrying...": "Connexion avec l'éditeur en temps réel perdue. Nouvelle tentative...",
"Table of contents": "", "Table of contents": "",
"Add headings (H1, H2, H3) to generate a table of contents.": "Ajoutez des titres (H1, H2, H3) pour générer une table des matières.", "Add headings (H1, H2, H3) to generate a table of contents.": "Ajoutez des titres (H1, H2, H3) pour générer une table des matières."
"Share": "Partager",
"Public sharing": "Partage public",
"Shared by": "Partagé par",
"Shared at": "Partagé à",
"Inherits public sharing from": "Hérite du partage public de",
"Share to web": "Partager sur le web",
"Shared to web": "Partagé sur le web",
"Anyone with the link can view this page": "Toute personne avec le lien peut voir cette page",
"Make this page publicly accessible": "Rendre cette page accessible au public",
"Include sub-pages": "Inclure les sous-pages",
"Make sub-pages public too": "Rendre également les sous-pages publiques",
"Allow search engines to index page": "Autoriser les moteurs de recherche à indexer la page",
"Open page": "Ouvrir la page",
"Page": "Page",
"Delete public share link": "Supprimer le lien de partage public",
"Delete share": "Supprimer le partage",
"Are you sure you want to delete this shared link?": "Êtes-vous sûr de vouloir supprimer ce lien partagé ?",
"Publicly shared pages from spaces you are a member of will appear here": "Les pages partagées publiquement des espaces dont vous êtes membre apparaîtront ici",
"Share deleted successfully": "Partage supprimé avec succès",
"Share not found": "Partage non trouvé",
"Failed to share page": "Échec du partage de la page"
} }

View File

@ -362,26 +362,5 @@
"Move page to a different space.": "Sposta la pagina in un altro spazio.", "Move page to a different space.": "Sposta la pagina in un altro spazio.",
"Real-time editor connection lost. Retrying...": "Connessione all'editor in tempo reale persa. Riprovo...", "Real-time editor connection lost. Retrying...": "Connessione all'editor in tempo reale persa. Riprovo...",
"Table of contents": "Indice dei contenuti", "Table of contents": "Indice dei contenuti",
"Add headings (H1, H2, H3) to generate a table of contents.": "Aggiungi intestazioni (H1, H2, H3) per generare un sommario.", "Add headings (H1, H2, H3) to generate a table of contents.": "Aggiungi intestazioni (H1, H2, H3) per generare un sommario."
"Share": "Condividi",
"Public sharing": "Condivisione pubblica",
"Shared by": "Condiviso da",
"Shared at": "Condiviso il",
"Inherits public sharing from": "Eredita la condivisione pubblica da",
"Share to web": "Condividi su web",
"Shared to web": "Condiviso su web",
"Anyone with the link can view this page": "Chiunque abbia il link può visualizzare questa pagina",
"Make this page publicly accessible": "Rendi questa pagina accessibile pubblicamente",
"Include sub-pages": "Includi sotto-pagine",
"Make sub-pages public too": "Rendi pubbliche anche le sotto-pagine",
"Allow search engines to index page": "Permetti ai motori di ricerca di indicizzare la pagina",
"Open page": "Apri pagina",
"Page": "Pagina",
"Delete public share link": "Elimina il link di condivisione pubblica",
"Delete share": "Elimina condivisione",
"Are you sure you want to delete this shared link?": "Sei sicuro di voler eliminare questo link condiviso?",
"Publicly shared pages from spaces you are a member of will appear here": "Le pagine condivise pubblicamente dagli spazi di cui sei membro appariranno qui",
"Share deleted successfully": "Condivisione eliminata con successo",
"Share not found": "Condivisione non trovata",
"Failed to share page": "Condivisione della pagina fallita"
} }

View File

@ -362,26 +362,5 @@
"Move page to a different space.": "ページを別のスペースに移動します。", "Move page to a different space.": "ページを別のスペースに移動します。",
"Real-time editor connection lost. Retrying...": "リアルタイムエディターの接続が失われました。再試行しています…", "Real-time editor connection lost. Retrying...": "リアルタイムエディターの接続が失われました。再試行しています…",
"Table of contents": "目次", "Table of contents": "目次",
"Add headings (H1, H2, H3) to generate a table of contents.": "見出しH1、H2、H3を追加して目次を生成します。", "Add headings (H1, H2, H3) to generate a table of contents.": "見出しH1、H2、H3を追加して目次を生成します。"
"Share": "共有",
"Public sharing": "公開共有",
"Shared by": "共有者",
"Shared at": "共有日時",
"Inherits public sharing from": "から公開共有を継承する",
"Share to web": "ウェブで共有",
"Shared to web": "ウェブに共有済み",
"Anyone with the link can view this page": "リンクを持っている人はこのページを閲覧できます",
"Make this page publicly accessible": "このページを公開します",
"Include sub-pages": "サブページを含む",
"Make sub-pages public too": "サブページも公開する",
"Allow search engines to index page": "検索エンジンにページのインデックス作成を許可する",
"Open page": "ページを開く",
"Page": "ページ",
"Delete public share link": "公開リンクを削除",
"Delete share": "共有を削除",
"Are you sure you want to delete this shared link?": "この共有リンクを削除してもよろしいですか?",
"Publicly shared pages from spaces you are a member of will appear here": "メンバーであるスペースからの公開ページがここに表示されます",
"Share deleted successfully": "共有が正常に削除されました",
"Share not found": "共有が見つかりません",
"Failed to share page": "ページの共有に失敗しました"
} }

View File

@ -362,26 +362,5 @@
"Move page to a different space.": "페이지를 다른 공간으로 이동합니다.", "Move page to a different space.": "페이지를 다른 공간으로 이동합니다.",
"Real-time editor connection lost. Retrying...": "실시간 편집기 연결이 끊어졌습니다. 재시도 중...", "Real-time editor connection lost. Retrying...": "실시간 편집기 연결이 끊어졌습니다. 재시도 중...",
"Table of contents": "목차", "Table of contents": "목차",
"Add headings (H1, H2, H3) to generate a table of contents.": "목차를 생성하려면 제목 (H1, H2, H3)을 추가하세요.", "Add headings (H1, H2, H3) to generate a table of contents.": "목차를 생성하려면 제목 (H1, H2, H3)을 추가하세요."
"Share": "공유",
"Public sharing": "공개 공유",
"Shared by": "공유자",
"Shared at": "공유 시간",
"Inherits public sharing from": "로부터 공개 공유를 상속함",
"Share to web": "웹에 공유",
"Shared to web": "웹에 공유됨",
"Anyone with the link can view this page": "링크가 있는 사람은 이 페이지를 볼 수 있습니다",
"Make this page publicly accessible": "이 페이지를 공개적으로 접근 가능하게 만들기",
"Include sub-pages": "하위 페이지 포함",
"Make sub-pages public too": "하위 페이지도 공개로 설정",
"Allow search engines to index page": "검색 엔진이 페이지를 색인할 수 있도록 허용",
"Open page": "페이지 열기",
"Page": "페이지",
"Delete public share link": "공유 링크 삭제",
"Delete share": "공유 삭제",
"Are you sure you want to delete this shared link?": "이 공유 링크를 삭제하시겠습니까?",
"Publicly shared pages from spaces you are a member of will appear here": "회원인 공간의 공개 공유된 페이지가 여기에 표시됩니다",
"Share deleted successfully": "공유가 성공적으로 삭제되었습니다",
"Share not found": "공유를 찾을 수 없습니다",
"Failed to share page": "페이지 공유에 실패했습니다"
} }

View File

@ -362,26 +362,5 @@
"Move page to a different space.": "Verplaats pagina naar een andere ruimte.", "Move page to a different space.": "Verplaats pagina naar een andere ruimte.",
"Real-time editor connection lost. Retrying...": "Realtime editorverbinding verloren. Opnieuw proberen...", "Real-time editor connection lost. Retrying...": "Realtime editorverbinding verloren. Opnieuw proberen...",
"Table of contents": "Inhoudsopgave", "Table of contents": "Inhoudsopgave",
"Add headings (H1, H2, H3) to generate a table of contents.": "Voeg koppen (H1, H2, H3) toe om een inhoudsopgave te genereren.", "Add headings (H1, H2, H3) to generate a table of contents.": "Voeg koppen (H1, H2, H3) toe om een inhoudsopgave te genereren."
"Share": "Delen",
"Public sharing": "Openbaar delen",
"Shared by": "Gedeeld door",
"Shared at": "Gedeeld op",
"Inherits public sharing from": "Erft openbaar delen van",
"Share to web": "Delen naar web",
"Shared to web": "Gedeeld naar web",
"Anyone with the link can view this page": "Iedereen met de link kan deze pagina bekijken",
"Make this page publicly accessible": "Maak deze pagina openbaar toegankelijk",
"Include sub-pages": "Inclusief subpagina's",
"Make sub-pages public too": "Maak subpagina's ook openbaar",
"Allow search engines to index page": "Sta zoekmachines toe om pagina te indexeren",
"Open page": "Pagina openen",
"Page": "Pagina",
"Delete public share link": "Verwijder openbare deel-link",
"Delete share": "Verwijder deel",
"Are you sure you want to delete this shared link?": "Weet u zeker dat u deze gedeelde link wilt verwijderen?",
"Publicly shared pages from spaces you are a member of will appear here": "Openbaar gedeelde pagina's van ruimtes waarvan u lid bent, verschijnen hier",
"Share deleted successfully": "Delen succesvol verwijderd",
"Share not found": "Delen niet gevonden",
"Failed to share page": "Pagina delen mislukt"
} }

View File

@ -362,26 +362,5 @@
"Move page to a different space.": "Mover página para um espaço diferente.", "Move page to a different space.": "Mover página para um espaço diferente.",
"Real-time editor connection lost. Retrying...": "Conexão do editor em tempo real perdida. Tentando novamente...", "Real-time editor connection lost. Retrying...": "Conexão do editor em tempo real perdida. Tentando novamente...",
"Table of contents": "Tabela de conteúdos", "Table of contents": "Tabela de conteúdos",
"Add headings (H1, H2, H3) to generate a table of contents.": "Adicionar títulos (H1, H2, H3) para gerar uma tabela de conteúdo.", "Add headings (H1, H2, H3) to generate a table of contents.": "Adicionar títulos (H1, H2, H3) para gerar uma tabela de conteúdo."
"Share": "Compartilhar",
"Public sharing": "Compartilhamento público",
"Shared by": "Compartilhado por",
"Shared at": "Compartilhado em",
"Inherits public sharing from": "Herdado do compartilhamento público de",
"Share to web": "Compartilhar na web",
"Shared to web": "Compartilhado na web",
"Anyone with the link can view this page": "Qualquer um com o link pode ver esta página",
"Make this page publicly accessible": "Tornar esta página publicamente acessível",
"Include sub-pages": "Incluir sub-páginas",
"Make sub-pages public too": "Tornar as sub-páginas públicas também",
"Allow search engines to index page": "Permitir que mecanismos de busca indexem a página",
"Open page": "Abrir página",
"Page": "Página",
"Delete public share link": "Excluir o link público compartilhado",
"Delete share": "Excluir compartilhamento",
"Are you sure you want to delete this shared link?": "Tem certeza de que deseja excluir este link compartilhado?",
"Publicly shared pages from spaces you are a member of will appear here": "Páginas compartilhadas publicamente de espaços que você é membro aparecerão aqui",
"Share deleted successfully": "Compartilhamento excluído com sucesso",
"Share not found": "Compartilhamento não encontrado",
"Failed to share page": "Falha ao compartilhar página"
} }

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 group? The user will lose access to resources this group has access to.": "Вы уверены, что хотите удалить этого пользователя из группы? Пользователь потеряет доступ к материалам, к которым у этой группы есть доступ.",
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Вы уверены, что хотите удалить этого пользователя из пространства? Пользователь потеряет весь доступ к этому пространству.", "Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Вы уверены, что хотите удалить этого пользователя из пространства? Пользователь потеряет весь доступ к этому пространству.",
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Вы уверены, что хотите восстановить эту версию? Все не зафиксированные изменения будут потеряны.", "Are you sure you want to restore this version? Any changes not versioned will be lost.": "Вы уверены, что хотите восстановить эту версию? Все не зафиксированные изменения будут потеряны.",
"Can become members of groups and spaces in workspace": "Могут становиться участниками групп и пространств в рабочей области", "Can become members of groups and spaces in workspace": "Могут становиться участниками групп и пространств в рабочем пространстве",
"Can create and edit pages in space.": "Может создавать и редактировать страницы в пространстве.", "Can create and edit pages in space.": "Может создавать и редактировать страницы в пространстве.",
"Can edit": "Может изменять", "Can edit": "Может изменять",
"Can manage workspace": "Может управлять рабочей областью", "Can manage workspace": "Может управлять рабочим пространством",
"Can manage workspace but cannot delete it": "Может управлять рабочей областью, но не может ее удалить", "Can manage workspace but cannot delete it": "Может управлять рабочим пространством, но не может его удалить",
"Can view": "Может просматривать", "Can view": "Может просматривать",
"Can view pages in space but not edit.": "Может просматривать страницы в пространстве, но не может их редактировать.", "Can view pages in space but not edit.": "Может просматривать страницы в пространстве, но не может их редактировать.",
"Cancel": "Отменить", "Cancel": "Отменить",
@ -34,7 +34,7 @@
"Create group": "Создать группу", "Create group": "Создать группу",
"Create page": "Создать страницу", "Create page": "Создать страницу",
"Create space": "Создать пространство", "Create space": "Создать пространство",
"Create workspace": "Создать рабочую область", "Create workspace": "Создать рабочее пространство",
"Current password": "Текущий пароль", "Current password": "Текущий пароль",
"Dark": "Темная", "Dark": "Темная",
"Date": "Дата", "Date": "Дата",
@ -92,7 +92,7 @@
"Invite new members": "Пригласить новых участников", "Invite new members": "Пригласить новых участников",
"Invited members who are yet to accept their invitation will appear here.": "Приглашённые участники, которые ещё не приняли приглашение, появятся здесь.", "Invited members who are yet to accept their invitation will appear here.": "Приглашённые участники, которые ещё не приняли приглашение, появятся здесь.",
"Invited members will be granted access to spaces the groups can access": "Приглашённые участники получат доступ к пространствам, доступ к которым есть у группы", "Invited members will be granted access to spaces the groups can access": "Приглашённые участники получат доступ к пространствам, доступ к которым есть у группы",
"Join the workspace": "Присоединиться к рабочей области", "Join the workspace": "Присоединиться к рабочему пространству",
"Language": "Язык", "Language": "Язык",
"Light": "Светлая", "Light": "Светлая",
"Link copied": "Ссылка скопирована", "Link copied": "Ссылка скопирована",
@ -150,7 +150,7 @@
"Send invitation": "Отправить приглашение", "Send invitation": "Отправить приглашение",
"Invitation sent": "Приглашение отправлено", "Invitation sent": "Приглашение отправлено",
"Settings": "Настройки", "Settings": "Настройки",
"Setup workspace": "Настроить рабочую область", "Setup workspace": "Настроить рабочее пространство",
"Sign In": "Вход", "Sign In": "Вход",
"Sign Up": "Регистрация", "Sign Up": "Регистрация",
"Slug": "Slug", "Slug": "Slug",
@ -177,9 +177,9 @@
"Untitled": "Без названия", "Untitled": "Без названия",
"Updated successfully": "Обновлено успешно", "Updated successfully": "Обновлено успешно",
"User": "Пользователь", "User": "Пользователь",
"Workspace": "Рабочая область", "Workspace": "Рабочее пространство",
"Workspace Name": "Имя рабочей области", "Workspace Name": "Имя рабочего пространства",
"Workspace settings": "Настройки рабочей области", "Workspace settings": "Настройки рабочего пространства",
"You can change your password here.": "Вы можете изменить свой пароль здесь.", "You can change your password here.": "Вы можете изменить свой пароль здесь.",
"Your Email": "Ваш адрес электронной почты", "Your Email": "Ваш адрес электронной почты",
"Your import is complete.": "Ваш импорт завершен.", "Your import is complete.": "Ваш импорт завершен.",
@ -217,9 +217,9 @@
"Revoke invitation": "Отозвать приглашение", "Revoke invitation": "Отозвать приглашение",
"Revoke": "Отозвать", "Revoke": "Отозвать",
"Don't": "Нет", "Don't": "Нет",
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "Вы уверены, что хотите отозвать это приглашение? Пользователь не сможет присоединиться к рабочей области.", "Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "Вы уверены, что хотите отозвать это приглашение? Пользователь не сможет присоединиться к рабочему пространству.",
"Resend invitation": "Отправить приглашение повторно", "Resend invitation": "Отправить приглашение повторно",
"Anyone with this link can join this workspace.": "Любой, у кого есть данная ссылка, может присоединиться к этой рабочей области.", "Anyone with this link can join this workspace.": "Любой, у кого есть эта ссылка, может присоединиться к этому рабочему пространству.",
"Invite link": "Ссылка для приглашения", "Invite link": "Ссылка для приглашения",
"Copy": "Копировать", "Copy": "Копировать",
"Copied": "Скопировано", "Copied": "Скопировано",
@ -362,26 +362,5 @@
"Move page to a different space.": "Переместите страницу в другое пространство.", "Move page to a different space.": "Переместите страницу в другое пространство.",
"Real-time editor connection lost. Retrying...": "Соединение с редактором в реальном времени потеряно. Повторная попытка...", "Real-time editor connection lost. Retrying...": "Соединение с редактором в реальном времени потеряно. Повторная попытка...",
"Table of contents": "Содержание", "Table of contents": "Содержание",
"Add headings (H1, H2, H3) to generate a table of contents.": "Добавьте заголовки (H1, H2, H3), чтобы создать оглавление.", "Add headings (H1, H2, H3) to generate a table of contents.": "Добавьте заголовки (H1, H2, H3), чтобы создать оглавление."
"Share": "Поделиться",
"Public sharing": "Общий доступ",
"Shared by": "Поделился",
"Shared at": "Поделился в",
"Inherits public sharing from": "Наследует общий доступ от",
"Share to web": "Поделиться в интернете",
"Shared to web": "Размещено в интернете",
"Anyone with the link can view this page": "Любой, у кого есть ссылка, может просмотреть эту страницу",
"Make this page publicly accessible": "Сделать эту страницу общедоступной",
"Include sub-pages": "Включить подстраницы",
"Make sub-pages public too": "Сделать подстраницы также общедоступными",
"Allow search engines to index page": "Разрешить поисковым системам индексировать страницу",
"Open page": "Открыть страницу",
"Page": "Страница",
"Delete public share link": "Удалить ссылку на общий доступ",
"Delete share": "Удалить общий доступ",
"Are you sure you want to delete this shared link?": "Вы уверены, что хотите удалить эту ссылку общего доступа?",
"Publicly shared pages from spaces you are a member of will appear here": "Общие страницы из пространств, участником которых вы являетесь, появятся здесь",
"Share deleted successfully": "Общий доступ успешно удален",
"Share not found": "Общий доступ не найден",
"Failed to share page": "Не удалось поделиться страницей"
} }

View File

@ -298,7 +298,7 @@
"Heading 2": "2 级标题", "Heading 2": "2 级标题",
"Heading 3": "3 级标题", "Heading 3": "3 级标题",
"To-do List": "代办列表", "To-do List": "代办列表",
"Bullet List": "无列表", "Bullet List": "无列表",
"Numbered List": "有序列表", "Numbered List": "有序列表",
"Blockquote": "引用块", "Blockquote": "引用块",
"Just start typing with plain text.": "只需开始键入纯文本", "Just start typing with plain text.": "只需开始键入纯文本",
@ -362,26 +362,5 @@
"Move page to a different space.": "将页面移动到不同的空间。", "Move page to a different space.": "将页面移动到不同的空间。",
"Real-time editor connection lost. Retrying...": "实时编辑器连接丢失。重试中……", "Real-time editor connection lost. Retrying...": "实时编辑器连接丢失。重试中……",
"Table of contents": "目录", "Table of contents": "目录",
"Add headings (H1, H2, H3) to generate a table of contents.": "添加标题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,16 +26,10 @@ import { useTranslation } from "react-i18next";
import Security from "@/ee/security/pages/security.tsx"; import Security from "@/ee/security/pages/security.tsx";
import License from "@/ee/licence/pages/license.tsx"; import License from "@/ee/licence/pages/license.tsx";
import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx"; import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx";
import SharedPage from "@/pages/share/shared-page.tsx";
import Shares from "@/pages/settings/shares/shares.tsx";
import ShareLayout from "@/features/share/components/share-layout.tsx";
import ShareRedirect from '@/pages/share/share-redirect.tsx';
import { useTrackOrigin } from "@/hooks/use-track-origin";
export default function App() { export default function App() {
const { t } = useTranslation(); const { t } = useTranslation();
useRedirectToCloudSelect(); useRedirectToCloudSelect();
useTrackOrigin();
return ( return (
<> <>
@ -57,12 +51,6 @@ export default function App() {
</> </>
)} )}
<Route element={<ShareLayout />}>
<Route path={"/share/:shareId/p/:pageSlug"} element={<SharedPage />} />
<Route path={"/share/p/:pageSlug"} element={<SharedPage />} />
</Route>
<Route path={"/share/:shareId"} element={<ShareRedirect />} />
<Route path={"/p/:pageSlug"} element={<PageRedirect />} /> <Route path={"/p/:pageSlug"} element={<PageRedirect />} />
<Route element={<Layout />}> <Route element={<Layout />}>
@ -90,7 +78,6 @@ export default function App() {
<Route path={"groups"} element={<Groups />} /> <Route path={"groups"} element={<Groups />} />
<Route path={"groups/:groupId"} element={<GroupInfo />} /> <Route path={"groups/:groupId"} element={<GroupInfo />} />
<Route path={"spaces"} element={<Spaces />} /> <Route path={"spaces"} element={<Spaces />} />
<Route path={"sharing"} element={<Shares />} />
<Route path={"security"} element={<Security />} /> <Route path={"security"} element={<Security />} />
{!isCloud() && <Route path={"license"} element={<License />} />} {!isCloud() && <Route path={"license"} element={<License />} />}
{isCloud() && <Route path={"billing"} element={<Billing />} />} {isCloud() && <Route path={"billing"} element={<Billing />} />}

View File

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

View File

@ -1,10 +0,0 @@
import { atom, WritableAtom } from "jotai";
export const settingsOriginAtom: WritableAtom<string | null, [string | null], void> = atom(
null,
(get, set, newValue) => {
if (get(settingsOriginAtom) !== newValue) {
set(settingsOriginAtom, newValue);
}
}
);

View File

@ -8,8 +8,7 @@ import { getGroups } from "@/features/group/services/group-service.ts";
import { QueryParams } from "@/lib/types.ts"; import { QueryParams } from "@/lib/types.ts";
import { getWorkspaceMembers } from "@/features/workspace/services/workspace-service.ts"; import { getWorkspaceMembers } from "@/features/workspace/services/workspace-service.ts";
import { getLicenseInfo } from "@/ee/licence/services/license-service.ts"; import { getLicenseInfo } from "@/ee/licence/services/license-service.ts";
import { getSsoProviders } from "@/ee/security/services/security-service.ts"; import { getSsoProviders } from '@/ee/security/services/security-service.ts';
import { getShares } from "@/features/share/services/share-service.ts";
export const prefetchWorkspaceMembers = () => { export const prefetchWorkspaceMembers = () => {
const params = { limit: 100, page: 1, query: "" } as QueryParams; const params = { limit: 100, page: 1, query: "" } as QueryParams;
@ -58,10 +57,3 @@ export const prefetchSsoProviders = () => {
queryFn: () => getSsoProviders(), queryFn: () => getSsoProviders(),
}); });
}; };
export const prefetchShares = () => {
queryClient.prefetchQuery({
queryKey: ["share-list", { page: 1 }],
queryFn: () => getShares({ page: 1, limit: 100 }),
});
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,7 +15,6 @@ import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-
import { useEditor } from "@tiptap/react"; import { useEditor } from "@tiptap/react";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
interface CommentDialogProps { interface CommentDialogProps {
editor: ReturnType<typeof useEditor>; editor: ReturnType<typeof useEditor>;
@ -36,8 +35,6 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
const createCommentMutation = useCreateCommentMutation(); const createCommentMutation = useCreateCommentMutation();
const { isPending } = createCommentMutation; const { isPending } = createCommentMutation;
const emit = useQueryEmit();
const handleDialogClose = () => { const handleDialogClose = () => {
setShowCommentPopup(false); setShowCommentPopup(false);
editor.chain().focus().unsetCommentDecoration().run(); editor.chain().focus().unsetCommentDecoration().run();
@ -66,23 +63,11 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
.run(); .run();
setActiveCommentId(createdComment.id); setActiveCommentId(createdComment.id);
//unselect text to close bubble menu
editor.commands.setTextSelection({ from: editor.view.state.selection.from, to: editor.view.state.selection.from });
setAsideState({ tab: "comments", isAsideOpen: true }); setAsideState({ tab: "comments", isAsideOpen: true });
setTimeout(() => { setTimeout(() => {
const selector = `div[data-comment-id="${createdComment.id}"]`; const selector = `div[data-comment-id="${createdComment.id}"]`;
const commentElement = document.querySelector(selector); const commentElement = document.querySelector(selector);
commentElement?.scrollIntoView({ behavior: "smooth", block: "center" }); commentElement?.scrollIntoView();
editor.view.dispatch(
editor.state.tr.scrollIntoView()
);
}, 400);
emit({
operation: "invalidateComment",
pageId: pageId,
}); });
} finally { } finally {
setShowCommentPopup(false); setShowCommentPopup(false);

View File

@ -53,10 +53,6 @@ const CommentEditor = forwardRef(
autofocus: (autofocus && "end") || false, autofocus: (autofocus && "end") || false,
}); });
useEffect(() => {
commentEditor.commands.setContent(defaultContent);
}, [defaultContent]);
useEffect(() => { useEffect(() => {
setTimeout(() => { setTimeout(() => {
if (autofocus) { if (autofocus) {

View File

@ -1,5 +1,5 @@
import { Group, Text, Box } from "@mantine/core"; import { Group, Text, Box } from "@mantine/core";
import React, { useEffect, useState } from "react"; import React, { useState } from "react";
import classes from "./comment.module.css"; import classes from "./comment.module.css";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { timeAgo } from "@/lib/time"; import { timeAgo } from "@/lib/time";
@ -15,14 +15,12 @@ import {
import { IComment } from "@/features/comment/types/comment.types"; import { IComment } from "@/features/comment/types/comment.types";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
interface CommentListItemProps { interface CommentListItemProps {
comment: IComment; comment: IComment;
pageId: string;
} }
function CommentListItem({ comment, pageId }: CommentListItemProps) { function CommentListItem({ comment }: CommentListItemProps) {
const { hovered, ref } = useHover(); const { hovered, ref } = useHover();
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@ -31,11 +29,6 @@ function CommentListItem({ comment, pageId }: CommentListItemProps) {
const updateCommentMutation = useUpdateCommentMutation(); const updateCommentMutation = useUpdateCommentMutation();
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId); const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
const [currentUser] = useAtom(currentUserAtom); const [currentUser] = useAtom(currentUserAtom);
const emit = useQueryEmit();
useEffect(() => {
setContent(comment.content)
}, [comment]);
async function handleUpdateComment() { async function handleUpdateComment() {
try { try {
@ -46,11 +39,6 @@ function CommentListItem({ comment, pageId }: CommentListItemProps) {
}; };
await updateCommentMutation.mutateAsync(commentToUpdate); await updateCommentMutation.mutateAsync(commentToUpdate);
setIsEditing(false); setIsEditing(false);
emit({
operation: "invalidateComment",
pageId: pageId,
});
} catch (error) { } catch (error) {
console.error("Failed to update comment:", error); console.error("Failed to update comment:", error);
} finally { } finally {
@ -62,27 +50,11 @@ function CommentListItem({ comment, pageId }: CommentListItemProps) {
try { try {
await deleteCommentMutation.mutateAsync(comment.id); await deleteCommentMutation.mutateAsync(comment.id);
editor?.commands.unsetComment(comment.id); editor?.commands.unsetComment(comment.id);
emit({
operation: "invalidateComment",
pageId: pageId,
});
} catch (error) { } catch (error) {
console.error("Failed to delete comment:", error); console.error("Failed to delete comment:", error);
} }
} }
function handleCommentClick(comment: IComment) {
const el = document.querySelector(`.comment-mark[data-comment-id="${comment.id}"]`);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
el.classList.add("comment-highlight");
setTimeout(() => {
el.classList.remove("comment-highlight");
}, 3000);
}
}
function handleEditToggle() { function handleEditToggle() {
setIsEditing(true); setIsEditing(true);
} }
@ -127,7 +99,7 @@ function CommentListItem({ comment, pageId }: CommentListItemProps) {
<div> <div>
{!comment.parentCommentId && comment?.selection && ( {!comment.parentCommentId && comment?.selection && (
<Box className={classes.textSelection} onClick={() => handleCommentClick(comment)}> <Box className={classes.textSelection}>
<Text size="sm">{comment?.selection}</Text> <Text size="sm">{comment?.selection}</Text>
</Box> </Box>
)} )}

View File

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

View File

@ -11,7 +11,6 @@
border-left: 2px solid var(--mantine-color-gray-6); border-left: 2px solid var(--mantine-color-gray-6);
padding: 8px; padding: 8px;
background: var(--mantine-color-gray-light); background: var(--mantine-color-gray-light);
cursor: pointer;
} }
.commentEditor { .commentEditor {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -219,12 +219,9 @@ export default function PageEditor({
setActiveCommentId(commentId); setActiveCommentId(commentId);
setAsideState({ tab: "comments", isAsideOpen: true }); setAsideState({ tab: "comments", isAsideOpen: true });
//wait if aside is closed
setTimeout(() => {
const selector = `div[data-comment-id="${commentId}"]`; const selector = `div[data-comment-id="${commentId}"]`;
const commentElement = document.querySelector(selector); const commentElement = document.querySelector(selector);
commentElement?.scrollIntoView({ behavior: "smooth", block: "center" }); commentElement?.scrollIntoView();
}, 400);
}; };
useEffect(() => { useEffect(() => {

View File

@ -1,67 +0,0 @@
import "@/features/editor/styles/index.css";
import React, { useMemo } from "react";
import { EditorProvider } from "@tiptap/react";
import { mainExtensions } from "@/features/editor/extensions/extensions";
import { Document } from "@tiptap/extension-document";
import { Heading } from "@tiptap/extension-heading";
import { Text } from "@tiptap/extension-text";
import { Placeholder } from "@tiptap/extension-placeholder";
import { useAtom } from "jotai/index";
import {
pageEditorAtom,
readOnlyEditorAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
import { Editor } from "@tiptap/core";
interface PageEditorProps {
title: string;
content: any;
}
export default function ReadonlyPageEditor({
title,
content,
}: PageEditorProps) {
const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom);
const extensions = useMemo(() => {
return [...mainExtensions];
}, []);
const titleExtensions = [
Document.extend({
content: "heading",
}),
Heading,
Text,
Placeholder.configure({
placeholder: "Untitled",
showOnlyWhenEditable: false,
}),
];
return (
<>
<EditorProvider
editable={false}
immediatelyRender={true}
extensions={titleExtensions}
content={title}
></EditorProvider>
<EditorProvider
editable={false}
immediatelyRender={true}
extensions={extensions}
content={content}
onCreate={({ editor }) => {
if (editor) {
// @ts-ignore
setReadOnlyEditor(editor);
}
}}
></EditorProvider>
<div style={{ paddingBottom: "20vh" }}></div>
</>
);
}

View File

@ -144,19 +144,6 @@
border-bottom: 2px solid rgb(166, 158, 12); border-bottom: 2px solid rgb(166, 158, 12);
} }
.comment-highlight {
animation: flash-highlight 3s ease-out;
}
@keyframes flash-highlight {
0% {
background-color: #ff4d4d;
}
100% {
background-color: rgba(255, 215, 0, 0.14);
}
}
.resize-cursor { .resize-cursor {
cursor: ew-resize; cursor: ew-resize;
cursor: col-resize; cursor: col-resize;

View File

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

View File

@ -10,7 +10,7 @@ import {
pageEditorAtom, pageEditorAtom,
titleEditorAtom, titleEditorAtom,
} from "@/features/editor/atoms/editor-atoms"; } from "@/features/editor/atoms/editor-atoms";
import { updatePageData, useUpdateTitlePageMutation } from "@/features/page/queries/page-query"; import { useUpdatePageMutation } from "@/features/page/queries/page-query";
import { useDebouncedCallback } from "@mantine/hooks"; import { useDebouncedCallback } from "@mantine/hooks";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts"; import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
@ -38,7 +38,7 @@ export function TitleEditor({
editable, editable,
}: TitleEditorProps) { }: TitleEditorProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { mutateAsync: updateTitlePageMutationAsync } = useUpdateTitlePageMutation(); const { mutateAsync: updatePageMutationAsync } = useUpdatePageMutation();
const pageEditor = useAtomValue(pageEditorAtom); const pageEditor = useAtomValue(pageEditorAtom);
const [, setTitleEditor] = useAtom(titleEditorAtom); const [, setTitleEditor] = useAtom(titleEditorAtom);
const emit = useQueryEmit(); const emit = useQueryEmit();
@ -94,7 +94,7 @@ export function TitleEditor({
return; return;
} }
updateTitlePageMutationAsync({ updatePageMutationAsync({
pageId: pageId, pageId: pageId,
title: titleEditor.getText(), title: titleEditor.getText(),
}).then((page) => { }).then((page) => {
@ -106,10 +106,6 @@ export function TitleEditor({
payload: { title: page.title, slugId: page.slugId }, payload: { title: page.title, slugId: page.slugId },
}; };
if (page.title !== titleEditor.getText()) return;
updatePageData(page);
localEmitter.emit("message", event); localEmitter.emit("message", event);
emit(event); emit(event);
}); });

View File

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

View File

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

View File

@ -1,105 +0,0 @@
import { Modal, Button, Group, Text } from "@mantine/core";
import { copyPageToSpace } from "@/features/page/services/page-service.ts";
import { useState } from "react";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { ISpace } from "@/features/space/types/space.types.ts";
import { queryClient } from "@/main.tsx";
import { SpaceSelect } from "@/features/space/components/sidebar/space-select.tsx";
import { useNavigate } from "react-router-dom";
import { buildPageUrl } from "@/features/page/page.utils.ts";
interface CopyPageModalProps {
pageId: string;
currentSpaceSlug: string;
open: boolean;
onClose: () => void;
}
export default function CopyPageModal({
pageId,
currentSpaceSlug,
open,
onClose,
}: CopyPageModalProps) {
const { t } = useTranslation();
const [targetSpace, setTargetSpace] = useState<ISpace>(null);
const navigate = useNavigate();
const handleCopy = async () => {
if (!targetSpace) return;
try {
const copiedPage = await copyPageToSpace({
pageId,
spaceId: targetSpace.id,
});
queryClient.removeQueries({
predicate: (item) =>
["pages", "sidebar-pages", "root-sidebar-pages"].includes(
item.queryKey[0] as string,
),
});
const pageUrl = buildPageUrl(
copiedPage.space.slug,
copiedPage.slugId,
copiedPage.title,
);
navigate(pageUrl);
notifications.show({
message: t("Page copied successfully"),
});
onClose();
setTargetSpace(null);
} catch (err) {
notifications.show({
message: err.response?.data.message || "An error occurred",
color: "red",
});
console.log(err);
}
};
const handleChange = (space: ISpace) => {
setTargetSpace(space);
};
return (
<Modal.Root
opened={open}
onClose={onClose}
size={500}
padding="xl"
yOffset="10vh"
xOffset={0}
mah={400}
onClick={(e) => e.stopPropagation()}
>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header py={0}>
<Modal.Title fw={500}>{t("Copy page")}</Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>
<Text mb="xs" c="dimmed" size="sm">
{t("Copy page to a different space.")}
</Text>
<SpaceSelect
value={currentSpaceSlug}
clearable={false}
onChange={handleChange}
/>
<Group justify="end" mt="md">
<Button onClick={onClose} variant="default">
{t("Cancel")}
</Button>
<Button onClick={handleCopy}>{t("Copy")}</Button>
</Group>
</Modal.Body>
</Modal.Content>
</Modal.Root>
);
}

View File

@ -12,7 +12,7 @@ import {
IconTrash, IconTrash,
IconWifiOff, IconWifiOff,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import React from "react"; import React, { useEffect } from "react";
import useToggleAside from "@/hooks/use-toggle-aside.tsx"; import useToggleAside from "@/hooks/use-toggle-aside.tsx";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts"; import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
@ -35,7 +35,6 @@ import {
import { formattedDate, timeAgo } from "@/lib/time.ts"; import { formattedDate, timeAgo } from "@/lib/time.ts";
import MovePageModal from "@/features/page/components/move-page-modal.tsx"; import MovePageModal from "@/features/page/components/move-page-modal.tsx";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx"; import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
import ShareModal from "@/features/share/components/share-modal.tsx";
interface PageHeaderMenuProps { interface PageHeaderMenuProps {
readOnly?: boolean; readOnly?: boolean;
@ -59,8 +58,6 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
</Tooltip> </Tooltip>
)} )}
<ShareModal readOnly={readOnly} />
<Tooltip label={t("Comments")} openDelay={250} withArrow> <Tooltip label={t("Comments")} openDelay={250} withArrow>
<ActionIcon <ActionIcon
variant="default" variant="default"
@ -106,7 +103,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
{ open: openMovePageModal, close: closeMoveSpaceModal }, { open: openMovePageModal, close: closeMoveSpaceModal },
] = useDisclosure(false); ] = useDisclosure(false);
const [pageEditor] = useAtom(pageEditorAtom); const [pageEditor] = useAtom(pageEditorAtom);
const pageUpdatedAt = useTimeAgo(page?.updatedAt); const pageUpdatedAt = useTimeAgo(page.updatedAt);
const handleCopyLink = () => { const handleCopyLink = () => {
const pageUrl = const pageUrl =

View File

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

View File

@ -8,7 +8,7 @@ const buildPageSlug = (pageSlugId: string, pageTitle?: string): string => {
], ],
}); });
return `${titleSlug}-${pageSlugId}`; return `p/${titleSlug}-${pageSlugId}`;
}; };
export const buildPageUrl = ( export const buildPageUrl = (
@ -17,20 +17,7 @@ export const buildPageUrl = (
pageTitle?: string, pageTitle?: string,
): string => { ): string => {
if (spaceName === undefined) { if (spaceName === undefined) {
return `/p/${buildPageSlug(pageSlugId, pageTitle)}`; return `/${buildPageSlug(pageSlugId, pageTitle)}`;
} }
return `/s/${spaceName}/p/${buildPageSlug(pageSlugId, pageTitle)}`; return `/s/${spaceName}/${buildPageSlug(pageSlugId, pageTitle)}`;
};
export const buildSharedPageUrl = (opts: {
shareId: string;
pageSlugId: string;
pageTitle?: string;
}): string => {
const { shareId, pageSlugId, pageTitle } = opts;
if (!shareId) {
return `/share/p/${buildPageSlug(pageSlugId, pageTitle)}`;
}
return `/share/${shareId}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
}; };

View File

@ -63,7 +63,12 @@ export function useCreatePageMutation() {
}); });
} }
export function updatePageData(data: IPage) { export function useUpdatePageMutation() {
const queryClient = useQueryClient();
return useMutation<IPage, Error, Partial<IPageInput>>({
mutationFn: (data) => updatePage(data),
onSuccess: (data) => {
const pageBySlug = queryClient.getQueryData<IPage>([ const pageBySlug = queryClient.getQueryData<IPage>([
"pages", "pages",
data.slugId, data.slugId,
@ -80,19 +85,6 @@ export function updatePageData(data: IPage) {
if (pageById) { if (pageById) {
queryClient.setQueryData(["pages", data.id], { ...pageById, ...data }); queryClient.setQueryData(["pages", data.id], { ...pageById, ...data });
} }
}
export function useUpdateTitlePageMutation() {
return useMutation<IPage, Error, Partial<IPageInput>>({
mutationFn: (data) => updatePage(data),
});
}
export function useUpdatePageMutation() {
return useMutation<IPage, Error, Partial<IPageInput>>({
mutationFn: (data) => updatePage(data),
onSuccess: (data) => {
updatePage(data);
}, },
}); });
} }

View File

@ -1,6 +1,5 @@
import api from "@/lib/api-client"; import api from "@/lib/api-client";
import { import {
ICopyPageToSpace,
IExportPageParams, IExportPageParams,
IMovePage, IMovePage,
IMovePageToSpace, IMovePageToSpace,
@ -40,11 +39,6 @@ export async function movePageToSpace(data: IMovePageToSpace): Promise<void> {
await api.post<void>("/pages/move-to-space", data); await api.post<void>("/pages/move-to-space", data);
} }
export async function copyPageToSpace(data: ICopyPageToSpace): Promise<IPage> {
const req = await api.post<IPage>("/pages/copy-to-space", data);
return req.data;
}
export async function getSidebarPages( export async function getSidebarPages(
params: SidebarPagesParams, params: SidebarPagesParams,
): Promise<IPagination<IPage>> { ): Promise<IPagination<IPage>> {

View File

@ -8,14 +8,13 @@ import {
useUpdatePageMutation, useUpdatePageMutation,
} from "@/features/page/queries/page-query.ts"; } from "@/features/page/queries/page-query.ts";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Link, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import classes from "@/features/page/tree/styles/tree.module.css"; import classes from "@/features/page/tree/styles/tree.module.css";
import { ActionIcon, Box, Menu, rem } from "@mantine/core"; import { ActionIcon, Menu, rem } from "@mantine/core";
import { import {
IconArrowRight, IconArrowRight,
IconChevronDown, IconChevronDown,
IconChevronRight, IconChevronRight,
IconCopy,
IconDotsVertical, IconDotsVertical,
IconFileDescription, IconFileDescription,
IconFileExport, IconFileExport,
@ -59,9 +58,6 @@ import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import ExportModal from "@/components/common/export-modal"; import ExportModal from "@/components/common/export-modal";
import MovePageModal from "../../components/move-page-modal.tsx"; import MovePageModal from "../../components/move-page-modal.tsx";
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import CopyPageModal from "../../components/copy-page-modal.tsx";
interface SpaceTreeProps { interface SpaceTreeProps {
spaceId: string; spaceId: string;
@ -234,14 +230,13 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
} }
function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) { function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
const { t } = useTranslation(); const navigate = useNavigate();
const updatePageMutation = useUpdatePageMutation(); const updatePageMutation = useUpdatePageMutation();
const [treeData, setTreeData] = useAtom(treeDataAtom); const [treeData, setTreeData] = useAtom(treeDataAtom);
const emit = useQueryEmit(); const emit = useQueryEmit();
const { spaceSlug } = useParams(); const { spaceSlug } = useParams();
const timerRef = useRef(null); const timerRef = useRef(null);
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom); const { t } = useTranslation();
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
const prefetchPage = () => { const prefetchPage = () => {
timerRef.current = setTimeout(() => { timerRef.current = setTimeout(() => {
@ -292,6 +287,11 @@ 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 handleUpdateNodeIcon = (nodeId: string, newIcon: string) => {
const updatedTree = updateTreeNodeIcon(treeData, nodeId, newIcon); const updatedTree = updateTreeNodeIcon(treeData, nodeId, newIcon);
setTreeData(updatedTree); setTreeData(updatedTree);
@ -345,22 +345,13 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
}, 650); }, 650);
} }
const pageUrl = buildPageUrl(spaceSlug, node.data.slugId, node.data.name);
return ( return (
<> <>
<Box <div
style={style} style={style}
className={clsx(classes.node, node.state)} className={clsx(classes.node, node.state)}
component={Link}
to={pageUrl}
// @ts-ignore
ref={dragHandle} ref={dragHandle}
onClick={() => { onClick={handleClick}
if (mobileSidebarOpened) {
toggleMobileSidebar();
}
}}
onMouseEnter={prefetchPage} onMouseEnter={prefetchPage}
onMouseLeave={cancelPagePrefetch} onMouseLeave={cancelPagePrefetch}
> >
@ -394,7 +385,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
/> />
)} )}
</div> </div>
</Box> </div>
</> </>
); );
} }
@ -450,10 +441,6 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
movePageModalOpened, movePageModalOpened,
{ open: openMovePageModal, close: closeMoveSpaceModal }, { open: openMovePageModal, close: closeMoveSpaceModal },
] = useDisclosure(false); ] = useDisclosure(false);
const [
copyPageModalOpened,
{ open: openCopyPageModal, close: closeCopySpaceModal },
] = useDisclosure(false);
const handleCopyLink = () => { const handleCopyLink = () => {
const pageUrl = const pageUrl =
@ -517,17 +504,6 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
{t("Move")} {t("Move")}
</Menu.Item> </Menu.Item>
<Menu.Item
leftSection={<IconCopy size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openCopyPageModal();
}}
>
{t("Copy")}
</Menu.Item>
<Menu.Divider /> <Menu.Divider />
<Menu.Item <Menu.Item
c="red" c="red"
@ -553,13 +529,6 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
open={movePageModalOpened} open={movePageModalOpened}
/> />
<CopyPageModal
pageId={node.id}
currentSpaceSlug={spaceSlug}
onClose={closeCopySpaceModal}
open={copyPageModalOpened}
/>
<ExportModal <ExportModal
type="page" type="page"
id={node.id} id={node.id}

View File

@ -18,7 +18,7 @@
align-items: center; align-items: center;
height: 100%; height: 100%;
width: 93%; /* not to overlap with scroll bar */ width: 93%; /* not to overlap with scroll bar */
text-decoration: none;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0)); color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
&:hover { &:hover {
@ -70,10 +70,6 @@
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5)); background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
} }
.row:focus .node:global(.isFocused) {
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
}
.row { .row {
white-space: nowrap; white-space: nowrap;
cursor: pointer; cursor: pointer;

View File

@ -1,7 +1,7 @@
import { IPage } from "@/features/page/types/page.types.ts"; import { IPage } from "@/features/page/types/page.types.ts";
import { SpaceTreeNode } from "@/features/page/tree/types.ts"; import { SpaceTreeNode } from "@/features/page/tree/types.ts";
export function sortPositionKeys(keys: any[]) { function sortPositionKeys(keys: any[]) {
return keys.sort((a, b) => { return keys.sort((a, b) => {
if (a.position < b.position) return -1; if (a.position < b.position) return -1;
if (a.position > b.position) return 1; if (a.position > b.position) return 1;

View File

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

View File

@ -1,44 +0,0 @@
.root {
height: 34px;
padding-left: var(--mantine-spacing-sm);
padding-right: 4px;
border-radius: var(--mantine-radius-md);
color: var(--mantine-color-placeholder);
border: 1px solid;
@mixin light {
border-color: var(--mantine-color-gray-3);
background-color: var(--mantine-color-white);
}
@mixin dark {
border-color: var(--mantine-color-dark-4);
background-color: var(--mantine-color-dark-6);
}
@mixin rtl {
padding-left: 4px;
padding-right: var(--mantine-spacing-sm);
}
}
.shortcut {
font-size: 11px;
line-height: 1;
padding: 4px 7px;
border-radius: var(--mantine-radius-sm);
border: 1px solid;
font-weight: bold;
@mixin light {
color: var(--mantine-color-gray-7);
border-color: var(--mantine-color-gray-2);
background-color: var(--mantine-color-gray-0);
}
@mixin dark {
color: var(--mantine-color-dark-0);
border-color: var(--mantine-color-dark-7);
background-color: var(--mantine-color-dark-7);
}
}

View File

@ -1,56 +0,0 @@
import { IconSearch } from "@tabler/icons-react";
import cx from "clsx";
import {
ActionIcon,
BoxProps,
ElementProps,
Group,
rem,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import classes from "./search-control.module.css";
import React from "react";
import { useTranslation } from "react-i18next";
interface SearchControlProps extends BoxProps, ElementProps<"button"> {}
export function SearchControl({ className, ...others }: SearchControlProps) {
const { t } = useTranslation();
return (
<UnstyledButton {...others} className={cx(classes.root, className)}>
<Group gap="xs" wrap="nowrap">
<IconSearch style={{ width: rem(15), height: rem(15) }} stroke={1.5} />
<Text fz="sm" c="dimmed" pr={80}>
{t("Search")}
</Text>
<Text fw={700} className={classes.shortcut}>
Ctrl + K
</Text>
</Group>
</UnstyledButton>
);
}
interface SearchMobileControlProps {
onSearch: () => void;
}
export function SearchMobileControl({ onSearch }: SearchMobileControlProps) {
const { t } = useTranslation();
return (
<Tooltip label={t("Search")} withArrow>
<ActionIcon
variant="default"
style={{ border: "none" }}
onClick={onSearch}
size="sm"
>
<IconSearch size={20} stroke={2} />
</ActionIcon>
</Tooltip>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,87 +0,0 @@
import { Group, Center, Text } from "@mantine/core";
import { Spotlight } from "@mantine/spotlight";
import { IconSearch } from "@tabler/icons-react";
import React, { useState } from "react";
import { Link } from "react-router-dom";
import { useDebouncedValue } from "@mantine/hooks";
import { useShareSearchQuery } from "@/features/search/queries/search-query";
import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
import { getPageIcon } from "@/lib";
import { useTranslation } from "react-i18next";
import { shareSearchSpotlightStore } from "@/features/search/constants.ts";
interface ShareSearchSpotlightProps {
shareId?: string;
}
export function ShareSearchSpotlight({ shareId }: ShareSearchSpotlightProps) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const [debouncedSearchQuery] = useDebouncedValue(query, 300);
const { data: searchResults } = useShareSearchQuery({
query: debouncedSearchQuery,
shareId,
});
const pages = (
searchResults && searchResults.length > 0 ? searchResults : []
).map((page) => (
<Spotlight.Action
key={page.id}
component={Link}
//@ts-ignore
to={buildSharedPageUrl({
shareId: shareId,
pageTitle: page.title,
pageSlugId: page.slugId,
})}
style={{ userSelect: "none" }}
>
<Group wrap="nowrap" w="100%">
<Center>{getPageIcon(page?.icon)}</Center>
<div style={{ flex: 1 }}>
<Text>{page.title}</Text>
{page?.highlight && (
<Text
opacity={0.6}
size="xs"
dangerouslySetInnerHTML={{ __html: page.highlight }}
/>
)}
</div>
</Group>
</Spotlight.Action>
));
return (
<>
<Spotlight.Root
store={shareSearchSpotlightStore}
query={query}
onQueryChange={setQuery}
scrollable
overlayProps={{
backgroundOpacity: 0.55,
}}
>
<Spotlight.Search
placeholder={t("Search...")}
leftSection={<IconSearch size={20} stroke={1.5} />}
/>
<Spotlight.ActionsList>
{query.length === 0 && pages.length === 0 && (
<Spotlight.Empty>{t("Start typing to search...")}</Spotlight.Empty>
)}
{query.length > 0 && pages.length === 0 && (
<Spotlight.Empty>{t("No results found...")}</Spotlight.Empty>
)}
{pages.length > 0 && pages}
</Spotlight.ActionsList>
</Spotlight.Root>
</>
);
}

View File

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

View File

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

View File

@ -1,106 +0,0 @@
import { Menu, ActionIcon, Text } from "@mantine/core";
import React from "react";
import {
IconCopy,
IconDots,
IconFileDescription,
IconTrash,
} from "@tabler/icons-react";
import { modals } from "@mantine/modals";
import { useTranslation } from "react-i18next";
import { ISharedItem } from "@/features/share/types/share.types.ts";
import {
buildPageUrl,
buildSharedPageUrl,
} from "@/features/page/page.utils.ts";
import { useClipboard } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import { useNavigate } from "react-router-dom";
import { useDeleteShareMutation } from "@/features/share/queries/share-query.ts";
interface Props {
share: ISharedItem;
}
export default function ShareActionMenu({ share }: Props) {
const { t } = useTranslation();
const navigate = useNavigate();
const clipboard = useClipboard();
const deleteShareMutation = useDeleteShareMutation();
const openPage = () => {
const pageLink = buildPageUrl(
share.space.slug,
share.page.slugId,
share.page.title,
);
navigate(pageLink);
};
const copyLink = () => {
const shareLink = buildSharedPageUrl({
shareId: share.key,
pageTitle: share.page.title,
pageSlugId: share.page.slugId,
});
clipboard.copy(shareLink);
notifications.show({ message: t("Link copied") });
};
const onDelete = async () => {
deleteShareMutation.mutateAsync(share.key);
};
const openDeleteModal = () =>
modals.openConfirmModal({
title: t("Delete public share link"),
children: (
<Text size="sm">
{t("Are you sure you want to delete this shared link?")}
</Text>
),
centered: true,
labels: { confirm: t("Delete"), cancel: t("Don't") },
confirmProps: { color: "red" },
onConfirm: onDelete,
});
return (
<>
<Menu
shadow="xl"
position="bottom-end"
offset={20}
width={200}
withArrow
arrowPosition="center"
>
<Menu.Target>
<ActionIcon variant="subtle" c="gray">
<IconDots size={20} stroke={2} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={copyLink} leftSection={<IconCopy size={16} />}>
{t("Copy link")}
</Menu.Item>
<Menu.Item
onClick={openPage}
leftSection={<IconFileDescription size={16} />}
>
{t("Open page")}
</Menu.Item>
<Menu.Item
c="red"
onClick={openDeleteModal}
leftSection={<IconTrash size={16} />}
disabled={share.space?.userRole === "reader"}
>
{t("Delete share")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</>
);
}

View File

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

View File

@ -1,97 +0,0 @@
import { Table, Group, Text, Anchor } from "@mantine/core";
import React, { useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import Paginate from "@/components/common/paginate.tsx";
import { useGetSharesQuery } from "@/features/share/queries/share-query.ts";
import { ISharedItem } from "@/features/share/types/share.types.ts";
import { format } from "date-fns";
import ShareActionMenu from "@/features/share/components/share-action-menu.tsx";
import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
import { getPageIcon } from "@/lib";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import classes from "./share.module.css";
export default function ShareList() {
const { t } = useTranslation();
const [page, setPage] = useState(1);
const { data, isLoading } = useGetSharesQuery({ page });
return (
<>
<Table.ScrollContainer minWidth={500}>
<Table verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Page")}</Table.Th>
<Table.Th>{t("Shared by")}</Table.Th>
<Table.Th>{t("Shared at")}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data?.items.map((share: ISharedItem, index: number) => (
<Table.Tr key={index}>
<Table.Td>
<Anchor
size="sm"
underline="never"
style={{
cursor: "pointer",
color: "var(--mantine-color-text)",
}}
component={Link}
target="_blank"
to={buildSharedPageUrl({
shareId: share.key,
pageTitle: share.page.title,
pageSlugId: share.page.slugId,
})}
>
<Group gap="4" wrap="nowrap">
{getPageIcon(share.page.icon)}
<div className={classes.shareLinkText}>
<Text fz="sm" fw={500} lineClamp={1}>
{share.page.title || t("untitled")}
</Text>
</div>
</Group>
</Anchor>
</Table.Td>
<Table.Td>
<Group gap="4" wrap="nowrap">
<CustomAvatar
avatarUrl={share.creator?.avatarUrl}
name={share.creator.name}
size="sm"
/>
<Text fz="sm" lineClamp={1}>
{share.creator.name}
</Text>
</Group>
</Table.Td>
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{format(new Date(share.createdAt), "MMM dd, yyyy")}
</Text>
</Table.Td>
<Table.Td>
<ShareActionMenu share={share} />
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
{data?.items.length > 0 && (
<Paginate
currentPage={page}
hasPrevPage={data?.meta.hasPrevPage}
hasNextPage={data?.meta.hasNextPage}
onPageChange={setPage}
/>
)}
</>
);
}

View File

@ -1,227 +0,0 @@
import {
ActionIcon,
Anchor,
Button,
Group,
Indicator,
Popover,
Switch,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import { IconExternalLink, IconWorld } from "@tabler/icons-react";
import React, { useEffect, useMemo, useState } from "react";
import {
useCreateShareMutation,
useDeleteShareMutation,
useShareForPageQuery,
useUpdateShareMutation,
} from "@/features/share/queries/share-query.ts";
import { Link, useParams } from "react-router-dom";
import { extractPageSlugId, getPageIcon } from "@/lib";
import { useTranslation } from "react-i18next";
import CopyTextButton from "@/components/common/copy.tsx";
import { getAppUrl } from "@/lib/config.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import classes from "@/features/share/components/share.module.css";
interface ShareModalProps {
readOnly: boolean;
}
export default function ShareModal({ readOnly }: ShareModalProps) {
const { t } = useTranslation();
const { pageSlug } = useParams();
const pageId = extractPageSlugId(pageSlug);
const { data: share } = useShareForPageQuery(pageId);
const { spaceSlug } = useParams();
const createShareMutation = useCreateShareMutation();
const updateShareMutation = useUpdateShareMutation();
const deleteShareMutation = useDeleteShareMutation();
// pageIsShared means that the share exists and its level equals zero.
const pageIsShared = share && share.level === 0;
// if level is greater than zero, then it is a descendant page from a shared page
const isDescendantShared = share && share.level > 0;
const publicLink = `${getAppUrl()}/share/${share?.key}/p/${pageSlug}`;
const [isPagePublic, setIsPagePublic] = useState<boolean>(false);
useEffect(() => {
if (share) {
setIsPagePublic(true);
} else {
setIsPagePublic(false);
}
}, [share, pageId]);
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
if (value) {
createShareMutation.mutateAsync({
pageId: pageId,
includeSubPages: true,
searchIndexing: true,
});
setIsPagePublic(value);
} else {
if (share && share.id) {
deleteShareMutation.mutateAsync(share.id);
setIsPagePublic(value);
}
}
};
const handleSubPagesChange = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const value = event.currentTarget.checked;
updateShareMutation.mutateAsync({
shareId: share.id,
includeSubPages: value,
});
};
const handleIndexSearchChange = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const value = event.currentTarget.checked;
updateShareMutation.mutateAsync({
shareId: share.id,
searchIndexing: value,
});
};
const shareLink = useMemo(() => (
<Group my="sm" gap={4} wrap="nowrap">
<TextInput
variant="filled"
value={publicLink}
readOnly
rightSection={<CopyTextButton text={publicLink} />}
style={{ width: "100%" }}
/>
<ActionIcon
component="a"
variant="default"
target="_blank"
href={publicLink}
size="sm"
>
<IconExternalLink size={16} />
</ActionIcon>
</Group>
), [publicLink]);
return (
<Popover width={350} position="bottom" withArrow shadow="md">
<Popover.Target>
<Button
style={{ border: "none" }}
size="compact-sm"
leftSection={
<Indicator
color="green"
offset={5}
disabled={!isPagePublic}
withBorder
>
<IconWorld size={20} stroke={1.5} />
</Indicator>
}
variant="default"
>
{t("Share")}
</Button>
</Popover.Target>
<Popover.Dropdown style={{ userSelect: "none" }}>
{isDescendantShared ? (
<>
<Text size="sm">{t("Inherits public sharing from")}</Text>
<Anchor
size="sm"
underline="never"
style={{
cursor: "pointer",
color: "var(--mantine-color-text)",
}}
component={Link}
to={buildPageUrl(
spaceSlug,
share.sharedPage.slugId,
share.sharedPage.title,
)}
>
<Group gap="4" wrap="nowrap" my="sm">
{getPageIcon(share.sharedPage.icon)}
<div className={classes.shareLinkText}>
<Text fz="sm" fw={500} lineClamp={1}>
{share.sharedPage.title || t("untitled")}
</Text>
</div>
</Group>
</Anchor>
{shareLink}
</>
) : (
<>
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="sm">
{isPagePublic ? t("Shared to web") : t("Share to web")}
</Text>
<Text size="xs" c="dimmed">
{isPagePublic
? t("Anyone with the link can view this page")
: t("Make this page publicly accessible")}
</Text>
</div>
<Switch
onChange={handleChange}
defaultChecked={isPagePublic}
disabled={readOnly}
size="xs"
/>
</Group>
{pageIsShared && (
<>
{shareLink}
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="sm">{t("Include sub-pages")}</Text>
<Text size="xs" c="dimmed">
{t("Make sub-pages public too")}
</Text>
</div>
<Switch
onChange={handleSubPagesChange}
checked={share.includeSubPages}
size="xs"
disabled={readOnly}
/>
</Group>
<Group justify="space-between" wrap="nowrap" gap="xl" mt="sm">
<div>
<Text size="sm">{t("Search engine indexing")}</Text>
<Text size="xs" c="dimmed">
{t("Allow search engines to index page")}
</Text>
</div>
<Switch
onChange={handleIndexSearchChange}
checked={share.searchIndexing}
size="xs"
disabled={readOnly}
/>
</Group>
</>
)}
</>
)}
</Popover.Dropdown>
</Popover>
);
}

View File

@ -1,195 +0,0 @@
import React from "react";
import {
ActionIcon,
Affix,
AppShell,
Button,
Group,
ScrollArea,
Tooltip,
} from "@mantine/core";
import { useGetSharedPageTreeQuery } from "@/features/share/queries/share-query.ts";
import { useParams } from "react-router-dom";
import SharedTree from "@/features/share/components/shared-tree.tsx";
import { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx";
import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import { ThemeToggle } from "@/components/theme-toggle.tsx";
import { useAtomValue } from "jotai";
import { useAtom } from "jotai";
import {
desktopSidebarAtom,
mobileSidebarAtom,
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
import { useTranslation } from "react-i18next";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import {
mobileTableOfContentAsideAtom,
tableOfContentAsideAtom,
} from "@/features/share/atoms/sidebar-atom.ts";
import { IconList } from "@tabler/icons-react";
import { useToggleToc } from "@/features/share/hooks/use-toggle-toc.ts";
import classes from "./share.module.css";
import {
SearchControl,
SearchMobileControl,
} from "@/features/search/components/search-control.tsx";
import { ShareSearchSpotlight } from "@/features/search/share-search-spotlight";
import { shareSearchSpotlight } from "@/features/search/constants";
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

@ -1,20 +0,0 @@
.shareLinkText {
@mixin light {
border-bottom: 0.05em solid var(--mantine-color-dark-0);
}
@mixin dark {
border-bottom: 0.05em solid var(--mantine-color-dark-2);
}
}
.treeNode {
text-decoration: none;
user-select: none;
}
.navbar,
.aside {
@media (max-width: $mantine-breakpoint-sm) {
width: 350px;
}
}

View File

@ -1,195 +0,0 @@
import { ISharedPageTree } from "@/features/share/types/share.types.ts";
import { NodeApi, NodeRendererProps, Tree, TreeApi } from "react-arborist";
import {
buildSharedPageTree,
SharedPageTreeNode,
} from "@/features/share/utils.ts";
import { useEffect, useMemo, useRef, useState } from "react";
import { useElementSize, useMergedRef } from "@mantine/hooks";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { Link, useParams } from "react-router-dom";
import { atom, useAtom } from "jotai/index";
import { useTranslation } from "react-i18next";
import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
import clsx from "clsx";
import {
IconChevronDown,
IconChevronRight,
IconFileDescription,
IconPointFilled,
} from "@tabler/icons-react";
import { ActionIcon, Box } from "@mantine/core";
import { extractPageSlugId } from "@/lib";
import { OpenMap } from "react-arborist/dist/main/state/open-slice";
import classes from "@/features/page/tree/styles/tree.module.css";
import styles from "./share.module.css";
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
interface SharedTree {
sharedPageTree: ISharedPageTree;
}
const openSharedTreeNodesAtom = atom<OpenMap>({});
export default function SharedTree({ sharedPageTree }: SharedTree) {
const [tree, setTree] = useState<
TreeApi<SharedPageTreeNode> | null | undefined
>(null);
const rootElement = useRef<HTMLDivElement>();
const { ref: sizeRef, width, height } = useElementSize();
const mergedRef = useMergedRef(rootElement, sizeRef);
const { pageSlug } = useParams();
const [openTreeNodes, setOpenTreeNodes] = useAtom<OpenMap>(
openSharedTreeNodesAtom,
);
const currentNodeId = extractPageSlugId(pageSlug);
const treeData: SharedPageTreeNode[] = useMemo(() => {
if (!sharedPageTree?.pageTree) return;
return buildSharedPageTree(sharedPageTree.pageTree);
}, [sharedPageTree?.pageTree]);
useEffect(() => {
const parentNodeId = treeData?.[0]?.slugId;
if (parentNodeId && tree) {
const parentNode = tree.get(parentNodeId);
setTimeout(() => {
if (parentNode) {
tree.openSiblings(parentNode);
}
});
// open direct children of parent node
parentNode?.children.forEach((node) => {
tree.openSiblings(node);
});
}
}, [treeData, tree]);
useEffect(() => {
if (currentNodeId && tree) {
setTimeout(() => {
// focus on node and open all parents
tree?.select(currentNodeId, { align: "auto" });
}, 200);
} else {
tree?.deselectAll();
}
}, [currentNodeId, tree]);
if (!sharedPageTree || !sharedPageTree?.pageTree) {
return null;
}
return (
<div ref={mergedRef} className={classes.treeContainer}>
{rootElement.current && (
<Tree
data={treeData}
disableDrag={true}
disableDrop={true}
disableEdit={true}
width={width}
height={rootElement.current.clientHeight}
ref={(t) => setTree(t)}
openByDefault={false}
disableMultiSelection={true}
className={classes.tree}
rowClassName={classes.row}
rowHeight={30}
overscanCount={10}
dndRootElement={rootElement.current}
onToggle={() => {
setOpenTreeNodes(tree?.openState);
}}
initialOpenState={openTreeNodes}
onClick={(e) => {
if (tree && tree.focusedNode) {
tree.select(tree.focusedNode);
}
}}
>
{Node}
</Tree>
)}
</div>
);
}
function Node({ node, style, tree }: NodeRendererProps<any>) {
const { shareId } = useParams();
const { t } = useTranslation();
const [, setMobileSidebarState] = useAtom(mobileSidebarAtom);
const pageUrl = buildSharedPageUrl({
shareId: shareId,
pageSlugId: node.data.slugId,
pageTitle: node.data.name,
});
return (
<>
<Box
style={style}
className={clsx(classes.node, node.state, styles.treeNode)}
component={Link}
to={pageUrl}
onClick={() => {
setMobileSidebarState(false);
}}
>
<PageArrow node={node} />
<div style={{ marginRight: "4px" }}>
<EmojiPicker
onEmojiSelect={() => {}}
icon={
node.data.icon ? (
node.data.icon
) : (
<IconFileDescription size="18" />
)
}
readOnly={true}
removeEmojiAction={() => {}}
/>
</div>
<span className={classes.text}>{node.data.name || t("untitled")}</span>
</Box>
</>
);
}
interface PageArrowProps {
node: NodeApi<SpaceTreeNode>;
}
function PageArrow({ node }: PageArrowProps) {
return (
<ActionIcon
size={20}
variant="subtle"
c="gray"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
node.toggle();
}}
>
{node.isInternal ? (
node.children && (node.children.length > 0 || node.data.hasChildren) ? (
node.isOpen ? (
<IconChevronDown stroke={2} size={16} />
) : (
<IconChevronRight stroke={2} size={16} />
)
) : (
<IconPointFilled size={4} />
)
) : null}
</ActionIcon>
);
}

View File

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

View File

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

View File

@ -1,59 +0,0 @@
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

@ -1,73 +0,0 @@
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

@ -1,68 +0,0 @@
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

@ -81,7 +81,7 @@ export function SpaceSelect({
nothingFoundMessage={t("No space found")} nothingFoundMessage={t("No space found")}
limit={50} limit={50}
checkIconPosition="right" checkIconPosition="right"
comboboxProps={{ width, withinPortal: true, position: "bottom" }} comboboxProps={{ width, withinPortal: false }}
dropdownOpened={opened} dropdownOpened={opened}
/> />
); );

View File

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

View File

@ -7,11 +7,6 @@ export type InvalidateEvent = {
id?: string; id?: string;
}; };
export type InvalidateCommentsEvent = {
operation: "invalidateComment";
pageId: string;
};
export type UpdateEvent = { export type UpdateEvent = {
operation: "updateOne"; operation: "updateOne";
spaceId: string; spaceId: string;
@ -57,4 +52,4 @@ export type DeleteTreeNodeEvent = {
} }
}; };
export type WebSocketEvent = InvalidateEvent | InvalidateCommentsEvent | UpdateEvent | DeleteEvent | AddTreeNodeEvent | MoveTreeNodeEvent | DeleteTreeNodeEvent; export type WebSocketEvent = InvalidateEvent | UpdateEvent | DeleteEvent | AddTreeNodeEvent | MoveTreeNodeEvent | DeleteTreeNodeEvent;

View File

@ -3,7 +3,6 @@ import { socketAtom } from "@/features/websocket/atoms/socket-atom.ts";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { WebSocketEvent } from "@/features/websocket/types"; import { WebSocketEvent } from "@/features/websocket/types";
import { RQ_KEY } from "../comment/queries/comment-query";
export const useQuerySubscription = () => { export const useQuerySubscription = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -22,11 +21,6 @@ export const useQuerySubscription = () => {
queryKey: [...data.entity, data.id].filter(Boolean), queryKey: [...data.entity, data.id].filter(Boolean),
}); });
break; break;
case "invalidateComment":
queryClient.invalidateQueries({
queryKey: RQ_KEY(data.pageId),
});
break;
case "updateOne": case "updateOne":
entity = data.entity[0]; entity = data.entity[0];
if (entity === "pages") { if (entity === "pages") {

View File

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

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

View File

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

View File

@ -1,31 +0,0 @@
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

@ -1,35 +0,0 @@
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

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

View File

@ -1,6 +1,6 @@
{ {
"name": "server", "name": "server",
"version": "0.20.4", "version": "0.10.2",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@ -37,18 +37,18 @@
"@fastify/multipart": "^9.0.3", "@fastify/multipart": "^9.0.3",
"@fastify/static": "^8.1.1", "@fastify/static": "^8.1.1",
"@nestjs/bullmq": "^11.0.2", "@nestjs/bullmq": "^11.0.2",
"@nestjs/common": "^11.0.20", "@nestjs/common": "^11.0.10",
"@nestjs/config": "^4.0.2", "@nestjs/config": "^4.0.0",
"@nestjs/core": "^11.0.20", "@nestjs/core": "^11.0.10",
"@nestjs/event-emitter": "^3.0.0", "@nestjs/event-emitter": "^3.0.0",
"@nestjs/jwt": "^11.0.0", "@nestjs/jwt": "^11.0.0",
"@nestjs/mapped-types": "^2.1.0", "@nestjs/mapped-types": "^2.1.0",
"@nestjs/passport": "^11.0.5", "@nestjs/passport": "^11.0.5",
"@nestjs/platform-fastify": "^11.0.20", "@nestjs/platform-fastify": "^11.0.10",
"@nestjs/platform-socket.io": "^11.0.20", "@nestjs/platform-socket.io": "^11.0.10",
"@nestjs/schedule": "^5.0.1", "@nestjs/schedule": "^5.0.1",
"@nestjs/terminus": "^11.0.0", "@nestjs/terminus": "^11.0.0",
"@nestjs/websockets": "^11.0.20", "@nestjs/websockets": "^11.0.10",
"@node-saml/passport-saml": "^5.0.1", "@node-saml/passport-saml": "^5.0.1",
"@react-email/components": "0.0.28", "@react-email/components": "0.0.28",
"@react-email/render": "1.0.2", "@react-email/render": "1.0.2",

View File

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

View File

@ -46,7 +46,7 @@ export const tiptapExtensions = [
codeBlock: false, codeBlock: false,
}), }),
Comment, Comment,
TextAlign.configure({ types: ["heading", "paragraph"] }), TextAlign,
TaskList, TaskList,
TaskItem, TaskItem,
Underline, Underline,

View File

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

View File

@ -1,12 +1,5 @@
import { Node } from '@tiptap/pm/model'; import { Node } from '@tiptap/pm/model';
import { import { jsonToNode } from '../../../collaboration/collaboration.util';
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 { export interface MentionNode {
id: string; id: string;
@ -63,67 +56,3 @@ export function extractPageMentions(mentionList: MentionNode[]): MentionNode[] {
} }
return pageMentionList as 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

@ -9,7 +9,6 @@ import {
NotFoundException, NotFoundException,
Param, Param,
Post, Post,
Query,
Req, Req,
Res, Res,
UseGuards, UseGuards,
@ -47,9 +46,7 @@ import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory
import {PageRepo} from '@docmost/db/repos/page/page.repo'; import {PageRepo} from '@docmost/db/repos/page/page.repo';
import {AttachmentRepo} from '@docmost/db/repos/attachment/attachment.repo'; import {AttachmentRepo} from '@docmost/db/repos/attachment/attachment.repo';
import {validate as isValidUUID} from 'uuid'; import {validate as isValidUUID} from 'uuid';
import { EnvironmentService } from '../../integrations/environment/environment.service'; import {EnvironmentService} from "../../integrations/environment/environment.service";
import { TokenService } from '../auth/services/token.service';
import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload';
@Controller() @Controller()
export class AttachmentController { export class AttachmentController {
@ -63,8 +60,8 @@ export class AttachmentController {
private readonly pageRepo: PageRepo, private readonly pageRepo: PageRepo,
private readonly attachmentRepo: AttachmentRepo, private readonly attachmentRepo: AttachmentRepo,
private readonly environmentService: EnvironmentService, private readonly environmentService: EnvironmentService,
private readonly tokenService: TokenService, ) {
) {} }
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ -198,66 +195,6 @@ export class AttachmentController {
} }
} }
@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',
);
}
if (
!isValidUUID(fileId) ||
fileId !== jwtPayload.attachmentId ||
jwtPayload.workspaceId !== workspace.id
) {
throw new NotFoundException('File not found');
}
const attachment = await this.attachmentRepo.findById(fileId);
if (
!attachment ||
attachment.workspaceId !== workspace.id ||
!attachment.pageId ||
!attachment.spaceId ||
jwtPayload.pageId !== attachment.pageId
) {
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) @UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('attachments/upload-image') @Post('attachments/upload-image')

View File

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

View File

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

View File

@ -6,7 +6,6 @@ import {
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { EnvironmentService } from '../../../integrations/environment/environment.service'; import { EnvironmentService } from '../../../integrations/environment/environment.service';
import { import {
JwtAttachmentPayload,
JwtCollabPayload, JwtCollabPayload,
JwtExchangePayload, JwtExchangePayload,
JwtPayload, JwtPayload,
@ -60,21 +59,6 @@ export class TokenService {
return this.jwtService.sign(payload, { expiresIn: '10s' }); 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) { async verifyJwt(token: string, tokenType: string) {
const payload = await this.jwtService.verifyAsync(token, { const payload = await this.jwtService.verifyAsync(token, {
secret: this.environmentService.getAppSecret(), secret: this.environmentService.getAppSecret(),

View File

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

View File

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

View File

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

View File

@ -1,24 +0,0 @@
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,10 +1,4 @@
import { import { IsString, IsOptional, MinLength, MaxLength } from 'class-validator';
IsString,
IsOptional,
MinLength,
MaxLength,
IsNotEmpty,
} from 'class-validator';
export class MovePageDto { export class MovePageDto {
@IsString() @IsString()
@ -21,11 +15,9 @@ export class MovePageDto {
} }
export class MovePageToSpaceDto { export class MovePageToSpaceDto {
@IsNotEmpty()
@IsString() @IsString()
pageId: string; pageId: string;
@IsNotEmpty()
@IsString() @IsString()
spaceId: string; spaceId: string;
} }

View File

@ -28,7 +28,6 @@ import {
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory'; import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { RecentPageDto } from './dto/recent-page.dto'; import { RecentPageDto } from './dto/recent-page.dto';
import { CopyPageToSpaceDto } from './dto/copy-page.dto';
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('pages') @Controller('pages')
@ -238,36 +237,6 @@ export class PageController {
return this.pageService.movePageToSpace(movedPage, dto.spaceId); 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) @HttpCode(HttpStatus.OK)
@Post('move') @Post('move')
async movePage(@Body() dto: MovePageDto, @AuthUser() user: User) { async movePage(@Body() dto: MovePageDto, @AuthUser() user: User) {

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