mirror of
https://github.com/docmost/docmost.git
synced 2025-11-13 11:22:37 +10:00
Compare commits
1 Commits
v0.20.1
...
revert-104
| Author | SHA1 | Date | |
|---|---|---|---|
| dc9ed4fa88 |
@ -6,7 +6,6 @@
|
|||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Docmost</title>
|
<title>Docmost</title>
|
||||||
<!--meta-tags-->
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.20.1",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -362,26 +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"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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": "ページの共有に失敗しました"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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": "페이지 공유에 실패했습니다"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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": "Не удалось поделиться страницей"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.": "添加标题(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": "页面分享失败"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 />} />}
|
||||||
|
|||||||
@ -14,8 +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 { useClickOutside, useMergedRef } from "@mantine/hooks";
|
|
||||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
|
||||||
|
|
||||||
export default function GlobalAppShell({
|
export default function GlobalAppShell({
|
||||||
children,
|
children,
|
||||||
@ -24,19 +22,11 @@ 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);
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
const sidebarRef = useRef(null);
|
const sidebarRef = useRef(null);
|
||||||
const navbarOutsideRef = useClickOutside(() => {
|
|
||||||
if (mobileOpened) {
|
|
||||||
toggleMobile();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const mergedRef = useMergedRef(sidebarRef, navbarOutsideRef);
|
|
||||||
|
|
||||||
const startResizing = React.useCallback((mouseDownEvent) => {
|
const startResizing = React.useCallback((mouseDownEvent) => {
|
||||||
mouseDownEvent.preventDefault();
|
mouseDownEvent.preventDefault();
|
||||||
@ -112,7 +102,7 @@ export default function GlobalAppShell({
|
|||||||
<AppShell.Navbar
|
<AppShell.Navbar
|
||||||
className={classes.navbar}
|
className={classes.navbar}
|
||||||
withBorder={false}
|
withBorder={false}
|
||||||
ref={mergedRef}
|
ref={sidebarRef}
|
||||||
>
|
>
|
||||||
<div className={classes.resizeHandle} onMouseDown={startResizing} />
|
<div className={classes.resizeHandle} onMouseDown={startResizing} />
|
||||||
{isSpaceRoute && <SpaceSidebar />}
|
{isSpaceRoute && <SpaceSidebar />}
|
||||||
@ -121,7 +111,7 @@ export default function GlobalAppShell({
|
|||||||
)}
|
)}
|
||||||
<AppShell.Main>
|
<AppShell.Main>
|
||||||
{isSettingsRoute ? (
|
{isSettingsRoute ? (
|
||||||
<Container size={850}>{children}</Container>
|
<Container size={800}>{children}</Container>
|
||||||
) : (
|
) : (
|
||||||
children
|
children
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,10 +0,0 @@
|
|||||||
import { atom, WritableAtom } from "jotai";
|
|
||||||
|
|
||||||
export const settingsOriginAtom: WritableAtom<string | null, [string | null], void> = atom(
|
|
||||||
null,
|
|
||||||
(get, set, newValue) => {
|
|
||||||
if (get(settingsOriginAtom) !== newValue) {
|
|
||||||
set(settingsOriginAtom, newValue);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
@ -8,8 +8,7 @@ import { getGroups } from "@/features/group/services/group-service.ts";
|
|||||||
import { QueryParams } from "@/lib/types.ts";
|
import { QueryParams } from "@/lib/types.ts";
|
||||||
import { getWorkspaceMembers } from "@/features/workspace/services/workspace-service.ts";
|
import { getWorkspaceMembers } from "@/features/workspace/services/workspace-service.ts";
|
||||||
import { getLicenseInfo } from "@/ee/licence/services/license-service.ts";
|
import { getLicenseInfo } from "@/ee/licence/services/license-service.ts";
|
||||||
import { getSsoProviders } from "@/ee/security/services/security-service.ts";
|
import { getSsoProviders } from '@/ee/security/services/security-service.ts';
|
||||||
import { getShares } from "@/features/share/services/share-service.ts";
|
|
||||||
|
|
||||||
export const prefetchWorkspaceMembers = () => {
|
export const prefetchWorkspaceMembers = () => {
|
||||||
const params = { limit: 100, page: 1, query: "" } as QueryParams;
|
const params = { limit: 100, page: 1, query: "" } as QueryParams;
|
||||||
@ -57,11 +56,4 @@ export const prefetchSsoProviders = () => {
|
|||||||
queryKey: ["sso-providers"],
|
queryKey: ["sso-providers"],
|
||||||
queryFn: () => getSsoProviders(),
|
queryFn: () => getSsoProviders(),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const prefetchShares = () => {
|
|
||||||
queryClient.prefetchQuery({
|
|
||||||
queryKey: ["share-list", { page: 1 }],
|
|
||||||
queryFn: () => getShares({ page: 1, limit: 100 }),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -11,9 +11,8 @@ import {
|
|||||||
IconCoin,
|
IconCoin,
|
||||||
IconLock,
|
IconLock,
|
||||||
IconKey,
|
IconKey,
|
||||||
IconWorld,
|
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||||
import classes from "./settings.module.css";
|
import classes from "./settings.module.css";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { isCloud } from "@/lib/config.ts";
|
import { isCloud } from "@/lib/config.ts";
|
||||||
@ -24,15 +23,11 @@ import {
|
|||||||
prefetchBilling,
|
prefetchBilling,
|
||||||
prefetchGroups,
|
prefetchGroups,
|
||||||
prefetchLicense,
|
prefetchLicense,
|
||||||
prefetchShares,
|
|
||||||
prefetchSpaces,
|
prefetchSpaces,
|
||||||
prefetchSsoProviders,
|
prefetchSsoProviders,
|
||||||
prefetchWorkspaceMembers,
|
prefetchWorkspaceMembers,
|
||||||
} from "@/components/settings/settings-queries.tsx";
|
} from "@/components/settings/settings-queries.tsx";
|
||||||
import AppVersion from "@/components/settings/app-version.tsx";
|
import AppVersion from "@/components/settings/app-version.tsx";
|
||||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
|
||||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
|
||||||
import { useSettingsNavigation } from "@/hooks/use-settings-navigation";
|
|
||||||
|
|
||||||
interface DataItem {
|
interface DataItem {
|
||||||
label: string;
|
label: string;
|
||||||
@ -87,7 +82,6 @@ const groupedData: DataGroup[] = [
|
|||||||
},
|
},
|
||||||
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
|
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
|
||||||
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
|
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
|
||||||
{ label: "Public sharing", icon: IconWorld, path: "/settings/sharing" },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -106,11 +100,9 @@ export default function SettingsSidebar() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [active, setActive] = useState(location.pathname);
|
const [active, setActive] = useState(location.pathname);
|
||||||
const { goBack } = useSettingsNavigation();
|
const navigate = useNavigate();
|
||||||
const { isAdmin } = useUserRole();
|
const { isAdmin } = useUserRole();
|
||||||
const [workspace] = useAtom(workspaceAtom);
|
const [workspace] = useAtom(workspaceAtom);
|
||||||
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
|
|
||||||
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActive(location.pathname);
|
setActive(location.pathname);
|
||||||
@ -178,9 +170,6 @@ export default function SettingsSidebar() {
|
|||||||
case "Security & SSO":
|
case "Security & SSO":
|
||||||
prefetchHandler = prefetchSsoProviders;
|
prefetchHandler = prefetchSsoProviders;
|
||||||
break;
|
break;
|
||||||
case "Public sharing":
|
|
||||||
prefetchHandler = prefetchShares;
|
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -192,11 +181,6 @@ export default function SettingsSidebar() {
|
|||||||
data-active={active.startsWith(item.path) || undefined}
|
data-active={active.startsWith(item.path) || undefined}
|
||||||
key={item.label}
|
key={item.label}
|
||||||
to={item.path}
|
to={item.path}
|
||||||
onClick={() => {
|
|
||||||
if (mobileSidebarOpened) {
|
|
||||||
toggleMobileSidebar();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<item.icon className={classes.linkIcon} stroke={2} />
|
<item.icon className={classes.linkIcon} stroke={2} />
|
||||||
<span>{t(item.label)}</span>
|
<span>{t(item.label)}</span>
|
||||||
@ -211,12 +195,7 @@ export default function SettingsSidebar() {
|
|||||||
<div className={classes.navbar}>
|
<div className={classes.navbar}>
|
||||||
<Group className={classes.title} justify="flex-start">
|
<Group className={classes.title} justify="flex-start">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={() => {
|
onClick={() => navigate(-1)}
|
||||||
goBack();
|
|
||||||
if (mobileSidebarOpened) {
|
|
||||||
toggleMobileSidebar();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
variant="transparent"
|
variant="transparent"
|
||||||
c="gray"
|
c="gray"
|
||||||
aria-label="Back"
|
aria-label="Back"
|
||||||
|
|||||||
@ -1,19 +0,0 @@
|
|||||||
.dark {
|
|
||||||
@mixin dark {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin light {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.light {
|
|
||||||
@mixin light {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin dark {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,28 +1,13 @@
|
|||||||
import {
|
import { Button, Group, useMantineColorScheme } from '@mantine/core';
|
||||||
ActionIcon,
|
|
||||||
Tooltip,
|
|
||||||
useComputedColorScheme,
|
|
||||||
useMantineColorScheme,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { IconMoon, IconSun } from "@tabler/icons-react";
|
|
||||||
import classes from "./theme-toggle.module.css";
|
|
||||||
|
|
||||||
export function ThemeToggle() {
|
export function ThemeToggle() {
|
||||||
const { setColorScheme } = useMantineColorScheme();
|
const { setColorScheme } = useMantineColorScheme();
|
||||||
const computedColorScheme = useComputedColorScheme();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip label="Toggle Color Scheme">
|
<Group justify="center" mt="xl">
|
||||||
<ActionIcon
|
<Button onClick={() => setColorScheme('light')}>Light</Button>
|
||||||
variant="default"
|
<Button onClick={() => setColorScheme('dark')}>Dark</Button>
|
||||||
onClick={() => {
|
<Button onClick={() => setColorScheme('auto')}>Auto</Button>
|
||||||
setColorScheme(computedColorScheme === "light" ? "dark" : "light");
|
</Group>
|
||||||
}}
|
);
|
||||||
aria-label="Toggle color scheme"
|
|
||||||
>
|
|
||||||
<IconSun className={classes.light} size={18} stroke={1.5} />
|
|
||||||
<IconMoon className={classes.dark} size={18} stroke={1.5} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>("");
|
||||||
|
|||||||
@ -139,7 +139,7 @@ export default function DrawioView(props: NodeViewProps) {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{selected && editor.isEditable && (
|
{selected && (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={handleOpen}
|
onClick={handleOpen}
|
||||||
variant="default"
|
variant="default"
|
||||||
|
|||||||
@ -170,7 +170,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{selected && editor.isEditable && (
|
{selected && (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={handleOpen}
|
onClick={handleOpen}
|
||||||
variant="default"
|
variant="default"
|
||||||
|
|||||||
@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -52,8 +52,3 @@
|
|||||||
) !important;
|
) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.leftBorder {
|
|
||||||
border-left: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
|
||||||
}
|
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
type TableOfContentsProps = {
|
type TableOfContentsProps = {
|
||||||
editor: ReturnType<typeof useEditor>;
|
editor: ReturnType<typeof useEditor>;
|
||||||
isShare?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HeadingLink = {
|
export type HeadingLink = {
|
||||||
@ -74,7 +73,6 @@ export const TableOfContents: FC<TableOfContentsProps> = (props) => {
|
|||||||
|
|
||||||
const handleUpdate = () => {
|
const handleUpdate = () => {
|
||||||
const result = recalculateLinks(props.editor?.$nodes("heading"));
|
const result = recalculateLinks(props.editor?.$nodes("heading"));
|
||||||
|
|
||||||
setLinks(result.links);
|
setLinks(result.links);
|
||||||
setHeadingDOMNodes(result.nodes);
|
setHeadingDOMNodes(result.nodes);
|
||||||
};
|
};
|
||||||
@ -87,12 +85,9 @@ export const TableOfContents: FC<TableOfContentsProps> = (props) => {
|
|||||||
};
|
};
|
||||||
}, [props.editor]);
|
}, [props.editor]);
|
||||||
|
|
||||||
useEffect(
|
useEffect(() => {
|
||||||
() => {
|
handleUpdate();
|
||||||
handleUpdate();
|
}, []);
|
||||||
},
|
|
||||||
props.isShare ? [props.editor] : [],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
@ -138,29 +133,16 @@ export const TableOfContents: FC<TableOfContentsProps> = (props) => {
|
|||||||
if (!links.length) {
|
if (!links.length) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!props.isShare && (
|
<Text size="sm">
|
||||||
<Text size="sm">
|
{t("Add headings (H1, H2, H3) to generate a table of contents.")}
|
||||||
{t("Add headings (H1, H2, H3) to generate a table of contents.")}
|
</Text>
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{props.isShare && (
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t("No table of contents.")}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{props.isShare && (
|
<div>
|
||||||
<Text mb="md" fw={500}>
|
|
||||||
{t("Table of contents")}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<div className={props.isShare ? classes.leftBorder : ""}>
|
|
||||||
{links.map((item, idx) => (
|
{links.map((item, idx) => (
|
||||||
<Box<"button">
|
<Box<"button">
|
||||||
component="button"
|
component="button"
|
||||||
|
|||||||
@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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) => (
|
<Anchor
|
||||||
<Button.Group orientation="vertical" key={node.id}>
|
component={Link}
|
||||||
<Button
|
to={buildPageUrl(spaceSlug, node.slugId, node.name)}
|
||||||
justify="start"
|
underline="never"
|
||||||
component={Link}
|
fz={"sm"}
|
||||||
to={buildPageUrl(spaceSlug, node.slugId, node.name)}
|
key={node.id}
|
||||||
variant="default"
|
className={classes.truncatedText}
|
||||||
style={{ border: "none" }}
|
>
|
||||||
>
|
{getTitle(node.name, node.icon)}
|
||||||
<Text fz={"sm"} className={classes.truncatedText}>
|
</Anchor>
|
||||||
{getTitle(node.name, node.icon)}
|
|
||||||
</Text>
|
|
||||||
</Button>
|
|
||||||
</Button.Group>
|
|
||||||
));
|
|
||||||
|
|
||||||
const renderAnchor = useCallback(
|
|
||||||
(node: SpaceTreeNode) => (
|
|
||||||
<Tooltip label={node.name} key={node.id}>
|
|
||||||
<Anchor
|
|
||||||
component={Link}
|
|
||||||
to={buildPageUrl(spaceSlug, node.slugId, node.name)}
|
|
||||||
underline="never"
|
|
||||||
fz="sm"
|
|
||||||
key={node.id}
|
|
||||||
className={classes.truncatedText}
|
|
||||||
>
|
|
||||||
{getTitle(node.name, node.icon)}
|
|
||||||
</Anchor>
|
|
||||||
</Tooltip>
|
|
||||||
),
|
|
||||||
[spaceSlug],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const getBreadcrumbItems = () => {
|
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>
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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)}`;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -8,9 +8,9 @@ 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,
|
||||||
@ -58,8 +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";
|
|
||||||
|
|
||||||
interface SpaceTreeProps {
|
interface SpaceTreeProps {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
@ -232,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(() => {
|
||||||
@ -290,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);
|
||||||
@ -343,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}
|
||||||
>
|
>
|
||||||
@ -392,7 +385,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Box>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,7 +18,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 93%; /* not to overlap with scroll bar */
|
width: 93%; /* not to overlap with scroll bar */
|
||||||
text-decoration: none;
|
|
||||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@ -70,10 +70,6 @@
|
|||||||
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
|
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
|
||||||
}
|
}
|
||||||
|
|
||||||
.row:focus .node:global(.isFocused) {
|
|
||||||
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
|
|
||||||
}
|
|
||||||
|
|
||||||
.row {
|
.row {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { IPage } from "@/features/page/types/page.types.ts";
|
import { IPage } from "@/features/page/types/page.types.ts";
|
||||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||||
|
|
||||||
export function sortPositionKeys(keys: any[]) {
|
function sortPositionKeys(keys: any[]) {
|
||||||
return keys.sort((a, b) => {
|
return keys.sort((a, b) => {
|
||||||
if (a.position < b.position) return -1;
|
if (a.position < b.position) return -1;
|
||||||
if (a.position > b.position) return 1;
|
if (a.position > b.position) return 1;
|
||||||
|
|||||||
@ -1,44 +0,0 @@
|
|||||||
.root {
|
|
||||||
height: 34px;
|
|
||||||
padding-left: var(--mantine-spacing-sm);
|
|
||||||
padding-right: 4px;
|
|
||||||
border-radius: var(--mantine-radius-md);
|
|
||||||
color: var(--mantine-color-placeholder);
|
|
||||||
border: 1px solid;
|
|
||||||
|
|
||||||
@mixin light {
|
|
||||||
border-color: var(--mantine-color-gray-3);
|
|
||||||
background-color: var(--mantine-color-white);
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin dark {
|
|
||||||
border-color: var(--mantine-color-dark-4);
|
|
||||||
background-color: var(--mantine-color-dark-6);
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin rtl {
|
|
||||||
padding-left: 4px;
|
|
||||||
padding-right: var(--mantine-spacing-sm);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.shortcut {
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 1;
|
|
||||||
padding: 4px 7px;
|
|
||||||
border-radius: var(--mantine-radius-sm);
|
|
||||||
border: 1px solid;
|
|
||||||
font-weight: bold;
|
|
||||||
|
|
||||||
@mixin light {
|
|
||||||
color: var(--mantine-color-gray-7);
|
|
||||||
border-color: var(--mantine-color-gray-2);
|
|
||||||
background-color: var(--mantine-color-gray-0);
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin dark {
|
|
||||||
color: var(--mantine-color-dark-0);
|
|
||||||
border-color: var(--mantine-color-dark-7);
|
|
||||||
background-color: var(--mantine-color-dark-7);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
import { IconSearch } from "@tabler/icons-react";
|
|
||||||
import cx from "clsx";
|
|
||||||
import {
|
|
||||||
ActionIcon,
|
|
||||||
BoxProps,
|
|
||||||
ElementProps,
|
|
||||||
Group,
|
|
||||||
rem,
|
|
||||||
Text,
|
|
||||||
Tooltip,
|
|
||||||
UnstyledButton,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import classes from "./search-control.module.css";
|
|
||||||
import React from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
interface SearchControlProps extends BoxProps, ElementProps<"button"> {}
|
|
||||||
|
|
||||||
export function SearchControl({ className, ...others }: SearchControlProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<UnstyledButton {...others} className={cx(classes.root, className)}>
|
|
||||||
<Group gap="xs" wrap="nowrap">
|
|
||||||
<IconSearch style={{ width: rem(15), height: rem(15) }} stroke={1.5} />
|
|
||||||
<Text fz="sm" c="dimmed" pr={80}>
|
|
||||||
{t("Search")}
|
|
||||||
</Text>
|
|
||||||
<Text fw={700} className={classes.shortcut}>
|
|
||||||
Ctrl + K
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</UnstyledButton>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SearchMobileControlProps {
|
|
||||||
onSearch: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SearchMobileControl({ onSearch }: SearchMobileControlProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip label={t("Search")} withArrow>
|
|
||||||
<ActionIcon
|
|
||||||
variant="default"
|
|
||||||
style={{ border: "none" }}
|
|
||||||
onClick={onSearch}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<IconSearch size={20} stroke={2} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
import { createSpotlight } from '@mantine/spotlight';
|
|
||||||
|
|
||||||
export const [searchSpotlightStore, searchSpotlight] = createSpotlight();
|
|
||||||
|
|
||||||
export const [shareSearchSpotlightStore, shareSearchSpotlight] =
|
|
||||||
createSpotlight();
|
|
||||||
|
|
||||||
@ -1,7 +1,6 @@
|
|||||||
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
searchPage,
|
searchPage,
|
||||||
searchShare,
|
|
||||||
searchSuggestions,
|
searchSuggestions,
|
||||||
} from "@/features/search/services/search-service";
|
} from "@/features/search/services/search-service";
|
||||||
import {
|
import {
|
||||||
@ -31,13 +30,3 @@ export function useSearchSuggestionsQuery(
|
|||||||
enabled: !!params.query,
|
enabled: !!params.query,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useShareSearchQuery(
|
|
||||||
params: IPageSearchParams,
|
|
||||||
): UseQueryResult<IPageSearch[], Error> {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["share-search", params],
|
|
||||||
queryFn: () => searchShare(params),
|
|
||||||
enabled: !!params.query,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@ -2,36 +2,36 @@ import { Group, Center, Text } from "@mantine/core";
|
|||||||
import { Spotlight } from "@mantine/spotlight";
|
import { Spotlight } from "@mantine/spotlight";
|
||||||
import { IconSearch } from "@tabler/icons-react";
|
import { IconSearch } from "@tabler/icons-react";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
import { usePageSearchQuery } from "@/features/search/queries/search-query";
|
import { usePageSearchQuery } from "@/features/search/queries/search-query";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
import { getPageIcon } from "@/lib";
|
import { getPageIcon } from "@/lib";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { searchSpotlightStore } from "./constants";
|
|
||||||
|
|
||||||
interface SearchSpotlightProps {
|
interface SearchSpotlightProps {
|
||||||
spaceId?: string;
|
spaceId?: string;
|
||||||
}
|
}
|
||||||
export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [debouncedSearchQuery] = useDebouncedValue(query, 300);
|
const [debouncedSearchQuery] = useDebouncedValue(query, 300);
|
||||||
|
|
||||||
const { data: searchResults } = usePageSearchQuery({
|
const {
|
||||||
query: debouncedSearchQuery,
|
data: searchResults,
|
||||||
spaceId,
|
isLoading,
|
||||||
});
|
error,
|
||||||
|
} = usePageSearchQuery({ query: debouncedSearchQuery, spaceId });
|
||||||
|
|
||||||
const pages = (
|
const pages = (
|
||||||
searchResults && searchResults.length > 0 ? searchResults : []
|
searchResults && searchResults.length > 0 ? searchResults : []
|
||||||
).map((page) => (
|
).map((page) => (
|
||||||
<Spotlight.Action
|
<Spotlight.Action
|
||||||
key={page.id}
|
key={page.id}
|
||||||
component={Link}
|
onClick={() =>
|
||||||
//@ts-ignore
|
navigate(buildPageUrl(page.space.slug, page.slugId, page.title))
|
||||||
to={buildPageUrl(page.space.slug, page.slugId, page.title)}
|
}
|
||||||
style={{ userSelect: "none" }}
|
|
||||||
>
|
>
|
||||||
<Group wrap="nowrap" w="100%">
|
<Group wrap="nowrap" w="100%">
|
||||||
<Center>{getPageIcon(page?.icon)}</Center>
|
<Center>{getPageIcon(page?.icon)}</Center>
|
||||||
@ -54,7 +54,6 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Spotlight.Root
|
<Spotlight.Root
|
||||||
store={searchSpotlightStore}
|
|
||||||
query={query}
|
query={query}
|
||||||
onQueryChange={setQuery}
|
onQueryChange={setQuery}
|
||||||
scrollable
|
scrollable
|
||||||
|
|||||||
@ -19,10 +19,3 @@ export async function searchSuggestions(
|
|||||||
const req = await api.post<ISuggestionResult>("/search/suggest", params);
|
const req = await api.post<ISuggestionResult>("/search/suggest", params);
|
||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchShare(
|
|
||||||
params: IPageSearchParams,
|
|
||||||
): Promise<IPageSearch[]> {
|
|
||||||
const req = await api.post<IPageSearch[]>("/search/share-search", params);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,87 +0,0 @@
|
|||||||
import { Group, Center, Text } from "@mantine/core";
|
|
||||||
import { Spotlight } from "@mantine/spotlight";
|
|
||||||
import { IconSearch } from "@tabler/icons-react";
|
|
||||||
import React, { useState } from "react";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
|
||||||
import { useShareSearchQuery } from "@/features/search/queries/search-query";
|
|
||||||
import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
|
|
||||||
import { getPageIcon } from "@/lib";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { shareSearchSpotlightStore } from "@/features/search/constants.ts";
|
|
||||||
|
|
||||||
interface ShareSearchSpotlightProps {
|
|
||||||
shareId?: string;
|
|
||||||
}
|
|
||||||
export function ShareSearchSpotlight({ shareId }: ShareSearchSpotlightProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const [debouncedSearchQuery] = useDebouncedValue(query, 300);
|
|
||||||
|
|
||||||
const { data: searchResults } = useShareSearchQuery({
|
|
||||||
query: debouncedSearchQuery,
|
|
||||||
shareId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const pages = (
|
|
||||||
searchResults && searchResults.length > 0 ? searchResults : []
|
|
||||||
).map((page) => (
|
|
||||||
<Spotlight.Action
|
|
||||||
key={page.id}
|
|
||||||
component={Link}
|
|
||||||
//@ts-ignore
|
|
||||||
to={buildSharedPageUrl({
|
|
||||||
shareId: shareId,
|
|
||||||
pageTitle: page.title,
|
|
||||||
pageSlugId: page.slugId,
|
|
||||||
})}
|
|
||||||
style={{ userSelect: "none" }}
|
|
||||||
>
|
|
||||||
<Group wrap="nowrap" w="100%">
|
|
||||||
<Center>{getPageIcon(page?.icon)}</Center>
|
|
||||||
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<Text>{page.title}</Text>
|
|
||||||
|
|
||||||
{page?.highlight && (
|
|
||||||
<Text
|
|
||||||
opacity={0.6}
|
|
||||||
size="xs"
|
|
||||||
dangerouslySetInnerHTML={{ __html: page.highlight }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
</Spotlight.Action>
|
|
||||||
));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Spotlight.Root
|
|
||||||
store={shareSearchSpotlightStore}
|
|
||||||
query={query}
|
|
||||||
onQueryChange={setQuery}
|
|
||||||
scrollable
|
|
||||||
overlayProps={{
|
|
||||||
backgroundOpacity: 0.55,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Spotlight.Search
|
|
||||||
placeholder={t("Search...")}
|
|
||||||
leftSection={<IconSearch size={20} stroke={1.5} />}
|
|
||||||
/>
|
|
||||||
<Spotlight.ActionsList>
|
|
||||||
{query.length === 0 && pages.length === 0 && (
|
|
||||||
<Spotlight.Empty>{t("Start typing to search...")}</Spotlight.Empty>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{query.length > 0 && pages.length === 0 && (
|
|
||||||
<Spotlight.Empty>{t("No results found...")}</Spotlight.Empty>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{pages.length > 0 && pages}
|
|
||||||
</Spotlight.ActionsList>
|
|
||||||
</Spotlight.Root>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -35,5 +35,4 @@ export interface ISuggestionResult {
|
|||||||
export interface IPageSearchParams {
|
export interface IPageSearchParams {
|
||||||
query: string;
|
query: string;
|
||||||
spaceId?: string;
|
spaceId?: string;
|
||||||
shareId?: string;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +0,0 @@
|
|||||||
import { atomWithWebStorage } from "@/lib/jotai-helper.ts";
|
|
||||||
import { atom } from 'jotai';
|
|
||||||
|
|
||||||
export const tableOfContentAsideAtom = atomWithWebStorage<boolean>(
|
|
||||||
"showTOC",
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
export const mobileTableOfContentAsideAtom = atom<boolean>(false);
|
|
||||||
@ -1,106 +0,0 @@
|
|||||||
import { Menu, ActionIcon, Text } from "@mantine/core";
|
|
||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
IconCopy,
|
|
||||||
IconDots,
|
|
||||||
IconFileDescription,
|
|
||||||
IconTrash,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import { modals } from "@mantine/modals";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { ISharedItem } from "@/features/share/types/share.types.ts";
|
|
||||||
import {
|
|
||||||
buildPageUrl,
|
|
||||||
buildSharedPageUrl,
|
|
||||||
} from "@/features/page/page.utils.ts";
|
|
||||||
import { useClipboard } from "@mantine/hooks";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { useDeleteShareMutation } from "@/features/share/queries/share-query.ts";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
share: ISharedItem;
|
|
||||||
}
|
|
||||||
export default function ShareActionMenu({ share }: Props) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const clipboard = useClipboard();
|
|
||||||
const deleteShareMutation = useDeleteShareMutation();
|
|
||||||
|
|
||||||
const openPage = () => {
|
|
||||||
const pageLink = buildPageUrl(
|
|
||||||
share.space.slug,
|
|
||||||
share.page.slugId,
|
|
||||||
share.page.title,
|
|
||||||
);
|
|
||||||
navigate(pageLink);
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyLink = () => {
|
|
||||||
const shareLink = buildSharedPageUrl({
|
|
||||||
shareId: share.key,
|
|
||||||
pageTitle: share.page.title,
|
|
||||||
pageSlugId: share.page.slugId,
|
|
||||||
});
|
|
||||||
|
|
||||||
clipboard.copy(shareLink);
|
|
||||||
notifications.show({ message: t("Link copied") });
|
|
||||||
};
|
|
||||||
const onDelete = async () => {
|
|
||||||
deleteShareMutation.mutateAsync(share.key);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openDeleteModal = () =>
|
|
||||||
modals.openConfirmModal({
|
|
||||||
title: t("Delete public share link"),
|
|
||||||
children: (
|
|
||||||
<Text size="sm">
|
|
||||||
{t("Are you sure you want to delete this shared link?")}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
centered: true,
|
|
||||||
labels: { confirm: t("Delete"), cancel: t("Don't") },
|
|
||||||
confirmProps: { color: "red" },
|
|
||||||
onConfirm: onDelete,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Menu
|
|
||||||
shadow="xl"
|
|
||||||
position="bottom-end"
|
|
||||||
offset={20}
|
|
||||||
width={200}
|
|
||||||
withArrow
|
|
||||||
arrowPosition="center"
|
|
||||||
>
|
|
||||||
<Menu.Target>
|
|
||||||
<ActionIcon variant="subtle" c="gray">
|
|
||||||
<IconDots size={20} stroke={2} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Menu.Target>
|
|
||||||
|
|
||||||
<Menu.Dropdown>
|
|
||||||
<Menu.Item onClick={copyLink} leftSection={<IconCopy size={16} />}>
|
|
||||||
{t("Copy link")}
|
|
||||||
</Menu.Item>
|
|
||||||
|
|
||||||
<Menu.Item
|
|
||||||
onClick={openPage}
|
|
||||||
leftSection={<IconFileDescription size={16} />}
|
|
||||||
>
|
|
||||||
{t("Open page")}
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Item
|
|
||||||
c="red"
|
|
||||||
onClick={openDeleteModal}
|
|
||||||
leftSection={<IconTrash size={16} />}
|
|
||||||
disabled={share.space?.userRole === "reader"}
|
|
||||||
>
|
|
||||||
{t("Delete share")}
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu.Dropdown>
|
|
||||||
</Menu>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
import { Outlet } from "react-router-dom";
|
|
||||||
import ShareShell from "@/features/share/components/share-shell.tsx";
|
|
||||||
|
|
||||||
export default function ShareLayout() {
|
|
||||||
return (
|
|
||||||
<ShareShell>
|
|
||||||
<Outlet />
|
|
||||||
</ShareShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,97 +0,0 @@
|
|||||||
import { Table, Group, Text, Anchor } from "@mantine/core";
|
|
||||||
import React, { useState } from "react";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import Paginate from "@/components/common/paginate.tsx";
|
|
||||||
import { useGetSharesQuery } from "@/features/share/queries/share-query.ts";
|
|
||||||
import { ISharedItem } from "@/features/share/types/share.types.ts";
|
|
||||||
import { format } from "date-fns";
|
|
||||||
import ShareActionMenu from "@/features/share/components/share-action-menu.tsx";
|
|
||||||
import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
|
|
||||||
import { getPageIcon } from "@/lib";
|
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
|
||||||
import classes from "./share.module.css";
|
|
||||||
|
|
||||||
export default function ShareList() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const { data, isLoading } = useGetSharesQuery({ page });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Table.ScrollContainer minWidth={500}>
|
|
||||||
<Table verticalSpacing="xs">
|
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th>{t("Page")}</Table.Th>
|
|
||||||
<Table.Th>{t("Shared by")}</Table.Th>
|
|
||||||
<Table.Th>{t("Shared at")}</Table.Th>
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
|
|
||||||
<Table.Tbody>
|
|
||||||
{data?.items.map((share: ISharedItem, index: number) => (
|
|
||||||
<Table.Tr key={index}>
|
|
||||||
<Table.Td>
|
|
||||||
<Anchor
|
|
||||||
size="sm"
|
|
||||||
underline="never"
|
|
||||||
style={{
|
|
||||||
cursor: "pointer",
|
|
||||||
color: "var(--mantine-color-text)",
|
|
||||||
}}
|
|
||||||
component={Link}
|
|
||||||
target="_blank"
|
|
||||||
to={buildSharedPageUrl({
|
|
||||||
shareId: share.key,
|
|
||||||
pageTitle: share.page.title,
|
|
||||||
pageSlugId: share.page.slugId,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Group gap="4" wrap="nowrap">
|
|
||||||
{getPageIcon(share.page.icon)}
|
|
||||||
<div className={classes.shareLinkText}>
|
|
||||||
<Text fz="sm" fw={500} lineClamp={1}>
|
|
||||||
{share.page.title || t("untitled")}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
</Anchor>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Group gap="4" wrap="nowrap">
|
|
||||||
<CustomAvatar
|
|
||||||
avatarUrl={share.creator?.avatarUrl}
|
|
||||||
name={share.creator.name}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
<Text fz="sm" lineClamp={1}>
|
|
||||||
{share.creator.name}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
|
|
||||||
{format(new Date(share.createdAt), "MMM dd, yyyy")}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<ShareActionMenu share={share} />
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</Table.ScrollContainer>
|
|
||||||
|
|
||||||
{data?.items.length > 0 && (
|
|
||||||
<Paginate
|
|
||||||
currentPage={page}
|
|
||||||
hasPrevPage={data?.meta.hasPrevPage}
|
|
||||||
hasNextPage={data?.meta.hasNextPage}
|
|
||||||
onPageChange={setPage}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,227 +0,0 @@
|
|||||||
import {
|
|
||||||
ActionIcon,
|
|
||||||
Anchor,
|
|
||||||
Button,
|
|
||||||
Group,
|
|
||||||
Indicator,
|
|
||||||
Popover,
|
|
||||||
Switch,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
Tooltip,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { IconExternalLink, IconWorld } from "@tabler/icons-react";
|
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
useCreateShareMutation,
|
|
||||||
useDeleteShareMutation,
|
|
||||||
useShareForPageQuery,
|
|
||||||
useUpdateShareMutation,
|
|
||||||
} from "@/features/share/queries/share-query.ts";
|
|
||||||
import { Link, useParams } from "react-router-dom";
|
|
||||||
import { extractPageSlugId, getPageIcon } from "@/lib";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import CopyTextButton from "@/components/common/copy.tsx";
|
|
||||||
import { getAppUrl } from "@/lib/config.ts";
|
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
|
||||||
import classes from "@/features/share/components/share.module.css";
|
|
||||||
|
|
||||||
interface ShareModalProps {
|
|
||||||
readOnly: boolean;
|
|
||||||
}
|
|
||||||
export default function ShareModal({ readOnly }: ShareModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { pageSlug } = useParams();
|
|
||||||
const pageId = extractPageSlugId(pageSlug);
|
|
||||||
const { data: share } = useShareForPageQuery(pageId);
|
|
||||||
const { spaceSlug } = useParams();
|
|
||||||
const createShareMutation = useCreateShareMutation();
|
|
||||||
const updateShareMutation = useUpdateShareMutation();
|
|
||||||
const deleteShareMutation = useDeleteShareMutation();
|
|
||||||
// pageIsShared means that the share exists and its level equals zero.
|
|
||||||
const pageIsShared = share && share.level === 0;
|
|
||||||
// if level is greater than zero, then it is a descendant page from a shared page
|
|
||||||
const isDescendantShared = share && share.level > 0;
|
|
||||||
|
|
||||||
const publicLink = `${getAppUrl()}/share/${share?.key}/p/${pageSlug}`;
|
|
||||||
|
|
||||||
const [isPagePublic, setIsPagePublic] = useState<boolean>(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (share) {
|
|
||||||
setIsPagePublic(true);
|
|
||||||
} else {
|
|
||||||
setIsPagePublic(false);
|
|
||||||
}
|
|
||||||
}, [share, pageId]);
|
|
||||||
|
|
||||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const value = event.currentTarget.checked;
|
|
||||||
|
|
||||||
if (value) {
|
|
||||||
createShareMutation.mutateAsync({
|
|
||||||
pageId: pageId,
|
|
||||||
includeSubPages: true,
|
|
||||||
searchIndexing: true,
|
|
||||||
});
|
|
||||||
setIsPagePublic(value);
|
|
||||||
} else {
|
|
||||||
if (share && share.id) {
|
|
||||||
deleteShareMutation.mutateAsync(share.id);
|
|
||||||
setIsPagePublic(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubPagesChange = async (
|
|
||||||
event: React.ChangeEvent<HTMLInputElement>,
|
|
||||||
) => {
|
|
||||||
const value = event.currentTarget.checked;
|
|
||||||
updateShareMutation.mutateAsync({
|
|
||||||
shareId: share.id,
|
|
||||||
includeSubPages: value,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleIndexSearchChange = async (
|
|
||||||
event: React.ChangeEvent<HTMLInputElement>,
|
|
||||||
) => {
|
|
||||||
const value = event.currentTarget.checked;
|
|
||||||
updateShareMutation.mutateAsync({
|
|
||||||
shareId: share.id,
|
|
||||||
searchIndexing: value,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const shareLink = useMemo(() => (
|
|
||||||
<Group my="sm" gap={4} wrap="nowrap">
|
|
||||||
<TextInput
|
|
||||||
variant="filled"
|
|
||||||
value={publicLink}
|
|
||||||
readOnly
|
|
||||||
rightSection={<CopyTextButton text={publicLink} />}
|
|
||||||
style={{ width: "100%" }}
|
|
||||||
/>
|
|
||||||
<ActionIcon
|
|
||||||
component="a"
|
|
||||||
variant="default"
|
|
||||||
target="_blank"
|
|
||||||
href={publicLink}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<IconExternalLink size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Group>
|
|
||||||
), [publicLink]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover width={350} position="bottom" withArrow shadow="md">
|
|
||||||
<Popover.Target>
|
|
||||||
<Button
|
|
||||||
style={{ border: "none" }}
|
|
||||||
size="compact-sm"
|
|
||||||
leftSection={
|
|
||||||
<Indicator
|
|
||||||
color="green"
|
|
||||||
offset={5}
|
|
||||||
disabled={!isPagePublic}
|
|
||||||
withBorder
|
|
||||||
>
|
|
||||||
<IconWorld size={20} stroke={1.5} />
|
|
||||||
</Indicator>
|
|
||||||
}
|
|
||||||
variant="default"
|
|
||||||
>
|
|
||||||
{t("Share")}
|
|
||||||
</Button>
|
|
||||||
</Popover.Target>
|
|
||||||
<Popover.Dropdown style={{ userSelect: "none" }}>
|
|
||||||
{isDescendantShared ? (
|
|
||||||
<>
|
|
||||||
<Text size="sm">{t("Inherits public sharing from")}</Text>
|
|
||||||
<Anchor
|
|
||||||
size="sm"
|
|
||||||
underline="never"
|
|
||||||
style={{
|
|
||||||
cursor: "pointer",
|
|
||||||
color: "var(--mantine-color-text)",
|
|
||||||
}}
|
|
||||||
component={Link}
|
|
||||||
to={buildPageUrl(
|
|
||||||
spaceSlug,
|
|
||||||
share.sharedPage.slugId,
|
|
||||||
share.sharedPage.title,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Group gap="4" wrap="nowrap" my="sm">
|
|
||||||
{getPageIcon(share.sharedPage.icon)}
|
|
||||||
<div className={classes.shareLinkText}>
|
|
||||||
<Text fz="sm" fw={500} lineClamp={1}>
|
|
||||||
{share.sharedPage.title || t("untitled")}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
</Anchor>
|
|
||||||
|
|
||||||
{shareLink}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
|
||||||
<div>
|
|
||||||
<Text size="sm">
|
|
||||||
{isPagePublic ? t("Shared to web") : t("Share to web")}
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{isPagePublic
|
|
||||||
? t("Anyone with the link can view this page")
|
|
||||||
: t("Make this page publicly accessible")}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
onChange={handleChange}
|
|
||||||
defaultChecked={isPagePublic}
|
|
||||||
disabled={readOnly}
|
|
||||||
size="xs"
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{pageIsShared && (
|
|
||||||
<>
|
|
||||||
{shareLink}
|
|
||||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
|
||||||
<div>
|
|
||||||
<Text size="sm">{t("Include sub-pages")}</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{t("Make sub-pages public too")}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Switch
|
|
||||||
onChange={handleSubPagesChange}
|
|
||||||
checked={share.includeSubPages}
|
|
||||||
size="xs"
|
|
||||||
disabled={readOnly}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
<Group justify="space-between" wrap="nowrap" gap="xl" mt="sm">
|
|
||||||
<div>
|
|
||||||
<Text size="sm">{t("Search engine indexing")}</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{t("Allow search engines to index page")}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
onChange={handleIndexSearchChange}
|
|
||||||
checked={share.searchIndexing}
|
|
||||||
size="xs"
|
|
||||||
disabled={readOnly}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Popover.Dropdown>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
.shareLinkText {
|
|
||||||
@mixin light {
|
|
||||||
border-bottom: 0.05em solid var(--mantine-color-dark-0);
|
|
||||||
}
|
|
||||||
@mixin dark {
|
|
||||||
border-bottom: 0.05em solid var(--mantine-color-dark-2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.treeNode {
|
|
||||||
text-decoration: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar,
|
|
||||||
.aside {
|
|
||||||
@media (max-width: $mantine-breakpoint-sm) {
|
|
||||||
width: 350px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,195 +0,0 @@
|
|||||||
import { ISharedPageTree } from "@/features/share/types/share.types.ts";
|
|
||||||
import { NodeApi, NodeRendererProps, Tree, TreeApi } from "react-arborist";
|
|
||||||
import {
|
|
||||||
buildSharedPageTree,
|
|
||||||
SharedPageTreeNode,
|
|
||||||
} from "@/features/share/utils.ts";
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { useElementSize, useMergedRef } from "@mantine/hooks";
|
|
||||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
|
||||||
import { Link, useParams } from "react-router-dom";
|
|
||||||
import { atom, useAtom } from "jotai/index";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import {
|
|
||||||
IconChevronDown,
|
|
||||||
IconChevronRight,
|
|
||||||
IconFileDescription,
|
|
||||||
IconPointFilled,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import { ActionIcon, Box } from "@mantine/core";
|
|
||||||
import { extractPageSlugId } from "@/lib";
|
|
||||||
import { OpenMap } from "react-arborist/dist/main/state/open-slice";
|
|
||||||
import classes from "@/features/page/tree/styles/tree.module.css";
|
|
||||||
import styles from "./share.module.css";
|
|
||||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
|
||||||
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
|
|
||||||
|
|
||||||
interface SharedTree {
|
|
||||||
sharedPageTree: ISharedPageTree;
|
|
||||||
}
|
|
||||||
|
|
||||||
const openSharedTreeNodesAtom = atom<OpenMap>({});
|
|
||||||
|
|
||||||
export default function SharedTree({ sharedPageTree }: SharedTree) {
|
|
||||||
const [tree, setTree] = useState<
|
|
||||||
TreeApi<SharedPageTreeNode> | null | undefined
|
|
||||||
>(null);
|
|
||||||
const rootElement = useRef<HTMLDivElement>();
|
|
||||||
const { ref: sizeRef, width, height } = useElementSize();
|
|
||||||
const mergedRef = useMergedRef(rootElement, sizeRef);
|
|
||||||
const { pageSlug } = useParams();
|
|
||||||
const [openTreeNodes, setOpenTreeNodes] = useAtom<OpenMap>(
|
|
||||||
openSharedTreeNodesAtom,
|
|
||||||
);
|
|
||||||
|
|
||||||
const currentNodeId = extractPageSlugId(pageSlug);
|
|
||||||
|
|
||||||
const treeData: SharedPageTreeNode[] = useMemo(() => {
|
|
||||||
if (!sharedPageTree?.pageTree) return;
|
|
||||||
return buildSharedPageTree(sharedPageTree.pageTree);
|
|
||||||
}, [sharedPageTree?.pageTree]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const parentNodeId = treeData?.[0]?.slugId;
|
|
||||||
|
|
||||||
if (parentNodeId && tree) {
|
|
||||||
const parentNode = tree.get(parentNodeId);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
if (parentNode) {
|
|
||||||
tree.openSiblings(parentNode);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// open direct children of parent node
|
|
||||||
parentNode?.children.forEach((node) => {
|
|
||||||
tree.openSiblings(node);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [treeData, tree]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentNodeId && tree) {
|
|
||||||
setTimeout(() => {
|
|
||||||
// focus on node and open all parents
|
|
||||||
tree?.select(currentNodeId, { align: "auto" });
|
|
||||||
}, 200);
|
|
||||||
} else {
|
|
||||||
tree?.deselectAll();
|
|
||||||
}
|
|
||||||
}, [currentNodeId, tree]);
|
|
||||||
|
|
||||||
if (!sharedPageTree || !sharedPageTree?.pageTree) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={mergedRef} className={classes.treeContainer}>
|
|
||||||
{rootElement.current && (
|
|
||||||
<Tree
|
|
||||||
data={treeData}
|
|
||||||
disableDrag={true}
|
|
||||||
disableDrop={true}
|
|
||||||
disableEdit={true}
|
|
||||||
width={width}
|
|
||||||
height={rootElement.current.clientHeight}
|
|
||||||
ref={(t) => setTree(t)}
|
|
||||||
openByDefault={false}
|
|
||||||
disableMultiSelection={true}
|
|
||||||
className={classes.tree}
|
|
||||||
rowClassName={classes.row}
|
|
||||||
rowHeight={30}
|
|
||||||
overscanCount={10}
|
|
||||||
dndRootElement={rootElement.current}
|
|
||||||
onToggle={() => {
|
|
||||||
setOpenTreeNodes(tree?.openState);
|
|
||||||
}}
|
|
||||||
initialOpenState={openTreeNodes}
|
|
||||||
onClick={(e) => {
|
|
||||||
if (tree && tree.focusedNode) {
|
|
||||||
tree.select(tree.focusedNode);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Node}
|
|
||||||
</Tree>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Node({ node, style, tree }: NodeRendererProps<any>) {
|
|
||||||
const { shareId } = useParams();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [, setMobileSidebarState] = useAtom(mobileSidebarAtom);
|
|
||||||
|
|
||||||
const pageUrl = buildSharedPageUrl({
|
|
||||||
shareId: shareId,
|
|
||||||
pageSlugId: node.data.slugId,
|
|
||||||
pageTitle: node.data.name,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Box
|
|
||||||
style={style}
|
|
||||||
className={clsx(classes.node, node.state, styles.treeNode)}
|
|
||||||
component={Link}
|
|
||||||
to={pageUrl}
|
|
||||||
onClick={() => {
|
|
||||||
setMobileSidebarState(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PageArrow node={node} />
|
|
||||||
<div style={{ marginRight: "4px" }}>
|
|
||||||
<EmojiPicker
|
|
||||||
onEmojiSelect={() => {}}
|
|
||||||
icon={
|
|
||||||
node.data.icon ? (
|
|
||||||
node.data.icon
|
|
||||||
) : (
|
|
||||||
<IconFileDescription size="18" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
readOnly={true}
|
|
||||||
removeEmojiAction={() => {}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className={classes.text}>{node.data.name || t("untitled")}</span>
|
|
||||||
</Box>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PageArrowProps {
|
|
||||||
node: NodeApi<SpaceTreeNode>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function PageArrow({ node }: PageArrowProps) {
|
|
||||||
return (
|
|
||||||
<ActionIcon
|
|
||||||
size={20}
|
|
||||||
variant="subtle"
|
|
||||||
c="gray"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
node.toggle();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{node.isInternal ? (
|
|
||||||
node.children && (node.children.length > 0 || node.data.hasChildren) ? (
|
|
||||||
node.isOpen ? (
|
|
||||||
<IconChevronDown stroke={2} size={16} />
|
|
||||||
) : (
|
|
||||||
<IconChevronRight stroke={2} size={16} />
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<IconPointFilled size={4} />
|
|
||||||
)
|
|
||||||
) : null}
|
|
||||||
</ActionIcon>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
import { useAtom } from "jotai";
|
|
||||||
|
|
||||||
export function useToggleToc(tocAtom: any) {
|
|
||||||
const [tocState, setTocState] = useAtom(tocAtom);
|
|
||||||
return () => {
|
|
||||||
setTocState(!tocState);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,179 +0,0 @@
|
|||||||
import {
|
|
||||||
keepPreviousData,
|
|
||||||
useMutation,
|
|
||||||
useQuery,
|
|
||||||
useQueryClient,
|
|
||||||
UseQueryResult,
|
|
||||||
} from "@tanstack/react-query";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
ICreateShare,
|
|
||||||
IShare,
|
|
||||||
ISharedItem,
|
|
||||||
ISharedPage,
|
|
||||||
ISharedPageTree,
|
|
||||||
IShareForPage,
|
|
||||||
IShareInfoInput,
|
|
||||||
IUpdateShare,
|
|
||||||
} from "@/features/share/types/share.types.ts";
|
|
||||||
import {
|
|
||||||
createShare,
|
|
||||||
deleteShare,
|
|
||||||
getSharedPageTree,
|
|
||||||
getShareForPage,
|
|
||||||
getShareInfo,
|
|
||||||
getSharePageInfo,
|
|
||||||
getShares,
|
|
||||||
updateShare,
|
|
||||||
} from "@/features/share/services/share-service.ts";
|
|
||||||
import { IPage } from "@/features/page/types/page.types.ts";
|
|
||||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
|
|
||||||
export function useGetSharesQuery(
|
|
||||||
params?: QueryParams,
|
|
||||||
): UseQueryResult<IPagination<ISharedItem>, Error> {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["share-list"],
|
|
||||||
queryFn: () => getShares(params),
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useGetShareByIdQuery(
|
|
||||||
shareId: string,
|
|
||||||
): UseQueryResult<IShare, Error> {
|
|
||||||
const query = useQuery({
|
|
||||||
queryKey: ["share-by-id", shareId],
|
|
||||||
queryFn: () => getShareInfo(shareId),
|
|
||||||
enabled: !!shareId,
|
|
||||||
});
|
|
||||||
|
|
||||||
return query;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSharePageQuery(
|
|
||||||
shareInput: Partial<IShareInfoInput>,
|
|
||||||
): UseQueryResult<ISharedPage, Error> {
|
|
||||||
const query = useQuery({
|
|
||||||
queryKey: ["shares", shareInput],
|
|
||||||
queryFn: () => getSharePageInfo(shareInput),
|
|
||||||
enabled: !!shareInput.pageId,
|
|
||||||
});
|
|
||||||
|
|
||||||
return query;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useShareForPageQuery(
|
|
||||||
pageId: string,
|
|
||||||
): UseQueryResult<IShareForPage, Error> {
|
|
||||||
const query = useQuery({
|
|
||||||
queryKey: ["share-for-page", pageId],
|
|
||||||
queryFn: () => getShareForPage(pageId),
|
|
||||||
enabled: !!pageId,
|
|
||||||
staleTime: 0,
|
|
||||||
retry: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
return query;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreateShareMutation() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation<any, Error, ICreateShare>({
|
|
||||||
mutationFn: (data) => createShare(data),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
predicate: (item) =>
|
|
||||||
["share-for-page", "share-list"].includes(item.queryKey[0] as string),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
notifications.show({ message: t("Failed to share page"), color: "red" });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateShareMutation() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation<any, Error, IUpdateShare>({
|
|
||||||
mutationFn: (data) => updateShare(data),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
predicate: (item) =>
|
|
||||||
["share-for-page", "share-list"].includes(item.queryKey[0] as string),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (error, params) => {
|
|
||||||
if (error?.["status"] === 404) {
|
|
||||||
queryClient.removeQueries({
|
|
||||||
predicate: (item) =>
|
|
||||||
["share-for-page"].includes(item.queryKey[0] as string),
|
|
||||||
});
|
|
||||||
|
|
||||||
notifications.show({
|
|
||||||
message: t("Share not found"),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
notifications.show({
|
|
||||||
message: error?.["response"]?.data?.message || "Share not found",
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteShareMutation() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (shareId: string) => deleteShare(shareId),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
queryClient.removeQueries({
|
|
||||||
predicate: (item) =>
|
|
||||||
["share-for-page"].includes(item.queryKey[0] as string),
|
|
||||||
});
|
|
||||||
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
predicate: (item) =>
|
|
||||||
["share-list"].includes(item.queryKey[0] as string),
|
|
||||||
});
|
|
||||||
|
|
||||||
notifications.show({ message: t("Share deleted successfully") });
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
if (error?.["status"] === 404) {
|
|
||||||
queryClient.removeQueries({
|
|
||||||
predicate: (item) =>
|
|
||||||
["share-for-page"].includes(item.queryKey[0] as string),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
notifications.show({
|
|
||||||
message: error?.["response"]?.data?.message || "Failed to delete share",
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useGetSharedPageTreeQuery(
|
|
||||||
shareId: string,
|
|
||||||
): UseQueryResult<ISharedPageTree, Error> {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["shared-page-tree", shareId],
|
|
||||||
queryFn: () => getSharedPageTree(shareId),
|
|
||||||
enabled: !!shareId,
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
staleTime: 60 * 60 * 1000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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[]>;
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -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
|
||||||
|
|||||||
@ -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 };
|
|
||||||
}
|
|
||||||
@ -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]);
|
|
||||||
}
|
|
||||||
@ -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();
|
||||||
|
|||||||
@ -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 />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "server",
|
"name": "server",
|
||||||
"version": "0.20.1",
|
"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",
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
import { Node } from '@tiptap/pm/model';
|
import { Node } from '@tiptap/pm/model';
|
||||||
import { jsonToNode } from '../../../collaboration/collaboration.util';
|
import { jsonToNode } from '../../../collaboration/collaboration.util';
|
||||||
import { validate as isValidUUID } from 'uuid';
|
|
||||||
import { Transform } from '@tiptap/pm/transform';
|
|
||||||
|
|
||||||
export interface MentionNode {
|
export interface MentionNode {
|
||||||
id: string;
|
id: string;
|
||||||
@ -58,53 +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;
|
|
||||||
}
|
|
||||||
@ -1,373 +1,310 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
Controller,
|
Controller,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Get,
|
Get,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Req,
|
||||||
Req,
|
Res,
|
||||||
Res,
|
UseGuards,
|
||||||
UseGuards,
|
UseInterceptors,
|
||||||
UseInterceptors,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AttachmentService } from './services/attachment.service';
|
import {AttachmentService} from './services/attachment.service';
|
||||||
import { FastifyReply } from 'fastify';
|
import {FastifyReply} from 'fastify';
|
||||||
import { FileInterceptor } from '../../common/interceptors/file.interceptor';
|
import {FileInterceptor} from '../../common/interceptors/file.interceptor';
|
||||||
import * as bytes from 'bytes';
|
import * as bytes from 'bytes';
|
||||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
import {AuthUser} from '../../common/decorators/auth-user.decorator';
|
||||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
import {AuthWorkspace} from '../../common/decorators/auth-workspace.decorator';
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
import {JwtAuthGuard} from '../../common/guards/jwt-auth.guard';
|
||||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
import {User, Workspace} from '@docmost/db/types/entity.types';
|
||||||
import { StorageService } from '../../integrations/storage/storage.service';
|
import {StorageService} from '../../integrations/storage/storage.service';
|
||||||
import {
|
import {
|
||||||
getAttachmentFolderPath,
|
getAttachmentFolderPath,
|
||||||
validAttachmentTypes,
|
validAttachmentTypes,
|
||||||
} from './attachment.utils';
|
} from './attachment.utils';
|
||||||
import { getMimeType } from '../../common/helpers';
|
import {getMimeType} from '../../common/helpers';
|
||||||
import {
|
import {
|
||||||
AttachmentType,
|
AttachmentType,
|
||||||
inlineFileExtensions,
|
inlineFileExtensions,
|
||||||
MAX_AVATAR_SIZE,
|
MAX_AVATAR_SIZE,
|
||||||
} from './attachment.constants';
|
} from './attachment.constants';
|
||||||
import {
|
import {
|
||||||
SpaceCaslAction,
|
SpaceCaslAction,
|
||||||
SpaceCaslSubject,
|
SpaceCaslSubject,
|
||||||
} from '../casl/interfaces/space-ability.type';
|
} from '../casl/interfaces/space-ability.type';
|
||||||
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
||||||
import {
|
import {
|
||||||
WorkspaceCaslAction,
|
WorkspaceCaslAction,
|
||||||
WorkspaceCaslSubject,
|
WorkspaceCaslSubject,
|
||||||
} from '../casl/interfaces/workspace-ability.type';
|
} from '../casl/interfaces/workspace-ability.type';
|
||||||
import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory';
|
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 {
|
||||||
private readonly logger = new Logger(AttachmentController.name);
|
private readonly logger = new Logger(AttachmentController.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly attachmentService: AttachmentService,
|
private readonly attachmentService: AttachmentService,
|
||||||
private readonly storageService: StorageService,
|
private readonly storageService: StorageService,
|
||||||
private readonly workspaceAbility: WorkspaceAbilityFactory,
|
private readonly workspaceAbility: WorkspaceAbilityFactory,
|
||||||
private readonly spaceAbility: SpaceAbilityFactory,
|
private readonly spaceAbility: SpaceAbilityFactory,
|
||||||
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)
|
||||||
@Post('files/upload')
|
@Post('files/upload')
|
||||||
@UseInterceptors(FileInterceptor)
|
@UseInterceptors(FileInterceptor)
|
||||||
async uploadFile(
|
async uploadFile(
|
||||||
@Req() req: any,
|
@Req() req: any,
|
||||||
@Res() res: FastifyReply,
|
@Res() res: FastifyReply,
|
||||||
@AuthUser() user: User,
|
@AuthUser() user: User,
|
||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
) {
|
) {
|
||||||
const maxFileSize = bytes(this.environmentService.getFileUploadSizeLimit());
|
const maxFileSize = bytes(this.environmentService.getFileUploadSizeLimit());
|
||||||
|
|
||||||
let file = null;
|
let file = null;
|
||||||
try {
|
try {
|
||||||
file = await req.file({
|
file = await req.file({
|
||||||
limits: { fileSize: maxFileSize, fields: 3, files: 1 },
|
limits: {fileSize: maxFileSize, fields: 3, files: 1},
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.error(err.message);
|
this.logger.error(err.message);
|
||||||
if (err?.statusCode === 413) {
|
if (err?.statusCode === 413) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`,
|
`File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
throw new BadRequestException('Failed to upload file');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageId = file.fields?.pageId?.value;
|
||||||
|
|
||||||
|
if (!pageId) {
|
||||||
|
throw new BadRequestException('PageId is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = await this.pageRepo.findById(pageId);
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
throw new NotFoundException('Page not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const spaceAbility = await this.spaceAbility.createForUser(
|
||||||
|
user,
|
||||||
|
page.spaceId,
|
||||||
);
|
);
|
||||||
}
|
if (spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
const spaceId = page.spaceId;
|
||||||
|
|
||||||
|
const attachmentId = file.fields?.attachmentId?.value;
|
||||||
|
if (attachmentId && !isValidUUID(attachmentId)) {
|
||||||
|
throw new BadRequestException('Invalid attachment id');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileResponse = await this.attachmentService.uploadFile({
|
||||||
|
filePromise: file,
|
||||||
|
pageId: pageId,
|
||||||
|
spaceId: spaceId,
|
||||||
|
userId: user.id,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
attachmentId: attachmentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.send(fileResponse);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.statusCode === 413) {
|
||||||
|
const errMessage = `File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`;
|
||||||
|
this.logger.error(errMessage);
|
||||||
|
throw new BadRequestException(errMessage);
|
||||||
|
}
|
||||||
|
this.logger.error(err);
|
||||||
|
throw new BadRequestException('Error processing file upload.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!file) {
|
@UseGuards(JwtAuthGuard)
|
||||||
throw new BadRequestException('Failed to upload file');
|
@Get('/files/:fileId/:fileName')
|
||||||
}
|
async getFile(
|
||||||
|
@Res() res: FastifyReply,
|
||||||
const pageId = file.fields?.pageId?.value;
|
@AuthUser() user: User,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
if (!pageId) {
|
@Param('fileId') fileId: string,
|
||||||
throw new BadRequestException('PageId is required');
|
@Param('fileName') fileName?: string,
|
||||||
}
|
|
||||||
|
|
||||||
const page = await this.pageRepo.findById(pageId);
|
|
||||||
|
|
||||||
if (!page) {
|
|
||||||
throw new NotFoundException('Page not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const spaceAbility = await this.spaceAbility.createForUser(
|
|
||||||
user,
|
|
||||||
page.spaceId,
|
|
||||||
);
|
|
||||||
if (spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
|
|
||||||
const spaceId = page.spaceId;
|
|
||||||
|
|
||||||
const attachmentId = file.fields?.attachmentId?.value;
|
|
||||||
if (attachmentId && !isValidUUID(attachmentId)) {
|
|
||||||
throw new BadRequestException('Invalid attachment id');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const fileResponse = await this.attachmentService.uploadFile({
|
|
||||||
filePromise: file,
|
|
||||||
pageId: pageId,
|
|
||||||
spaceId: spaceId,
|
|
||||||
userId: user.id,
|
|
||||||
workspaceId: workspace.id,
|
|
||||||
attachmentId: attachmentId,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.send(fileResponse);
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err?.statusCode === 413) {
|
|
||||||
const errMessage = `File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`;
|
|
||||||
this.logger.error(errMessage);
|
|
||||||
throw new BadRequestException(errMessage);
|
|
||||||
}
|
|
||||||
this.logger.error(err);
|
|
||||||
throw new BadRequestException('Error processing file upload.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@Get('/files/:fileId/:fileName')
|
|
||||||
async getFile(
|
|
||||||
@Res() res: FastifyReply,
|
|
||||||
@AuthUser() user: User,
|
|
||||||
@AuthWorkspace() workspace: Workspace,
|
|
||||||
@Param('fileId') fileId: string,
|
|
||||||
@Param('fileName') fileName?: string,
|
|
||||||
) {
|
|
||||||
if (!isValidUUID(fileId)) {
|
|
||||||
throw new NotFoundException('Invalid file id');
|
|
||||||
}
|
|
||||||
|
|
||||||
const attachment = await this.attachmentRepo.findById(fileId);
|
|
||||||
if (
|
|
||||||
!attachment ||
|
|
||||||
attachment.workspaceId !== workspace.id ||
|
|
||||||
!attachment.pageId ||
|
|
||||||
!attachment.spaceId
|
|
||||||
) {
|
) {
|
||||||
throw new NotFoundException();
|
if (!isValidUUID(fileId)) {
|
||||||
}
|
throw new NotFoundException('Invalid file id');
|
||||||
|
}
|
||||||
|
|
||||||
const spaceAbility = await this.spaceAbility.createForUser(
|
const attachment = await this.attachmentRepo.findById(fileId);
|
||||||
user,
|
if (
|
||||||
attachment.spaceId,
|
!attachment ||
|
||||||
);
|
attachment.workspaceId !== workspace.id ||
|
||||||
|
!attachment.pageId ||
|
||||||
|
!attachment.spaceId
|
||||||
|
) {
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
if (spaceAbility.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
const spaceAbility = await this.spaceAbility.createForUser(
|
||||||
throw new ForbiddenException();
|
user,
|
||||||
}
|
attachment.spaceId,
|
||||||
|
|
||||||
try {
|
|
||||||
const fileStream = await this.storageService.read(attachment.filePath);
|
|
||||||
res.headers({
|
|
||||||
'Content-Type': attachment.mimeType,
|
|
||||||
'Cache-Control': 'private, max-age=3600',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!inlineFileExtensions.includes(attachment.fileExt)) {
|
|
||||||
res.header(
|
|
||||||
'Content-Disposition',
|
|
||||||
`attachment; filename="${encodeURIComponent(attachment.fileName)}"`,
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
return res.send(fileStream);
|
if (spaceAbility.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||||
} catch (err) {
|
throw new ForbiddenException();
|
||||||
this.logger.error(err);
|
}
|
||||||
throw new NotFoundException('File not found');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('/files/public/:fileId/:fileName')
|
try {
|
||||||
async getPublicFile(
|
const fileStream = await this.storageService.read(attachment.filePath);
|
||||||
@Res() res: FastifyReply,
|
res.headers({
|
||||||
@AuthWorkspace() workspace: Workspace,
|
'Content-Type': attachment.mimeType,
|
||||||
@Param('fileId') fileId: string,
|
'Cache-Control': 'private, max-age=3600',
|
||||||
@Param('fileName') fileName?: string,
|
});
|
||||||
@Query('jwt') jwtToken?: string,
|
|
||||||
) {
|
if (!inlineFileExtensions.includes(attachment.fileExt)) {
|
||||||
let jwtPayload: JwtAttachmentPayload = null;
|
res.header(
|
||||||
try {
|
'Content-Disposition',
|
||||||
jwtPayload = await this.tokenService.verifyJwt(
|
`attachment; filename="${encodeURIComponent(attachment.fileName)}"`,
|
||||||
jwtToken,
|
);
|
||||||
JwtType.ATTACHMENT,
|
}
|
||||||
);
|
|
||||||
} catch (err) {
|
return res.send(fileStream);
|
||||||
throw new BadRequestException(
|
} catch (err) {
|
||||||
'Expired or invalid attachment access token',
|
this.logger.error(err);
|
||||||
);
|
throw new NotFoundException('File not found');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
@UseGuards(JwtAuthGuard)
|
||||||
!isValidUUID(fileId) ||
|
@HttpCode(HttpStatus.OK)
|
||||||
fileId !== jwtPayload.attachmentId ||
|
@Post('attachments/upload-image')
|
||||||
jwtPayload.workspaceId !== workspace.id
|
@UseInterceptors(FileInterceptor)
|
||||||
|
async uploadAvatarOrLogo(
|
||||||
|
@Req() req: any,
|
||||||
|
@Res() res: FastifyReply,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
) {
|
) {
|
||||||
throw new NotFoundException('File not found');
|
const maxFileSize = bytes(MAX_AVATAR_SIZE);
|
||||||
|
|
||||||
|
let file = null;
|
||||||
|
try {
|
||||||
|
file = await req.file({
|
||||||
|
limits: {fileSize: maxFileSize, fields: 3, files: 1},
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.statusCode === 413) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`File too large. Exceeds the ${MAX_AVATAR_SIZE} limit`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
throw new BadRequestException('Invalid file upload');
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachmentType = file.fields?.type?.value;
|
||||||
|
const spaceId = file.fields?.spaceId?.value;
|
||||||
|
|
||||||
|
if (!attachmentType) {
|
||||||
|
throw new BadRequestException('attachment type is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!validAttachmentTypes.includes(attachmentType) ||
|
||||||
|
attachmentType === AttachmentType.File
|
||||||
|
) {
|
||||||
|
throw new BadRequestException('Invalid image attachment type');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachmentType === AttachmentType.WorkspaceLogo) {
|
||||||
|
const ability = this.workspaceAbility.createForUser(user, workspace);
|
||||||
|
if (
|
||||||
|
ability.cannot(
|
||||||
|
WorkspaceCaslAction.Manage,
|
||||||
|
WorkspaceCaslSubject.Settings,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachmentType === AttachmentType.SpaceLogo) {
|
||||||
|
if (!spaceId) {
|
||||||
|
throw new BadRequestException('spaceId is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const spaceAbility = await this.spaceAbility.createForUser(user, spaceId);
|
||||||
|
if (
|
||||||
|
spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileResponse = await this.attachmentService.uploadImage(
|
||||||
|
file,
|
||||||
|
attachmentType,
|
||||||
|
user.id,
|
||||||
|
workspace.id,
|
||||||
|
spaceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.send(fileResponse);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(err);
|
||||||
|
throw new BadRequestException('Error processing file upload.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachment = await this.attachmentRepo.findById(fileId);
|
@Get('attachments/img/:attachmentType/:fileName')
|
||||||
if (
|
async getLogoOrAvatar(
|
||||||
!attachment ||
|
@Res() res: FastifyReply,
|
||||||
attachment.workspaceId !== workspace.id ||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
!attachment.pageId ||
|
@Param('attachmentType') attachmentType: AttachmentType,
|
||||||
!attachment.spaceId ||
|
@Param('fileName') fileName?: string,
|
||||||
jwtPayload.pageId !== attachment.pageId
|
|
||||||
) {
|
) {
|
||||||
throw new NotFoundException('File not found');
|
if (
|
||||||
|
!validAttachmentTypes.includes(attachmentType) ||
|
||||||
|
attachmentType === AttachmentType.File
|
||||||
|
) {
|
||||||
|
throw new BadRequestException('Invalid image attachment type');
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = `${getAttachmentFolderPath(attachmentType, workspace.id)}/${fileName}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileStream = await this.storageService.read(filePath);
|
||||||
|
res.headers({
|
||||||
|
'Content-Type': getMimeType(filePath),
|
||||||
|
'Cache-Control': 'private, max-age=86400',
|
||||||
|
});
|
||||||
|
return res.send(fileStream);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(err);
|
||||||
|
throw new NotFoundException('File not found');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
const fileStream = await this.storageService.read(attachment.filePath);
|
|
||||||
res.headers({
|
|
||||||
'Content-Type': attachment.mimeType,
|
|
||||||
'Cache-Control': 'public, max-age=3600',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!inlineFileExtensions.includes(attachment.fileExt)) {
|
|
||||||
res.header(
|
|
||||||
'Content-Disposition',
|
|
||||||
`attachment; filename="${encodeURIComponent(attachment.fileName)}"`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.send(fileStream);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(err);
|
|
||||||
throw new NotFoundException('File not found');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post('attachments/upload-image')
|
|
||||||
@UseInterceptors(FileInterceptor)
|
|
||||||
async uploadAvatarOrLogo(
|
|
||||||
@Req() req: any,
|
|
||||||
@Res() res: FastifyReply,
|
|
||||||
@AuthUser() user: User,
|
|
||||||
@AuthWorkspace() workspace: Workspace,
|
|
||||||
) {
|
|
||||||
const maxFileSize = bytes(MAX_AVATAR_SIZE);
|
|
||||||
|
|
||||||
let file = null;
|
|
||||||
try {
|
|
||||||
file = await req.file({
|
|
||||||
limits: { fileSize: maxFileSize, fields: 3, files: 1 },
|
|
||||||
});
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err?.statusCode === 413) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`File too large. Exceeds the ${MAX_AVATAR_SIZE} limit`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!file) {
|
|
||||||
throw new BadRequestException('Invalid file upload');
|
|
||||||
}
|
|
||||||
|
|
||||||
const attachmentType = file.fields?.type?.value;
|
|
||||||
const spaceId = file.fields?.spaceId?.value;
|
|
||||||
|
|
||||||
if (!attachmentType) {
|
|
||||||
throw new BadRequestException('attachment type is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!validAttachmentTypes.includes(attachmentType) ||
|
|
||||||
attachmentType === AttachmentType.File
|
|
||||||
) {
|
|
||||||
throw new BadRequestException('Invalid image attachment type');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attachmentType === AttachmentType.WorkspaceLogo) {
|
|
||||||
const ability = this.workspaceAbility.createForUser(user, workspace);
|
|
||||||
if (
|
|
||||||
ability.cannot(
|
|
||||||
WorkspaceCaslAction.Manage,
|
|
||||||
WorkspaceCaslSubject.Settings,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attachmentType === AttachmentType.SpaceLogo) {
|
|
||||||
if (!spaceId) {
|
|
||||||
throw new BadRequestException('spaceId is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
const spaceAbility = await this.spaceAbility.createForUser(user, spaceId);
|
|
||||||
if (
|
|
||||||
spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)
|
|
||||||
) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const fileResponse = await this.attachmentService.uploadImage(
|
|
||||||
file,
|
|
||||||
attachmentType,
|
|
||||||
user.id,
|
|
||||||
workspace.id,
|
|
||||||
spaceId,
|
|
||||||
);
|
|
||||||
|
|
||||||
return res.send(fileResponse);
|
|
||||||
} catch (err: any) {
|
|
||||||
this.logger.error(err);
|
|
||||||
throw new BadRequestException('Error processing file upload.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('attachments/img/:attachmentType/:fileName')
|
|
||||||
async getLogoOrAvatar(
|
|
||||||
@Res() res: FastifyReply,
|
|
||||||
@AuthWorkspace() workspace: Workspace,
|
|
||||||
@Param('attachmentType') attachmentType: AttachmentType,
|
|
||||||
@Param('fileName') fileName?: string,
|
|
||||||
) {
|
|
||||||
if (
|
|
||||||
!validAttachmentTypes.includes(attachmentType) ||
|
|
||||||
attachmentType === AttachmentType.File
|
|
||||||
) {
|
|
||||||
throw new BadRequestException('Invalid image attachment type');
|
|
||||||
}
|
|
||||||
|
|
||||||
const filePath = `${getAttachmentFolderPath(attachmentType, workspace.id)}/${fileName}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const fileStream = await this.storageService.read(filePath);
|
|
||||||
res.headers({
|
|
||||||
'Content-Type': getMimeType(filePath),
|
|
||||||
'Cache-Control': 'private, max-age=86400',
|
|
||||||
});
|
|
||||||
return res.send(fileStream);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(err);
|
|
||||||
throw new NotFoundException('File not found');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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';
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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];
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -212,7 +212,7 @@ export class PageService {
|
|||||||
trx,
|
trx,
|
||||||
);
|
);
|
||||||
const pageIds = await this.pageRepo
|
const pageIds = await this.pageRepo
|
||||||
.getPageAndDescendants(rootPage.id, { includeContent: false })
|
.getPageAndDescendants(rootPage.id)
|
||||||
.then((pages) => pages.map((page) => page.id));
|
.then((pages) => pages.map((page) => page.id));
|
||||||
// The first id is the root page id
|
// The first id is the root page id
|
||||||
if (pageIds.length > 1) {
|
if (pageIds.length > 1) {
|
||||||
@ -223,16 +223,6 @@ export class PageService {
|
|||||||
trx,
|
trx,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// update spaceId in shares
|
|
||||||
if (pageIds.length > 0) {
|
|
||||||
await trx
|
|
||||||
.updateTable('shares')
|
|
||||||
.set({ spaceId: spaceId })
|
|
||||||
.where('pageId', 'in', pageIds)
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update attachments
|
// Update attachments
|
||||||
await this.attachmentRepo.updateAttachmentsByPageId(
|
await this.attachmentRepo.updateAttachmentsByPageId(
|
||||||
{ spaceId },
|
{ spaceId },
|
||||||
|
|||||||
@ -5,11 +5,8 @@ import {
|
|||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { PartialType } from '@nestjs/mapped-types';
|
|
||||||
import { CreateWorkspaceDto } from '../../workspace/dto/create-workspace.dto';
|
|
||||||
|
|
||||||
export class SearchDTO {
|
export class SearchDTO {
|
||||||
@IsNotEmpty()
|
|
||||||
@IsString()
|
@IsString()
|
||||||
query: string;
|
query: string;
|
||||||
|
|
||||||
@ -17,10 +14,6 @@ export class SearchDTO {
|
|||||||
@IsString()
|
@IsString()
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
shareId?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
creatorId?: string;
|
creatorId?: string;
|
||||||
@ -34,16 +27,6 @@ export class SearchDTO {
|
|||||||
offset?: number;
|
offset?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SearchShareDTO extends SearchDTO {
|
|
||||||
@IsNotEmpty()
|
|
||||||
@IsString()
|
|
||||||
shareId: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
spaceId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SearchSuggestionDTO {
|
export class SearchSuggestionDTO {
|
||||||
@IsString()
|
@IsString()
|
||||||
query: string;
|
query: string;
|
||||||
|
|||||||
@ -1,19 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
|
NotImplementedException,
|
||||||
Post,
|
Post,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { SearchService } from './search.service';
|
import { SearchService } from './search.service';
|
||||||
import {
|
import { SearchDTO, SearchSuggestionDTO } from './dto/search.dto';
|
||||||
SearchDTO,
|
|
||||||
SearchShareDTO,
|
|
||||||
SearchSuggestionDTO,
|
|
||||||
} from './dto/search.dto';
|
|
||||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||||
@ -23,7 +19,6 @@ import {
|
|||||||
SpaceCaslSubject,
|
SpaceCaslSubject,
|
||||||
} from '../casl/interfaces/space-ability.type';
|
} from '../casl/interfaces/space-ability.type';
|
||||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||||
import { Public } from 'src/common/decorators/public.decorator';
|
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('search')
|
@Controller('search')
|
||||||
@ -35,13 +30,7 @@ export class SearchController {
|
|||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post()
|
@Post()
|
||||||
async pageSearch(
|
async pageSearch(@Body() searchDto: SearchDTO, @AuthUser() user: User) {
|
||||||
@Body() searchDto: SearchDTO,
|
|
||||||
@AuthUser() user: User,
|
|
||||||
@AuthWorkspace() workspace: Workspace,
|
|
||||||
) {
|
|
||||||
delete searchDto.shareId;
|
|
||||||
|
|
||||||
if (searchDto.spaceId) {
|
if (searchDto.spaceId) {
|
||||||
const ability = await this.spaceAbility.createForUser(
|
const ability = await this.spaceAbility.createForUser(
|
||||||
user,
|
user,
|
||||||
@ -51,12 +40,12 @@ export class SearchController {
|
|||||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||||
throw new ForbiddenException();
|
throw new ForbiddenException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return this.searchService.searchPage(searchDto.query, searchDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.searchService.searchPage(searchDto.query, searchDto, {
|
// TODO: search all spaces user is a member of if no spaceId provided
|
||||||
userId: user.id,
|
throw new NotImplementedException();
|
||||||
workspaceId: workspace.id,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ -68,21 +57,4 @@ export class SearchController {
|
|||||||
) {
|
) {
|
||||||
return this.searchService.searchSuggestions(dto, user.id, workspace.id);
|
return this.searchService.searchSuggestions(dto, user.id, workspace.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Public()
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post('share-search')
|
|
||||||
async searchShare(
|
|
||||||
@Body() searchDto: SearchShareDTO,
|
|
||||||
@AuthWorkspace() workspace: Workspace,
|
|
||||||
) {
|
|
||||||
delete searchDto.spaceId;
|
|
||||||
if (!searchDto.shareId) {
|
|
||||||
throw new BadRequestException('shareId is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.searchService.searchPage(searchDto.query, searchDto, {
|
|
||||||
workspaceId: workspace.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import { KyselyDB } from '@docmost/db/types/kysely.types';
|
|||||||
import { sql } from 'kysely';
|
import { sql } from 'kysely';
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
const tsquery = require('pg-tsquery')();
|
const tsquery = require('pg-tsquery')();
|
||||||
@ -16,24 +15,19 @@ export class SearchService {
|
|||||||
constructor(
|
constructor(
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
private pageRepo: PageRepo,
|
private pageRepo: PageRepo,
|
||||||
private shareRepo: ShareRepo,
|
|
||||||
private spaceMemberRepo: SpaceMemberRepo,
|
private spaceMemberRepo: SpaceMemberRepo,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async searchPage(
|
async searchPage(
|
||||||
query: string,
|
query: string,
|
||||||
searchParams: SearchDTO,
|
searchParams: SearchDTO,
|
||||||
opts: {
|
|
||||||
userId?: string;
|
|
||||||
workspaceId: string;
|
|
||||||
},
|
|
||||||
): Promise<SearchResponseDto[]> {
|
): Promise<SearchResponseDto[]> {
|
||||||
if (query.length < 1) {
|
if (query.length < 1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const searchQuery = tsquery(query.trim() + '*');
|
const searchQuery = tsquery(query.trim() + '*');
|
||||||
|
|
||||||
let queryResults = this.db
|
const queryResults = await this.db
|
||||||
.selectFrom('pages')
|
.selectFrom('pages')
|
||||||
.select([
|
.select([
|
||||||
'id',
|
'id',
|
||||||
@ -49,71 +43,18 @@ export class SearchService {
|
|||||||
'highlight',
|
'highlight',
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
|
.select((eb) => this.pageRepo.withSpace(eb))
|
||||||
|
.where('spaceId', '=', searchParams.spaceId)
|
||||||
.where('tsv', '@@', sql<string>`to_tsquery(${searchQuery})`)
|
.where('tsv', '@@', sql<string>`to_tsquery(${searchQuery})`)
|
||||||
.$if(Boolean(searchParams.creatorId), (qb) =>
|
.$if(Boolean(searchParams.creatorId), (qb) =>
|
||||||
qb.where('creatorId', '=', searchParams.creatorId),
|
qb.where('creatorId', '=', searchParams.creatorId),
|
||||||
)
|
)
|
||||||
.orderBy('rank', 'desc')
|
.orderBy('rank', 'desc')
|
||||||
.limit(searchParams.limit | 20)
|
.limit(searchParams.limit | 20)
|
||||||
.offset(searchParams.offset || 0);
|
.offset(searchParams.offset || 0)
|
||||||
|
.execute();
|
||||||
|
|
||||||
if (!searchParams.shareId) {
|
const searchResults = queryResults.map((result) => {
|
||||||
queryResults = queryResults.select((eb) => this.pageRepo.withSpace(eb));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (searchParams.spaceId) {
|
|
||||||
// search by spaceId
|
|
||||||
queryResults = queryResults.where('spaceId', '=', searchParams.spaceId);
|
|
||||||
} else if (opts.userId && !searchParams.spaceId) {
|
|
||||||
// only search spaces the user is a member of
|
|
||||||
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(
|
|
||||||
opts.userId,
|
|
||||||
);
|
|
||||||
if (userSpaceIds.length > 0) {
|
|
||||||
queryResults = queryResults
|
|
||||||
.where('spaceId', 'in', userSpaceIds)
|
|
||||||
.where('workspaceId', '=', opts.workspaceId);
|
|
||||||
} else {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
} else if (searchParams.shareId && !searchParams.spaceId && !opts.userId) {
|
|
||||||
// search in shares
|
|
||||||
const shareId = searchParams.shareId;
|
|
||||||
const share = await this.shareRepo.findById(shareId);
|
|
||||||
if (!share || share.workspaceId !== opts.workspaceId) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const pageIdsToSearch = [];
|
|
||||||
if (share.includeSubPages) {
|
|
||||||
const pageList = await this.pageRepo.getPageAndDescendants(
|
|
||||||
share.pageId,
|
|
||||||
{
|
|
||||||
includeContent: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
pageIdsToSearch.push(...pageList.map((page) => page.id));
|
|
||||||
} else {
|
|
||||||
pageIdsToSearch.push(share.pageId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pageIdsToSearch.length > 0) {
|
|
||||||
queryResults = queryResults
|
|
||||||
.where('id', 'in', pageIdsToSearch)
|
|
||||||
.where('workspaceId', '=', opts.workspaceId);
|
|
||||||
} else {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
//@ts-ignore
|
|
||||||
queryResults = await queryResults.execute();
|
|
||||||
|
|
||||||
//@ts-ignore
|
|
||||||
const searchResults = queryResults.map((result: SearchResponseDto) => {
|
|
||||||
if (result.highlight) {
|
if (result.highlight) {
|
||||||
result.highlight = result.highlight
|
result.highlight = result.highlight
|
||||||
.replace(/\r\n|\r|\n/g, ' ')
|
.replace(/\r\n|\r|\n/g, ' ')
|
||||||
|
|||||||
@ -1,58 +0,0 @@
|
|||||||
import {
|
|
||||||
IsBoolean,
|
|
||||||
IsNotEmpty,
|
|
||||||
IsOptional,
|
|
||||||
IsString,
|
|
||||||
IsUUID,
|
|
||||||
} from 'class-validator';
|
|
||||||
|
|
||||||
export class CreateShareDto {
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
pageId: string;
|
|
||||||
|
|
||||||
@IsBoolean()
|
|
||||||
@IsOptional()
|
|
||||||
includeSubPages: boolean;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
searchIndexing: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UpdateShareDto extends CreateShareDto {
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
shareId: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
pageId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ShareIdDto {
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
shareId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SpaceIdDto {
|
|
||||||
@IsUUID()
|
|
||||||
spaceId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ShareInfoDto {
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
shareId?: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
pageId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SharePageIdDto {
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
pageId: string;
|
|
||||||
}
|
|
||||||
@ -1,109 +0,0 @@
|
|||||||
import { Controller, Get, Param, Req, Res } from '@nestjs/common';
|
|
||||||
import { ShareService } from './share.service';
|
|
||||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
|
||||||
import { join } from 'path';
|
|
||||||
import * as fs from 'node:fs';
|
|
||||||
import { validate as isValidUUID } from 'uuid';
|
|
||||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
|
||||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
|
||||||
import { Workspace } from '@docmost/db/types/entity.types';
|
|
||||||
|
|
||||||
@Controller('share')
|
|
||||||
export class ShareSeoController {
|
|
||||||
constructor(
|
|
||||||
private readonly shareService: ShareService,
|
|
||||||
private workspaceRepo: WorkspaceRepo,
|
|
||||||
private environmentService: EnvironmentService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* add meta tags to publicly shared pages
|
|
||||||
*/
|
|
||||||
@Get([':shareId/p/:pageSlug', 'p/:pageSlug'])
|
|
||||||
async getShare(
|
|
||||||
@Res({ passthrough: false }) res: FastifyReply,
|
|
||||||
@Req() req: FastifyRequest,
|
|
||||||
@Param('shareId') shareId: string,
|
|
||||||
@Param('pageSlug') pageSlug: string,
|
|
||||||
) {
|
|
||||||
// Nestjs does not to apply middlewares to paths excluded from the global /api prefix
|
|
||||||
// https://github.com/nestjs/nest/issues/9124
|
|
||||||
// https://github.com/nestjs/nest/issues/11572
|
|
||||||
// https://github.com/nestjs/nest/issues/13401
|
|
||||||
// we have to duplicate the DomainMiddleware code here as a workaround
|
|
||||||
|
|
||||||
let workspace: Workspace = null;
|
|
||||||
if (this.environmentService.isSelfHosted()) {
|
|
||||||
workspace = await this.workspaceRepo.findFirst();
|
|
||||||
} else {
|
|
||||||
const header = req.raw.headers.host;
|
|
||||||
const subdomain = header.split('.')[0];
|
|
||||||
workspace = await this.workspaceRepo.findByHostname(subdomain);
|
|
||||||
}
|
|
||||||
|
|
||||||
const clientDistPath = join(
|
|
||||||
__dirname,
|
|
||||||
'..',
|
|
||||||
'..',
|
|
||||||
'..',
|
|
||||||
'..',
|
|
||||||
'client/dist',
|
|
||||||
);
|
|
||||||
|
|
||||||
if (fs.existsSync(clientDistPath)) {
|
|
||||||
const indexFilePath = join(clientDistPath, 'index.html');
|
|
||||||
|
|
||||||
if (!workspace) {
|
|
||||||
return this.sendIndex(indexFilePath, res);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pageId = this.extractPageSlugId(pageSlug);
|
|
||||||
|
|
||||||
const share = await this.shareService.getShareForPage(
|
|
||||||
pageId,
|
|
||||||
workspace.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!share) {
|
|
||||||
return this.sendIndex(indexFilePath, res);
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawTitle = share.sharedPage.title ?? 'untitled';
|
|
||||||
const metaTitle =
|
|
||||||
rawTitle.length > 80 ? `${rawTitle.slice(0, 77)}…` : rawTitle;
|
|
||||||
|
|
||||||
const metaTagVar = '<!--meta-tags-->';
|
|
||||||
|
|
||||||
const metaTags = [
|
|
||||||
`<meta property="og:title" content="${metaTitle}" />`,
|
|
||||||
`<meta property="twitter:title" content="${metaTitle}" />`,
|
|
||||||
!share.searchIndexing ? `<meta name="robots" content="noindex" />` : '',
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join('\n ');
|
|
||||||
|
|
||||||
const html = fs.readFileSync(indexFilePath, 'utf8');
|
|
||||||
const transformedHtml = html
|
|
||||||
.replace(/<title>[\s\S]*?<\/title>/i, `<title>${metaTitle}</title>`)
|
|
||||||
.replace(metaTagVar, metaTags);
|
|
||||||
|
|
||||||
res.type('text/html').send(transformedHtml);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sendIndex(indexFilePath: string, res: FastifyReply) {
|
|
||||||
const stream = fs.createReadStream(indexFilePath);
|
|
||||||
res.type('text/html').send(stream);
|
|
||||||
}
|
|
||||||
|
|
||||||
extractPageSlugId(slug: string): string {
|
|
||||||
if (!slug) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (isValidUUID(slug)) {
|
|
||||||
return slug;
|
|
||||||
}
|
|
||||||
const parts = slug.split('-');
|
|
||||||
return parts.length > 1 ? parts[parts.length - 1] : slug;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,171 +0,0 @@
|
|||||||
import {
|
|
||||||
BadRequestException,
|
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
ForbiddenException,
|
|
||||||
HttpCode,
|
|
||||||
HttpStatus,
|
|
||||||
NotFoundException,
|
|
||||||
Post,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
|
||||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
|
||||||
import {
|
|
||||||
SpaceCaslAction,
|
|
||||||
SpaceCaslSubject,
|
|
||||||
} from '../casl/interfaces/space-ability.type';
|
|
||||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
|
||||||
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
|
||||||
import { ShareService } from './share.service';
|
|
||||||
import {
|
|
||||||
CreateShareDto,
|
|
||||||
ShareIdDto,
|
|
||||||
ShareInfoDto,
|
|
||||||
SharePageIdDto,
|
|
||||||
UpdateShareDto,
|
|
||||||
} from './dto/share.dto';
|
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
|
||||||
import { Public } from '../../common/decorators/public.decorator';
|
|
||||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
|
||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@Controller('shares')
|
|
||||||
export class ShareController {
|
|
||||||
constructor(
|
|
||||||
private readonly shareService: ShareService,
|
|
||||||
private readonly spaceAbility: SpaceAbilityFactory,
|
|
||||||
private readonly shareRepo: ShareRepo,
|
|
||||||
private readonly pageRepo: PageRepo,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post('/')
|
|
||||||
async getShares(
|
|
||||||
@AuthUser() user: User,
|
|
||||||
@Body() pagination: PaginationOptions,
|
|
||||||
) {
|
|
||||||
return this.shareRepo.getShares(user.id, pagination);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Public()
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post('/page-info')
|
|
||||||
async getSharedPageInfo(
|
|
||||||
@Body() dto: ShareInfoDto,
|
|
||||||
@AuthWorkspace() workspace: Workspace,
|
|
||||||
) {
|
|
||||||
if (!dto.pageId && !dto.shareId) {
|
|
||||||
throw new BadRequestException();
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.shareService.getSharedPage(dto, workspace.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Public()
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post('/info')
|
|
||||||
async getShare(@Body() dto: ShareIdDto) {
|
|
||||||
const share = await this.shareRepo.findById(dto.shareId, {
|
|
||||||
includeSharedPage: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!share) {
|
|
||||||
throw new NotFoundException('Share not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
return share;
|
|
||||||
}
|
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post('/for-page')
|
|
||||||
async getShareForPage(
|
|
||||||
@Body() dto: SharePageIdDto,
|
|
||||||
@AuthUser() user: User,
|
|
||||||
@AuthWorkspace() workspace: Workspace,
|
|
||||||
) {
|
|
||||||
const page = await this.pageRepo.findById(dto.pageId);
|
|
||||||
if (!page) {
|
|
||||||
throw new NotFoundException('Shared page not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
|
||||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Share)) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.shareService.getShareForPage(page.id, workspace.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post('create')
|
|
||||||
async create(
|
|
||||||
@Body() createShareDto: CreateShareDto,
|
|
||||||
@AuthUser() user: User,
|
|
||||||
@AuthWorkspace() workspace: Workspace,
|
|
||||||
) {
|
|
||||||
const page = await this.pageRepo.findById(createShareDto.pageId);
|
|
||||||
|
|
||||||
if (!page || workspace.id !== page.workspaceId) {
|
|
||||||
throw new NotFoundException('Page not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
|
||||||
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Share)) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.shareService.createShare({
|
|
||||||
page,
|
|
||||||
authUserId: user.id,
|
|
||||||
workspaceId: workspace.id,
|
|
||||||
createShareDto,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post('update')
|
|
||||||
async update(@Body() updateShareDto: UpdateShareDto, @AuthUser() user: User) {
|
|
||||||
const share = await this.shareRepo.findById(updateShareDto.shareId);
|
|
||||||
|
|
||||||
if (!share) {
|
|
||||||
throw new NotFoundException('Share not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const ability = await this.spaceAbility.createForUser(user, share.spaceId);
|
|
||||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Share)) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.shareService.updateShare(share.id, updateShareDto);
|
|
||||||
}
|
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post('delete')
|
|
||||||
async delete(@Body() shareIdDto: ShareIdDto, @AuthUser() user: User) {
|
|
||||||
const share = await this.shareRepo.findById(shareIdDto.shareId);
|
|
||||||
|
|
||||||
if (!share) {
|
|
||||||
throw new NotFoundException('Share not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const ability = await this.spaceAbility.createForUser(user, share.spaceId);
|
|
||||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Share)) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.shareRepo.deleteShare(share.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Public()
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post('/tree')
|
|
||||||
async getSharePageTree(
|
|
||||||
@Body() dto: ShareIdDto,
|
|
||||||
@AuthWorkspace() workspace: Workspace,
|
|
||||||
) {
|
|
||||||
return this.shareService.getShareTree(dto.shareId, workspace.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { ShareController } from './share.controller';
|
|
||||||
import { ShareService } from './share.service';
|
|
||||||
import { TokenModule } from '../auth/token.module';
|
|
||||||
import { ShareSeoController } from './share-seo.controller';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [TokenModule],
|
|
||||||
controllers: [ShareController, ShareSeoController],
|
|
||||||
providers: [ShareService],
|
|
||||||
exports: [ShareService],
|
|
||||||
})
|
|
||||||
export class ShareModule {}
|
|
||||||
@ -1,295 +0,0 @@
|
|||||||
import {
|
|
||||||
BadRequestException,
|
|
||||||
Injectable,
|
|
||||||
Logger,
|
|
||||||
NotFoundException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { CreateShareDto, ShareInfoDto, UpdateShareDto } from './dto/share.dto';
|
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
|
||||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
|
||||||
import { nanoIdGen } from '../../common/helpers';
|
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
|
||||||
import { TokenService } from '../auth/services/token.service';
|
|
||||||
import { jsonToNode } from '../../collaboration/collaboration.util';
|
|
||||||
import {
|
|
||||||
getAttachmentIds,
|
|
||||||
getProsemirrorContent,
|
|
||||||
isAttachmentNode,
|
|
||||||
removeMarkTypeFromDoc,
|
|
||||||
} from '../../common/helpers/prosemirror/utils';
|
|
||||||
import { Node } from '@tiptap/pm/model';
|
|
||||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
|
||||||
import { updateAttachmentAttr } from './share.util';
|
|
||||||
import { Page } from '@docmost/db/types/entity.types';
|
|
||||||
import { validate as isValidUUID } from 'uuid';
|
|
||||||
import { sql } from 'kysely';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class ShareService {
|
|
||||||
private readonly logger = new Logger(ShareService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly shareRepo: ShareRepo,
|
|
||||||
private readonly pageRepo: PageRepo,
|
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
|
||||||
private readonly tokenService: TokenService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async getShareTree(shareId: string, workspaceId: string) {
|
|
||||||
const share = await this.shareRepo.findById(shareId);
|
|
||||||
if (!share || share.workspaceId !== workspaceId) {
|
|
||||||
throw new NotFoundException('Share not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (share.includeSubPages) {
|
|
||||||
const pageList = await this.pageRepo.getPageAndDescendants(share.pageId, {
|
|
||||||
includeContent: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { share, pageTree: pageList };
|
|
||||||
} else {
|
|
||||||
return { share, pageTree: [] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async createShare(opts: {
|
|
||||||
authUserId: string;
|
|
||||||
workspaceId: string;
|
|
||||||
page: Page;
|
|
||||||
createShareDto: CreateShareDto;
|
|
||||||
}) {
|
|
||||||
const { authUserId, workspaceId, page, createShareDto } = opts;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const shares = await this.shareRepo.findByPageId(page.id);
|
|
||||||
if (shares) {
|
|
||||||
return shares;
|
|
||||||
}
|
|
||||||
|
|
||||||
return await this.shareRepo.insertShare({
|
|
||||||
key: nanoIdGen().toLowerCase(),
|
|
||||||
pageId: page.id,
|
|
||||||
includeSubPages: createShareDto.includeSubPages || true,
|
|
||||||
searchIndexing: createShareDto.searchIndexing || true,
|
|
||||||
creatorId: authUserId,
|
|
||||||
spaceId: page.spaceId,
|
|
||||||
workspaceId,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(err);
|
|
||||||
throw new BadRequestException('Failed to share page');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateShare(shareId: string, updateShareDto: UpdateShareDto) {
|
|
||||||
try {
|
|
||||||
return this.shareRepo.updateShare(
|
|
||||||
{
|
|
||||||
includeSubPages: updateShareDto.includeSubPages,
|
|
||||||
searchIndexing: updateShareDto.searchIndexing,
|
|
||||||
},
|
|
||||||
shareId,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(err);
|
|
||||||
throw new BadRequestException('Failed to update share');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getSharedPage(dto: ShareInfoDto, workspaceId: string) {
|
|
||||||
const share = await this.getShareForPage(dto.pageId, workspaceId);
|
|
||||||
|
|
||||||
if (!share) {
|
|
||||||
throw new NotFoundException('Shared page not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const page = await this.pageRepo.findById(dto.pageId, {
|
|
||||||
includeContent: true,
|
|
||||||
includeCreator: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
page.content = await this.updatePublicAttachments(page);
|
|
||||||
|
|
||||||
if (!page) {
|
|
||||||
throw new NotFoundException('Shared page not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
return { page, share };
|
|
||||||
}
|
|
||||||
|
|
||||||
async getShareForPage(pageId: string, workspaceId: string) {
|
|
||||||
// here we try to check if a page was shared directly or if it inherits the share from its closest shared ancestor
|
|
||||||
const share = await this.db
|
|
||||||
.withRecursive('page_hierarchy', (cte) =>
|
|
||||||
cte
|
|
||||||
.selectFrom('pages')
|
|
||||||
.select([
|
|
||||||
'id',
|
|
||||||
'slugId',
|
|
||||||
'pages.title',
|
|
||||||
'pages.icon',
|
|
||||||
'parentPageId',
|
|
||||||
sql`0`.as('level'),
|
|
||||||
])
|
|
||||||
.where(isValidUUID(pageId) ? 'id' : 'slugId', '=', pageId)
|
|
||||||
.unionAll((union) =>
|
|
||||||
union
|
|
||||||
.selectFrom('pages as p')
|
|
||||||
.select([
|
|
||||||
'p.id',
|
|
||||||
'p.slugId',
|
|
||||||
'p.title',
|
|
||||||
'p.icon',
|
|
||||||
'p.parentPageId',
|
|
||||||
// Increase the level by 1 for each ancestor.
|
|
||||||
sql`ph.level + 1`.as('level'),
|
|
||||||
])
|
|
||||||
.innerJoin('page_hierarchy as ph', 'ph.parentPageId', 'p.id'),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.selectFrom('page_hierarchy')
|
|
||||||
.leftJoin('shares', 'shares.pageId', 'page_hierarchy.id')
|
|
||||||
.select([
|
|
||||||
'page_hierarchy.id as sharedPageId',
|
|
||||||
'page_hierarchy.slugId as sharedPageSlugId',
|
|
||||||
'page_hierarchy.title as sharedPageTitle',
|
|
||||||
'page_hierarchy.icon as sharedPageIcon',
|
|
||||||
'page_hierarchy.level as level',
|
|
||||||
'shares.id',
|
|
||||||
'shares.key',
|
|
||||||
'shares.pageId',
|
|
||||||
'shares.includeSubPages',
|
|
||||||
'shares.searchIndexing',
|
|
||||||
'shares.creatorId',
|
|
||||||
'shares.spaceId',
|
|
||||||
'shares.workspaceId',
|
|
||||||
'shares.createdAt',
|
|
||||||
'shares.updatedAt',
|
|
||||||
])
|
|
||||||
.where('shares.id', 'is not', null)
|
|
||||||
.orderBy('page_hierarchy.level', 'asc')
|
|
||||||
.executeTakeFirst();
|
|
||||||
|
|
||||||
if (!share || share.workspaceId != workspaceId) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (share.level === 1 && !share.includeSubPages) {
|
|
||||||
// we can only show a page if its shared ancestor permits it
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: share.id,
|
|
||||||
key: share.key,
|
|
||||||
includeSubPages: share.includeSubPages,
|
|
||||||
searchIndexing: share.searchIndexing,
|
|
||||||
pageId: share.pageId,
|
|
||||||
creatorId: share.creatorId,
|
|
||||||
spaceId: share.spaceId,
|
|
||||||
workspaceId: share.workspaceId,
|
|
||||||
createdAt: share.createdAt,
|
|
||||||
level: share.level,
|
|
||||||
sharedPage: {
|
|
||||||
id: share.sharedPageId,
|
|
||||||
slugId: share.sharedPageSlugId,
|
|
||||||
title: share.sharedPageTitle,
|
|
||||||
icon: share.sharedPageIcon,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async getShareAncestorPage(
|
|
||||||
ancestorPageId: string,
|
|
||||||
childPageId: string,
|
|
||||||
): Promise<any> {
|
|
||||||
let ancestor = null;
|
|
||||||
try {
|
|
||||||
ancestor = await this.db
|
|
||||||
.withRecursive('page_ancestors', (db) =>
|
|
||||||
db
|
|
||||||
.selectFrom('pages')
|
|
||||||
.select([
|
|
||||||
'id',
|
|
||||||
'slugId',
|
|
||||||
'title',
|
|
||||||
'parentPageId',
|
|
||||||
'spaceId',
|
|
||||||
(eb) =>
|
|
||||||
eb
|
|
||||||
.case()
|
|
||||||
.when(eb.ref('id'), '=', ancestorPageId)
|
|
||||||
.then(true)
|
|
||||||
.else(false)
|
|
||||||
.end()
|
|
||||||
.as('found'),
|
|
||||||
])
|
|
||||||
.where(isValidUUID(childPageId) ? 'id' : 'slugId', '=', childPageId)
|
|
||||||
.unionAll((exp) =>
|
|
||||||
exp
|
|
||||||
.selectFrom('pages as p')
|
|
||||||
.select([
|
|
||||||
'p.id',
|
|
||||||
'p.slugId',
|
|
||||||
'p.title',
|
|
||||||
'p.parentPageId',
|
|
||||||
'p.spaceId',
|
|
||||||
(eb) =>
|
|
||||||
eb
|
|
||||||
.case()
|
|
||||||
.when(eb.ref('p.id'), '=', ancestorPageId)
|
|
||||||
.then(true)
|
|
||||||
.else(false)
|
|
||||||
.end()
|
|
||||||
.as('found'),
|
|
||||||
])
|
|
||||||
.innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id')
|
|
||||||
// Continue recursing only when the target ancestor hasn't been found on that branch.
|
|
||||||
.where('pa.found', '=', false),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.selectFrom('page_ancestors')
|
|
||||||
.selectAll()
|
|
||||||
.where('found', '=', true)
|
|
||||||
.limit(1)
|
|
||||||
.executeTakeFirst();
|
|
||||||
} catch (err) {
|
|
||||||
// empty
|
|
||||||
}
|
|
||||||
|
|
||||||
return ancestor;
|
|
||||||
}
|
|
||||||
|
|
||||||
async updatePublicAttachments(page: Page): Promise<any> {
|
|
||||||
const prosemirrorJson = getProsemirrorContent(page.content);
|
|
||||||
const attachmentIds = getAttachmentIds(prosemirrorJson);
|
|
||||||
const attachmentMap = new Map<string, string>();
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
attachmentIds.map(async (attachmentId: string) => {
|
|
||||||
const token = await this.tokenService.generateAttachmentToken({
|
|
||||||
attachmentId,
|
|
||||||
pageId: page.id,
|
|
||||||
workspaceId: page.workspaceId,
|
|
||||||
});
|
|
||||||
attachmentMap.set(attachmentId, token);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const doc = jsonToNode(prosemirrorJson);
|
|
||||||
|
|
||||||
doc?.descendants((node: Node) => {
|
|
||||||
if (!isAttachmentNode(node.type.name)) return;
|
|
||||||
|
|
||||||
const attachmentId = node.attrs.attachmentId;
|
|
||||||
const token = attachmentMap.get(attachmentId);
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
updateAttachmentAttr(node, 'src', token);
|
|
||||||
updateAttachmentAttr(node, 'url', token);
|
|
||||||
});
|
|
||||||
|
|
||||||
const removeCommentMarks = removeMarkTypeFromDoc(doc, 'comment');
|
|
||||||
return removeCommentMarks.toJSON();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import { Node } from '@tiptap/pm/model';
|
|
||||||
|
|
||||||
export function updateAttachmentAttr(
|
|
||||||
node: Node,
|
|
||||||
attr: 'src' | 'url',
|
|
||||||
token: string,
|
|
||||||
) {
|
|
||||||
const attrVal = node.attrs[attr];
|
|
||||||
if (
|
|
||||||
attrVal &&
|
|
||||||
(attrVal.startsWith('/files') || attrVal.startsWith('/api/files'))
|
|
||||||
) {
|
|
||||||
// @ts-ignore
|
|
||||||
node.attrs[attr] = updateAttachmentUrl(attrVal, token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateAttachmentUrl(src: string, jwtToken: string) {
|
|
||||||
const updatedSrc = src.replace('/files/', '/files/public/');
|
|
||||||
const separator = updatedSrc.includes('?') ? '&' : '?';
|
|
||||||
return `${updatedSrc}${separator}jwt=${jwtToken}`;
|
|
||||||
}
|
|
||||||
@ -24,7 +24,6 @@ import * as process from 'node:process';
|
|||||||
import { MigrationService } from '@docmost/db/services/migration.service';
|
import { MigrationService } from '@docmost/db/services/migration.service';
|
||||||
import { UserTokenRepo } from './repos/user-token/user-token.repo';
|
import { UserTokenRepo } from './repos/user-token/user-token.repo';
|
||||||
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
|
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
|
||||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
|
||||||
|
|
||||||
// https://github.com/brianc/node-postgres/issues/811
|
// https://github.com/brianc/node-postgres/issues/811
|
||||||
types.setTypeParser(types.builtins.INT8, (val) => Number(val));
|
types.setTypeParser(types.builtins.INT8, (val) => Number(val));
|
||||||
@ -75,7 +74,6 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
|
|||||||
AttachmentRepo,
|
AttachmentRepo,
|
||||||
UserTokenRepo,
|
UserTokenRepo,
|
||||||
BacklinkRepo,
|
BacklinkRepo,
|
||||||
ShareRepo
|
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
WorkspaceRepo,
|
WorkspaceRepo,
|
||||||
@ -90,7 +88,6 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
|
|||||||
AttachmentRepo,
|
AttachmentRepo,
|
||||||
UserTokenRepo,
|
UserTokenRepo,
|
||||||
BacklinkRepo,
|
BacklinkRepo,
|
||||||
ShareRepo
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class DatabaseModule
|
export class DatabaseModule
|
||||||
|
|||||||
@ -1,38 +0,0 @@
|
|||||||
import { Kysely, sql } from 'kysely';
|
|
||||||
|
|
||||||
export async function up(db: Kysely<any>): Promise<void> {
|
|
||||||
await db.schema
|
|
||||||
.createTable('shares')
|
|
||||||
.addColumn('id', 'uuid', (col) =>
|
|
||||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
|
||||||
)
|
|
||||||
.addColumn('key', 'varchar', (col) => col.notNull())
|
|
||||||
.addColumn('page_id', 'uuid', (col) =>
|
|
||||||
col.references('pages.id').onDelete('cascade'),
|
|
||||||
)
|
|
||||||
.addColumn('include_sub_pages', 'boolean', (col) => col.defaultTo(false))
|
|
||||||
.addColumn('search_indexing', 'boolean', (col) => col.defaultTo(false))
|
|
||||||
.addColumn('creator_id', 'uuid', (col) => col.references('users.id'))
|
|
||||||
.addColumn('space_id', 'uuid', (col) =>
|
|
||||||
col.references('spaces.id').onDelete('cascade').notNull(),
|
|
||||||
)
|
|
||||||
.addColumn('workspace_id', 'uuid', (col) =>
|
|
||||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
|
||||||
)
|
|
||||||
.addColumn('created_at', 'timestamptz', (col) =>
|
|
||||||
col.notNull().defaultTo(sql`now()`),
|
|
||||||
)
|
|
||||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
|
||||||
col.notNull().defaultTo(sql`now()`),
|
|
||||||
)
|
|
||||||
.addColumn('deleted_at', 'timestamptz', (col) => col)
|
|
||||||
.addUniqueConstraint('shares_key_workspace_id_unique', [
|
|
||||||
'key',
|
|
||||||
'workspace_id',
|
|
||||||
])
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function down(db: Kysely<any>): Promise<void> {
|
|
||||||
await db.schema.dropTable('shares').execute();
|
|
||||||
}
|
|
||||||
@ -211,10 +211,7 @@ export class PageRepo {
|
|||||||
).as('contributors');
|
).as('contributors');
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPageAndDescendants(
|
async getPageAndDescendants(parentPageId: string) {
|
||||||
parentPageId: string,
|
|
||||||
opts: { includeContent: boolean },
|
|
||||||
) {
|
|
||||||
return this.db
|
return this.db
|
||||||
.withRecursive('page_hierarchy', (db) =>
|
.withRecursive('page_hierarchy', (db) =>
|
||||||
db
|
db
|
||||||
@ -224,12 +221,11 @@ export class PageRepo {
|
|||||||
'slugId',
|
'slugId',
|
||||||
'title',
|
'title',
|
||||||
'icon',
|
'icon',
|
||||||
'position',
|
'content',
|
||||||
'parentPageId',
|
'parentPageId',
|
||||||
'spaceId',
|
'spaceId',
|
||||||
'workspaceId',
|
'workspaceId',
|
||||||
])
|
])
|
||||||
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
|
||||||
.where('id', '=', parentPageId)
|
.where('id', '=', parentPageId)
|
||||||
.unionAll((exp) =>
|
.unionAll((exp) =>
|
||||||
exp
|
exp
|
||||||
@ -239,12 +235,11 @@ export class PageRepo {
|
|||||||
'p.slugId',
|
'p.slugId',
|
||||||
'p.title',
|
'p.title',
|
||||||
'p.icon',
|
'p.icon',
|
||||||
'p.position',
|
'p.content',
|
||||||
'p.parentPageId',
|
'p.parentPageId',
|
||||||
'p.spaceId',
|
'p.spaceId',
|
||||||
'p.workspaceId',
|
'p.workspaceId',
|
||||||
])
|
])
|
||||||
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
|
||||||
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id'),
|
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id'),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,242 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
|
||||||
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
|
|
||||||
import { dbOrTx } from '../../utils';
|
|
||||||
import {
|
|
||||||
InsertableShare,
|
|
||||||
Share,
|
|
||||||
UpdatableShare,
|
|
||||||
} from '@docmost/db/types/entity.types';
|
|
||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
|
||||||
import { executeWithPagination } from '@docmost/db/pagination/pagination';
|
|
||||||
import { validate as isValidUUID } from 'uuid';
|
|
||||||
import { ExpressionBuilder, sql } from 'kysely';
|
|
||||||
import { DB } from '@docmost/db/types/db';
|
|
||||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
|
||||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class ShareRepo {
|
|
||||||
constructor(
|
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
|
||||||
private spaceMemberRepo: SpaceMemberRepo,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
private baseFields: Array<keyof Share> = [
|
|
||||||
'id',
|
|
||||||
'key',
|
|
||||||
'pageId',
|
|
||||||
'includeSubPages',
|
|
||||||
'searchIndexing',
|
|
||||||
'creatorId',
|
|
||||||
'spaceId',
|
|
||||||
'workspaceId',
|
|
||||||
'createdAt',
|
|
||||||
'updatedAt',
|
|
||||||
'deletedAt',
|
|
||||||
];
|
|
||||||
|
|
||||||
async findById(
|
|
||||||
shareId: string,
|
|
||||||
opts?: {
|
|
||||||
includeSharedPage?: boolean;
|
|
||||||
includeCreator?: boolean;
|
|
||||||
withLock?: boolean;
|
|
||||||
trx?: KyselyTransaction;
|
|
||||||
},
|
|
||||||
): Promise<Share> {
|
|
||||||
const db = dbOrTx(this.db, opts?.trx);
|
|
||||||
|
|
||||||
let query = db.selectFrom('shares').select(this.baseFields);
|
|
||||||
|
|
||||||
if (opts?.includeSharedPage) {
|
|
||||||
query = query.select((eb) => this.withSharedPage(eb));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts?.includeCreator) {
|
|
||||||
query = query.select((eb) => this.withCreator(eb));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts?.withLock && opts?.trx) {
|
|
||||||
query = query.forUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isValidUUID(shareId)) {
|
|
||||||
query = query.where('id', '=', shareId);
|
|
||||||
} else {
|
|
||||||
query = query.where(sql`LOWER(key)`, '=', shareId.toLowerCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
return query.executeTakeFirst();
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByPageId(
|
|
||||||
pageId: string,
|
|
||||||
opts?: {
|
|
||||||
includeCreator?: boolean;
|
|
||||||
withLock?: boolean;
|
|
||||||
trx?: KyselyTransaction;
|
|
||||||
},
|
|
||||||
): Promise<Share> {
|
|
||||||
const db = dbOrTx(this.db, opts?.trx);
|
|
||||||
|
|
||||||
let query = db
|
|
||||||
.selectFrom('shares')
|
|
||||||
.select(this.baseFields)
|
|
||||||
.where('pageId', '=', pageId);
|
|
||||||
|
|
||||||
if (opts?.includeCreator) {
|
|
||||||
query = query.select((eb) => this.withCreator(eb));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts?.withLock && opts?.trx) {
|
|
||||||
query = query.forUpdate();
|
|
||||||
}
|
|
||||||
return query.executeTakeFirst();
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateShare(
|
|
||||||
updatableShare: UpdatableShare,
|
|
||||||
shareId: string,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
) {
|
|
||||||
return dbOrTx(this.db, trx)
|
|
||||||
.updateTable('shares')
|
|
||||||
.set({ ...updatableShare, updatedAt: new Date() })
|
|
||||||
.where(
|
|
||||||
isValidUUID(shareId) ? 'id' : sql`LOWER(key)`,
|
|
||||||
'=',
|
|
||||||
shareId.toLowerCase(),
|
|
||||||
)
|
|
||||||
.returning(this.baseFields)
|
|
||||||
.executeTakeFirst();
|
|
||||||
}
|
|
||||||
|
|
||||||
async insertShare(
|
|
||||||
insertableShare: InsertableShare,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<Share> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
return db
|
|
||||||
.insertInto('shares')
|
|
||||||
.values(insertableShare)
|
|
||||||
.returning(this.baseFields)
|
|
||||||
.executeTakeFirst();
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteShare(shareId: string): Promise<void> {
|
|
||||||
let query = this.db.deleteFrom('shares');
|
|
||||||
|
|
||||||
if (isValidUUID(shareId)) {
|
|
||||||
query = query.where('id', '=', shareId);
|
|
||||||
} else {
|
|
||||||
query = query.where(sql`LOWER(key)`, '=', shareId.toLowerCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
await query.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
async getShares(userId: string, pagination: PaginationOptions) {
|
|
||||||
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
|
|
||||||
|
|
||||||
const query = this.db
|
|
||||||
.selectFrom('shares')
|
|
||||||
.select(this.baseFields)
|
|
||||||
.select((eb) => this.withPage(eb))
|
|
||||||
.select((eb) => this.withSpace(eb, userId))
|
|
||||||
.select((eb) => this.withCreator(eb))
|
|
||||||
.where('spaceId', 'in', userSpaceIds)
|
|
||||||
.orderBy('updatedAt', 'desc');
|
|
||||||
|
|
||||||
const hasEmptyIds = userSpaceIds.length === 0;
|
|
||||||
const result = executeWithPagination(query, {
|
|
||||||
page: pagination.page,
|
|
||||||
perPage: pagination.limit,
|
|
||||||
hasEmptyIds,
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
withPage(eb: ExpressionBuilder<DB, 'shares'>) {
|
|
||||||
return jsonObjectFrom(
|
|
||||||
eb
|
|
||||||
.selectFrom('pages')
|
|
||||||
.select(['pages.id', 'pages.title', 'pages.slugId', 'pages.icon'])
|
|
||||||
.whereRef('pages.id', '=', 'shares.pageId'),
|
|
||||||
).as('page');
|
|
||||||
}
|
|
||||||
|
|
||||||
withSpace(eb: ExpressionBuilder<DB, 'shares'>, userId?: string) {
|
|
||||||
return jsonObjectFrom(
|
|
||||||
eb
|
|
||||||
.selectFrom('spaces')
|
|
||||||
.select(['spaces.id', 'spaces.name', 'spaces.slug'])
|
|
||||||
.$if(Boolean(userId), (qb) =>
|
|
||||||
qb.select((eb) => this.withUserSpaceRole(eb, userId)),
|
|
||||||
)
|
|
||||||
.whereRef('spaces.id', '=', 'shares.spaceId'),
|
|
||||||
).as('space');
|
|
||||||
}
|
|
||||||
|
|
||||||
withUserSpaceRole(eb: ExpressionBuilder<DB, 'spaces'>, userId: string) {
|
|
||||||
return eb
|
|
||||||
.selectFrom(
|
|
||||||
eb
|
|
||||||
.selectFrom('spaceMembers')
|
|
||||||
.select(['spaceMembers.role'])
|
|
||||||
.whereRef('spaceMembers.spaceId', '=', 'spaces.id')
|
|
||||||
.where('spaceMembers.userId', '=', userId)
|
|
||||||
.unionAll(
|
|
||||||
eb
|
|
||||||
.selectFrom('spaceMembers')
|
|
||||||
.innerJoin(
|
|
||||||
'groupUsers',
|
|
||||||
'groupUsers.groupId',
|
|
||||||
'spaceMembers.groupId',
|
|
||||||
)
|
|
||||||
.select(['spaceMembers.role'])
|
|
||||||
.whereRef('spaceMembers.spaceId', '=', 'spaces.id')
|
|
||||||
.where('groupUsers.userId', '=', userId),
|
|
||||||
)
|
|
||||||
.as('roles_union'),
|
|
||||||
)
|
|
||||||
.select('roles_union.role')
|
|
||||||
.orderBy(
|
|
||||||
sql`CASE roles_union.role
|
|
||||||
WHEN 'admin' THEN 3
|
|
||||||
WHEN 'writer' THEN 2
|
|
||||||
WHEN 'reader' THEN 1
|
|
||||||
ELSE 0
|
|
||||||
END`,
|
|
||||||
|
|
||||||
'desc',
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
.as('userRole');
|
|
||||||
}
|
|
||||||
|
|
||||||
withCreator(eb: ExpressionBuilder<DB, 'shares'>) {
|
|
||||||
return jsonObjectFrom(
|
|
||||||
eb
|
|
||||||
.selectFrom('users')
|
|
||||||
.select(['users.id', 'users.name', 'users.avatarUrl'])
|
|
||||||
.whereRef('users.id', '=', 'shares.creatorId'),
|
|
||||||
).as('creator');
|
|
||||||
}
|
|
||||||
|
|
||||||
withSharedPage(eb: ExpressionBuilder<DB, 'shares'>) {
|
|
||||||
return jsonObjectFrom(
|
|
||||||
eb
|
|
||||||
.selectFrom('pages')
|
|
||||||
.select([
|
|
||||||
'pages.id',
|
|
||||||
'pages.slugId',
|
|
||||||
'pages.title',
|
|
||||||
'pages.icon',
|
|
||||||
'pages.parentPageId',
|
|
||||||
])
|
|
||||||
.whereRef('pages.id', '=', 'shares.pageId'),
|
|
||||||
).as('sharedPage');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
15
apps/server/src/database/types/db.d.ts
vendored
15
apps/server/src/database/types/db.d.ts
vendored
@ -183,20 +183,6 @@ export interface Pages {
|
|||||||
ydoc: Buffer | null;
|
ydoc: Buffer | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Shares {
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
creatorId: string | null;
|
|
||||||
deletedAt: Timestamp | null;
|
|
||||||
id: Generated<string>;
|
|
||||||
includeSubPages: Generated<boolean | null>;
|
|
||||||
key: string;
|
|
||||||
pageId: string | null;
|
|
||||||
searchIndexing: Generated<boolean | null>;
|
|
||||||
spaceId: string;
|
|
||||||
updatedAt: Generated<Timestamp>;
|
|
||||||
workspaceId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SpaceMembers {
|
export interface SpaceMembers {
|
||||||
addedById: string | null;
|
addedById: string | null;
|
||||||
createdAt: Generated<Timestamp>;
|
createdAt: Generated<Timestamp>;
|
||||||
@ -302,7 +288,6 @@ export interface DB {
|
|||||||
groupUsers: GroupUsers;
|
groupUsers: GroupUsers;
|
||||||
pageHistory: PageHistory;
|
pageHistory: PageHistory;
|
||||||
pages: Pages;
|
pages: Pages;
|
||||||
shares: Shares;
|
|
||||||
spaceMembers: SpaceMembers;
|
spaceMembers: SpaceMembers;
|
||||||
spaces: Spaces;
|
spaces: Spaces;
|
||||||
users: Users;
|
users: Users;
|
||||||
|
|||||||
@ -16,7 +16,6 @@ import {
|
|||||||
Billing as BillingSubscription,
|
Billing as BillingSubscription,
|
||||||
AuthProviders,
|
AuthProviders,
|
||||||
AuthAccounts,
|
AuthAccounts,
|
||||||
Shares,
|
|
||||||
} from './db';
|
} from './db';
|
||||||
|
|
||||||
// Workspace
|
// Workspace
|
||||||
@ -102,8 +101,3 @@ export type UpdatableAuthProvider = Updateable<Omit<AuthProviders, 'id'>>;
|
|||||||
export type AuthAccount = Selectable<AuthAccounts>;
|
export type AuthAccount = Selectable<AuthAccounts>;
|
||||||
export type InsertableAuthAccount = Insertable<AuthAccounts>;
|
export type InsertableAuthAccount = Insertable<AuthAccounts>;
|
||||||
export type UpdatableAuthAccount = Updateable<Omit<AuthAccounts, 'id'>>;
|
export type UpdatableAuthAccount = Updateable<Omit<AuthAccounts, 'id'>>;
|
||||||
|
|
||||||
// Share
|
|
||||||
export type Share = Selectable<Shares>;
|
|
||||||
export type InsertableShare = Insertable<Shares>;
|
|
||||||
export type UpdatableShare = Updateable<Omit<Shares, 'id'>>;
|
|
||||||
|
|||||||
@ -15,8 +15,10 @@ import { StorageService } from '../storage/storage.service';
|
|||||||
import {
|
import {
|
||||||
buildTree,
|
buildTree,
|
||||||
computeLocalPath,
|
computeLocalPath,
|
||||||
|
getAttachmentIds,
|
||||||
getExportExtension,
|
getExportExtension,
|
||||||
getPageTitle,
|
getPageTitle,
|
||||||
|
getProsemirrorContent,
|
||||||
PageExportTree,
|
PageExportTree,
|
||||||
replaceInternalLinks,
|
replaceInternalLinks,
|
||||||
updateAttachmentUrlsToLocalPaths,
|
updateAttachmentUrlsToLocalPaths,
|
||||||
@ -27,10 +29,6 @@ import { EditorState } from '@tiptap/pm/state';
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
import slugify = require('@sindresorhus/slugify');
|
import slugify = require('@sindresorhus/slugify');
|
||||||
import { EnvironmentService } from '../environment/environment.service';
|
import { EnvironmentService } from '../environment/environment.service';
|
||||||
import {
|
|
||||||
getAttachmentIds,
|
|
||||||
getProsemirrorContent,
|
|
||||||
} from '../../common/helpers/prosemirror/utils';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExportService {
|
export class ExportService {
|
||||||
@ -78,11 +76,8 @@ export class ExportService {
|
|||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (format === ExportFormat.Markdown) {
|
if (format === ExportFormat.Markdown) {
|
||||||
const newPageHtml = pageHtml.replace(
|
const newPageHtml = pageHtml.replace(/<colgroup[^>]*>[\s\S]*?<\/colgroup>/gmi, '');
|
||||||
/<colgroup[^>]*>[\s\S]*?<\/colgroup>/gim,
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
return turndown(newPageHtml);
|
return turndown(newPageHtml);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -90,9 +85,7 @@ export class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async exportPageWithChildren(pageId: string, format: string) {
|
async exportPageWithChildren(pageId: string, format: string) {
|
||||||
const pages = await this.pageRepo.getPageAndDescendants(pageId, {
|
const pages = await this.pageRepo.getPageAndDescendants(pageId);
|
||||||
includeContent: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!pages || pages.length === 0) {
|
if (!pages || pages.length === 0) {
|
||||||
throw new BadRequestException('No pages to export');
|
throw new BadRequestException('No pages to export');
|
||||||
@ -267,7 +260,14 @@ export class ExportService {
|
|||||||
|
|
||||||
const pages = await this.db
|
const pages = await this.db
|
||||||
.selectFrom('pages')
|
.selectFrom('pages')
|
||||||
.select(['id', 'slugId', 'title', 'creatorId', 'spaceId', 'workspaceId'])
|
.select([
|
||||||
|
'id',
|
||||||
|
'slugId',
|
||||||
|
'title',
|
||||||
|
'creatorId',
|
||||||
|
'spaceId',
|
||||||
|
'workspaceId',
|
||||||
|
])
|
||||||
.select((eb) => this.pageRepo.withSpace(eb))
|
.select((eb) => this.pageRepo.withSpace(eb))
|
||||||
.where('id', 'in', pageMentionIds)
|
.where('id', 'in', pageMentionIds)
|
||||||
.where('workspaceId', '=', workspaceId)
|
.where('workspaceId', '=', workspaceId)
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import { Node } from '@tiptap/pm/model';
|
|||||||
import { validate as isValidUUID } from 'uuid';
|
import { validate as isValidUUID } from 'uuid';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { Page } from '@docmost/db/types/entity.types';
|
import { Page } from '@docmost/db/types/entity.types';
|
||||||
import { isAttachmentNode } from '../../common/helpers/prosemirror/utils';
|
|
||||||
|
|
||||||
export type PageExportTree = Record<string, Page[]>;
|
export type PageExportTree = Record<string, Page[]>;
|
||||||
|
|
||||||
@ -26,6 +25,43 @@ export function getPageTitle(title: string) {
|
|||||||
return title ? title : 'untitled';
|
return title ? title : 'untitled';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getProsemirrorContent(content: any) {
|
||||||
|
return (
|
||||||
|
content ?? {
|
||||||
|
type: 'doc',
|
||||||
|
content: [{ type: 'paragraph', attrs: { textAlign: 'left' } }],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 isAttachmentNode(nodeType: string) {
|
||||||
|
const attachmentNodeTypes = [
|
||||||
|
'attachment',
|
||||||
|
'image',
|
||||||
|
'video',
|
||||||
|
'excalidraw',
|
||||||
|
'drawio',
|
||||||
|
];
|
||||||
|
return attachmentNodeTypes.includes(nodeType);
|
||||||
|
}
|
||||||
|
|
||||||
export function updateAttachmentUrlsToLocalPaths(prosemirrorJson: any) {
|
export function updateAttachmentUrlsToLocalPaths(prosemirrorJson: any) {
|
||||||
const doc = jsonToNode(prosemirrorJson);
|
const doc = jsonToNode(prosemirrorJson);
|
||||||
if (!doc) return null;
|
if (!doc) return null;
|
||||||
|
|||||||
@ -4,12 +4,7 @@ import {
|
|||||||
FastifyAdapter,
|
FastifyAdapter,
|
||||||
NestFastifyApplication,
|
NestFastifyApplication,
|
||||||
} from '@nestjs/platform-fastify';
|
} from '@nestjs/platform-fastify';
|
||||||
import {
|
import { Logger, NotFoundException, ValidationPipe } from '@nestjs/common';
|
||||||
Logger,
|
|
||||||
NotFoundException,
|
|
||||||
RequestMethod,
|
|
||||||
ValidationPipe,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { TransformHttpResponseInterceptor } from './common/interceptors/http-response.interceptor';
|
import { TransformHttpResponseInterceptor } from './common/interceptors/http-response.interceptor';
|
||||||
import { WsRedisIoAdapter } from './ws/adapter/ws-redis.adapter';
|
import { WsRedisIoAdapter } from './ws/adapter/ws-redis.adapter';
|
||||||
import { InternalLogFilter } from './common/logger/internal-log-filter';
|
import { InternalLogFilter } from './common/logger/internal-log-filter';
|
||||||
@ -31,9 +26,7 @@ async function bootstrap() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
app.setGlobalPrefix('api', {
|
app.setGlobalPrefix('api', { exclude: ['robots.txt'] });
|
||||||
exclude: ['robots.txt', 'share/:shareId/p/:pageSlug'],
|
|
||||||
});
|
|
||||||
|
|
||||||
const reflector = app.get(Reflector);
|
const reflector = app.get(Reflector);
|
||||||
const redisIoAdapter = new WsRedisIoAdapter(app);
|
const redisIoAdapter = new WsRedisIoAdapter(app);
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "docmost",
|
"name": "docmost",
|
||||||
"homepage": "https://docmost.com",
|
"homepage": "https://docmost.com",
|
||||||
"version": "0.20.1",
|
"version": "0.10.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nx run-many -t build",
|
"build": "nx run-many -t build",
|
||||||
|
|||||||
968
pnpm-lock.yaml
generated
968
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user